From b78d7b7bea720edd22028a084cbe32a83a9fcd9c Mon Sep 17 00:00:00 2001 From: Dami Date: Fri, 4 Sep 2026 18:10:50 -0600 Subject: [PATCH 01/14] pull: history imports resume ordinary queries when sync is unavailable and report coverage per source Bounds query windows and response buffers, retains partial and refused source results, and continues later sources after failed retries. Tests cover fallback, persistence, timestamp saturation and invalid responses. --- docs/04-data-and-names.md | 6 +- docs/13-scripts-and-agents.md | 2 +- src/console/console.js | 4 +- src/gen/console.ts | 2 +- src/jobs.ts | 34 +++-- src/pull.ts | 208 ++++++++++++++++++++++++++----- src/relay.ts | 1 + test/object/pull.test.ts | 225 ++++++++++++++++++++++++++++++++++ 8 files changed, 441 insertions(+), 41 deletions(-) create mode 100644 test/object/pull.test.ts diff --git a/docs/04-data-and-names.md b/docs/04-data-and-names.md index 49314b6..7e469fb 100644 --- a/docs/04-data-and-names.md +++ b/docs/04-data-and-names.md @@ -13,11 +13,13 @@ The Data tab shows bytes by kind, the files people uploaded and a keep-for rule ## Jobs -A job is work the relay does on its own, one small round at a time, so it keeps going while the relay sleeps between rounds. The **Jobs** table on the Sync tab lists each job with its relays, filter, schedule and last result, with run-now and remove. A job that fails three rounds in a row stops and says why. +A job is work the relay does on its own, one small round at a time, so it keeps going while the relay sleeps between rounds. The **Jobs** table on the Sync tab lists each job with its relays, filter, schedule and last result, with run-now and remove. A job that fails three rounds in a row stops and says why. A pull retries a failing source three times, then continues with the next source. An explicit refusal skips that source immediately. A job runs once, or every hour, six hours or day. Up to five standing jobs and 20 jobs in all per relay. Your bans and kind rules apply to what arrives. Your write rule does not: you asked for these events. -**Pull** copies what another relay has and yours lacks, by sync (NIP-77). Run it again and only new events come over. Files come along when the other relay is on bind.ws and the pull has no filter. With an interval, a pull is a standing mirror that keeps your name in step with the other relay. The other relay has to let anyone read. +**Pull** copies what another relay has and yours lacks. It tries sync (NIP-77) first, then ordinary NIP-01 queries if sync is unavailable. Query progress survives between rounds and relay restarts. Run it again and events already stored are deduplicated. Files come along when the other relay is on bind.ws and the pull has no filter. With an interval, a pull is a standing mirror that keeps your name in step with the other relay. The other relay has to let anyone read. + +The Jobs table exposes **Source results** with mode, status, counts and any refusal or coverage warning for each relay. A completed NIP-77 sync is marked complete. Query scans are best effort: relays may silently cap or omit history. Full query windows split by time; a full one-second window is explicitly partial because more events may share its timestamp. Imports stop at 2,048 pages per source and 500 events per query. Narrow the author or time filter and run again if a source reaches a limit. Imports never send your signing key or authenticate on your behalf. **Fetch my history** pulls your own events from every relay in your relay list, if a client has published that list here. Or give it relays to fetch from, separated by commas. diff --git a/docs/13-scripts-and-agents.md b/docs/13-scripts-and-agents.md index a12cd70..3d25ad2 100644 --- a/docs/13-scripts-and-agents.md +++ b/docs/13-scripts-and-agents.md @@ -153,7 +153,7 @@ The bridge takes the same header. `POST /events` answers `{ event_id, accepted, - `pullfrom url`, `pullstatus`: copy one relay and follow it. - `addjob {kind, relays, filter?, every?, label?}`: a `pull` or `push`, once or every 1, 6 or 24 hours. Up to 10 relays; filters take up to 50 authors and 50 kinds and a `since`. -- `removejob id`, `runjob id`, `listjobs`. +- `removejob id`, `runjob id`, `listjobs`. Pull jobs expose `pullSources` while running and `last.sources` after finishing: each source carries its URL, mode, status, stored/skipped/blob counts, retry count, error and coverage warning. Query window progress is persisted with the job. - `backfill [relays?]`: your own events from your kind 10002 here, or from the list. **Transfer** diff --git a/src/console/console.js b/src/console/console.js index 86c4f8a..51341f0 100644 --- a/src/console/console.js +++ b/src/console/console.js @@ -593,7 +593,9 @@ const l = j.last; const count = (stored, blobs, sent, refused) => (j.kind === "mirror" ? blobs.toLocaleString() + " files mirrored" : j.kind === "import" ? stored.toLocaleString() + " events" + ((j.last ? j.last.duplicates : j.duplicates) ? ", " + (j.last ? j.last.duplicates : j.duplicates) + " already here" : "") : j.kind === "pull" ? stored.toLocaleString() + " events" + (blobs ? ", " + blobs + " files" : "") : sent.toLocaleString() + " sent" + (refused ? ", " + refused + " refused" : "")); const res = j.running ? "running: " + count(j.stored, j.blobs, j.sent, j.refused) + "..." : !l ? "waiting" : l.error ? "failed: " + l.error : count(l.stored, l.blobs, l.sent, l.refused) + (l.skipped ? ", " + l.skipped + " skipped" : "") + ", " + fmtTime(l.finishedAt); - return "" + what + "" + j.relays.map(esc).join("
") + "" + (f.join(", ") || "everything") + "" + when + "" + esc(res) + "" + (j.running ? "" : ib("undo", "Run now", "runjob", j.id)) + ib("x", "Remove", "removejob", j.id) + ""; + const sources = j.running ? j.pullSources : l?.sources; + const details = sources?.length ? '
Source results' + sources.map((s) => '

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

').join('') + '
' : ''; + return "" + what + "" + j.relays.map(esc).join("
") + "" + (f.join(", ") || "everything") + "" + when + "" + esc(res) + details + "" + (j.running ? "" : ib("undo", "Run now", "runjob", j.id)) + ib("x", "Remove", "removejob", j.id) + ""; }; async function pollJobs() { clearTimeout(jobsTimer); diff --git a/src/gen/console.ts b/src/gen/console.ts index 1564f6e..a061299 100644 --- a/src/gen/console.ts +++ b/src/gen/console.ts @@ -1,4 +1,4 @@ // Generated by scripts/build/build-console.mjs from src/console. Do not edit; run npm run build:console. export const CONSOLE_HTML = "
\n
\n
\"\"
\n
\"\"

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

Nobody owns this relay yet.

\n

Claim it and it's yours: you decide who can post, who can read, and what stays. One signature with a nostr browser extension; no account, no email, no card.

\n
\n
\n\n
\n

A temporary relay, for now.

\n

Anyone can read and write here until . Then everything on it is deleted and the name is freed. Claim it and it stays, events and files included: one signature with a nostr browser extension. Or claim a new name and pull this one into it from its Storage tab.

\n
\n
\n\n
\n

Connect a remote signer.

\n

Your key stays in a signer app such as Amber or nsec.app; this page asks it to sign. On a phone, open the link and approve there. On a computer, paste the bunker:// URL the app gives you.

\n \n
\n \"QR\n

\n
\n\n
\n
\n

\n

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

Git repositories, ntig

\n
\n

Use ordinary Git to clone a repository hosted here.

\n
Clone a repository
\n

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

\n

To host your own, use a Nostr Git client to publish your repository and signed branch state to this relay before pushing to the same remote. Git hosting guide.

\n
\n
\n
\n

Open it in an app

\n

Relay apps open this relay as a place. Feed apps do not: they find the owner here through a profile link that carries this relay as the hint, and learn the relay from that. Either way, add under the app's relay settings to post here.

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

People

\n

Hidden from visitors. Only you see this list.

\n
\n
\n\n
\n

Fuel

\n

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

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

\n
sats
\n
\n

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

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

Your invites

\n

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

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

    People

    \n

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

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

    Invites

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

      Joining

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

      Moderation

      \n

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

      \n
      \n

      Reports

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

      Log

      \n

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

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

      Bans

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

          Blocked addresses

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

            Recent events

            \n

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

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

            Pinned

            \n

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

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

              Rules

              \n

              Bans apply regardless of these.

              \n
              \n

              Presets

              \n

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

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

              \n
              \n
              \n
              \n

              Writes

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

              Reads

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

              Kinds

              \n

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

              \n
              \n

              Allowed:

              \n

              Blocked:

              \n
              \n
              \n

              Features

              \n

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

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

              Blocked words

              \n

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

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

              Identity

              \n
              \n

              Profile

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

              For directories

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

              Your own domain

              \n

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

              \n
              \n
              \n

              \n
              \n
              \n

              Your relay lists

              \n

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

              \n
              \n
              \n
              \n

              Share

              \n

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

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

              Data

              \n

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

              \n
              \n
              \n
              \n

              By kind

              \n
              KindCountSizeOldestKeep for
              \n
              \n
              \n

              Files

              \n
              TimeFileSizeUploader
              \n
              \n
              \n

              Sites

              \n

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

              \n
              AuthorNameURLFilesSizeExpiry
              \n
              \n
              \n

              Dumps

              \n

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

              \n
              \n
                \n
                \n
                \n

                Import a file

                \n

                A JSONL of events, one per line, such as a dump or a strfry export, or a JSON array. Up to 64 MB. Signatures are checked; bans and kind rules apply; the write rule does not. Progress shows under Jobs.

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

                Sync

                \n

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

                \n
                \n

                Jobs

                \n

                Work the relay does on its own, one round at a time. A pull copies what another relay has and this one lacks. Fetch my history pulls your own events from the relays in your relay list. Rebroadcast sends what is here to other relays. Bans and kind rules apply to what arrives.

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

                Fork this relay

                \n

                A new name, temporary until claimed, filled from this relay. Yours to split a name by job, or somebody else's to hand them a community with its history. One fork an hour.

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

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

                Views

                \n

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

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

                Health

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

                Zaps received

                \n
                WhenFromSats
                \n
                \n
                \n

                Notifications

                \n

                The relay writes you a private message (NIP-17) with its own key. It lands here, in your inbox on your own relay, and on your DM relays when this relay holds your kind 10050.

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

                Owner

                \n

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

                \n
                \n

                Configuration

                \n

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

                \n
                \n
                \n
                \n

                Transfer ownership

                \n

                Hands the relay to a member. You stay on as a moderator. The relay's key, events, files and fuel do not change. There is no undo.

                \n
                \n
                \n
                \n

                If I lose my key

                \n

                Name a member as your heir. If you do not sign in here for the time you pick, the relay writes to you once a week for a month, then hands itself to the heir and keeps you on as a moderator. Any signed action on the relay resets the clock.

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

                \n
                \n
                \n

                Delete this relay

                \n

                Deletes every event, file, member, invite and setting, closes every connection, and returns the name to unclaimed for anyone to take. There is no undo.

                \n \n
                \n
                \n
                \n\n \n
                \n"; export const CONSOLE_CSS = "main { max-width: 64rem; }\n.mast { display: grid; grid-template-columns: 1fr auto; gap: 1.5rem 2rem; align-items: end; margin-bottom: 2rem; }\n.mast .wordmark { grid-column: 1 / -1; justify-self: center; margin-bottom: .5rem; }\n.mast .right { display: grid; gap: .7rem; justify-items: end; text-align: right; }\n.urlrow, .owner { display: inline-flex; align-items: center; gap: .4rem; color: var(--ink-2); }\n.owner b { color: var(--ink); } .owner em { font-style: normal; color: var(--forest); }\n.urlrow .ib, .owner .ib { width: 26px; height: 26px; margin-left: .2rem; box-shadow: 1px 1px 0 var(--ink); } .urlrow .ib svg, .owner .ib svg { width: 13px; height: 13px; }\n.care { display: flex; gap: .9rem; flex-wrap: wrap; }\n.care .g { display: grid; justify-items: center; gap: .3rem; width: 5.4rem; }\n.care .g i { display: grid; place-items: center; width: 44px; height: 44px; border: 2px solid var(--ink); border-radius: 10px; background: var(--paper); box-shadow: 2px 2px 0 var(--ink); position: relative; }\n.care .g i svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }\n.care .g.off i::after { content: \"\"; position: absolute; width: 3px; height: 40px; background: var(--ink); transform: rotate(45deg); border-radius: 2px; box-shadow: 0 0 0 2px var(--paper); }\n.care .g small { font: 500 10px/1.2 var(--mono); text-transform: uppercase; letter-spacing: .04em; color: var(--ink-2); text-align: center; }\n#unclaimed { background: var(--butter); }\n.doors { display: grid; grid-template-columns: repeat(4, 1fr); gap: .8rem; } @media (max-width: 52rem) { .doors { grid-template-columns: 1fr 1fr; } } @media (max-width: 30rem) { .doors { grid-template-columns: 1fr; } }\n.door { display: grid; grid-template-rows: auto 1fr; align-content: start; gap: .35rem; padding: .8rem .9rem; border: 2px solid var(--ink); border-radius: 12px; background: var(--paper); box-shadow: 3px 3px 0 var(--ink); min-width: 0; }\n.apps { display: grid; gap: 1.4rem; margin-top: 1rem; } .appgroup h4 { margin: 0 0 .2rem; font-size: 1rem; } .appgroup > .note { margin-top: 0; }\n.appgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: .8rem; } @media (max-width: 52rem) { .appgrid { grid-template-columns: 1fr 1fr; } } @media (max-width: 30rem) { .appgrid { grid-template-columns: 1fr; } }\n.app { display: grid; align-content: start; gap: .4rem; padding: .8rem .9rem; border: 2px solid var(--ink); border-radius: 12px; background: var(--paper); box-shadow: 3px 3px 0 var(--ink); min-width: 0; }\n.app-head { display: flex; align-items: baseline; gap: .5rem; flex-wrap: wrap; } .app-head b { font-size: 15px; } .app-head small { color: var(--ink-3); } .app p { margin: 0; font-size: 13px; color: var(--ink-2); }\n.app-acts { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .2rem; } .app-acts .btn { padding: .3rem .7rem; font-size: 13px; }\n.phones { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, max-content)); gap: .8rem; margin-top: 1rem; } .phones img { display: block; width: 150px; height: 150px; } .phones .door { justify-items: start; }\n.door small { font: 700 11px var(--sans); text-transform: uppercase; letter-spacing: .06em; color: var(--ink-2); }\n.door .v { display: grid; grid-template-columns: 1fr auto; align-items: start; gap: .5rem; font: 500 13px/1.45 var(--mono); } .door .v span { min-width: 0; overflow-wrap: anywhere; }\n.door .ib { width: 24px; height: 24px; box-shadow: 1px 1px 0 var(--ink); flex: 0 0 auto; } .door .ib svg { width: 12px; height: 12px; }\n#peoplesec { background: var(--peach); }\n.dir { display: flex; flex-wrap: wrap; gap: .5rem; }\n.who { display: inline-flex; align-items: center; gap: .1rem; padding: .3rem .7rem .3rem .4rem; border: 2px solid var(--ink); border-radius: 999px; background: var(--paper); box-shadow: 2px 2px 0 var(--ink); font-size: 14px; }\n.who .role { font: 700 10px var(--mono); text-transform: uppercase; letter-spacing: .05em; color: var(--forest); margin-left: .4rem; }\n.who.me { background: var(--sun); }\n#fuelsec { background: var(--mint); }\n.gauges { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }\n.gauge small { display: block; font-weight: 600; font-size: 13px; }\n.gauge .bar { position: relative; height: 22px; border: 2px solid var(--ink); border-radius: 999px; margin: .5rem 0 .35rem; overflow: hidden; padding: 3px; background: var(--paper) linear-gradient(90deg, transparent calc(25% - 1px), var(--line) calc(25% - 1px) 25%, transparent 25% calc(50% - 1px), var(--line) calc(50% - 1px) 50%, transparent 50% calc(75% - 1px), var(--line) calc(75% - 1px) 75%, transparent 75%); }\n.gauge .bar b { position: absolute; right: .6rem; top: 0; bottom: 0; display: flex; align-items: center; font: 600 11px var(--mono); color: var(--ink-3); pointer-events: none; }\n.gauge .bar i { display: block; height: 100%; width: 0; min-width: 14px; border-radius: 999px; background: repeating-linear-gradient(-45deg, var(--forest) 0 8px, var(--forest-2) 8px 16px); transition: width .4s; }\n.gauge .bar i.warm { background: repeating-linear-gradient(-45deg, #d9a52a 0 8px, var(--sun) 8px 16px); }\n.gauge .bar i.over { background: repeating-linear-gradient(-45deg, var(--red) 0 8px, #d4614c 8px 16px); }\n.gauge span { font-size: 13px; color: var(--ink-3); }\n.balance { margin: 1.2rem 0 0; color: var(--ink-2); } .balance b { color: var(--ink); font-weight: 700; }\n.sats { display: inline-flex; align-items: center; width: auto; } .sats input { font: 15px var(--sans); width: 6rem; text-align: right; border: 0; outline: none; padding: 0; background: transparent; color: var(--ink); } .sats span { color: var(--ink-3); padding-left: .4rem; }\n.topup { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; margin-top: .8rem; }\n.invoice { margin-top: 1rem; border: 2px dashed var(--ink); border-radius: 12px; padding: .9rem 1rem; background: var(--paper); }\n.invoice p { margin: 0 0 .6rem; color: var(--ink-2); font-size: 14px; } .invoice .state { font-size: 13px; color: var(--forest); }\n.invoice textarea { margin-top: .7rem; min-height: 3.4rem; font-family: var(--mono); font-size: 11.5px; color: var(--ink-3); }\ndetails.disclosure summary { cursor: pointer; font: 400 1.6rem/1 var(--display); list-style: none; display: flex; align-items: center; gap: .5rem; }\ndetails.disclosure summary::-webkit-details-marker { display: none; }\n.folds details + details, .folds #frontsec:not(.hidden) + details { border-top: 2px solid var(--line); margin-top: 1.2rem; padding-top: 1.2rem; } .folds #frontsec .metarow { margin-bottom: 0; } .folds .doors { margin-top: 1rem; } .folds > details > .block:first-of-type { margin-top: 1.4rem; }\ndetails.disclosure summary::before { content: \"+\"; font: 700 1.4rem var(--mono); width: 1.4rem; } details.disclosure[open] summary::before { content: \"–\"; }\n.about { display: grid; grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); gap: .6rem 1.2rem; margin-top: 1rem; font-size: 14px; }\n.about small { display: block; color: var(--ink-3); font-size: 12px; } .about div > div { overflow-wrap: anywhere; }\n.console { margin-top: 3rem; }\n.tabs { display: flex; gap: .45rem; flex-wrap: nowrap; align-items: flex-end; margin: 0 0 -2px 1rem; padding: 2px 3rem 2px 0; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; }\n.tabs.fade-r { -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 2.5rem), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 2.5rem), transparent); }\n.tabs.fade-l { -webkit-mask-image: linear-gradient(90deg, transparent, #000 2.5rem); mask-image: linear-gradient(90deg, transparent, #000 2.5rem); }\n.tabs.fade-l.fade-r { -webkit-mask-image: linear-gradient(90deg, transparent, #000 2.5rem, #000 calc(100% - 2.5rem), transparent); mask-image: linear-gradient(90deg, transparent, #000 2.5rem, #000 calc(100% - 2.5rem), transparent); } .tabs::-webkit-scrollbar { display: none; } .tabs a { flex: 0 0 auto; white-space: nowrap; }\n.tabs a { font: 600 14px var(--sans); padding: .55rem 1rem; border: 2px solid var(--ink); border-bottom: 0; border-radius: 12px 12px 0 0; background: var(--head); color: var(--ink-2); text-decoration: none; margin-bottom: -2px; position: relative; z-index: 1; }\n.tabs a.on { color: var(--ink); z-index: 3; padding-top: .7rem; } .tabs a.on:nth-child(4n+1) { background: var(--sun); } .tabs a.on:nth-child(4n+2) { background: var(--peach); } .tabs a.on:nth-child(4n+3) { background: var(--sky); } .tabs a.on:nth-child(4n+4) { background: var(--mint); }\n.count { display: inline-grid; place-items: center; min-width: 1.5em; height: 1.5em; padding: 0 .4em; margin-left: .45rem; border: 1.5px solid var(--ink); border-radius: 999px; background: var(--sun); color: var(--ink); font: 700 11px/1 var(--mono); vertical-align: middle; box-shadow: 1px 1px 0 var(--ink); } .count:empty { display: none; } h2 .count { font-size: 12px; vertical-align: .45em; }\n.panel { display: none; margin: 0; border-radius: 0 18px 18px 18px; position: relative; z-index: 2; }\n.panel.on { display: block; }\n.panel h2 { font-size: 2rem; }\n.block { margin-top: 1.8rem; }\n.two { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; } .two > * { min-width: 0; }\n.actions { display: flex; justify-content: flex-end; margin-top: 1.1rem; } .actions.left { justify-content: flex-start; }\n.addrow { display: flex; gap: .5rem; align-items: center; margin-bottom: .9rem; } .addrow input { flex: 1; min-width: 0; } .addrow label { display: inline-flex; align-items: center; gap: .35rem; flex: 0 0 auto; white-space: nowrap; font-size: 14px; color: var(--ink-2); cursor: pointer; } .addrow label input { flex: none; width: auto; margin: 0; accent-color: var(--forest); } .addrow .ib { width: 38px; height: 38px; flex: 0 0 auto; border-radius: 10px; }\n.choices { display: grid; gap: .5rem; }\n.choices label { display: grid; grid-template-columns: auto 1fr; gap: 0 .7rem; cursor: pointer; padding: .7rem .8rem; border: 2px solid var(--line-2); border-radius: 12px; background: var(--paper); }\n.choices label:has(:checked) { border-color: var(--ink); box-shadow: 3px 3px 0 var(--ink); }\n.choices input { grid-row: span 2; margin: .2rem 0 0; accent-color: var(--forest); }\n.choices b { font-weight: 600; } .choices small { color: var(--ink-2); font-size: 13px; }\n.limits { display: grid; grid-template-columns: 1fr 1fr; gap: .6rem 1.5rem; margin-top: 1.3rem; }\n.limits label { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: .6rem; } .limits label small { color: var(--ink-3); }\n.form { display: grid; grid-template-columns: 1fr 1fr; gap: .9rem 1.5rem; } .form label { display: grid; gap: .3rem; font-weight: 600; font-size: 14px; } .form .wide { grid-column: 1 / -1; }\n.danger-zone { border: 2px solid var(--red); border-radius: 12px; padding: 1rem 1.1rem; background: var(--red-soft); } .danger-zone h3 { color: var(--red); } .form label.switch { display: flex; flex-direction: row; align-items: center; gap: .6rem; font-weight: 600; font-size: 14px; } .switch input { accent-color: var(--forest); width: 18px; height: 18px; }\ntable { width: 100%; border-collapse: collapse; }\nth { text-align: left; font-size: 12px; font-weight: 600; color: var(--ink-3); padding: .4rem .5rem; border-bottom: 2px solid var(--ink); white-space: nowrap; }\ntd { padding: .5rem .5rem; vertical-align: middle; border-bottom: 1px solid var(--line); }\ntbody tr:nth-child(even) td { background: rgba(255,255,255,.55); }\nth.r, td.r { text-align: right; white-space: nowrap; } td.mono, td.dim { white-space: nowrap; } .events td.c:empty::before { content: \"(no content)\"; color: var(--ink-3); }\ntd .ib { width: 26px; height: 26px; box-shadow: 1px 1px 0 var(--ink); } td .ib svg { width: 13px; height: 13px; }\ntd input.txt { padding: .25rem .5rem; font-size: 14px; } td select.role { width: auto; min-width: 7rem; padding: .25rem .4rem; font-size: 13px; } .people-table input.name { width: 7rem; } .people-table input.note { width: 9.5rem; }\n.kind { font-family: var(--mono); font-size: 12px; background: var(--sun); border: 1.5px solid var(--ink); padding: 0 .5rem; border-radius: 999px; font-weight: 500; white-space: nowrap; }\n.events td.c { max-width: 0; width: 100%; overflow-x: auto; white-space: nowrap; scrollbar-width: thin; -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 2rem), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 2rem), transparent); }\n.people-table td.name { width: 12rem; }\n.ev { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 4px; vertical-align: -5px; margin-right: .45rem; font: 500 9px var(--mono); background: var(--line); color: var(--ink-2); font-style: normal; }\n.plain { list-style: none; margin: 0; padding: 0; } .plain li { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: .5rem; padding: .5rem 0; border-bottom: 2px dotted var(--line-2); } .plain li > span { min-width: 0; overflow-wrap: anywhere; } .plain li:last-child { border-bottom: 0; } .plain li.empty { grid-template-columns: 1fr; color: var(--ink-3); }\n.kindline { margin: .4rem 0; color: var(--ink-2); display: flex; flex-wrap: wrap; align-items: center; gap: .35rem; }\n.tag { display: inline-flex; align-items: center; gap: .2rem; font-family: var(--mono); font-size: 13px; padding: .05rem .2rem .05rem .55rem; border: 2px solid var(--ink); border-radius: 999px; background: var(--paper); box-shadow: 1px 1px 0 var(--ink); }\n.tag.plain { padding-right: .55rem; box-shadow: none; border-color: var(--line-2); color: var(--ink-3); } .tag.blk { color: var(--red); }\n.tag .ib { width: 18px; height: 18px; border-width: 1.5px; box-shadow: none; margin-left: .2rem; } .tag .ib svg { width: 9px; height: 9px; }\n.counters { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1.2rem; margin-bottom: 1.6rem; }\n.counter { display: grid; justify-items: center; align-content: center; gap: .15rem; min-height: 7.2rem; text-align: center; padding: 1rem .8rem; border: 2px solid var(--ink); border-radius: 16px; box-shadow: 4px 4px 0 var(--ink); background: var(--paper); }\n.counter:nth-child(1) { background: var(--sun); } .counter:nth-child(2) { background: var(--peach); } .counter:nth-child(3) { background: var(--sky); } .counter:nth-child(4) { background: var(--mint); }\n.counter .big { font: 400 2.6rem/1 var(--display); white-space: nowrap; } .counter small { display: block; margin-top: .35rem; font: 700 12px var(--sans); text-transform: uppercase; letter-spacing: .08em; } .counter .sub { font-size: 12px; color: var(--ink-2); }\n.kbar { display: flex; width: 100%; height: 22px; border: 2px solid var(--ink); border-radius: 999px; overflow: hidden; background: var(--paper); margin-bottom: .35rem; } .kbar i { display: block; height: 100%; border-right: 2px solid var(--ink); } .kbar i:last-child { border-right: 0; }\n.k1 { background: var(--ink); } .k2 { background: var(--forest); } .k3 { background: var(--id-3); } .k4 { background: var(--id-2); } .k5 { background: var(--line-2); }\n.legend { display: flex; flex-wrap: wrap; justify-content: center; gap: .1rem .6rem; font-family: var(--mono); font-size: 10.5px; text-transform: uppercase; } .legend i { display: inline-block; width: 9px; height: 9px; border: 1.5px solid var(--ink); border-radius: 2px; margin-right: .3rem; vertical-align: -1px; }\n.usage { display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: .8rem; } .usage div { padding: .7rem .9rem; border: 2px solid var(--ink); border-radius: 12px; background: var(--paper); } .usage small { display: block; color: var(--ink-3); font-size: 12px; } .usage b { font: 400 1.5rem/1.2 var(--display); }\n@media (max-width: 52rem) { .two, .gauges, .limits, .form, .counters { grid-template-columns: 1fr !important; } .addrow { flex-wrap: wrap; } .mast { grid-template-columns: 1fr; } .mast .right { justify-items: start; text-align: left; } }\n@media (max-width: 40rem) { .events:not(#kinds):not(#jobs) th:first-child, .events:not(#kinds):not(#jobs) td:first-child { display: none; } }\n\n.slegend { justify-content: flex-start; margin: 0 0 1.2rem; }\n#s-totals { margin-bottom: .4rem; }\ntd.keep { white-space: nowrap; } td.keep input { width: 5.5rem; display: inline-block; margin-right: .3rem; } td.keep input::placeholder { color: var(--ink-3); }\n#kinds tr.any td:first-child { font-weight: 600; }\n#kinds td:first-child .kind { display: inline-block; min-width: 5.2em; text-align: center; margin-right: .35rem; }\n/* Tables keep their columns and scroll inside the card on narrow screens instead of pushing the page sideways. */\n.scroll { overflow-x: auto; max-width: 100%; scrollbar-width: thin; } .scroll table { min-width: 100%; }\nsection, .card { overflow-x: clip; }\n.kind.sys { background: var(--mint); border-color: var(--forest); color: var(--forest); }\ntd.keep.sys { color: var(--ink-3); font-size: 13px; white-space: normal; }\n\n#console.mod .tabs a:not([data-tab=people]):not([data-tab=moderation]) { display: none; } #console.mod #thresholdform { display: none; }\n#members select.role { width: auto; padding: .1rem .3rem; font-size: 12px; margin-left: .3rem; }\n.wire-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .5rem 1.2rem; padding: .8rem 0; border-bottom: 1px solid var(--line); align-items: start; } .wire-row:first-child { border-top: 2px solid var(--ink); }\n.wire-main { min-width: 0; } .wire-side { display: grid; justify-items: end; gap: .45rem; max-width: 26rem; }\n.wire-acts { display: flex; gap: .4rem; white-space: nowrap; } .wire-acts .btn { padding: .35rem .75rem; font-size: 13px; }\n.wire-meta { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .35rem .5rem; align-items: center; font-size: 13px; color: var(--ink-2); text-align: right; } .wire-meta:empty { display: none; }\n.pill { display: inline-block; padding: .1rem .6rem; border: 1.5px solid var(--ink); border-radius: 999px; font: 600 12px var(--sans); color: var(--ink); background: var(--paper); } .pill.on { background: var(--mint); } .pill.off { color: var(--ink-3); } .pill.bad { color: var(--red); }\n.chip { display: inline-block; padding: .05rem .45rem; border: 1px solid var(--forest); border-radius: 6px; font: 12px var(--mono); color: var(--forest); } .chip.bad { border-color: var(--red); color: var(--red); }\n.share { display: grid; grid-template-columns: minmax(0, 3fr) minmax(14rem, 2fr); gap: 1.4rem; align-items: start; } .share #cardimg { width: 100%; height: auto; display: block; }\n.share-side { display: grid; gap: .8rem; justify-items: start; } .naddr { display: block; font-size: 12px; line-height: 1.5; word-break: break-all; color: var(--ink-2); }\n@media (max-width: 52rem) { .share { grid-template-columns: 1fr; } .wire-side { display: contents; } .wire-acts { justify-self: end; } .wire-meta { grid-column: 1 / -1; justify-content: flex-start; text-align: left; } }\n#presets .btn { margin: 0 .4rem .4rem 0; }\n.panel form h3.gap { margin-top: 1.4rem; }\n.key { position: relative; display: inline-block; font-family: var(--mono); } .key::before { content: attr(data-short); } .key .full { position: absolute; left: 0; top: 0; width: 1px; height: 1px; overflow: hidden; opacity: 0; white-space: nowrap; }\n.mast .banner { grid-column: 1 / -1; width: 100%; aspect-ratio: 4 / 1; overflow: hidden; border: 2px solid var(--ink); border-radius: 18px; box-shadow: 4px 4px 0 var(--ink); background: var(--paper); margin-bottom: .5rem; } .mast .banner img { width: 100%; height: 100%; object-fit: cover; display: block; }\n.mast .wordmark { display: flex; align-items: center; gap: 1rem; } .mast .icon { width: 64px; height: 64px; border-radius: 50%; border: 2px solid var(--ink); box-shadow: 3px 3px 0 var(--ink); background: var(--paper); object-fit: cover; }\n.metarow { display: flex; flex-wrap: wrap; gap: .4rem .6rem; align-items: center; margin: -.4rem 0 1rem; font-size: 13px; color: var(--ink-2); }\n.metarow .tag { display: inline-block; padding: .1rem .6rem; border: 1.5px solid var(--ink); border-radius: 999px; font: 600 12px var(--sans); color: var(--ink); background: var(--sun); }\n.metarow .sep { color: var(--ink-3); } .metarow a { color: var(--forest); text-decoration: none; border-bottom: 1.5px solid var(--forest); } .metarow a:hover { border-bottom-width: 2px; }\n.about a { color: var(--forest); text-decoration: none; border-bottom: 1.5px solid var(--forest); }\n.wire-acts label { display: inline-flex; align-items: center; gap: .35rem; font-size: 14px; color: var(--ink-2); cursor: pointer; } .wire-acts label input { margin: 0; accent-color: var(--forest); } .wire-row.dim .wire-main { color: var(--ink-3); }\n"; -export const CONSOLE_JS = "(async () => {\n const $ = (s) => document.querySelector(s);\n const $$ = (s) => document.querySelectorAll(s);\n const host = location.host;\n const wsURL = (location.protocol === \"https:\" ? \"wss://\" : \"ws://\") + host;\n const rpcURL = location.origin + \"/\";\n let info = null, me = null, owner = \"\", policy = null, fuel = null, people = null, myRole = \"\";\n\n const toast = (msg) => { const t = $(\"#toast\"); t.textContent = msg; t.classList.add(\"show\"); clearTimeout(t._t); t._t = setTimeout(() => t.classList.remove(\"show\"), 2600); };\n // guard wraps a handler: disables the button, shows errors as toasts. Defined first, since handlers below use it.\n const guard = (fn) => async (ev) => { ev.preventDefault(); const b = ev.submitter || ev.target; if (b) b.disabled = true; try { await fn(ev); } catch (e) { toast(e.message); } finally { if (b) b.disabled = false; } };\n const short = (hex) => hex ? hex.slice(0, 8) + \"…\" + hex.slice(-4) : \"\";\n // key shows a key short but keeps the whole of it in the DOM: selecting, double-clicking or clicking it copies the full hex.\n const key = (hex) => hex ? '' + hex + '' : '';\n const hue = (hex) => (parseInt(hex.slice(0, 2), 16) * 360 / 256).toFixed(0) + \"deg\";\n const av = (hex) => '';\n const fmtBytes = (n) => n < 1e6 ? (n / 1e3).toFixed(0) + \" KB\" : n < 1e9 ? (n / 1e6).toFixed(1) + \" MB\" : (n / 1e9).toFixed(2) + \" GB\";\n const fmtHours = (ms) => ms < 3600e3 ? Math.round(ms / 60e3) + \" min\" : (ms / 3600e3).toFixed(ms < 36e6 ? 1 : 0) + \" h\";\n const fuelOver = () => !!fuel && (fuel.eventBytes > fuel.freeEventBytes || fuel.mediaBytes > fuel.freeMediaBytes || fuel.activeMs > fuel.freeActiveMs || fuel.rowsWritten > fuel.freeRowsWritten);\n const fmtTime = (t) => t ? new Date(t * 1000).toLocaleString(undefined, { month: \"short\", day: \"numeric\", hour: \"2-digit\", minute: \"2-digit\" }) : \"–\";\n const fmtDay = (t) => t ? new Date(t * 1000).toLocaleDateString(undefined, { month: \"short\", day: \"numeric\" }) : \"–\";\n const ago = (t) => { const s = Math.max(0, Math.floor(Date.now() / 1000) - t); return s < 60 ? \"just now\" : s < 3600 ? Math.floor(s / 60) + \" min\" : s < 86400 ? Math.floor(s / 3600) + \" h\" : Math.floor(s / 86400) + \" d\"; };\n const esc = (s) => String(s ?? \"\").replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c]);\n\n const IC = {\n x: '',\n undo: '',\n check: '',\n trash: '',\n ban: '',\n banuser: '',\n eye: '',\n lock: '',\n pen: '',\n people: '',\n person: '',\n gauge: '',\n bolt: '',\n copy: '',\n pin: '',\n };\n const ib = (icon, label, act, id, extra) => '\";\n\n const CH = \"qpzry9x8gf2tvdw0s3jn54khce6mua7l\";\n function npubToHex(s) {\n s = s.trim().toLowerCase();\n if (/^[0-9a-f]{64}$/.test(s)) return s;\n if (!s.startsWith(\"npub1\")) return null;\n const data = s.slice(5, -6).split(\"\").map((c) => CH.indexOf(c));\n if (data.some((d) => d < 0)) return null;\n let bits = 0, acc = 0, out = [];\n for (const d of data) { acc = (acc << 5) | d; bits += 5; if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 255); } }\n return out.length === 32 ? out.map((b) => b.toString(16).padStart(2, \"0\")).join(\"\") : null;\n }\n\n async function sha256hex(s) {\n const b = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(s));\n return [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, \"0\")).join(\"\");\n }\n // ---- signing: a NIP-07 extension, or a NIP-46 remote signer ----\n // The remote path loads the bundled library on first use; its session\n // (client key, signer pubkey, relays) lives in localStorage until sign-out.\n let remote = null, lib = null, pendingNote = null;\n const NO_SIGNER = \"Install a nostr extension (Alby, nos2x, …) or connect a remote signer.\";\n const withTimeout = (p, ms, what) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(what + \" did not answer; is the signer app open?\")), ms))]);\n const signer = {\n ready: () => !!remote || !!window.nostr,\n async getPublicKey() { if (remote) return withTimeout(remote.getPublicKey(), 20000, \"The remote signer\"); if (window.nostr) return window.nostr.getPublicKey(); throw new Error(NO_SIGNER); },\n async signEvent(ev) { if (remote) return withTimeout(remote.signEvent(ev), 60000, \"The remote signer\"); if (window.nostr) return window.nostr.signEvent(ev); throw new Error(NO_SIGNER); },\n };\n async function signerLib() {\n if (lib) return lib;\n if (!window.NostrSigner) await new Promise((res, rej) => { const s = document.createElement(\"script\"); s.src = window.SIGNER_URL || \"/signer.js\"; s.onload = res; s.onerror = () => rej(new Error(\"Could not load the signer library.\")); document.head.appendChild(s); });\n return (lib = window.NostrSigner);\n }\n const onauth = (u) => window.open(u, \"_blank\");\n // The signer conversation rides this relay, and a relay may ask the socket to\n // authenticate before it delivers kind 24133 to its parties; answer with the\n // session's own key.\n const authPool = (L, sk) => new L.SimplePool({ automaticallyAuth: () => (evt) => Promise.resolve(L.finalizeEvent(evt, sk)) });\n const saveSession = (sk, s, secret) => localStorage.setItem(\"nip46\", JSON.stringify({ sk: lib.bytesToHex(sk), pubkey: s.bp.pubkey, relays: s.bp.relays, secret: secret || null }));\n async function resumeRemote() {\n const raw = localStorage.getItem(\"nip46\");\n if (!raw) return false;\n try {\n const s = JSON.parse(raw); const L = await signerLib();\n remote = L.BunkerSigner.fromBunker(L.hexToBytes(s.sk), { pubkey: s.pubkey, relays: s.relays, secret: s.secret }, { onauth, pool: authPool(L, L.hexToBytes(s.sk)) });\n return true;\n } catch { localStorage.removeItem(\"nip46\"); return false; }\n }\n async function connectBunker(input) {\n const L = await signerLib();\n const bp = await L.parseBunkerInput(input.trim());\n if (!bp) throw new Error(\"That is not a bunker:// URL.\");\n if (!bp.relays.length) throw new Error(\"The bunker URL names no relay.\");\n const sk = L.generateSecretKey();\n const s = L.BunkerSigner.fromBunker(sk, bp, { onauth, pool: authPool(L, sk) });\n await withTimeout(s.connect({ name: host, url: location.origin }), 60000, \"The signer\");\n await Promise.race([s.switchRelays(), new Promise((r) => setTimeout(r, 3000))]);\n remote = s; saveSession(sk, s, bp.secret);\n }\n // The nostrconnect:// flow: this relay carries the traffic, so no third relay is involved.\n let ncPending = null;\n async function offerNostrConnect() {\n if (ncPending) return ncPending;\n const L = await signerLib();\n const sk = L.generateSecretKey();\n const secret = L.bytesToHex(crypto.getRandomValues(new Uint8Array(8)));\n const uri = L.createNostrConnectURI({ clientPubkey: L.getPublicKey(sk), relays: [wsURL], secret, name: host, url: location.origin, perms: [\"sign_event:27235\", \"sign_event:9734\"] });\n $(\"#nclink\").href = uri; $(\"#ncnote\").textContent = \"\";\n $(\"#ncqr\").src = \"/qr.svg?text=\" + encodeURIComponent(uri); $(\"#ncqr\").classList.remove(\"hidden\");\n ncPending = L.BunkerSigner.fromURI(sk, uri, { onauth, pool: authPool(L, sk) }, 600000).then((s) => { remote = s; saveSession(sk, s, secret); ncPending = null; return s; }, (e) => { ncPending = null; throw e; });\n return ncPending;\n }\n async function remoteDone() {\n me = await signer.getPublicKey();\n localStorage.setItem(\"me\", me);\n $(\"#remotesec\").classList.add(\"hidden\");\n toast(\"Remote signer connected\");\n if (pendingNote && !owner) { const n = pendingNote; pendingNote = null; await claimNow(n); return; }\n pendingNote = null;\n renderHeader(); await loadAdmin(); await loadPeople();\n }\n function showRemote(note) {\n pendingNote = note || null;\n $(\"#remotesec\").classList.remove(\"hidden\");\n $(\"#remotesec\").scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n offerNostrConnect().then(remoteDone).catch((e) => { $(\"#ncnote\").textContent = e.message; });\n }\n\n async function rpc(method, ...params) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const body = JSON.stringify({ method, params });\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", rpcURL], [\"method\", \"POST\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(rpcURL, { method: \"POST\", headers: { \"content-type\": \"application/nostr+json+rpc\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body });\n const json = await resp.json();\n if (json.error) throw new Error(json.error);\n return json.result;\n }\n\n // ---- front of house ----\n // urlish shows a URL as a short link: the host and the start of the path, the whole thing on hover and as the target.\n class Html { constructor(s) { this.s = s; } }\n const urlish = (u) => {\n if (!u) return \"\";\n let label = u; try { const x = new URL(u); label = x.host + (x.pathname.length > 1 ? x.pathname.slice(0, 18) + (x.pathname.length > 18 ? \"\\u2026\" : \"\") : \"\"); } catch { /* not a URL */ }\n if (!/^https?:\\/\\//.test(u)) return u;\n return new Html('' + esc(label) + \"\");\n };\n function renderHeader() {\n const name = (info && info.name) || host.split(\".\")[0];\n $(\"#title\").textContent = name;\n document.title = name + \" - relay\";\n $(\"#url\").textContent = wsURL;\n $(\"#d-ws\").textContent = wsURL; $(\"#d-blossom\").textContent = location.origin; $(\"#d-nip05\").textContent = \"you@\" + host;\n $(\"#nip05-example\").textContent = \"alice@\" + host;\n const desc = (info && info.description) || \"\";\n $(\"#desc\").textContent = desc; $(\"#desc\").classList.toggle(\"hidden\", !desc);\n // What the owner declared about the relay, on the page, not only in the document clients read.\n const img = (id, url) => { const el = $(id); if (url) el.src = url; el.classList.toggle(\"hidden\", !url); };\n img(\"#bannerimg\", info?.banner); $(\"#banner\").classList.toggle(\"hidden\", !info?.banner);\n img(\"#iconimg\", info?.icon);\n const meta = [];\n for (const t of info?.tags || []) meta.push('' + esc(t) + \"\");\n const where = [...(info?.language_tags || []), ...(info?.relay_countries || [])];\n if (where.length) meta.push(\"\" + esc(where.join(\", \")) + \"\");\n const link = (label, href) => href ? '' + label + \"\" : \"\";\n const contact = info?.contact ? (/^(mailto:|https?:\\/\\/)/.test(info.contact) ? link(\"Contact\", info.contact) : \"\" + esc(info.contact) + \"\") : \"\";\n for (const x of [contact, link(\"Terms\", info?.terms_of_service), link(\"Posting policy\", info?.posting_policy), link(\"Privacy\", info?.privacy_policy)]) if (x) meta.push(x);\n $(\"#metarow\").innerHTML = meta.join('|'); $(\"#metarow\").classList.toggle(\"hidden\", meta.length === 0);\n $(\"#frontsec\").classList.toggle(\"hidden\", !desc && meta.length === 0);\n owner = (info && info.pubkey) || \"\";\n const isOwner = !!me && me === owner;\n const lease = !owner && info && info.lease ? info.lease : null;\n $(\"#ownerline\").innerHTML = owner ? 'run by ' + key(owner) + \"\" : lease ? \"temporary until \" + fmtDay(lease.expires_at) : \"unclaimed\";\n $(\"#owner-av\").classList.toggle(\"hidden\", !owner);\n if (owner) $(\"#owner-av\").style.setProperty(\"--h\", hue(owner));\n $(\"#who\").textContent = me ? (isOwner ? \"(that's you)\" : \"\") : \"\";\n $(\"#unclaimed\").classList.toggle(\"hidden\", !!owner || !!lease);\n $(\"#leased\").classList.toggle(\"hidden\", !lease);\n if (lease) {\n $(\"#lease-until\").textContent = fmtTime(lease.expires_at);\n $(\"#leasenote\").textContent = lease.holder ? \"Reserved for the key that asked for it; sign with that key.\" : \"\";\n }\n $(\"#signin\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signin46\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signout\").classList.toggle(\"hidden\", !me);\n $(\"#about\").innerHTML = [\n [\"Name\", info?.name], [\"Description\", info?.description], [\"Contact\", info?.contact], [\"Owner\", owner ? short(owner) : \"\"], [\"Relay key\", info?.self ? short(info.self) : \"\"], [\"Software\", urlish(info?.software)], [\"Version\", info?.version],\n [\"Max query\", info?.limitation?.max_limit], [\"Auth required\", String(!!info?.limitation?.auth_required)], [\"Restricted writes\", String(!!info?.limitation?.restricted_writes)], [\"Min PoW\", info?.limitation?.min_pow_difficulty || 0],\n [\"Tags\", info?.tags?.join(\", \")], [\"Languages\", info?.language_tags?.join(\", \")], [\"Countries\", info?.relay_countries?.join(\", \")],\n [\"Terms\", urlish(info?.terms_of_service)], [\"Posting policy\", urlish(info?.posting_policy)], [\"Privacy policy\", urlish(info?.privacy_policy)], [\"Icon\", urlish(info?.icon)], [\"Banner\", urlish(info?.banner)],\n [\"NIPs\", info?.supported_nips?.join(\" \")],\n ].map(([k, v]) => \"
                \" + k + \"\" + (v === undefined || v === \"\" ? '' : v instanceof Html ? v.s : esc(v)) + \"
                \").join(\"\");\n renderCare();\n }\n\n function renderCare() {\n const g = (icon, label, off) => '' + IC[icon] + \"\" + label + \"\";\n const writes = policy ? policy.writes : (info?.limitation?.restricted_writes ? \"allowlist\" : \"open\");\n const reads = policy ? policy.reads : (info?.limitation?.auth_required ? \"auth\" : \"open\");\n let out = writes === \"owner\" ? g(\"person\", \"only owner writes\") : writes === \"allowlist\" ? g(\"people\", \"members write\") : writes === \"wot\" ? g(\"people\", \"members and follows write\") : g(\"pen\", \"anyone writes\");\n out += reads === \"members\" ? g(\"people\", \"members read\") : reads === \"auth\" ? g(\"lock\", \"sign in to read\") : g(\"eye\", \"anyone reads\");\n if (fuel) {\n const over = fuelOver();\n out += fuel.outOfFuel ? g(\"gauge\", \"out of fuel\", true) : over ? g(\"bolt\", \"burning sats\") : g(\"gauge\", \"on free allowance\");\n }\n $(\"#care\").innerHTML = out;\n }\n\n async function loadPeople() {\n try { people = await (await fetch(\"/people\")).json(); } catch { return; }\n const isOwner = !!me && me === owner;\n let list = people.people || [];\n if (!people.public && isOwner && policy) list = (window.__members || []);\n $(\"#peoplesec\").classList.toggle(\"hidden\", !owner || list.length === 0);\n $(\"#people-note\").classList.toggle(\"hidden\", people.public);\n $(\"#people-count\").textContent = list.length || \"\";\n $(\"#dir\").innerHTML = list.map((m) => '' + av(m.pubkey) + '' + (m.name ? esc(m.name) + \"@\" + host : key(m.pubkey)) + \"\" + (m.role === \"owner\" ? 'owner' : \"\") + \"\").join(\"\");\n }\n\n async function loadInfo() {\n info = await (await fetch(\"/\", { headers: { accept: \"application/nostr+json\" } })).json();\n renderHeader();\n const git = info.supported_grasps?.includes(\"GRASP-01\");\n $(\"#git-connect\").classList.toggle(\"hidden\", !git);\n const clone = \"git clone '\" + location.origin + \"//.git'\";\n $(\"#git-clone\").textContent = clone;\n $(\"#git-copy\").dataset.copytext = clone;\n renderApps();\n }\n\n async function loadFuel() {\n try { fuel = await (await fetch(\"/fuel\")).json(); } catch { return; }\n $(\"#fuelsec\").classList.toggle(\"hidden\", !owner);\n const pct = (used, free) => Math.min(100, Math.round((used / Math.max(free, 1)) * 100));\n const gauge = (id, used, free, text) => { const g = $(\"#g-\" + id), share = free ? used / free : 0; g.style.width = pct(used, free) + \"%\"; g.classList.toggle(\"over\", share > 1); g.classList.toggle(\"warm\", share > 0.75 && share <= 1); $(\"#p-\" + id).textContent = share > 1 ? \"over\" : Math.round(share * 100) + \"%\"; $(\"#t-\" + id).textContent = text; };\n gauge(\"events\", fuel.eventBytes, fuel.freeEventBytes, fmtBytes(fuel.eventBytes) + \" of \" + fmtBytes(fuel.freeEventBytes) + \" free\");\n gauge(\"media\", fuel.mediaBytes, fuel.freeMediaBytes, fmtBytes(fuel.mediaBytes) + \" of \" + fmtBytes(fuel.freeMediaBytes) + \" free\");\n gauge(\"active\", fuel.activeMs, fuel.freeActiveMs, fmtHours(fuel.activeMs) + \" of \" + fmtHours(fuel.freeActiveMs) + \" free\");\n gauge(\"rows\", fuel.rowsWritten, fuel.freeRowsWritten, fuel.rowsWritten.toLocaleString() + \" of \" + fuel.freeRowsWritten.toLocaleString() + \" free\");\n const sats = Math.floor(fuel.balanceMsats / 1000);\n const r = fuel.rates;\n $(\"#fuel-balance\").innerHTML = fuel.outOfFuel\n ? 'out of fuel Writes are paused until someone tops up.'\n : \"Balance \" + sats.toLocaleString() + \" sats. Past the allowances, prices track what the hosting costs: \" + r.satsPerGBMonthEvents.toLocaleString() + \" sats per GB-month of events, \" + r.satsPerGBMonthMedia.toLocaleString() + \" per GB-month of files, \" + r.satsPerActiveHour.toLocaleString() + \" per hour awake, \" + r.satsPerMillionRows.toLocaleString() + \" per million rows written. Traffic is free.\";\n $(\"#topup\").classList.toggle(\"hidden\", !fuel.enabled);\n // Who paid is the owner's to see: it comes with the signed stats call.\n let credits = [];\n if (owner && signer.ready()) { try { credits = (await rpc(\"stats\")).credits || []; } catch { credits = []; } }\n $(\"#credits tbody\").innerHTML = credits.length ? credits.map((c) => '' + esc(fmtTime(c.at)) + '' + av(c.payer) + key(c.payer) + '' + Math.floor(c.msats / 1000).toLocaleString() + \"\").join(\"\") : 'no zaps yet';\n if (!fuel.enabled) $(\"#topup-note\").textContent = \"Top-ups are not enabled on this service yet.\";\n renderCare();\n renderFuelTile();\n }\n\n async function topUp(sats) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const msats = Math.round(sats * 1000);\n const zapRequest = await signer.signEvent({ kind: 9734, created_at: Math.floor(Date.now() / 1000), content: \"fuel for \" + host, tags: [[\"p\", fuel.servicePubkey], [\"amount\", String(msats)], [\"relays\", wsURL]] });\n const r = await (await fetch(\"/fuel/invoice\", { method: \"POST\", body: JSON.stringify({ zapRequest }) })).json();\n if (r.error) throw new Error(r.error);\n const inv = r.invoice;\n $(\"#invoice\").classList.remove(\"hidden\");\n $(\"#inv-text\").value = inv; $(\"#inv-link\").href = \"lightning:\" + inv; $(\"#inv-state\").textContent = \"waiting for payment…\";\n if (window.webln) { try { await window.webln.enable(); await window.webln.sendPayment(inv); } catch (e) { toast(e.message || \"wallet declined\"); } }\n const before = fuel.creditedMsats;\n for (let i = 0; i < 120; i++) {\n await new Promise((r) => setTimeout(r, 2500));\n await loadFuel();\n if (fuel.creditedMsats > before) { $(\"#inv-state\").textContent = \"paid, thank you\"; toast(\"Fuel credited\"); return; }\n }\n $(\"#inv-state\").textContent = \"no receipt yet; it can take a minute after paying\";\n }\n\n // ---- console ----\n function showTab(name) {\n if (name === \"content\" || name === \"storage\") name = \"data\";\n const known = myRole === \"moderator\" ? [\"people\", \"moderation\"] : [\"people\", \"moderation\", \"rules\", \"identity\", \"data\", \"sync\", \"views\", \"health\", \"owner\"];\n if (!known.includes(name)) name = \"people\";\n $$(\".tabs a\").forEach((a) => a.classList.toggle(\"on\", a.dataset.tab === name));\n $$(\".panel\").forEach((p) => p.classList.toggle(\"on\", p.dataset.panel === name));\n }\n // Tabs switch in place. A fragment still opens a tab when someone arrives with one,\n // but clicking never writes one, and any fragment already there is dropped.\n $$(\".tabs a\").forEach((a) => a.addEventListener(\"click\", (ev) => { ev.preventDefault(); showTab(a.dataset.tab); if (location.hash) history.replaceState(null, \"\", location.pathname + location.search); }));\n window.addEventListener(\"hashchange\", () => showTab(location.hash.slice(1)));\n // The strip fades on whichever side has more tabs off screen.\n const tabsFade = () => { const t = $(\"#tabs\"); t.classList.toggle(\"fade-l\", t.scrollLeft > 4); t.classList.toggle(\"fade-r\", t.scrollLeft + t.clientWidth < t.scrollWidth - 4); };\n $(\"#tabs\").addEventListener(\"scroll\", tabsFade); window.addEventListener(\"resize\", tabsFade); new ResizeObserver(tabsFade).observe($(\"#tabs\"));\n\n function renderFuelTile() {\n if (!fuel) return;\n const over = fuelOver();\n const sats = Math.max(0, Math.floor(fuel.balanceMsats / 1000));\n const d = new Date(), end = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1), days = Math.max(1, Math.ceil((end - Date.now()) / 86400000));\n if (fuel.outOfFuel) { $(\"#h-fuel\").textContent = \"0 sats\"; $(\"#h-fuel-label\").textContent = \"out of fuel\"; $(\"#h-fuel-sub\").textContent = \"writes are paused\"; }\n else if (over) { $(\"#h-fuel\").textContent = sats.toLocaleString() + \" sats\"; $(\"#h-fuel-label\").textContent = \"fuel left\"; $(\"#h-fuel-sub\").textContent = \"past the free allowance\"; }\n else { $(\"#h-fuel\").textContent = days + (days === 1 ? \" day\" : \" days\"); $(\"#h-fuel-label\").textContent = \"free allowance left\"; $(\"#h-fuel-sub\").textContent = sats ? \"then \" + sats.toLocaleString() + \" sats\" : \"then a top-up is needed\"; }\n $(\"#usage\").innerHTML = [[\"events stored\", fmtBytes(fuel.eventBytes)], [\"files stored\", fmtBytes(fuel.mediaBytes)], [\"awake this month\", fmtHours(fuel.activeMs)], [\"rows written\", fuel.rowsWritten.toLocaleString()], [\"rows read\", fuel.rowsRead.toLocaleString()], [\"received this month\", fmtBytes(fuel.bytesIn)], [\"served this month\", fmtBytes(fuel.bytesOut)], [\"charged\", Math.floor(fuel.chargedMsats / 1000).toLocaleString() + \" sats\"]]\n .map(([k, v]) => \"
                \" + k + \"\" + v + \"
                \").join(\"\");\n }\n\n function renderHealth(stats) {\n $(\"#h-last\").textContent = stats.newest ? ago(stats.newest) : \"none\";\n $(\"#h-last-sub\").textContent = stats.newest ? fmtTime(stats.newest) : \"no events yet\";\n $(\"#h-conns\").textContent = stats.connections;\n const buckets = [[\"notes\", \"k1\", [1]], [\"reactions\", \"k2\", [7]], [\"DMs\", \"k3\", [4, 14, 1059]], [\"long-form\", \"k4\", [30023]], [\"other\", \"k5\", null]];\n const sums = buckets.map(() => 0); let total = 0;\n for (const { kind, n } of stats.kinds || []) { total += n; const i = buckets.findIndex((b) => b[2] && b[2].includes(kind)); sums[i < 0 ? 4 : i] += n; }\n const parts = buckets.map((b, i) => [b[0], b[1], sums[i]]).filter((p) => p[2] > 0);\n $(\"#h-kinds\").innerHTML = total ? parts.map(([name, cls, n]) => '').join(\"\") : '';\n $(\"#h-kinds-legend\").innerHTML = total ? parts.map(([name, cls, n]) => '' + name + \" \" + Math.round(n * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n renderFuelTile();\n }\n\n async function loadAdmin() {\n const isOwner = !!me && me === owner;\n myRole = isOwner ? \"owner\" : \"\";\n let stats = null, p = null;\n // A signed-in member may be a moderator; the relay says by answering stats.\n if (me && owner && !isOwner) { try { [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]); myRole = \"moderator\"; } catch { myRole = \"\"; } }\n $(\"#console\").classList.toggle(\"hidden\", !myRole);\n loadMine();\n $(\"#console\").classList.toggle(\"mod\", myRole === \"moderator\");\n if (!myRole) { policy = null; renderCare(); return; }\n if (isOwner) [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]);\n policy = p;\n renderHealth(stats);\n renderCare();\n const fa = $(\"#access\"), fi = $(\"#identity\");\n fa.writes.value = p.writes; fa.reads.value = p.reads; fa.openKinds.value = (p.openKinds || []).join(\", \"); fa.guestReplies.checked = !!p.guestReplies;\n $(\"#wordsform\").words.value = (p.blockedWords || []).join(\"\\n\"); $(\"#wordsform\").inTags.checked = !!p.blockedWordsInTags; $(\"#thresholdform\").reportThreshold.value = p.reportThreshold || 0; fa.minPow.value = p.minPow; fa.maxFuture.value = p.maxFuture; fa.maxLimit.value = p.maxLimit; fa.maxSubs.value = p.maxSubs; fa.maxMessageKB.value = p.maxMessageKB;\n fa.eventsPerMinute.value = p.eventsPerMinute; fa.reqsPerMinute.value = p.reqsPerMinute; fa.maxBlobMB.value = p.maxBlobMB;\n renderFeatures(p.features || {});\n $(\"#push-policy-form\").elements.origins.value = (p.pushCallbacks || []).join(\"\\n\");\n $(\"#push-policy-form\").elements.lettered.checked = !!p.letteredNips;\n fi.name.value = p.name; fi.contact.value = p.contact; fi.description.value = p.description; fi.icon.value = p.icon;\n const fj = $(\"#joinform\"); fj.joinTerms.value = p.joinTerms; fj.directoryPublic.checked = !!p.directoryPublic;\n loadCard();\n fi.banner.value = p.banner || \"\"; fi.postingPolicy.value = p.postingPolicy || \"\"; fi.privacyPolicy.value = p.privacyPolicy || \"\";\n fi.tags.value = (p.tags || []).join(\", \"); fi.languageTags.value = (p.languageTags || []).join(\", \"); fi.relayCountries.value = (p.relayCountries || []).join(\", \");\n const fn = $(\"#notify\"), nt = p.notify || {};\n fn.reports.checked = !!nt.reports; fn.fuel.checked = !!nt.fuel; fn.jobs.checked = !!nt.jobs; fn.succession.checked = !!nt.succession; fn.digest.checked = !!nt.digest;\n showTab(location.hash.slice(1));\n if (isOwner) { await renderPresets(); renderWire(); renderSuccession(); loadDomains(); }\n await Promise.all([loadLists(), loadEvents(true), loadPins(), ...(isOwner ? [loadStorage()] : [])]);\n }\n\n async function loadAudit(before) {\n const rows = await rpc(\"listaudit\", before || 0);\n const html = rows.map((r) => '' + esc(fmtTime(r.at)) + '' + av(r.actor) + key(r.actor) + '' + esc(r.action) + '' + (/^[0-9a-f]{64}$/.test(r.target) ? key(r.target) : esc(r.target)) + '' + esc(r.detail) + '').join(\"\");\n const tb = $(\"#audit tbody\");\n if (before) tb.insertAdjacentHTML(\"beforeend\", html); else tb.innerHTML = html || 'Nothing yet';\n $(\"#audit-more\").style.display = rows.length < 100 ? \"none\" : \"\";\n }\n $(\"#audit-more\").onclick = guard(async () => { const last = $(\"#audit tbody tr:last-child\"); await loadAudit(last ? +last.dataset.seq : 0); });\n async function loadLists() {\n loadAudit(0);\n const [mem, bans, bannedEvents, allow, block, invites, reports, blobs, blocks, sites] = await Promise.all([rpc(\"listmembers\"), rpc(\"listbannedpubkeys\"), rpc(\"listbannedevents\"), rpc(\"listallowedkinds\"), rpc(\"listblockedkinds\"), rpc(\"listinvites\"), rpc(\"listreports\"), rpc(\"listblobs\", 100), rpc(\"listblockedips\"), rpc(\"listsites\")]);\n const members = mem.members;\n window.__members = members;\n const roleCell = (m) => m.role === \"owner\" ? ' owner' : myRole === \"owner\" ? ' ' : m.role === \"moderator\" ? ' moderator' : \"\";\n const untouchable = (m) => m.role === \"owner\" || (myRole !== \"owner\" && m.role === \"moderator\");\n // The tree: everyone under whoever invited them, the owner and the owner's own additions at the root.\n const known = new Set(members.map((m) => m.pubkey));\n const byInviter = new Map();\n for (const m of members) { const k = m.role !== \"owner\" && known.has(m.invited_by) ? m.invited_by : \"\"; if (!byInviter.has(k)) byInviter.set(k, []); byInviter.get(k).push(m); }\n const ordered = [], placed = new Set();\n const walk = (k, depth) => { for (const m of byInviter.get(k) || []) { if (placed.has(m.pubkey)) continue; placed.add(m.pubkey); m.depth = depth; ordered.push(m); walk(m.pubkey, depth + 1); } };\n walk(\"\", 0);\n for (const m of members) if (!placed.has(m.pubkey)) { m.depth = 0; ordered.push(m); }\n const nameOf = (pk) => { const x = members.find((m) => m.pubkey === pk); return x && x.name ? x.name : short(pk); };\n const limits = (m) => m.role === \"owner\" ? \"\" : myRole === \"owner\"\n ? ''\n : '' + (m.keep_days ? m.keep_days + \" d\" : \"\") + (m.max_bytes ? \" \" + fmtBytes(m.max_bytes) : \"\") + \"\";\n $(\"#members tbody\").innerHTML = ordered.map((m) => '' + av(m.pubkey) + key(m.pubkey) + roleCell(m) + (m.depth ? ' via ' + esc(nameOf(m.invited_by)) + \"\" : \"\") + (m.invites ? ' ' + m.invites + \" inv\" : \"\") + '' + limits(m) + '' + esc(fmtDay(m.joined_at)) + \", \" + esc(m.via) + '' + ib(\"check\", \"Save\", \"savemember\", m.pubkey) + (untouchable(m) ? \"\" : ib(\"x\", \"Remove\", \"removemember\", m.pubkey) + ib(\"banuser\", \"Ban\", \"banpubkey\", m.pubkey, \"danger\")) + \"\").join(\"\");\n const tf = $(\"#treeform\");\n tf.classList.toggle(\"hidden\", myRole !== \"owner\");\n if (policy && policy.memberInvites) { tf.depth.value = policy.memberInvites.depth; tf.quota.value = policy.memberInvites.quota; }\n $(\"#transfer [name=pubkey]\").innerHTML = members.filter((m) => m.role !== \"owner\").map((m) => '\").join(\"\");\n $(\"#succession [name=heir]\").innerHTML = $(\"#transfer [name=pubkey]\").innerHTML;\n $(\"#tc-people\").textContent = members.length;\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#invites\").innerHTML = invites.length ? invites.map((i) => '
              • inv…' + i.code.slice(-8) + \" \" + esc(i.note || \"\") + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
              • \").join(\"\") : '
              • no invites
              • ';\n const person = (r, icon, label, act) => \"
              • \" + av(r.pubkey) + '' + short(r.pubkey) + \" \" + esc(r.reason || \"\") + \"\" + ib(icon, label, act, r.pubkey) + \"
              • \";\n $(\"#bans\").innerHTML = bans.length ? bans.map((r) => person(r, \"undo\", \"Unban\", \"unrulepubkey\")).join(\"\") : '
              • nobody banned
              • ';\n $(\"#blocks\").innerHTML = blocks.length ? blocks.map((r) => '
              • ip' + esc(r.ip) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"undo\", \"Unblock\", \"unblockip\", r.ip) + \"
              • \").join(\"\") : '
              • no addresses blocked
              • ';\n $(\"#banned-events\").innerHTML = bannedEvents.map((r) => '
              • ev' + key(r.id) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"check\", \"Allow again\", \"allowevent\", r.id) + \"
              • \").join(\"\");\n $(\"#reports tbody\").innerHTML = reports.length ? reports.map((r) => '' + esc(fmtTime(r.at)) + '' + esc(r.type || \"report\") + '' + av(r.target_pubkey) + key(r.target_pubkey) + (r.target_event ? (r.blob ? ' file ' + key(r.target_event) + \"\" : ' ev ' + key(r.target_event) + (r.hidden ? ' hidden' : \"\") + \"\") : \"\") + '' + esc(r.content) + '' + ib(\"check\", \"Dismiss\", \"resolve:dismiss\", r.id) + ib(\"trash\", r.blob ? \"Delete the file\" : \"Delete the event\", \"resolve:delete\", r.id) + ib(\"banuser\", \"Ban the author\", \"resolve:ban\", r.id, \"danger\") + \"\").join(\"\") : 'nothing reported';\n $(\"#reports-count\").textContent = reports.length || \"\"; $(\"#tc-reports\").textContent = reports.length || \"\";\n $(\"#blobs tbody\").innerHTML = blobs.length ? blobs.map((b) => '' + esc(fmtTime(b.uploaded)) + '' + key(b.sha256) + ' ' + esc(b.type) + '' + fmtBytes(b.size) + '' + av(b.uploader) + key(b.uploader) + '' + ib(\"trash\", \"Delete file\", \"deleteblob\", b.sha256, \"danger\") + \"\").join(\"\") : 'no uploads';\n $(\"#blobs-count\").textContent = blobs.length || \"\";\n $(\"#sites tbody\").innerHTML = sites.length ? sites.map((s) => '' + av(s.author) + key(s.author) + '' + esc(s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + '' + esc(s.url) + '' + esc(s.paths) + (s.missing ? ' ' + esc(s.missing) + ' missing' : '') + '' + fmtBytes(s.size) + '' + esc(s.expires_at ? fmtTime(s.expires_at) : \"never\") + '' + ib(\"trash\", \"Delete site\", \"deleteevent\", s.id, \"danger\") + '').join(\"\") : 'no sites yet';\n $(\"#sites-count\").textContent = sites.length || \"\";\n const tag = (k, cls) => '' + k + ib(\"x\", \"Remove rule\", \"unrulekind\", String(k)) + \"\";\n $(\"#kinds-allow\").innerHTML = allow.length ? allow.map((k) => tag(k, \"ok\")).join(\"\") : 'all';\n $(\"#kinds-block\").innerHTML = block.length ? block.map((k) => tag(k, \"blk\")).join(\"\") : 'none';\n await loadPeople();\n }\n\n const KIND_NAMES = { 0: \"profiles\", 1: \"notes\", 3: \"contacts\", 4: \"DMs\", 5: \"deletions\", 6: \"reposts\", 7: \"reactions\", 16: \"reposts\", 1059: \"gift wraps\", 1063: \"file headers\", 1111: \"comments\", 1984: \"reports\", 9734: \"zap requests\", 9735: \"zap receipts\", 9802: \"highlights\", 10002: \"relay lists\", 13534: \"roster\", 9000: \"group adds\", 9001: \"group removals\", 9021: \"join requests\", 9022: \"leave requests\", 39000: \"group info\", 39001: \"group admins\", 39002: \"group members\", 39003: \"group roles\", 30023: \"articles\", 30024: \"drafts\", 30078: \"app data\", 30311: \"live events\", 30818: \"wiki\" };\n const kindName = (k) => KIND_NAMES[k] || (k >= 20000 && k < 30000 ? \"ephemeral\" : k >= 30000 && k < 40000 ? \"addressable\" : k >= 10000 && k < 20000 ? \"replaceable\" : \"kind \" + k);\n const SYS_KINDS = new Set([0, 3, 10002, 9735, 13534, 8000, 8001, 9000, 9001, 39000, 39001, 39002, 39003]);\n let storage = null;\n\n // Features (settings.ts): a select each; search has three modes, the rest on or off.\n const FEATURES = [\n [\"search\", \"Search\", \"NIP-50. Prose indexes notes, threads, comments, highlights, articles and wiki pages; full indexes every public kind with content. A change applies to events from then on.\", [\"prose:prose\", \"full:full\", \"off:off\"]],\n [\"sync\", \"Sync\", \"NIP-77 reconciliation, which reads the whole matching set per sync.\"],\n [\"count\", \"Counts\", \"NIP-45 COUNT, with HLL sketches.\"],\n [\"discovery\", \"Discovery record\", \"NIP-66: the record the relay signs about itself, for crawlers.\"],\n [\"names\", \"Names\", \"NIP-05 addresses under this relay's domain.\"],\n [\"files\", \"Files\", \"Blossom and NIP-96: uploads, downloads and listings.\"],\n [\"pages\", \"Pages and feed\", \"Notes and articles as pages, and the Atom feed.\"],\n [\"sites\", \"Static websites\", \"NIP-5A sites on their own hostnames. Mirroring copies missing files into this relay and costs fuel.\", [\"mirror:on, mirror files\", \"proxy:on, fetch as needed\", \"off:off\"]],\n [\"marmot\", \"Marmot transport\", \"Signed KeyPackages and encrypted group messages, with account admission for ephemeral authors.\"],\n [\"grasp\", \"Git repositories\", \"GRASP Git hosting with admitted repository state. The prototype backend has bounded storage and compute limits.\"],\n [\"push\", \"Relay push\", \"NIP-9a callback delivery for members and the owner. Requires approved callback origins; advertises lettered NIP identifiers.\"],\n [\"signer\", \"Signer traffic\", \"NIP-46 remote signing carried for anyone, never stored.\"],\n ];\n function renderFeatures(f) {\n $(\"#features\").innerHTML = FEATURES.map(([k, title, about, modes]) => {\n const cur = k === \"sites\" ? (f.sites?.enabled === false ? \"off\" : f.sites?.mirror === false ? \"proxy\" : \"mirror\") : modes ? String(f[k] || \"prose\") : String(f[k] !== false);\n const opts = (modes || [\"true:on\", \"false:off\"]).map((m) => { const [v, l] = m.split(\":\"); return '\"; }).join(\"\");\n return \"\";\n }).join(\"\");\n }\n $(\"#features\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-feature]\"); if (!sel) return;\n const k = sel.dataset.feature, v = k === \"search\" || k === \"sites\" ? sel.value : sel.value === \"true\";\n policy = await rpc(\"setpolicy\", { features: { [k]: k === \"sites\" ? { enabled: v !== \"off\", mirror: v === \"mirror\" } : v } });\n toast(k === \"search\" ? \"Search: \" + v : (v ? \"Switched on \" : \"Switched off \") + k); await loadInfo();\n }));\n\n $(\"#push-policy-form\").addEventListener(\"submit\", guard(async (ev) => {\n ev.preventDefault();\n const form = ev.target;\n const origins = form.elements.origins.value.split(/\\s+/).filter(Boolean);\n const updated = await rpc(\"setpolicy\", { pushCallbacks: origins, letteredNips: form.elements.lettered.checked });\n if (JSON.stringify(updated.pushCallbacks) !== JSON.stringify([...new Set(origins.map((s) => s.replace(/\\/$/, \"\")))])) throw new Error(\"Use up to sixteen exact HTTPS origins, with no path or credentials.\");\n policy = updated;\n toast(\"Saved callback policy\"); await loadInfo();\n }));\n\n async function loadViews() {\n let views;\n try { views = await rpc(\"listviews\"); } catch { return; }\n $(\"#views\").innerHTML = views.map((v) => {\n const runs = v.trigger === \"off\" ? \"off\" : v.trigger === \"live\" ? \"live, from memory\" : v.trigger === \"write\" ? \"on write and daily\" : v.trigger;\n const label = (c) => (c === \"off\" ? \"off\" : c === \"write\" ? \"on write\" : c === \"live\" ? \"on\" : c);\n const choices = [...v.choices, v.default].filter((c, i, a) => a.indexOf(c) === i);\n const pick = '\";\n const who = v.audience === \"members\" ? \"members\" + (v.stored ? \"\" : \", on request\") : \"anyone\";\n const last = v.trigger === \"live\" ? \"\" : v.last ? \"last run \" + fmtTime(v.last.at) : \"not run yet\";\n const rows = v.trigger === \"live\" ? \"no rows\" : v.last ? v.last.rows.toLocaleString() + \" rows\" : \"\";\n const meta = [runs, who, last, rows].filter(Boolean).map((x) => \"\" + esc(x) + \"\").join('|');\n return '
                ' + esc(v.name) + '
                ' + esc(v.about) + '
                ' + pick + (v.on ? 'Open' : \"\") + '
                ' + meta + \"
                \";\n }).join(\"\");\n }\n $(\"#views\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-view]\"); if (!sel) return;\n const value = sel.value === \"live\" ? true : sel.value;\n await rpc(\"setpolicy\", { views: { [sel.dataset.view]: value } });\n toast(sel.value === \"off\" ? \"Took down \" + sel.dataset.view : sel.dataset.view + \": \" + (sel.value === \"live\" ? \"on\" : sel.value === \"write\" ? \"on write\" : sel.value)); await loadViews(); await loadInfo();\n }));\n async function loadDumps() {\n const list = await rpc(\"listdumps\");\n const f = $(\"#dumpform\");\n if (policy) { f.dumps.value = policy.dumps || \"off\"; f.keep.value = policy.dumpsKeep || 7; }\n $(\"#dumps-count\").textContent = list.length || \"\";\n $(\"#dumps\").innerHTML = list.length ? list.map((d) => '
              • jsonl' + esc(d.name) + ' ' + d.events.toLocaleString() + \" events, \" + fmtBytes(d.bytes) + \"\" + ib(\"copy\", \"Download\", \"downloaddump\", d.name) + ib(\"trash\", \"Delete\", \"deletedump\", d.name, \"danger\") + \"
              • \").join(\"\") : '
              • no dumps yet
              • ';\n }\n // A dump is fetched with a signed request and handed to the browser as a file.\n async function downloadDump(name) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const url = location.origin + \"/dumps/\" + name;\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"GET\"]] });\n const resp = await fetch(url, { headers: { authorization: \"Nostr \" + btoa(JSON.stringify(ev)) } });\n if (!resp.ok) throw new Error((await resp.json()).error || \"download failed\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(\".\")[0] + \"-\" + name; a.click(); URL.revokeObjectURL(a.href);\n }\n // A plain member's own invites, when the owner lets members invite.\n async function loadMine() {\n const sec = $(\"#myinvites\");\n if (!me || !owner || myRole || !signer.ready()) { sec.classList.add(\"hidden\"); return; }\n let mine;\n try { mine = await rpc(\"listinvites\"); } catch { sec.classList.add(\"hidden\"); return; }\n sec.classList.remove(\"hidden\");\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#mine\").innerHTML = mine.length ? mine.map((i) => '
              • inv…' + i.code.slice(-8) + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
              • \").join(\"\") : '
              • no invites yet
              • ';\n }\n async function loadStorage() {\n storage = await rpc(\"storagestats\");\n pollJobs();\n loadViews();\n loadDumps().catch(() => {});\n const st = storage;\n const top = st.kinds.slice(0, 4), rest = st.kinds.slice(4).reduce((a, k) => a + k.bytes, 0);\n const parts = [...top.map((k, i) => [k.kind + \" \" + kindName(k.kind), \"k\" + (i + 1), k.bytes]), ...(rest ? [[\"other\", \"k5\", rest]] : [])];\n const total = st.eventBytes || 1;\n $(\"#s-bar\").innerHTML = parts.length ? parts.map(([n, c, b]) => '').join(\"\") : '';\n $(\"#s-legend\").innerHTML = parts.length ? parts.map(([n, c, b]) => '' + esc(n) + \" \" + Math.round(b * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n const overhead = Math.max(0, st.databaseBytes - st.eventBytes);\n $(\"#s-totals\").innerHTML = [[\"events\", st.events.toLocaleString() + \" \" + fmtBytes(st.eventBytes) + \"\"], [\"index and overhead\", fmtBytes(overhead)], [\"files\", st.blobs.toLocaleString() + \" \" + fmtBytes(st.mediaBytes) + \"\"]]\n .map(([k, v]) => \"
                \" + k + \"\" + v + \"
                \").join(\"\");\n const any = st.retention.find((r) => r.kind === null);\n const own = (kind) => { const r = st.retention.find((x) => x.kind === kind); return r ? r.days : \"\"; };\n const row = (k) => {\n const key = k.kind === null ? \"\" : String(k.kind);\n const placeholder = k.kind === null ? \"forever\" : k.replaceable || !any ? \"forever\" : any.days + \" (everything else)\";\n const pill = k.kind === null ? \"everything else\" : '' + k.kind + \" \" + esc(kindName(k.kind));\n const cells = k.protected\n ? 'required'\n : ' days' + ib(\"check\", \"Save keep-for rule\", \"saveretention\", key) + ib(\"trash\", \"Purge\", \"purgekind\", key, \"danger\") + \"\";\n return '' + pill + '' + (k.n === undefined ? \"\" : k.n.toLocaleString()) + '' + (k.bytes === undefined ? \"\" : fmtBytes(k.bytes)) + '' + (k.oldest ? esc(fmtDay(k.oldest)) : \"\") + \"\" + cells + \"\";\n };\n $(\"#kinds tbody\").innerHTML = [...st.kinds, { kind: null, days: any ? any.days : 0 }].map(row).join(\"\");\n }\n\n let searchQuery = \"\";\n async function loadEvents(reset) {\n const list = searchQuery ? await rpc(\"searchevents\", searchQuery, 200) : await rpc(\"listrecentevents\", reset ? 50 : 200);\n $(\"#events tbody\").innerHTML = list.length ? list.map((e) => '' + esc(fmtTime(e.created_at)) + '' + e.kind + '' + av(e.pubkey) + key(e.pubkey) + '' + esc(e.content) + '' + ib(\"pin\", \"Pin\", \"pinevent\", e.id) + ib(\"trash\", \"Delete event\", \"deleteevent\", e.id) + ib(\"ban\", \"Ban event\", \"banevent\", e.id, \"danger\") + ib(\"banuser\", \"Ban author\", \"banpubkey\", e.pubkey, \"danger\") + \"\").join(\"\") : 'no events yet';\n $(\"#more\").classList.toggle(\"hidden\", searchQuery || list.length < 50);\n }\n $(\"#searchform\").onsubmit = guard(async (ev) => {\n searchQuery = ev.target.q.value.trim();\n $(\"#searchclear\").classList.toggle(\"hidden\", !searchQuery);\n await loadEvents(true);\n });\n $(\"#searchclear\").onclick = guard(async () => {\n searchQuery = \"\"; $(\"#searchform\").reset(); $(\"#searchclear\").classList.add(\"hidden\");\n await loadEvents(true);\n });\n async function loadPins() {\n const pins = await rpc(\"listpins\");\n $(\"#pins-count\").textContent = pins.length || \"\";\n $(\"#pins\").innerHTML = pins.length ? pins.map((t) => '
              • ' + (t[0] === \"e\" ? \"event\" : \"address\") + '' + esc(t[1].length > 40 ? t[1].slice(0, 16) + \"\\u2026\" + t[1].slice(-8) : t[1]) + \"\" + ib(\"copy\", \"Copy\", \"copy\", t[1]) + ib(\"x\", \"Unpin\", \"unpinevent\", t[1]) + \"
              • \").join(\"\") : '
              • nothing pinned
              • ';\n }\n $(\"#pinform\").onsubmit = guard(async (ev) => {\n await rpc(\"pinevent\", ev.target.id.value.trim()); ev.target.reset(); toast(\"Pinned\"); await loadPins();\n });\n\n // ---- actions ----\n const refresh = () => Promise.all([loadLists(), loadEvents(true), loadStorage(), loadPins()]);\n\n $(\"#copy\").onclick = async () => { await navigator.clipboard.writeText(wsURL); toast(\"copied \" + wsURL); };\n $(\"#signin\").onclick = guard(async () => {\n if (!window.nostr) throw new Error(\"No nostr extension found. Install one (Alby, nos2x, …) and reload, or use a remote signer.\");\n me = await window.nostr.getPublicKey();\n localStorage.setItem(\"me\", me);\n renderHeader(); await loadAdmin(); await loadPeople();\n });\n $(\"#signin46\").onclick = () => showRemote(null);\n $$(\".remote\").forEach((b) => { b.onclick = () => showRemote($(\"#\" + b.dataset.note)); });\n $(\"#nccopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#nclink\").href); toast(\"Link copied; paste it into your signer app\"); };\n $(\"#bunkerform\").onsubmit = guard(async (ev) => {\n $(\"#remotenote\").textContent = \"Connecting…\";\n try { await connectBunker(ev.target.url.value); } catch (e) { $(\"#remotenote\").textContent = e.message; throw e; }\n $(\"#remotenote\").textContent = \"\"; ev.target.reset();\n await remoteDone();\n });\n $(\"#signout\").onclick = async () => {\n if (remote) { const r = remote; remote = null; try { await withTimeout(r.logout(), 5000, \"The signer\"); } catch { /* the session is gone either way */ } }\n localStorage.removeItem(\"nip46\");\n me = null; localStorage.removeItem(\"me\"); renderHeader(); await loadAdmin(); await loadPeople();\n };\n const claimNow = async (note) => {\n if (!signer.ready()) { note.textContent = \"Sign the claim with a nostr extension or a remote signer.\"; showRemote(note); return; }\n me = await signer.getPublicKey();\n const r = await rpc(\"claim\");\n if (!r.claimed) throw new Error(\"Somebody else claimed it first.\");\n localStorage.setItem(\"me\", me);\n toast(\"It's yours.\");\n await loadInfo(); await loadFuel(); await loadAdmin(); await loadPeople();\n if (r.converted && confirm(\"This relay began as a temporary one: anyone can write, and everything is deleted after 14 days. Switch to the default rules and keep everything from now on? Each rule can be changed later on the Rules and Storage tabs.\")) {\n await rpc(\"resetrules\"); toast(\"Rules reset\"); await loadInfo(); await loadAdmin();\n }\n };\n $(\"#claim\").onclick = guard(() => claimNow($(\"#claimnote\")));\n $(\"#claimlease\").onclick = guard(() => claimNow($(\"#leasenote\")));\n // Jobs run in the background; the table follows them while the tab is open.\n let jobsTimer = 0;\n const fmtJob = (j) => {\n const what = j.kind === \"mirror\" ? \"mirror site\" : j.kind === \"import\" ? \"import\" : j.label === \"backfill\" ? \"fetch my history\" : j.kind === \"pull\" ? \"pull\" : \"rebroadcast\";\n const f = [];\n if (j.filter.authors) f.push(j.filter.authors.length === 1 && j.filter.authors[0] === me ? \"my events\" : j.filter.authors.length + \" authors\");\n if (j.filter.kinds) f.push(\"kinds \" + j.filter.kinds.join(\", \"));\n if (j.filter.since) f.push(\"since \" + fmtDay(j.filter.since));\n const when = j.every ? \"every \" + (j.every === 24 ? \"day\" : j.every + \" h\") + (j.nextRun ? \", next \" + fmtTime(j.nextRun) : \"\") : \"once\";\n const l = j.last;\n const count = (stored, blobs, sent, refused) => (j.kind === \"mirror\" ? blobs.toLocaleString() + \" files mirrored\" : j.kind === \"import\" ? stored.toLocaleString() + \" events\" + ((j.last ? j.last.duplicates : j.duplicates) ? \", \" + (j.last ? j.last.duplicates : j.duplicates) + \" already here\" : \"\") : j.kind === \"pull\" ? stored.toLocaleString() + \" events\" + (blobs ? \", \" + blobs + \" files\" : \"\") : sent.toLocaleString() + \" sent\" + (refused ? \", \" + refused + \" refused\" : \"\"));\n const res = j.running ? \"running: \" + count(j.stored, j.blobs, j.sent, j.refused) + \"...\" : !l ? \"waiting\" : l.error ? \"failed: \" + l.error : count(l.stored, l.blobs, l.sent, l.refused) + (l.skipped ? \", \" + l.skipped + \" skipped\" : \"\") + \", \" + fmtTime(l.finishedAt);\n return \"\" + what + \"\" + j.relays.map(esc).join(\"
                \") + \"\" + (f.join(\", \") || \"everything\") + \"\" + when + \"\" + esc(res) + \"\" + (j.running ? \"\" : ib(\"undo\", \"Run now\", \"runjob\", j.id)) + ib(\"x\", \"Remove\", \"removejob\", j.id) + \"\";\n };\n async function pollJobs() {\n clearTimeout(jobsTimer);\n let jobs;\n try { jobs = await rpc(\"listjobs\"); } catch { return; }\n $(\"#jobs tbody\").innerHTML = jobs.length ? jobs.map(fmtJob).join(\"\") : 'no jobs yet';\n if (jobs.some((j) => j.running || (j.nextRun && j.nextRun <= Math.floor(Date.now() / 1000) + 1))) jobsTimer = setTimeout(pollJobs, 3000);\n }\n const urls = (s) => s.split(/[\\s,]+/).map((u) => u.trim()).filter(Boolean);\n const kindsOf = (s) => s.split(/[\\s,]+/).map((k) => parseInt(k, 10)).filter((k) => Number.isInteger(k) && k >= 0);\n $(\"#pullform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"addjob\", { kind: \"pull\", relays: [f.url.value.trim()], every: +f.every.value });\n toast(+f.every.value ? \"Mirror scheduled\" : \"Pull started\"); f.reset(); await pollJobs();\n });\n $(\"#backfillform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"backfill\", urls(f.relays.value));\n toast(\"Fetching your history\"); f.reset(); await pollJobs();\n });\n $(\"#pushform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const filter = {};\n const kinds = kindsOf(f.kinds.value); if (kinds.length) filter.kinds = kinds;\n const days = +f.days.value; if (days > 0) filter.since = Math.floor(Date.now() / 1000) - days * 86400;\n await rpc(\"addjob\", { kind: \"push\", relays: urls(f.targets.value), filter, every: +f.every.value });\n toast(\"Rebroadcast started\"); f.reset(); await pollJobs();\n });\n // ---- presets: writes, reads, kinds and keep-for rules in one click ----\n let presets = null;\n async function renderPresets() {\n if (!presets) { try { presets = await rpc(\"listpresets\"); } catch { presets = []; } }\n $(\"#presets\").innerHTML = presets.map((p) => '\").join(\"\");\n $(\"#presetsourcerow\").classList.toggle(\"hidden\", !presets.some((p) => p.source));\n }\n $(\"#presets\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-preset]\"); if (!b) return;\n const p = (presets || []).find((x) => x.name === b.dataset.preset); if (!p) return;\n if (!confirm(p.title + \": \" + p.about + \"\\n\\nThis replaces the writes and reads rules, the directory setting, the kind rules and the keep-for rules. Limits, identity, people and bans stay.\")) return;\n const source = $(\"#presetsource\").value.trim();\n if (p.source === \"required\" && !source) { $(\"#presetnote\").textContent = p.title + \" needs a source relay to mirror; enter its wss:// URL first.\"; return; }\n b.disabled = true;\n try { policy = await rpc(\"applypreset\", p.name, p.source && source ? { source } : undefined); $(\"#presetnote\").textContent = \"Now: \" + p.about + (policy.job ? \" Mirroring \" + source + \" every \" + policy.job.every + \" h.\" : \"\"); toast(p.title + \" applied\"); await loadInfo(); await loadAdmin(); }\n catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // ---- wire me in: this relay in the owner's own lists ----\n // The lists are replaceable, so a fresh list with only this relay would\n // clobber the real one once it spread. Every publish starts from the\n // newest copy found here or on the indexers, verified when the signer\n // library is around, and adds or removes this relay in it.\n const INDEXERS = [\"wss://purplepag.es\", \"wss://relay.nostr.band\", \"wss://relay.damus.io\", \"wss://nos.lol\"];\n const LISTS = [\n { kind: 10002, tag: \"r\", title: \"Relay list\", nip: \"NIP-65\", about: \"where clients read your notes and send you mentions\" },\n { kind: 10050, tag: \"relay\", title: \"DM inbox\", nip: \"NIP-17\", about: \"where people send you private messages\" },\n { kind: 10007, tag: \"relay\", title: \"Search relays\", nip: \"NIP-51\", about: \"where clients run your searches\" },\n { kind: 10063, tag: \"server\", title: \"Blossom servers\", nip: \"BUD-03\", about: \"where clients upload and look for your files\" },\n ];\n const mineFor = (l) => (l.tag === \"server\" ? location.origin : wsURL);\n const normURL = (u) => { try { const x = new URL(String(u).trim()); return (x.host + x.pathname).replace(/\\/+$/, \"\").toLowerCase(); } catch { return String(u).trim().toLowerCase(); } };\n const isMine = (l, t) => t[0] === l.tag && normURL(t[1] || \"\") === normURL(mineFor(l));\n const listHas = (list, l) => !!list && list.tags.some((t) => isMine(l, t));\n // mergeList is pure: the newest list with this relay put first or taken out, every other tag kept.\n function mergeList(list, l, include) {\n const tags = (list ? list.tags : []).filter((t) => !isMine(l, t));\n if (include) tags.unshift([l.tag, mineFor(l)]);\n return { kind: l.kind, created_at: Math.floor(Date.now() / 1000), content: list ? list.content : \"\", tags };\n }\n const relaysIn = (list) => list.tags.filter((t) => (t[0] === \"r\" || t[0] === \"relay\") && /^wss?:\\/\\//i.test(t[1] || \"\")).map((t) => t[1].trim());\n // overWS opens one socket, sends one message, feeds answers to onMessage until it says done or time runs out.\n function overWS(url, ms, first, onMessage) {\n return new Promise((res) => {\n let ws = null, done = false, out = null;\n const finish = () => { if (done) return; done = true; clearTimeout(timer); try { if (ws) ws.close(); } catch { /* closed */ } res(out); };\n const timer = setTimeout(finish, ms);\n try { ws = new WebSocket(url); } catch { return finish(); }\n ws.onopen = () => ws.send(JSON.stringify(first));\n ws.onmessage = (m) => { let d; try { d = JSON.parse(m.data); } catch { return; } if (!Array.isArray(d)) return; if (onMessage(d, (v) => { out = v; })) finish(); };\n ws.onerror = finish; ws.onclose = finish;\n });\n }\n function fetchNewest(url, filter, ms) {\n let best = null;\n return overWS(url, ms, [\"REQ\", \"w\", filter], (d, set) => {\n if (d[0] === \"EVENT\" && d[1] === \"w\" && d[2] && (!best || d[2].created_at > best.created_at)) { best = d[2]; set(best); }\n return (d[0] === \"EOSE\" || d[0] === \"CLOSED\") && d[1] === \"w\";\n });\n }\n function publishTo(url, event, ms) {\n return overWS(url, ms, [\"EVENT\", event], (d, set) => { if (d[0] === \"OK\" && d[1] === event.id) { set({ url, ok: !!d[2], msg: d[3] || \"\" }); return true; } return false; })\n .then((r) => r || { url, ok: false, msg: \"no answer\" });\n }\n // bridge signs a NIP-98 request to this relay's HTTP door, so the owner's own reads and writes pass any read rule.\n async function bridge(path, body) {\n const url = location.origin + path, raw = JSON.stringify(body);\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"POST\"], [\"payload\", await sha256hex(raw)]] });\n const resp = await fetch(url, { method: \"POST\", headers: { \"content-type\": \"application/json\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body: raw });\n const json = await resp.json();\n if (!resp.ok) throw new Error(json.error || \"HTTP \" + resp.status);\n return json;\n }\n const wire = {};\n function renderWire() {\n const pill = (state) => {\n const cls = state === \"listed\" ? \"on\" : state === \"failed\" ? \"bad\" : state === \"not listed\" ? \"off\" : \"\";\n const label = state === \"checking\" || state === \"publishing\" ? state + \"\\u2026\" : state;\n return '' + esc(label) + \"\";\n };\n $(\"#wire\").innerHTML = LISTS.map((l) => {\n const w = wire[l.kind] || { state: \"\", results: [], list: null };\n const busy = w.state === \"checking\" || w.state === \"publishing\";\n const btn = (act, cls, label) => '\";\n const buttons = w.state === \"listed\" ? btn(\"remove\", \"\", \"Remove this relay\") : btn(\"add\", \"pri\", \"Add this relay\") + btn(\"check\", \"\", w.state ? \"Check again\" : \"Check\");\n const n = w.list ? w.list.tags.filter((t) => t[0] === l.tag).length : 0;\n const from = w.list ? \"\" + (n === 1 ? \"1 entry\" : n + \" entries\") + \", \" + fmtTime(w.list.created_at) + \"\" : w.state && !busy ? \"no list found\" : \"\";\n const sent = (w.results || []).map((r) => '' + esc(r.url.replace(/^wss?:\\/\\//, \"\")) + (r.ok ? \"\" : \" failed\") + \"\").join(\"\");\n const meta = (w.state ? pill(w.state) : \"\") + from + sent;\n return '
                ' + l.title + ' ' + l.nip + \"
                \" + l.about + '
                ' + buttons + '
                ' + meta + \"
                \";\n }).join(\"\");\n }\n async function wireCheck(l) {\n const w = (wire[l.kind] = { state: \"checking\", list: null, results: [] }); renderWire();\n const filter = { kinds: [l.kind], authors: [me], limit: 1 };\n const found = [];\n try { found.push(...(await bridge(\"/query\", [filter]))); } catch { /* the relay may hold none */ }\n const remote = await Promise.all(INDEXERS.map((u) => fetchNewest(u, filter, 4000)));\n let lib = null; try { lib = await signerLib(); } catch { /* unverified lists are still the owner's own, by pubkey */ }\n for (const e of remote) if (e && e.pubkey === me && e.kind === l.kind && Array.isArray(e.tags) && (!lib || lib.verifyEvent(e))) found.push(e);\n w.list = found.sort((a, b) => b.created_at - a.created_at)[0] || null;\n w.state = listHas(w.list, l) ? \"listed\" : \"not listed\"; renderWire();\n }\n async function wirePublish(l, include) {\n if (!wire[l.kind] || !wire[l.kind].state || wire[l.kind].state === \"failed\") await wireCheck(l);\n const w = wire[l.kind]; w.state = \"publishing\"; w.results = []; renderWire();\n try {\n const signed = await signer.signEvent(mergeList(w.list, l, include));\n const here = await bridge(\"/events\", signed).then((r) => ({ url: wsURL, ok: !!r.accepted, msg: r.message || \"\" })).catch((e) => ({ url: wsURL, ok: false, msg: e.message }));\n const targets = [...new Set([...relaysIn(signed), ...INDEXERS])].filter((u) => normURL(u) !== normURL(wsURL));\n const rest = await Promise.all(targets.map((u) => publishTo(u, signed, 6000)));\n w.results = [here, ...rest]; w.list = signed; w.state = include ? \"listed\" : \"not listed\";\n toast((include ? \"Added to your \" : \"Removed from your \") + l.title.toLowerCase());\n } catch (e) { w.state = \"failed\"; w.results = [{ url: wsURL, ok: false, msg: e.message }]; toast(e.message); }\n renderWire();\n }\n $(\"#wire\").addEventListener(\"click\", (ev) => {\n const b = ev.target.closest(\"button[data-wire]\"); if (!b) return;\n const l = LISTS.find((x) => String(x.kind) === b.dataset.wire); if (!l) return;\n if (b.dataset.do === \"check\") wireCheck(l); else wirePublish(l, b.dataset.do === \"add\");\n });\n\n // ---- custom domains: this relay under a hostname the owner controls ----\n let domainSites = [];\n const domainOptions = (current = \"\") => [{ label: \"\", title: \"This relay\" }, ...domainSites.map((s) => ({ label: s.label, title: (s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + \" / \" + key(s.author) })), ...(current && !domainSites.some((s) => s.label === current) ? [{ label: current, title: \"Site \" + current }] : [])].map((s) => '\").join(\"\");\n let domains = null; // null: not enabled on this host\n function renderDomains() {\n $(\"#domains\").innerHTML = (domains || []).map((d) => {\n const state = d.ready ? \"active\" : \"hostname \" + d.status.replace(/_/g, \" \") + \", certificate \" + d.sslStatus.replace(/_/g, \" \");\n const btn = (act, cls, label) => '\";\n const rows = d.ready ? \"\" : '
                ' + d.records.map((r) => \"\").join(\"\") + \"
                TypeNameValue
                \" + esc(r.type) + '' + esc(r.name) + '' + esc(r.value) + \"\" + esc(r.note) + \"
                \";\n return '
                ' + esc(d.host) + '' + esc(state) + \"\" + btn(\"check\", \"\", \"Check\") + btn(\"remove\", \"danger\", \"Remove\") + '
                \" + rows;\n }).join(\"\");\n }\n async function loadDomains() {\n try { [domains, domainSites] = await Promise.all([rpc(\"listdomains\"), rpc(\"listsites\")]); $(\"#adddomain select[name=site]\").innerHTML = domainOptions(); $(\"#domainnote\").textContent = domains.length ? \"\" : \"No custom domain yet.\"; $(\"#adddomain\").classList.remove(\"hidden\"); }\n catch (e) { domains = null; $(\"#domainnote\").textContent = /^unsupported/.test(e.message) ? \"Custom domains are not enabled on this host.\" : e.message; $(\"#adddomain\").classList.add(\"hidden\"); }\n renderDomains();\n }\n $(\"#adddomain\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"adddomain\", f.host.value.trim(), f.site.value); f.reset(); toast(\"Domain added; now create the CNAME\"); await loadDomains();\n });\n $(\"#domains\").addEventListener(\"change\", guard(async (ev) => {\n const select = ev.target.closest(\"select[data-domain-site]\"); if (!select) return;\n await rpc(\"setdomainsite\", select.dataset.domainSite, select.value);\n toast(\"Domain destination saved\"); await loadDomains();\n }));\n $(\"#domains\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-domain]\"); if (!b) return;\n const host = b.dataset.domain;\n if (b.dataset.do === \"remove\" && !confirm(\"Remove \" + host + \"? Its certificate goes with it and the name stops answering.\")) return;\n b.disabled = true;\n try {\n if (b.dataset.do === \"remove\") { await rpc(\"removedomain\", host); toast(\"Removed\"); }\n else { const d = await rpc(\"checkdomain\", host); toast(d.ready ? host + \" is live\" : \"Not yet: \" + (d.status === \"active\" ? \"certificate pending\" : \"waiting for the CNAME\")); }\n await loadDomains();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n $(\"#access\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const openKinds = f.openKinds.value.split(/[\\s,]+/).filter(Boolean).map(Number);\n if (openKinds.some((k) => !Number.isInteger(k) || k < 0 || k > 65535)) throw new Error(\"Open kinds must be whole numbers.\");\n policy = await rpc(\"setpolicy\", { writes: f.writes.value, reads: f.reads.value, openKinds, guestReplies: f.guestReplies.checked, minPow: +f.minPow.value, maxFuture: +f.maxFuture.value, maxLimit: +f.maxLimit.value, maxSubs: +f.maxSubs.value, maxMessageKB: +f.maxMessageKB.value, eventsPerMinute: +f.eventsPerMinute.value, reqsPerMinute: +f.reqsPerMinute.value, maxBlobMB: +f.maxBlobMB.value });\n toast(\"Rules saved\"); await loadInfo();\n });\n $(\"#notify\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { notify: { reports: f.reports.checked, fuel: f.fuel.checked, jobs: f.jobs.checked, succession: f.succession.checked, digest: f.digest.checked } });\n toast(\"Notifications saved\");\n });\n $(\"#notifytest\").onclick = guard(async () => {\n const r = await rpc(\"notifytest\");\n toast(r.sent ? \"Sent. Look for a message from the relay in your DMs.\" : \"Could not send.\");\n });\n $(\"#identity\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const list = (v) => v.split(\",\").map((s) => s.trim()).filter(Boolean);\n policy = await rpc(\"setpolicy\", { name: f.name.value, contact: f.contact.value, description: f.description.value, icon: f.icon.value,\n banner: f.banner.value, postingPolicy: f.postingPolicy.value, privacyPolicy: f.privacyPolicy.value, tags: list(f.tags.value), languageTags: list(f.languageTags.value), relayCountries: list(f.relayCountries.value) });\n toast(\"Identity saved\"); await loadInfo(); await loadPeople(); loadCard();\n });\n $(\"#addmember\").onsubmit = guard(async (ev) => {\n const f = ev.target; const pk = npubToHex(f.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"setmember\", pk, { name: f.name.value.trim() || null, note: f.note.value }); f.reset(); toast(\"Member added\"); await loadLists();\n });\n $(\"#addblock\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"blockip\", f.ip.value.trim(), f.reason.value); f.reset(); toast(\"Blocked\"); await loadLists();\n });\n $(\"#wordsform\").onsubmit = guard(async (ev) => {\n const words = ev.target.words.value.split(\"\\n\").map((s) => s.trim()).filter(Boolean);\n const kept = await rpc(\"setblockedwords\", words);\n policy = await rpc(\"setpolicy\", { blockedWordsInTags: ev.target.inTags.checked });\n ev.target.words.value = kept.join(\"\\n\"); toast(kept.length ? kept.length + \" words blocked\" : \"No words blocked\");\n });\n $(\"#thresholdform\").onsubmit = guard(async (ev) => {\n policy = await rpc(\"setpolicy\", { reportThreshold: +ev.target.reportThreshold.value });\n toast(policy.reportThreshold ? \"Hidden after \" + policy.reportThreshold + \" reports\" : \"Never hidden by reports\"); await loadLists();\n });\n $(\"#addban\").onsubmit = guard(async (ev) => {\n const pk = npubToHex(ev.target.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"banpubkey\", pk, ev.target.reason.value, ev.target.erase.checked); ev.target.reset(); toast(ev.target.erase.checked ? \"Banned and erased\" : \"Banned\"); await loadLists();\n });\n $(\"#mintinvite\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const inv = await rpc(\"createinvite\", +f.ttl.value, +f.max.value, f.note.value);\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n f.note.value = \"\"; toast(\"Invite created and copied\"); await loadLists();\n });\n $(\"#kindform\").onsubmit = guard(async (ev) => {\n const rule = ev.submitter.value; const k = +ev.target.kind.value;\n await rpc(rule === \"allow\" ? \"allowkind\" : \"disallowkind\", k); ev.target.reset(); toast((rule === \"allow\" ? \"Allowed kind \" : \"Blocked kind \") + k); await loadLists();\n });\n $(\"#more\").onclick = guard(() => loadEvents(false));\n $(\"#dumpform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { dumps: f.dumps.value, dumpsKeep: Math.max(1, Math.min(60, Math.floor(+f.keep.value || 7))) });\n toast(policy.dumps === \"off\" ? \"Dumps off\" : \"Dumping \" + policy.dumps); await loadDumps();\n });\n $(\"#importform\").onsubmit = guard(async (ev) => {\n const file = ev.target.file.files[0]; if (!file) return;\n if (!signer.ready()) throw new Error(NO_SIGNER);\n if (file.size > 64 * 1024 * 1024) throw new Error(\"At most 64 MB per import.\");\n const body = await file.text();\n const url = location.origin + \"/import?name=\" + encodeURIComponent(file.name);\n const token = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"PUT\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(url, { method: \"PUT\", headers: { authorization: \"Nostr \" + btoa(JSON.stringify(token)), \"content-type\": \"application/x-ndjson\" }, body });\n const r = await resp.json();\n if (!resp.ok) throw new Error(r.error || \"import failed\");\n ev.target.reset(); toast(\"Importing \" + fmtBytes(r.bytes)); await pollJobs();\n });\n $(\"#dumpnow\").onclick = guard(async () => { const d = await rpc(\"dumpnow\"); toast(\"Dumped \" + d.events.toLocaleString() + \" events\"); await loadStorage(); });\n $(\"#treeform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { memberInvites: { depth: Math.max(0, Math.floor(+f.depth.value || 0)), quota: Math.max(0, Math.floor(+f.quota.value || 0)) } });\n toast(policy.memberInvites.depth ? \"Members may invite\" : \"Only you and moderators invite\");\n });\n $(\"#mintmine\").onclick = guard(async () => {\n const inv = await rpc(\"createinvite\", 259200, 1, \"\");\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n toast(\"Invite created and copied\"); await loadMine();\n });\n $(\"#exportcfg\").onclick = guard(async () => {\n const cfg = await rpc(\"exportconfig\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(new Blob([JSON.stringify(cfg, null, 2)], { type: \"application/json\" }));\n a.download = host.split(\".\")[0] + \".bind.ws.config.json\";\n a.click(); URL.revokeObjectURL(a.href);\n });\n $(\"#importcfg\").onclick = () => $(\"#cfgfile\").click();\n $(\"#cfgfile\").onchange = guard(async (ev) => {\n const file = ev.target.files[0]; if (!file) return;\n const cfg = JSON.parse(await file.text());\n const check = await rpc(\"importconfig\", cfg, { dryRun: true });\n const lines = check.changes.summary.length ? check.changes.summary.join(\"\\n\") : \"Nothing would change.\";\n const dropped = check.warnings.length ? \"\\n\\nNot taken:\\n\" + check.warnings.join(\"\\n\") : \"\";\n if (!confirm(\"Apply \" + file.name + \" to this relay?\\n\\n\" + lines + dropped)) { ev.target.value = \"\"; return; }\n await rpc(\"importconfig\", cfg); ev.target.value = \"\"; toast(\"Configuration imported\"); await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // Succession: the status line under the heir form, from successionstatus.\n async function renderSuccession() {\n const el = $(\"#successionnote\"), f = $(\"#succession\");\n let st;\n try { st = await rpc(\"successionstatus\"); } catch { el.textContent = \"\"; return; }\n const sc = st.succession;\n $(\"#clearsuccession\").classList.toggle(\"hidden\", !sc);\n f.querySelector(\"button.btn:not(#clearsuccession)\").textContent = sc ? \"Change heir\" : \"Set heir\";\n if (!sc) { el.textContent = \"No heir named. Last signed in \" + fmtTime(st.ownerSeenAt) + \".\"; return; }\n if (f.heir.querySelector('[value=\"' + sc.heir + '\"]')) f.heir.value = sc.heir;\n f.afterDays.value = String(sc.afterDays);\n const who = (window.__members || []).find((m) => m.pubkey === sc.heir);\n const heir = who && who.name ? who.name + \"@\" + host : short(sc.heir);\n el.textContent = \"Heir: \" + heir + \". Last signed in \" + fmtTime(st.ownerSeenAt) + (st.silentDays ? \" (\" + st.silentDays + \" days ago)\" : \"\") + \". \" +\n (st.warning ? \"The warning month is running: the relay goes to \" + heir + \" on \" + fmtDay(st.handoverAt) + \" unless you sign in.\" : \"If you stay away, it goes to \" + heir + \" around \" + fmtDay(st.handoverAt) + \".\") +\n (st.log && st.log.length ? \" Handed over before: \" + st.log.map((l) => fmtDay(l.at) + \" to \" + short(l.to)).join(\", \") + \".\" : \"\");\n }\n $(\"#succession\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"setsuccession\", { heir: f.heir.value, afterDays: +f.afterDays.value });\n toast(\"Heir set\"); await loadAdmin();\n });\n $(\"#clearsuccession\").onclick = guard(async () => {\n await rpc(\"clearsuccession\");\n toast(\"Heir cleared\"); await loadAdmin();\n });\n $(\"#transfer\").onsubmit = guard(async (ev) => {\n const pk = ev.target.pubkey.value; if (!pk) return;\n const name = host.split(\".\")[0];\n const typed = prompt(\"This hands \" + host + \" to \" + short(pk) + \" for good. You stay on as a moderator. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing changed.\"); return; }\n await rpc(\"transferowner\", pk);\n toast(\"Transferred\");\n await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // ---- fork: a new name with this relay's events, claim reserved for a key ----\n $(\"#forkform\").scope.onchange = (ev) => $(\"#forkkinds\").classList.toggle(\"hidden\", ev.target.value !== \"kinds\");\n $(\"#joinform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { joinTerms: f.joinTerms.value, directoryPublic: f.directoryPublic.checked });\n toast(\"Saved\"); await loadInfo(); await loadPeople();\n });\n $(\"#forkform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const opts = { people: f.people.checked };\n if (f.name.value.trim()) opts.name = f.name.value.trim().toLowerCase();\n if (f.holder.value.trim()) { const pk = npubToHex(f.holder.value); if (!pk) throw new Error(\"That is not a pubkey.\"); opts.holder = pk; }\n if (f.scope.value === \"mine\") opts.filter = { authors: [me] };\n if (f.scope.value === \"kinds\") { const kinds = f.kinds.value.split(/[\\s,]+/).filter(Boolean).map(Number); if (!kinds.length || kinds.some((k) => !Number.isInteger(k) || k < 0)) throw new Error(\"Give kinds as numbers.\"); opts.filter = { kinds }; }\n if (!confirm(\"Fork this relay into a new name\" + (opts.name ? \" (\" + opts.name + \")\" : \"\") + \"? It pulls \" + (f.scope.value === \"all\" ? \"everything\" : f.scope.value === \"mine\" ? \"your events\" : \"the chosen kinds\") + (opts.people ? \" and the people\" : \"\") + \", and only \" + (opts.holder ? \"that key\" : \"you\") + \" can claim it.\")) return;\n const r = await rpc(\"forkrelay\", opts);\n $(\"#forknote\").textContent = r.handover + \" Expires \" + fmtTime(r.expires_at) + \".\";\n $(\"#forkurl\").textContent = r.console;\n $(\"#forkresult\").classList.remove(\"hidden\");\n toast(\"Forked to \" + r.name);\n });\n $(\"#forkcopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#forkurl\").textContent); toast(\"copied\"); };\n $(\"#deleterelay\").onclick = guard(async () => {\n const name = host.split(\".\")[0];\n const typed = prompt(\"This deletes everything on \" + host + \" and gives the name up. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing was deleted.\"); return; }\n await rpc(\"deleterelay\", name);\n localStorage.removeItem(\"me\");\n location.href = \"/\";\n });\n $(\"#topup\").onsubmit = guard((ev) => topUp(+ev.target.sats.value));\n $(\"#inv-copy\").onclick = async () => { await navigator.clipboard.writeText($(\"#inv-text\").value); toast(\"invoice copied\"); };\n $$(\"button[data-copy]\").forEach((b) => { b.onclick = async () => { await navigator.clipboard.writeText(b.dataset.copy === \"ws\" ? wsURL : location.origin); toast(\"copied\"); }; });\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-act]\"); if (!b) return;\n const act = b.dataset.act, id = b.dataset.id;\n if (act === \"copy\") { await navigator.clipboard.writeText(id); toast(\"copied\"); return; }\n if ((act === \"banpubkey\" || act === \"banevent\" || act === \"resolve:ban\") && !confirm(act === \"banevent\" ? \"Delete this event and refuse it forever?\" : \"Ban this author and refuse everything they post?\")) return;\n const erase = (act === \"banpubkey\" || act === \"resolve:ban\") && confirm(\"Also erase everything they wrote and uploaded here?\");\n if (act === \"deleteblob\" && !confirm(\"Delete this file for good?\")) return;\n if (act === \"deletedump\" && !confirm(\"Delete this dump?\")) return;\n if (act === \"downloaddump\") { b.disabled = true; try { await downloadDump(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }\n if ((act === \"removemember\" || act === \"banpubkey\") && (window.__members || []).some((m) => m.invited_by === id) && confirm(\"Also remove everyone this member invited, and everyone they invited in turn?\")) {\n b.disabled = true;\n try { const r = await rpc(\"removesubtree\", id); if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase); toast(\"Removed \" + r.removed.length); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"removejob\" && !confirm(\"Remove this job?\")) return;\n if (act === \"purgekind\") {\n const kind = id === \"\" ? null : +id;\n const label = kind === null ? \"everything without its own rule\" : kindName(kind) + \" (kind \" + kind + \")\";\n const typed = prompt(\"Purge \" + label + \" older than how many days? 0 purges all of them. This cannot be undone.\", \"30\");\n if (typed === null) return;\n const days = Math.max(0, Math.floor(+typed || 0));\n b.disabled = true;\n try { const r = await rpc(\"purgekind\", kind, days); toast(\"Purged \" + r.deleted.toLocaleString() + \" events\"); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"saveretention\") {\n const kind = id === \"\" ? null : +id;\n const days = Math.max(0, Math.floor(+b.closest(\"tr\").querySelector(\".days\").value || 0));\n b.disabled = true;\n try { await rpc(\"setretention\", kind, days); toast(days ? \"Kept for \" + days + \" days\" : \"Kept forever\"); await loadStorage(); await loadInfo(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n b.disabled = true;\n try {\n if (act === \"savemember\") { const tr = b.closest(\"tr\"), role = tr.querySelector(\"select.role\"), keep = tr.querySelector(\".keep\"), cap = tr.querySelector(\".cap\"); await rpc(\"setmember\", id, { name: tr.querySelector(\".name\").value.trim() || null, note: tr.querySelector(\".note\").value, ...(role ? { role: role.value } : {}), ...(keep ? { keepDays: Math.max(0, Math.floor(+keep.value || 0)), maxBytes: Math.max(0, Math.floor(+cap.value || 0)) * 1024 } : {}) }); }\n else if (act.startsWith(\"resolve:\")) await rpc(\"resolvereport\", id, act.slice(8), erase);\n else if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase);\n else await rpc(act, act === \"unrulekind\" ? +id : id, \"\");\n toast(\"Done\"); await (myRole ? refresh() : loadMine());\n if (act === \"pinevent\" || act === \"unpinevent\") await loadPins();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // The share block: the card picture, the group naddr and its QR.\n let card = null;\n async function loadCard() {\n try {\n card = await (await fetch(\"/card.json\", { cache: \"no-store\" })).json();\n $(\"#cardimg\").src = \"/card.svg?t=\" + Date.now();\n $(\"#naddr\").textContent = card.naddr || \"\";\n const q = $(\"#naddrqr\");\n if (card.naddr) { q.src = \"/qr.svg?text=\" + encodeURIComponent(card.naddr); q.classList.remove(\"hidden\"); } else q.classList.add(\"hidden\");\n } catch { /* the card is decoration */ }\n renderApps();\n }\n // The app rows on the Connect section: one per client people actually use,\n // with the link that lands on this relay in it. Relay apps take the relay\n // or the group address; feed apps take the owner's profile with this relay\n // as the hint, since they have no notion of opening a relay.\n function renderApps() {\n const el = $(\"#apps\"); if (!el) return;\n const enc = encodeURIComponent;\n const nprofile = card && card.nprofile ? card.nprofile : \"\";\n const naddr = card && card.naddr ? card.naddr : \"\";\n const whose = me && owner && me === owner ? \"your\" : \"the owner's\";\n const link = (label, href) => '' + label + \"\";\n const app = (label, uri) => '' + label + \"\";\n const copy = (label, text) => '\";\n const row = (name, where, note, acts) => '
                ' + name + \"\" + where + \"

                \" + note + '

                ' + acts.filter(Boolean).join(\"\") + \"
                \";\n const profileNote = \"Opens \" + whose + \" profile with this relay attached.\";\n const groups = [\n [\"As a place\", \"These open the relay itself: its feed, its people, its group.\", [\n row(\"Jumble\", \"web\", \"A feed of everything on this relay.\", [link(\"Open\", \"https://jumble.social/?r=\" + enc(wsURL))]),\n row(\"Coracle\", \"web\", \"The relay's page: its feed and its people.\", [link(\"Open\", \"https://coracle.social/relays/\" + enc(host))]),\n row(\"Flotilla\", \"web, phone\", \"The relay as a space, with the group as a room.\", [link(\"Open\", \"https://app.flotilla.social/spaces/\" + enc(host)), naddr && app(\"Open group\", \"nostr:\" + naddr)]),\n row(\"0xchat\", \"phone\", \"The group, in a chat app.\", [naddr && app(\"Open group\", \"nostr:\" + naddr), naddr && copy(\"Copy naddr\", naddr)]),\n row(\"noStrudel\", \"web\", \"Relays, add this one, then open its page.\", [copy(\"Copy relay URL\", wsURL)]),\n ]],\n [\"Find me here\", \"Feed apps have no relay pages. They meet this relay through a profile link that names it, then keep it once it is in the relay settings.\", [\n row(\"Primal\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://primal.net/p/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"YakiHonne\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://yakihonne.com/profile/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"Damus\", \"iPhone\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Amethyst\", \"Android\", profileNote + \" Then Relays in the drawer.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Nostur\", \"iPhone, Mac\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n ]],\n ];\n if (info?.supported_grasps?.includes(\"GRASP-01\")) groups.push([\n \"Git repositories\", \"Use this relay with a Git client.\", [\n row(\"GitWorkshop\", \"web\", \"Browse this relay's Git repositories.\", [link(\"Open in app\", \"https://gitworkshop.dev/relay/\" + enc((wsURL.startsWith(\"ws://\") ? \"ws:\" : \"\") + host)), copy(\"Copy relay URL\", wsURL)]),\n ],\n ]);\n el.innerHTML = groups.map(([h, note, rows]) => '

                ' + h + '

                ' + note + '

                ' + rows.join(\"\") + \"
                \").join(\"\");\n $(\"#apps-ws\").textContent = wsURL;\n const tile = (label, text) => '
                ' + label + '\"QR
                ';\n $(\"#phones\").innerHTML = [nprofile && tile(\"Find me here, for a phone\", \"nostr:\" + nprofile), naddr && tile(\"The group, for a phone\", \"nostr:\" + naddr)].filter(Boolean).join(\"\");\n }\n // The folds are a group: opening one closes the rest. Browsers with the\n // details name attribute do this themselves; this covers the others.\n $$(\".folds details\").forEach((d) => d.addEventListener(\"toggle\", () => { if (d.open) $$(\".folds details\").forEach((o) => { if (o !== d && o.open) o.open = false; }); }));\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-copytext]\"); if (!b) return;\n try { await navigator.clipboard.writeText(b.dataset.copytext); toast(\"copied\"); } catch { /* no clipboard */ }\n });\n $(\"#copynaddr\").onclick = async () => { if (!card || !card.naddr) return; await navigator.clipboard.writeText(card.naddr); toast(\"copied naddr\"); };\n $(\"#copyembed\").onclick = async () => { await navigator.clipboard.writeText('\"''); toast(\"copied embed\"); };\n\n // A click on a key copies it; a double-click selects the whole key so the usual copy shortcut takes it too.\n document.addEventListener(\"dblclick\", (ev) => {\n const k = ev.target.closest(\".key\"); if (!k) return;\n const s = window.getSelection(), r = document.createRange(); r.selectNodeContents(k.querySelector(\".full\")); s.removeAllRanges(); s.addRange(r);\n });\n document.addEventListener(\"click\", async (ev) => {\n const k = ev.target.closest(\".key\"); if (!k || ev.detail > 1 || String(window.getSelection())) return;\n try { await navigator.clipboard.writeText(k.title); toast(\"copied \" + k.dataset.short); } catch { /* no clipboard */ }\n });\n // ---- boot ----\n try { await loadInfo(); } catch { renderHeader(); }\n await loadFuel();\n await loadCard();\n me = localStorage.getItem(\"me\");\n if (me && await resumeRemote()) { /* a remote session answers for itself when first used */ }\n else if (me && window.nostr) { try { const pk = await window.nostr.getPublicKey(); if (pk !== me) me = null; } catch { me = null; } }\n else if (me) me = null;\n renderHeader();\n renderApps();\n await loadAdmin();\n await loadPeople();\n})();\n"; +export const CONSOLE_JS = "(async () => {\n const $ = (s) => document.querySelector(s);\n const $$ = (s) => document.querySelectorAll(s);\n const host = location.host;\n const wsURL = (location.protocol === \"https:\" ? \"wss://\" : \"ws://\") + host;\n const rpcURL = location.origin + \"/\";\n let info = null, me = null, owner = \"\", policy = null, fuel = null, people = null, myRole = \"\";\n\n const toast = (msg) => { const t = $(\"#toast\"); t.textContent = msg; t.classList.add(\"show\"); clearTimeout(t._t); t._t = setTimeout(() => t.classList.remove(\"show\"), 2600); };\n // guard wraps a handler: disables the button, shows errors as toasts. Defined first, since handlers below use it.\n const guard = (fn) => async (ev) => { ev.preventDefault(); const b = ev.submitter || ev.target; if (b) b.disabled = true; try { await fn(ev); } catch (e) { toast(e.message); } finally { if (b) b.disabled = false; } };\n const short = (hex) => hex ? hex.slice(0, 8) + \"…\" + hex.slice(-4) : \"\";\n // key shows a key short but keeps the whole of it in the DOM: selecting, double-clicking or clicking it copies the full hex.\n const key = (hex) => hex ? '' + hex + '' : '';\n const hue = (hex) => (parseInt(hex.slice(0, 2), 16) * 360 / 256).toFixed(0) + \"deg\";\n const av = (hex) => '';\n const fmtBytes = (n) => n < 1e6 ? (n / 1e3).toFixed(0) + \" KB\" : n < 1e9 ? (n / 1e6).toFixed(1) + \" MB\" : (n / 1e9).toFixed(2) + \" GB\";\n const fmtHours = (ms) => ms < 3600e3 ? Math.round(ms / 60e3) + \" min\" : (ms / 3600e3).toFixed(ms < 36e6 ? 1 : 0) + \" h\";\n const fuelOver = () => !!fuel && (fuel.eventBytes > fuel.freeEventBytes || fuel.mediaBytes > fuel.freeMediaBytes || fuel.activeMs > fuel.freeActiveMs || fuel.rowsWritten > fuel.freeRowsWritten);\n const fmtTime = (t) => t ? new Date(t * 1000).toLocaleString(undefined, { month: \"short\", day: \"numeric\", hour: \"2-digit\", minute: \"2-digit\" }) : \"–\";\n const fmtDay = (t) => t ? new Date(t * 1000).toLocaleDateString(undefined, { month: \"short\", day: \"numeric\" }) : \"–\";\n const ago = (t) => { const s = Math.max(0, Math.floor(Date.now() / 1000) - t); return s < 60 ? \"just now\" : s < 3600 ? Math.floor(s / 60) + \" min\" : s < 86400 ? Math.floor(s / 3600) + \" h\" : Math.floor(s / 86400) + \" d\"; };\n const esc = (s) => String(s ?? \"\").replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c]);\n\n const IC = {\n x: '',\n undo: '',\n check: '',\n trash: '',\n ban: '',\n banuser: '',\n eye: '',\n lock: '',\n pen: '',\n people: '',\n person: '',\n gauge: '',\n bolt: '',\n copy: '',\n pin: '',\n };\n const ib = (icon, label, act, id, extra) => '\";\n\n const CH = \"qpzry9x8gf2tvdw0s3jn54khce6mua7l\";\n function npubToHex(s) {\n s = s.trim().toLowerCase();\n if (/^[0-9a-f]{64}$/.test(s)) return s;\n if (!s.startsWith(\"npub1\")) return null;\n const data = s.slice(5, -6).split(\"\").map((c) => CH.indexOf(c));\n if (data.some((d) => d < 0)) return null;\n let bits = 0, acc = 0, out = [];\n for (const d of data) { acc = (acc << 5) | d; bits += 5; if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 255); } }\n return out.length === 32 ? out.map((b) => b.toString(16).padStart(2, \"0\")).join(\"\") : null;\n }\n\n async function sha256hex(s) {\n const b = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(s));\n return [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, \"0\")).join(\"\");\n }\n // ---- signing: a NIP-07 extension, or a NIP-46 remote signer ----\n // The remote path loads the bundled library on first use; its session\n // (client key, signer pubkey, relays) lives in localStorage until sign-out.\n let remote = null, lib = null, pendingNote = null;\n const NO_SIGNER = \"Install a nostr extension (Alby, nos2x, …) or connect a remote signer.\";\n const withTimeout = (p, ms, what) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(what + \" did not answer; is the signer app open?\")), ms))]);\n const signer = {\n ready: () => !!remote || !!window.nostr,\n async getPublicKey() { if (remote) return withTimeout(remote.getPublicKey(), 20000, \"The remote signer\"); if (window.nostr) return window.nostr.getPublicKey(); throw new Error(NO_SIGNER); },\n async signEvent(ev) { if (remote) return withTimeout(remote.signEvent(ev), 60000, \"The remote signer\"); if (window.nostr) return window.nostr.signEvent(ev); throw new Error(NO_SIGNER); },\n };\n async function signerLib() {\n if (lib) return lib;\n if (!window.NostrSigner) await new Promise((res, rej) => { const s = document.createElement(\"script\"); s.src = window.SIGNER_URL || \"/signer.js\"; s.onload = res; s.onerror = () => rej(new Error(\"Could not load the signer library.\")); document.head.appendChild(s); });\n return (lib = window.NostrSigner);\n }\n const onauth = (u) => window.open(u, \"_blank\");\n // The signer conversation rides this relay, and a relay may ask the socket to\n // authenticate before it delivers kind 24133 to its parties; answer with the\n // session's own key.\n const authPool = (L, sk) => new L.SimplePool({ automaticallyAuth: () => (evt) => Promise.resolve(L.finalizeEvent(evt, sk)) });\n const saveSession = (sk, s, secret) => localStorage.setItem(\"nip46\", JSON.stringify({ sk: lib.bytesToHex(sk), pubkey: s.bp.pubkey, relays: s.bp.relays, secret: secret || null }));\n async function resumeRemote() {\n const raw = localStorage.getItem(\"nip46\");\n if (!raw) return false;\n try {\n const s = JSON.parse(raw); const L = await signerLib();\n remote = L.BunkerSigner.fromBunker(L.hexToBytes(s.sk), { pubkey: s.pubkey, relays: s.relays, secret: s.secret }, { onauth, pool: authPool(L, L.hexToBytes(s.sk)) });\n return true;\n } catch { localStorage.removeItem(\"nip46\"); return false; }\n }\n async function connectBunker(input) {\n const L = await signerLib();\n const bp = await L.parseBunkerInput(input.trim());\n if (!bp) throw new Error(\"That is not a bunker:// URL.\");\n if (!bp.relays.length) throw new Error(\"The bunker URL names no relay.\");\n const sk = L.generateSecretKey();\n const s = L.BunkerSigner.fromBunker(sk, bp, { onauth, pool: authPool(L, sk) });\n await withTimeout(s.connect({ name: host, url: location.origin }), 60000, \"The signer\");\n await Promise.race([s.switchRelays(), new Promise((r) => setTimeout(r, 3000))]);\n remote = s; saveSession(sk, s, bp.secret);\n }\n // The nostrconnect:// flow: this relay carries the traffic, so no third relay is involved.\n let ncPending = null;\n async function offerNostrConnect() {\n if (ncPending) return ncPending;\n const L = await signerLib();\n const sk = L.generateSecretKey();\n const secret = L.bytesToHex(crypto.getRandomValues(new Uint8Array(8)));\n const uri = L.createNostrConnectURI({ clientPubkey: L.getPublicKey(sk), relays: [wsURL], secret, name: host, url: location.origin, perms: [\"sign_event:27235\", \"sign_event:9734\"] });\n $(\"#nclink\").href = uri; $(\"#ncnote\").textContent = \"\";\n $(\"#ncqr\").src = \"/qr.svg?text=\" + encodeURIComponent(uri); $(\"#ncqr\").classList.remove(\"hidden\");\n ncPending = L.BunkerSigner.fromURI(sk, uri, { onauth, pool: authPool(L, sk) }, 600000).then((s) => { remote = s; saveSession(sk, s, secret); ncPending = null; return s; }, (e) => { ncPending = null; throw e; });\n return ncPending;\n }\n async function remoteDone() {\n me = await signer.getPublicKey();\n localStorage.setItem(\"me\", me);\n $(\"#remotesec\").classList.add(\"hidden\");\n toast(\"Remote signer connected\");\n if (pendingNote && !owner) { const n = pendingNote; pendingNote = null; await claimNow(n); return; }\n pendingNote = null;\n renderHeader(); await loadAdmin(); await loadPeople();\n }\n function showRemote(note) {\n pendingNote = note || null;\n $(\"#remotesec\").classList.remove(\"hidden\");\n $(\"#remotesec\").scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n offerNostrConnect().then(remoteDone).catch((e) => { $(\"#ncnote\").textContent = e.message; });\n }\n\n async function rpc(method, ...params) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const body = JSON.stringify({ method, params });\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", rpcURL], [\"method\", \"POST\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(rpcURL, { method: \"POST\", headers: { \"content-type\": \"application/nostr+json+rpc\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body });\n const json = await resp.json();\n if (json.error) throw new Error(json.error);\n return json.result;\n }\n\n // ---- front of house ----\n // urlish shows a URL as a short link: the host and the start of the path, the whole thing on hover and as the target.\n class Html { constructor(s) { this.s = s; } }\n const urlish = (u) => {\n if (!u) return \"\";\n let label = u; try { const x = new URL(u); label = x.host + (x.pathname.length > 1 ? x.pathname.slice(0, 18) + (x.pathname.length > 18 ? \"\\u2026\" : \"\") : \"\"); } catch { /* not a URL */ }\n if (!/^https?:\\/\\//.test(u)) return u;\n return new Html('' + esc(label) + \"\");\n };\n function renderHeader() {\n const name = (info && info.name) || host.split(\".\")[0];\n $(\"#title\").textContent = name;\n document.title = name + \" - relay\";\n $(\"#url\").textContent = wsURL;\n $(\"#d-ws\").textContent = wsURL; $(\"#d-blossom\").textContent = location.origin; $(\"#d-nip05\").textContent = \"you@\" + host;\n $(\"#nip05-example\").textContent = \"alice@\" + host;\n const desc = (info && info.description) || \"\";\n $(\"#desc\").textContent = desc; $(\"#desc\").classList.toggle(\"hidden\", !desc);\n // What the owner declared about the relay, on the page, not only in the document clients read.\n const img = (id, url) => { const el = $(id); if (url) el.src = url; el.classList.toggle(\"hidden\", !url); };\n img(\"#bannerimg\", info?.banner); $(\"#banner\").classList.toggle(\"hidden\", !info?.banner);\n img(\"#iconimg\", info?.icon);\n const meta = [];\n for (const t of info?.tags || []) meta.push('' + esc(t) + \"\");\n const where = [...(info?.language_tags || []), ...(info?.relay_countries || [])];\n if (where.length) meta.push(\"\" + esc(where.join(\", \")) + \"\");\n const link = (label, href) => href ? '' + label + \"\" : \"\";\n const contact = info?.contact ? (/^(mailto:|https?:\\/\\/)/.test(info.contact) ? link(\"Contact\", info.contact) : \"\" + esc(info.contact) + \"\") : \"\";\n for (const x of [contact, link(\"Terms\", info?.terms_of_service), link(\"Posting policy\", info?.posting_policy), link(\"Privacy\", info?.privacy_policy)]) if (x) meta.push(x);\n $(\"#metarow\").innerHTML = meta.join('|'); $(\"#metarow\").classList.toggle(\"hidden\", meta.length === 0);\n $(\"#frontsec\").classList.toggle(\"hidden\", !desc && meta.length === 0);\n owner = (info && info.pubkey) || \"\";\n const isOwner = !!me && me === owner;\n const lease = !owner && info && info.lease ? info.lease : null;\n $(\"#ownerline\").innerHTML = owner ? 'run by ' + key(owner) + \"\" : lease ? \"temporary until \" + fmtDay(lease.expires_at) : \"unclaimed\";\n $(\"#owner-av\").classList.toggle(\"hidden\", !owner);\n if (owner) $(\"#owner-av\").style.setProperty(\"--h\", hue(owner));\n $(\"#who\").textContent = me ? (isOwner ? \"(that's you)\" : \"\") : \"\";\n $(\"#unclaimed\").classList.toggle(\"hidden\", !!owner || !!lease);\n $(\"#leased\").classList.toggle(\"hidden\", !lease);\n if (lease) {\n $(\"#lease-until\").textContent = fmtTime(lease.expires_at);\n $(\"#leasenote\").textContent = lease.holder ? \"Reserved for the key that asked for it; sign with that key.\" : \"\";\n }\n $(\"#signin\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signin46\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signout\").classList.toggle(\"hidden\", !me);\n $(\"#about\").innerHTML = [\n [\"Name\", info?.name], [\"Description\", info?.description], [\"Contact\", info?.contact], [\"Owner\", owner ? short(owner) : \"\"], [\"Relay key\", info?.self ? short(info.self) : \"\"], [\"Software\", urlish(info?.software)], [\"Version\", info?.version],\n [\"Max query\", info?.limitation?.max_limit], [\"Auth required\", String(!!info?.limitation?.auth_required)], [\"Restricted writes\", String(!!info?.limitation?.restricted_writes)], [\"Min PoW\", info?.limitation?.min_pow_difficulty || 0],\n [\"Tags\", info?.tags?.join(\", \")], [\"Languages\", info?.language_tags?.join(\", \")], [\"Countries\", info?.relay_countries?.join(\", \")],\n [\"Terms\", urlish(info?.terms_of_service)], [\"Posting policy\", urlish(info?.posting_policy)], [\"Privacy policy\", urlish(info?.privacy_policy)], [\"Icon\", urlish(info?.icon)], [\"Banner\", urlish(info?.banner)],\n [\"NIPs\", info?.supported_nips?.join(\" \")],\n ].map(([k, v]) => \"
                \" + k + \"\" + (v === undefined || v === \"\" ? '' : v instanceof Html ? v.s : esc(v)) + \"
                \").join(\"\");\n renderCare();\n }\n\n function renderCare() {\n const g = (icon, label, off) => '' + IC[icon] + \"\" + label + \"\";\n const writes = policy ? policy.writes : (info?.limitation?.restricted_writes ? \"allowlist\" : \"open\");\n const reads = policy ? policy.reads : (info?.limitation?.auth_required ? \"auth\" : \"open\");\n let out = writes === \"owner\" ? g(\"person\", \"only owner writes\") : writes === \"allowlist\" ? g(\"people\", \"members write\") : writes === \"wot\" ? g(\"people\", \"members and follows write\") : g(\"pen\", \"anyone writes\");\n out += reads === \"members\" ? g(\"people\", \"members read\") : reads === \"auth\" ? g(\"lock\", \"sign in to read\") : g(\"eye\", \"anyone reads\");\n if (fuel) {\n const over = fuelOver();\n out += fuel.outOfFuel ? g(\"gauge\", \"out of fuel\", true) : over ? g(\"bolt\", \"burning sats\") : g(\"gauge\", \"on free allowance\");\n }\n $(\"#care\").innerHTML = out;\n }\n\n async function loadPeople() {\n try { people = await (await fetch(\"/people\")).json(); } catch { return; }\n const isOwner = !!me && me === owner;\n let list = people.people || [];\n if (!people.public && isOwner && policy) list = (window.__members || []);\n $(\"#peoplesec\").classList.toggle(\"hidden\", !owner || list.length === 0);\n $(\"#people-note\").classList.toggle(\"hidden\", people.public);\n $(\"#people-count\").textContent = list.length || \"\";\n $(\"#dir\").innerHTML = list.map((m) => '' + av(m.pubkey) + '' + (m.name ? esc(m.name) + \"@\" + host : key(m.pubkey)) + \"\" + (m.role === \"owner\" ? 'owner' : \"\") + \"\").join(\"\");\n }\n\n async function loadInfo() {\n info = await (await fetch(\"/\", { headers: { accept: \"application/nostr+json\" } })).json();\n renderHeader();\n const git = info.supported_grasps?.includes(\"GRASP-01\");\n $(\"#git-connect\").classList.toggle(\"hidden\", !git);\n const clone = \"git clone '\" + location.origin + \"//.git'\";\n $(\"#git-clone\").textContent = clone;\n $(\"#git-copy\").dataset.copytext = clone;\n renderApps();\n }\n\n async function loadFuel() {\n try { fuel = await (await fetch(\"/fuel\")).json(); } catch { return; }\n $(\"#fuelsec\").classList.toggle(\"hidden\", !owner);\n const pct = (used, free) => Math.min(100, Math.round((used / Math.max(free, 1)) * 100));\n const gauge = (id, used, free, text) => { const g = $(\"#g-\" + id), share = free ? used / free : 0; g.style.width = pct(used, free) + \"%\"; g.classList.toggle(\"over\", share > 1); g.classList.toggle(\"warm\", share > 0.75 && share <= 1); $(\"#p-\" + id).textContent = share > 1 ? \"over\" : Math.round(share * 100) + \"%\"; $(\"#t-\" + id).textContent = text; };\n gauge(\"events\", fuel.eventBytes, fuel.freeEventBytes, fmtBytes(fuel.eventBytes) + \" of \" + fmtBytes(fuel.freeEventBytes) + \" free\");\n gauge(\"media\", fuel.mediaBytes, fuel.freeMediaBytes, fmtBytes(fuel.mediaBytes) + \" of \" + fmtBytes(fuel.freeMediaBytes) + \" free\");\n gauge(\"active\", fuel.activeMs, fuel.freeActiveMs, fmtHours(fuel.activeMs) + \" of \" + fmtHours(fuel.freeActiveMs) + \" free\");\n gauge(\"rows\", fuel.rowsWritten, fuel.freeRowsWritten, fuel.rowsWritten.toLocaleString() + \" of \" + fuel.freeRowsWritten.toLocaleString() + \" free\");\n const sats = Math.floor(fuel.balanceMsats / 1000);\n const r = fuel.rates;\n $(\"#fuel-balance\").innerHTML = fuel.outOfFuel\n ? 'out of fuel Writes are paused until someone tops up.'\n : \"Balance \" + sats.toLocaleString() + \" sats. Past the allowances, prices track what the hosting costs: \" + r.satsPerGBMonthEvents.toLocaleString() + \" sats per GB-month of events, \" + r.satsPerGBMonthMedia.toLocaleString() + \" per GB-month of files, \" + r.satsPerActiveHour.toLocaleString() + \" per hour awake, \" + r.satsPerMillionRows.toLocaleString() + \" per million rows written. Traffic is free.\";\n $(\"#topup\").classList.toggle(\"hidden\", !fuel.enabled);\n // Who paid is the owner's to see: it comes with the signed stats call.\n let credits = [];\n if (owner && signer.ready()) { try { credits = (await rpc(\"stats\")).credits || []; } catch { credits = []; } }\n $(\"#credits tbody\").innerHTML = credits.length ? credits.map((c) => '' + esc(fmtTime(c.at)) + '' + av(c.payer) + key(c.payer) + '' + Math.floor(c.msats / 1000).toLocaleString() + \"\").join(\"\") : 'no zaps yet';\n if (!fuel.enabled) $(\"#topup-note\").textContent = \"Top-ups are not enabled on this service yet.\";\n renderCare();\n renderFuelTile();\n }\n\n async function topUp(sats) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const msats = Math.round(sats * 1000);\n const zapRequest = await signer.signEvent({ kind: 9734, created_at: Math.floor(Date.now() / 1000), content: \"fuel for \" + host, tags: [[\"p\", fuel.servicePubkey], [\"amount\", String(msats)], [\"relays\", wsURL]] });\n const r = await (await fetch(\"/fuel/invoice\", { method: \"POST\", body: JSON.stringify({ zapRequest }) })).json();\n if (r.error) throw new Error(r.error);\n const inv = r.invoice;\n $(\"#invoice\").classList.remove(\"hidden\");\n $(\"#inv-text\").value = inv; $(\"#inv-link\").href = \"lightning:\" + inv; $(\"#inv-state\").textContent = \"waiting for payment…\";\n if (window.webln) { try { await window.webln.enable(); await window.webln.sendPayment(inv); } catch (e) { toast(e.message || \"wallet declined\"); } }\n const before = fuel.creditedMsats;\n for (let i = 0; i < 120; i++) {\n await new Promise((r) => setTimeout(r, 2500));\n await loadFuel();\n if (fuel.creditedMsats > before) { $(\"#inv-state\").textContent = \"paid, thank you\"; toast(\"Fuel credited\"); return; }\n }\n $(\"#inv-state\").textContent = \"no receipt yet; it can take a minute after paying\";\n }\n\n // ---- console ----\n function showTab(name) {\n if (name === \"content\" || name === \"storage\") name = \"data\";\n const known = myRole === \"moderator\" ? [\"people\", \"moderation\"] : [\"people\", \"moderation\", \"rules\", \"identity\", \"data\", \"sync\", \"views\", \"health\", \"owner\"];\n if (!known.includes(name)) name = \"people\";\n $$(\".tabs a\").forEach((a) => a.classList.toggle(\"on\", a.dataset.tab === name));\n $$(\".panel\").forEach((p) => p.classList.toggle(\"on\", p.dataset.panel === name));\n }\n // Tabs switch in place. A fragment still opens a tab when someone arrives with one,\n // but clicking never writes one, and any fragment already there is dropped.\n $$(\".tabs a\").forEach((a) => a.addEventListener(\"click\", (ev) => { ev.preventDefault(); showTab(a.dataset.tab); if (location.hash) history.replaceState(null, \"\", location.pathname + location.search); }));\n window.addEventListener(\"hashchange\", () => showTab(location.hash.slice(1)));\n // The strip fades on whichever side has more tabs off screen.\n const tabsFade = () => { const t = $(\"#tabs\"); t.classList.toggle(\"fade-l\", t.scrollLeft > 4); t.classList.toggle(\"fade-r\", t.scrollLeft + t.clientWidth < t.scrollWidth - 4); };\n $(\"#tabs\").addEventListener(\"scroll\", tabsFade); window.addEventListener(\"resize\", tabsFade); new ResizeObserver(tabsFade).observe($(\"#tabs\"));\n\n function renderFuelTile() {\n if (!fuel) return;\n const over = fuelOver();\n const sats = Math.max(0, Math.floor(fuel.balanceMsats / 1000));\n const d = new Date(), end = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1), days = Math.max(1, Math.ceil((end - Date.now()) / 86400000));\n if (fuel.outOfFuel) { $(\"#h-fuel\").textContent = \"0 sats\"; $(\"#h-fuel-label\").textContent = \"out of fuel\"; $(\"#h-fuel-sub\").textContent = \"writes are paused\"; }\n else if (over) { $(\"#h-fuel\").textContent = sats.toLocaleString() + \" sats\"; $(\"#h-fuel-label\").textContent = \"fuel left\"; $(\"#h-fuel-sub\").textContent = \"past the free allowance\"; }\n else { $(\"#h-fuel\").textContent = days + (days === 1 ? \" day\" : \" days\"); $(\"#h-fuel-label\").textContent = \"free allowance left\"; $(\"#h-fuel-sub\").textContent = sats ? \"then \" + sats.toLocaleString() + \" sats\" : \"then a top-up is needed\"; }\n $(\"#usage\").innerHTML = [[\"events stored\", fmtBytes(fuel.eventBytes)], [\"files stored\", fmtBytes(fuel.mediaBytes)], [\"awake this month\", fmtHours(fuel.activeMs)], [\"rows written\", fuel.rowsWritten.toLocaleString()], [\"rows read\", fuel.rowsRead.toLocaleString()], [\"received this month\", fmtBytes(fuel.bytesIn)], [\"served this month\", fmtBytes(fuel.bytesOut)], [\"charged\", Math.floor(fuel.chargedMsats / 1000).toLocaleString() + \" sats\"]]\n .map(([k, v]) => \"
                \" + k + \"\" + v + \"
                \").join(\"\");\n }\n\n function renderHealth(stats) {\n $(\"#h-last\").textContent = stats.newest ? ago(stats.newest) : \"none\";\n $(\"#h-last-sub\").textContent = stats.newest ? fmtTime(stats.newest) : \"no events yet\";\n $(\"#h-conns\").textContent = stats.connections;\n const buckets = [[\"notes\", \"k1\", [1]], [\"reactions\", \"k2\", [7]], [\"DMs\", \"k3\", [4, 14, 1059]], [\"long-form\", \"k4\", [30023]], [\"other\", \"k5\", null]];\n const sums = buckets.map(() => 0); let total = 0;\n for (const { kind, n } of stats.kinds || []) { total += n; const i = buckets.findIndex((b) => b[2] && b[2].includes(kind)); sums[i < 0 ? 4 : i] += n; }\n const parts = buckets.map((b, i) => [b[0], b[1], sums[i]]).filter((p) => p[2] > 0);\n $(\"#h-kinds\").innerHTML = total ? parts.map(([name, cls, n]) => '').join(\"\") : '';\n $(\"#h-kinds-legend\").innerHTML = total ? parts.map(([name, cls, n]) => '' + name + \" \" + Math.round(n * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n renderFuelTile();\n }\n\n async function loadAdmin() {\n const isOwner = !!me && me === owner;\n myRole = isOwner ? \"owner\" : \"\";\n let stats = null, p = null;\n // A signed-in member may be a moderator; the relay says by answering stats.\n if (me && owner && !isOwner) { try { [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]); myRole = \"moderator\"; } catch { myRole = \"\"; } }\n $(\"#console\").classList.toggle(\"hidden\", !myRole);\n loadMine();\n $(\"#console\").classList.toggle(\"mod\", myRole === \"moderator\");\n if (!myRole) { policy = null; renderCare(); return; }\n if (isOwner) [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]);\n policy = p;\n renderHealth(stats);\n renderCare();\n const fa = $(\"#access\"), fi = $(\"#identity\");\n fa.writes.value = p.writes; fa.reads.value = p.reads; fa.openKinds.value = (p.openKinds || []).join(\", \"); fa.guestReplies.checked = !!p.guestReplies;\n $(\"#wordsform\").words.value = (p.blockedWords || []).join(\"\\n\"); $(\"#wordsform\").inTags.checked = !!p.blockedWordsInTags; $(\"#thresholdform\").reportThreshold.value = p.reportThreshold || 0; fa.minPow.value = p.minPow; fa.maxFuture.value = p.maxFuture; fa.maxLimit.value = p.maxLimit; fa.maxSubs.value = p.maxSubs; fa.maxMessageKB.value = p.maxMessageKB;\n fa.eventsPerMinute.value = p.eventsPerMinute; fa.reqsPerMinute.value = p.reqsPerMinute; fa.maxBlobMB.value = p.maxBlobMB;\n renderFeatures(p.features || {});\n $(\"#push-policy-form\").elements.origins.value = (p.pushCallbacks || []).join(\"\\n\");\n $(\"#push-policy-form\").elements.lettered.checked = !!p.letteredNips;\n fi.name.value = p.name; fi.contact.value = p.contact; fi.description.value = p.description; fi.icon.value = p.icon;\n const fj = $(\"#joinform\"); fj.joinTerms.value = p.joinTerms; fj.directoryPublic.checked = !!p.directoryPublic;\n loadCard();\n fi.banner.value = p.banner || \"\"; fi.postingPolicy.value = p.postingPolicy || \"\"; fi.privacyPolicy.value = p.privacyPolicy || \"\";\n fi.tags.value = (p.tags || []).join(\", \"); fi.languageTags.value = (p.languageTags || []).join(\", \"); fi.relayCountries.value = (p.relayCountries || []).join(\", \");\n const fn = $(\"#notify\"), nt = p.notify || {};\n fn.reports.checked = !!nt.reports; fn.fuel.checked = !!nt.fuel; fn.jobs.checked = !!nt.jobs; fn.succession.checked = !!nt.succession; fn.digest.checked = !!nt.digest;\n showTab(location.hash.slice(1));\n if (isOwner) { await renderPresets(); renderWire(); renderSuccession(); loadDomains(); }\n await Promise.all([loadLists(), loadEvents(true), loadPins(), ...(isOwner ? [loadStorage()] : [])]);\n }\n\n async function loadAudit(before) {\n const rows = await rpc(\"listaudit\", before || 0);\n const html = rows.map((r) => '' + esc(fmtTime(r.at)) + '' + av(r.actor) + key(r.actor) + '' + esc(r.action) + '' + (/^[0-9a-f]{64}$/.test(r.target) ? key(r.target) : esc(r.target)) + '' + esc(r.detail) + '').join(\"\");\n const tb = $(\"#audit tbody\");\n if (before) tb.insertAdjacentHTML(\"beforeend\", html); else tb.innerHTML = html || 'Nothing yet';\n $(\"#audit-more\").style.display = rows.length < 100 ? \"none\" : \"\";\n }\n $(\"#audit-more\").onclick = guard(async () => { const last = $(\"#audit tbody tr:last-child\"); await loadAudit(last ? +last.dataset.seq : 0); });\n async function loadLists() {\n loadAudit(0);\n const [mem, bans, bannedEvents, allow, block, invites, reports, blobs, blocks, sites] = await Promise.all([rpc(\"listmembers\"), rpc(\"listbannedpubkeys\"), rpc(\"listbannedevents\"), rpc(\"listallowedkinds\"), rpc(\"listblockedkinds\"), rpc(\"listinvites\"), rpc(\"listreports\"), rpc(\"listblobs\", 100), rpc(\"listblockedips\"), rpc(\"listsites\")]);\n const members = mem.members;\n window.__members = members;\n const roleCell = (m) => m.role === \"owner\" ? ' owner' : myRole === \"owner\" ? ' ' : m.role === \"moderator\" ? ' moderator' : \"\";\n const untouchable = (m) => m.role === \"owner\" || (myRole !== \"owner\" && m.role === \"moderator\");\n // The tree: everyone under whoever invited them, the owner and the owner's own additions at the root.\n const known = new Set(members.map((m) => m.pubkey));\n const byInviter = new Map();\n for (const m of members) { const k = m.role !== \"owner\" && known.has(m.invited_by) ? m.invited_by : \"\"; if (!byInviter.has(k)) byInviter.set(k, []); byInviter.get(k).push(m); }\n const ordered = [], placed = new Set();\n const walk = (k, depth) => { for (const m of byInviter.get(k) || []) { if (placed.has(m.pubkey)) continue; placed.add(m.pubkey); m.depth = depth; ordered.push(m); walk(m.pubkey, depth + 1); } };\n walk(\"\", 0);\n for (const m of members) if (!placed.has(m.pubkey)) { m.depth = 0; ordered.push(m); }\n const nameOf = (pk) => { const x = members.find((m) => m.pubkey === pk); return x && x.name ? x.name : short(pk); };\n const limits = (m) => m.role === \"owner\" ? \"\" : myRole === \"owner\"\n ? ''\n : '' + (m.keep_days ? m.keep_days + \" d\" : \"\") + (m.max_bytes ? \" \" + fmtBytes(m.max_bytes) : \"\") + \"\";\n $(\"#members tbody\").innerHTML = ordered.map((m) => '' + av(m.pubkey) + key(m.pubkey) + roleCell(m) + (m.depth ? ' via ' + esc(nameOf(m.invited_by)) + \"\" : \"\") + (m.invites ? ' ' + m.invites + \" inv\" : \"\") + '' + limits(m) + '' + esc(fmtDay(m.joined_at)) + \", \" + esc(m.via) + '' + ib(\"check\", \"Save\", \"savemember\", m.pubkey) + (untouchable(m) ? \"\" : ib(\"x\", \"Remove\", \"removemember\", m.pubkey) + ib(\"banuser\", \"Ban\", \"banpubkey\", m.pubkey, \"danger\")) + \"\").join(\"\");\n const tf = $(\"#treeform\");\n tf.classList.toggle(\"hidden\", myRole !== \"owner\");\n if (policy && policy.memberInvites) { tf.depth.value = policy.memberInvites.depth; tf.quota.value = policy.memberInvites.quota; }\n $(\"#transfer [name=pubkey]\").innerHTML = members.filter((m) => m.role !== \"owner\").map((m) => '\").join(\"\");\n $(\"#succession [name=heir]\").innerHTML = $(\"#transfer [name=pubkey]\").innerHTML;\n $(\"#tc-people\").textContent = members.length;\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#invites\").innerHTML = invites.length ? invites.map((i) => '
              • inv…' + i.code.slice(-8) + \" \" + esc(i.note || \"\") + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
              • \").join(\"\") : '
              • no invites
              • ';\n const person = (r, icon, label, act) => \"
              • \" + av(r.pubkey) + '' + short(r.pubkey) + \" \" + esc(r.reason || \"\") + \"\" + ib(icon, label, act, r.pubkey) + \"
              • \";\n $(\"#bans\").innerHTML = bans.length ? bans.map((r) => person(r, \"undo\", \"Unban\", \"unrulepubkey\")).join(\"\") : '
              • nobody banned
              • ';\n $(\"#blocks\").innerHTML = blocks.length ? blocks.map((r) => '
              • ip' + esc(r.ip) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"undo\", \"Unblock\", \"unblockip\", r.ip) + \"
              • \").join(\"\") : '
              • no addresses blocked
              • ';\n $(\"#banned-events\").innerHTML = bannedEvents.map((r) => '
              • ev' + key(r.id) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"check\", \"Allow again\", \"allowevent\", r.id) + \"
              • \").join(\"\");\n $(\"#reports tbody\").innerHTML = reports.length ? reports.map((r) => '' + esc(fmtTime(r.at)) + '' + esc(r.type || \"report\") + '' + av(r.target_pubkey) + key(r.target_pubkey) + (r.target_event ? (r.blob ? ' file ' + key(r.target_event) + \"\" : ' ev ' + key(r.target_event) + (r.hidden ? ' hidden' : \"\") + \"\") : \"\") + '' + esc(r.content) + '' + ib(\"check\", \"Dismiss\", \"resolve:dismiss\", r.id) + ib(\"trash\", r.blob ? \"Delete the file\" : \"Delete the event\", \"resolve:delete\", r.id) + ib(\"banuser\", \"Ban the author\", \"resolve:ban\", r.id, \"danger\") + \"\").join(\"\") : 'nothing reported';\n $(\"#reports-count\").textContent = reports.length || \"\"; $(\"#tc-reports\").textContent = reports.length || \"\";\n $(\"#blobs tbody\").innerHTML = blobs.length ? blobs.map((b) => '' + esc(fmtTime(b.uploaded)) + '' + key(b.sha256) + ' ' + esc(b.type) + '' + fmtBytes(b.size) + '' + av(b.uploader) + key(b.uploader) + '' + ib(\"trash\", \"Delete file\", \"deleteblob\", b.sha256, \"danger\") + \"\").join(\"\") : 'no uploads';\n $(\"#blobs-count\").textContent = blobs.length || \"\";\n $(\"#sites tbody\").innerHTML = sites.length ? sites.map((s) => '' + av(s.author) + key(s.author) + '' + esc(s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + '' + esc(s.url) + '' + esc(s.paths) + (s.missing ? ' ' + esc(s.missing) + ' missing' : '') + '' + fmtBytes(s.size) + '' + esc(s.expires_at ? fmtTime(s.expires_at) : \"never\") + '' + ib(\"trash\", \"Delete site\", \"deleteevent\", s.id, \"danger\") + '').join(\"\") : 'no sites yet';\n $(\"#sites-count\").textContent = sites.length || \"\";\n const tag = (k, cls) => '' + k + ib(\"x\", \"Remove rule\", \"unrulekind\", String(k)) + \"\";\n $(\"#kinds-allow\").innerHTML = allow.length ? allow.map((k) => tag(k, \"ok\")).join(\"\") : 'all';\n $(\"#kinds-block\").innerHTML = block.length ? block.map((k) => tag(k, \"blk\")).join(\"\") : 'none';\n await loadPeople();\n }\n\n const KIND_NAMES = { 0: \"profiles\", 1: \"notes\", 3: \"contacts\", 4: \"DMs\", 5: \"deletions\", 6: \"reposts\", 7: \"reactions\", 16: \"reposts\", 1059: \"gift wraps\", 1063: \"file headers\", 1111: \"comments\", 1984: \"reports\", 9734: \"zap requests\", 9735: \"zap receipts\", 9802: \"highlights\", 10002: \"relay lists\", 13534: \"roster\", 9000: \"group adds\", 9001: \"group removals\", 9021: \"join requests\", 9022: \"leave requests\", 39000: \"group info\", 39001: \"group admins\", 39002: \"group members\", 39003: \"group roles\", 30023: \"articles\", 30024: \"drafts\", 30078: \"app data\", 30311: \"live events\", 30818: \"wiki\" };\n const kindName = (k) => KIND_NAMES[k] || (k >= 20000 && k < 30000 ? \"ephemeral\" : k >= 30000 && k < 40000 ? \"addressable\" : k >= 10000 && k < 20000 ? \"replaceable\" : \"kind \" + k);\n const SYS_KINDS = new Set([0, 3, 10002, 9735, 13534, 8000, 8001, 9000, 9001, 39000, 39001, 39002, 39003]);\n let storage = null;\n\n // Features (settings.ts): a select each; search has three modes, the rest on or off.\n const FEATURES = [\n [\"search\", \"Search\", \"NIP-50. Prose indexes notes, threads, comments, highlights, articles and wiki pages; full indexes every public kind with content. A change applies to events from then on.\", [\"prose:prose\", \"full:full\", \"off:off\"]],\n [\"sync\", \"Sync\", \"NIP-77 reconciliation, which reads the whole matching set per sync.\"],\n [\"count\", \"Counts\", \"NIP-45 COUNT, with HLL sketches.\"],\n [\"discovery\", \"Discovery record\", \"NIP-66: the record the relay signs about itself, for crawlers.\"],\n [\"names\", \"Names\", \"NIP-05 addresses under this relay's domain.\"],\n [\"files\", \"Files\", \"Blossom and NIP-96: uploads, downloads and listings.\"],\n [\"pages\", \"Pages and feed\", \"Notes and articles as pages, and the Atom feed.\"],\n [\"sites\", \"Static websites\", \"NIP-5A sites on their own hostnames. Mirroring copies missing files into this relay and costs fuel.\", [\"mirror:on, mirror files\", \"proxy:on, fetch as needed\", \"off:off\"]],\n [\"marmot\", \"Marmot transport\", \"Signed KeyPackages and encrypted group messages, with account admission for ephemeral authors.\"],\n [\"grasp\", \"Git repositories\", \"GRASP Git hosting with admitted repository state. The prototype backend has bounded storage and compute limits.\"],\n [\"push\", \"Relay push\", \"NIP-9a callback delivery for members and the owner. Requires approved callback origins; advertises lettered NIP identifiers.\"],\n [\"signer\", \"Signer traffic\", \"NIP-46 remote signing carried for anyone, never stored.\"],\n ];\n function renderFeatures(f) {\n $(\"#features\").innerHTML = FEATURES.map(([k, title, about, modes]) => {\n const cur = k === \"sites\" ? (f.sites?.enabled === false ? \"off\" : f.sites?.mirror === false ? \"proxy\" : \"mirror\") : modes ? String(f[k] || \"prose\") : String(f[k] !== false);\n const opts = (modes || [\"true:on\", \"false:off\"]).map((m) => { const [v, l] = m.split(\":\"); return '\"; }).join(\"\");\n return \"\";\n }).join(\"\");\n }\n $(\"#features\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-feature]\"); if (!sel) return;\n const k = sel.dataset.feature, v = k === \"search\" || k === \"sites\" ? sel.value : sel.value === \"true\";\n policy = await rpc(\"setpolicy\", { features: { [k]: k === \"sites\" ? { enabled: v !== \"off\", mirror: v === \"mirror\" } : v } });\n toast(k === \"search\" ? \"Search: \" + v : (v ? \"Switched on \" : \"Switched off \") + k); await loadInfo();\n }));\n\n $(\"#push-policy-form\").addEventListener(\"submit\", guard(async (ev) => {\n ev.preventDefault();\n const form = ev.target;\n const origins = form.elements.origins.value.split(/\\s+/).filter(Boolean);\n const updated = await rpc(\"setpolicy\", { pushCallbacks: origins, letteredNips: form.elements.lettered.checked });\n if (JSON.stringify(updated.pushCallbacks) !== JSON.stringify([...new Set(origins.map((s) => s.replace(/\\/$/, \"\")))])) throw new Error(\"Use up to sixteen exact HTTPS origins, with no path or credentials.\");\n policy = updated;\n toast(\"Saved callback policy\"); await loadInfo();\n }));\n\n async function loadViews() {\n let views;\n try { views = await rpc(\"listviews\"); } catch { return; }\n $(\"#views\").innerHTML = views.map((v) => {\n const runs = v.trigger === \"off\" ? \"off\" : v.trigger === \"live\" ? \"live, from memory\" : v.trigger === \"write\" ? \"on write and daily\" : v.trigger;\n const label = (c) => (c === \"off\" ? \"off\" : c === \"write\" ? \"on write\" : c === \"live\" ? \"on\" : c);\n const choices = [...v.choices, v.default].filter((c, i, a) => a.indexOf(c) === i);\n const pick = '\";\n const who = v.audience === \"members\" ? \"members\" + (v.stored ? \"\" : \", on request\") : \"anyone\";\n const last = v.trigger === \"live\" ? \"\" : v.last ? \"last run \" + fmtTime(v.last.at) : \"not run yet\";\n const rows = v.trigger === \"live\" ? \"no rows\" : v.last ? v.last.rows.toLocaleString() + \" rows\" : \"\";\n const meta = [runs, who, last, rows].filter(Boolean).map((x) => \"\" + esc(x) + \"\").join('|');\n return '
                ' + esc(v.name) + '
                ' + esc(v.about) + '
                ' + pick + (v.on ? 'Open' : \"\") + '
                ' + meta + \"
                \";\n }).join(\"\");\n }\n $(\"#views\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-view]\"); if (!sel) return;\n const value = sel.value === \"live\" ? true : sel.value;\n await rpc(\"setpolicy\", { views: { [sel.dataset.view]: value } });\n toast(sel.value === \"off\" ? \"Took down \" + sel.dataset.view : sel.dataset.view + \": \" + (sel.value === \"live\" ? \"on\" : sel.value === \"write\" ? \"on write\" : sel.value)); await loadViews(); await loadInfo();\n }));\n async function loadDumps() {\n const list = await rpc(\"listdumps\");\n const f = $(\"#dumpform\");\n if (policy) { f.dumps.value = policy.dumps || \"off\"; f.keep.value = policy.dumpsKeep || 7; }\n $(\"#dumps-count\").textContent = list.length || \"\";\n $(\"#dumps\").innerHTML = list.length ? list.map((d) => '
              • jsonl' + esc(d.name) + ' ' + d.events.toLocaleString() + \" events, \" + fmtBytes(d.bytes) + \"\" + ib(\"copy\", \"Download\", \"downloaddump\", d.name) + ib(\"trash\", \"Delete\", \"deletedump\", d.name, \"danger\") + \"
              • \").join(\"\") : '
              • no dumps yet
              • ';\n }\n // A dump is fetched with a signed request and handed to the browser as a file.\n async function downloadDump(name) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const url = location.origin + \"/dumps/\" + name;\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"GET\"]] });\n const resp = await fetch(url, { headers: { authorization: \"Nostr \" + btoa(JSON.stringify(ev)) } });\n if (!resp.ok) throw new Error((await resp.json()).error || \"download failed\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(\".\")[0] + \"-\" + name; a.click(); URL.revokeObjectURL(a.href);\n }\n // A plain member's own invites, when the owner lets members invite.\n async function loadMine() {\n const sec = $(\"#myinvites\");\n if (!me || !owner || myRole || !signer.ready()) { sec.classList.add(\"hidden\"); return; }\n let mine;\n try { mine = await rpc(\"listinvites\"); } catch { sec.classList.add(\"hidden\"); return; }\n sec.classList.remove(\"hidden\");\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#mine\").innerHTML = mine.length ? mine.map((i) => '
              • inv…' + i.code.slice(-8) + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
              • \").join(\"\") : '
              • no invites yet
              • ';\n }\n async function loadStorage() {\n storage = await rpc(\"storagestats\");\n pollJobs();\n loadViews();\n loadDumps().catch(() => {});\n const st = storage;\n const top = st.kinds.slice(0, 4), rest = st.kinds.slice(4).reduce((a, k) => a + k.bytes, 0);\n const parts = [...top.map((k, i) => [k.kind + \" \" + kindName(k.kind), \"k\" + (i + 1), k.bytes]), ...(rest ? [[\"other\", \"k5\", rest]] : [])];\n const total = st.eventBytes || 1;\n $(\"#s-bar\").innerHTML = parts.length ? parts.map(([n, c, b]) => '').join(\"\") : '';\n $(\"#s-legend\").innerHTML = parts.length ? parts.map(([n, c, b]) => '' + esc(n) + \" \" + Math.round(b * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n const overhead = Math.max(0, st.databaseBytes - st.eventBytes);\n $(\"#s-totals\").innerHTML = [[\"events\", st.events.toLocaleString() + \" \" + fmtBytes(st.eventBytes) + \"\"], [\"index and overhead\", fmtBytes(overhead)], [\"files\", st.blobs.toLocaleString() + \" \" + fmtBytes(st.mediaBytes) + \"\"]]\n .map(([k, v]) => \"
                \" + k + \"\" + v + \"
                \").join(\"\");\n const any = st.retention.find((r) => r.kind === null);\n const own = (kind) => { const r = st.retention.find((x) => x.kind === kind); return r ? r.days : \"\"; };\n const row = (k) => {\n const key = k.kind === null ? \"\" : String(k.kind);\n const placeholder = k.kind === null ? \"forever\" : k.replaceable || !any ? \"forever\" : any.days + \" (everything else)\";\n const pill = k.kind === null ? \"everything else\" : '' + k.kind + \" \" + esc(kindName(k.kind));\n const cells = k.protected\n ? 'required'\n : ' days' + ib(\"check\", \"Save keep-for rule\", \"saveretention\", key) + ib(\"trash\", \"Purge\", \"purgekind\", key, \"danger\") + \"\";\n return '' + pill + '' + (k.n === undefined ? \"\" : k.n.toLocaleString()) + '' + (k.bytes === undefined ? \"\" : fmtBytes(k.bytes)) + '' + (k.oldest ? esc(fmtDay(k.oldest)) : \"\") + \"\" + cells + \"\";\n };\n $(\"#kinds tbody\").innerHTML = [...st.kinds, { kind: null, days: any ? any.days : 0 }].map(row).join(\"\");\n }\n\n let searchQuery = \"\";\n async function loadEvents(reset) {\n const list = searchQuery ? await rpc(\"searchevents\", searchQuery, 200) : await rpc(\"listrecentevents\", reset ? 50 : 200);\n $(\"#events tbody\").innerHTML = list.length ? list.map((e) => '' + esc(fmtTime(e.created_at)) + '' + e.kind + '' + av(e.pubkey) + key(e.pubkey) + '' + esc(e.content) + '' + ib(\"pin\", \"Pin\", \"pinevent\", e.id) + ib(\"trash\", \"Delete event\", \"deleteevent\", e.id) + ib(\"ban\", \"Ban event\", \"banevent\", e.id, \"danger\") + ib(\"banuser\", \"Ban author\", \"banpubkey\", e.pubkey, \"danger\") + \"\").join(\"\") : 'no events yet';\n $(\"#more\").classList.toggle(\"hidden\", searchQuery || list.length < 50);\n }\n $(\"#searchform\").onsubmit = guard(async (ev) => {\n searchQuery = ev.target.q.value.trim();\n $(\"#searchclear\").classList.toggle(\"hidden\", !searchQuery);\n await loadEvents(true);\n });\n $(\"#searchclear\").onclick = guard(async () => {\n searchQuery = \"\"; $(\"#searchform\").reset(); $(\"#searchclear\").classList.add(\"hidden\");\n await loadEvents(true);\n });\n async function loadPins() {\n const pins = await rpc(\"listpins\");\n $(\"#pins-count\").textContent = pins.length || \"\";\n $(\"#pins\").innerHTML = pins.length ? pins.map((t) => '
              • ' + (t[0] === \"e\" ? \"event\" : \"address\") + '' + esc(t[1].length > 40 ? t[1].slice(0, 16) + \"\\u2026\" + t[1].slice(-8) : t[1]) + \"\" + ib(\"copy\", \"Copy\", \"copy\", t[1]) + ib(\"x\", \"Unpin\", \"unpinevent\", t[1]) + \"
              • \").join(\"\") : '
              • nothing pinned
              • ';\n }\n $(\"#pinform\").onsubmit = guard(async (ev) => {\n await rpc(\"pinevent\", ev.target.id.value.trim()); ev.target.reset(); toast(\"Pinned\"); await loadPins();\n });\n\n // ---- actions ----\n const refresh = () => Promise.all([loadLists(), loadEvents(true), loadStorage(), loadPins()]);\n\n $(\"#copy\").onclick = async () => { await navigator.clipboard.writeText(wsURL); toast(\"copied \" + wsURL); };\n $(\"#signin\").onclick = guard(async () => {\n if (!window.nostr) throw new Error(\"No nostr extension found. Install one (Alby, nos2x, …) and reload, or use a remote signer.\");\n me = await window.nostr.getPublicKey();\n localStorage.setItem(\"me\", me);\n renderHeader(); await loadAdmin(); await loadPeople();\n });\n $(\"#signin46\").onclick = () => showRemote(null);\n $$(\".remote\").forEach((b) => { b.onclick = () => showRemote($(\"#\" + b.dataset.note)); });\n $(\"#nccopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#nclink\").href); toast(\"Link copied; paste it into your signer app\"); };\n $(\"#bunkerform\").onsubmit = guard(async (ev) => {\n $(\"#remotenote\").textContent = \"Connecting…\";\n try { await connectBunker(ev.target.url.value); } catch (e) { $(\"#remotenote\").textContent = e.message; throw e; }\n $(\"#remotenote\").textContent = \"\"; ev.target.reset();\n await remoteDone();\n });\n $(\"#signout\").onclick = async () => {\n if (remote) { const r = remote; remote = null; try { await withTimeout(r.logout(), 5000, \"The signer\"); } catch { /* the session is gone either way */ } }\n localStorage.removeItem(\"nip46\");\n me = null; localStorage.removeItem(\"me\"); renderHeader(); await loadAdmin(); await loadPeople();\n };\n const claimNow = async (note) => {\n if (!signer.ready()) { note.textContent = \"Sign the claim with a nostr extension or a remote signer.\"; showRemote(note); return; }\n me = await signer.getPublicKey();\n const r = await rpc(\"claim\");\n if (!r.claimed) throw new Error(\"Somebody else claimed it first.\");\n localStorage.setItem(\"me\", me);\n toast(\"It's yours.\");\n await loadInfo(); await loadFuel(); await loadAdmin(); await loadPeople();\n if (r.converted && confirm(\"This relay began as a temporary one: anyone can write, and everything is deleted after 14 days. Switch to the default rules and keep everything from now on? Each rule can be changed later on the Rules and Storage tabs.\")) {\n await rpc(\"resetrules\"); toast(\"Rules reset\"); await loadInfo(); await loadAdmin();\n }\n };\n $(\"#claim\").onclick = guard(() => claimNow($(\"#claimnote\")));\n $(\"#claimlease\").onclick = guard(() => claimNow($(\"#leasenote\")));\n // Jobs run in the background; the table follows them while the tab is open.\n let jobsTimer = 0;\n const fmtJob = (j) => {\n const what = j.kind === \"mirror\" ? \"mirror site\" : j.kind === \"import\" ? \"import\" : j.label === \"backfill\" ? \"fetch my history\" : j.kind === \"pull\" ? \"pull\" : \"rebroadcast\";\n const f = [];\n if (j.filter.authors) f.push(j.filter.authors.length === 1 && j.filter.authors[0] === me ? \"my events\" : j.filter.authors.length + \" authors\");\n if (j.filter.kinds) f.push(\"kinds \" + j.filter.kinds.join(\", \"));\n if (j.filter.since) f.push(\"since \" + fmtDay(j.filter.since));\n const when = j.every ? \"every \" + (j.every === 24 ? \"day\" : j.every + \" h\") + (j.nextRun ? \", next \" + fmtTime(j.nextRun) : \"\") : \"once\";\n const l = j.last;\n const count = (stored, blobs, sent, refused) => (j.kind === \"mirror\" ? blobs.toLocaleString() + \" files mirrored\" : j.kind === \"import\" ? stored.toLocaleString() + \" events\" + ((j.last ? j.last.duplicates : j.duplicates) ? \", \" + (j.last ? j.last.duplicates : j.duplicates) + \" already here\" : \"\") : j.kind === \"pull\" ? stored.toLocaleString() + \" events\" + (blobs ? \", \" + blobs + \" files\" : \"\") : sent.toLocaleString() + \" sent\" + (refused ? \", \" + refused + \" refused\" : \"\"));\n const res = j.running ? \"running: \" + count(j.stored, j.blobs, j.sent, j.refused) + \"...\" : !l ? \"waiting\" : l.error ? \"failed: \" + l.error : count(l.stored, l.blobs, l.sent, l.refused) + (l.skipped ? \", \" + l.skipped + \" skipped\" : \"\") + \", \" + fmtTime(l.finishedAt);\n const sources = j.running ? j.pullSources : l?.sources;\n const details = sources?.length ? '
                Source results' + sources.map((s) => '

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

                ').join('') + '
                ' : '';\n return \"\" + what + \"\" + j.relays.map(esc).join(\"
                \") + \"\" + (f.join(\", \") || \"everything\") + \"\" + when + \"\" + esc(res) + details + \"\" + (j.running ? \"\" : ib(\"undo\", \"Run now\", \"runjob\", j.id)) + ib(\"x\", \"Remove\", \"removejob\", j.id) + \"\";\n };\n async function pollJobs() {\n clearTimeout(jobsTimer);\n let jobs;\n try { jobs = await rpc(\"listjobs\"); } catch { return; }\n $(\"#jobs tbody\").innerHTML = jobs.length ? jobs.map(fmtJob).join(\"\") : 'no jobs yet';\n if (jobs.some((j) => j.running || (j.nextRun && j.nextRun <= Math.floor(Date.now() / 1000) + 1))) jobsTimer = setTimeout(pollJobs, 3000);\n }\n const urls = (s) => s.split(/[\\s,]+/).map((u) => u.trim()).filter(Boolean);\n const kindsOf = (s) => s.split(/[\\s,]+/).map((k) => parseInt(k, 10)).filter((k) => Number.isInteger(k) && k >= 0);\n $(\"#pullform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"addjob\", { kind: \"pull\", relays: [f.url.value.trim()], every: +f.every.value });\n toast(+f.every.value ? \"Mirror scheduled\" : \"Pull started\"); f.reset(); await pollJobs();\n });\n $(\"#backfillform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"backfill\", urls(f.relays.value));\n toast(\"Fetching your history\"); f.reset(); await pollJobs();\n });\n $(\"#pushform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const filter = {};\n const kinds = kindsOf(f.kinds.value); if (kinds.length) filter.kinds = kinds;\n const days = +f.days.value; if (days > 0) filter.since = Math.floor(Date.now() / 1000) - days * 86400;\n await rpc(\"addjob\", { kind: \"push\", relays: urls(f.targets.value), filter, every: +f.every.value });\n toast(\"Rebroadcast started\"); f.reset(); await pollJobs();\n });\n // ---- presets: writes, reads, kinds and keep-for rules in one click ----\n let presets = null;\n async function renderPresets() {\n if (!presets) { try { presets = await rpc(\"listpresets\"); } catch { presets = []; } }\n $(\"#presets\").innerHTML = presets.map((p) => '\").join(\"\");\n $(\"#presetsourcerow\").classList.toggle(\"hidden\", !presets.some((p) => p.source));\n }\n $(\"#presets\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-preset]\"); if (!b) return;\n const p = (presets || []).find((x) => x.name === b.dataset.preset); if (!p) return;\n if (!confirm(p.title + \": \" + p.about + \"\\n\\nThis replaces the writes and reads rules, the directory setting, the kind rules and the keep-for rules. Limits, identity, people and bans stay.\")) return;\n const source = $(\"#presetsource\").value.trim();\n if (p.source === \"required\" && !source) { $(\"#presetnote\").textContent = p.title + \" needs a source relay to mirror; enter its wss:// URL first.\"; return; }\n b.disabled = true;\n try { policy = await rpc(\"applypreset\", p.name, p.source && source ? { source } : undefined); $(\"#presetnote\").textContent = \"Now: \" + p.about + (policy.job ? \" Mirroring \" + source + \" every \" + policy.job.every + \" h.\" : \"\"); toast(p.title + \" applied\"); await loadInfo(); await loadAdmin(); }\n catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // ---- wire me in: this relay in the owner's own lists ----\n // The lists are replaceable, so a fresh list with only this relay would\n // clobber the real one once it spread. Every publish starts from the\n // newest copy found here or on the indexers, verified when the signer\n // library is around, and adds or removes this relay in it.\n const INDEXERS = [\"wss://purplepag.es\", \"wss://relay.nostr.band\", \"wss://relay.damus.io\", \"wss://nos.lol\"];\n const LISTS = [\n { kind: 10002, tag: \"r\", title: \"Relay list\", nip: \"NIP-65\", about: \"where clients read your notes and send you mentions\" },\n { kind: 10050, tag: \"relay\", title: \"DM inbox\", nip: \"NIP-17\", about: \"where people send you private messages\" },\n { kind: 10007, tag: \"relay\", title: \"Search relays\", nip: \"NIP-51\", about: \"where clients run your searches\" },\n { kind: 10063, tag: \"server\", title: \"Blossom servers\", nip: \"BUD-03\", about: \"where clients upload and look for your files\" },\n ];\n const mineFor = (l) => (l.tag === \"server\" ? location.origin : wsURL);\n const normURL = (u) => { try { const x = new URL(String(u).trim()); return (x.host + x.pathname).replace(/\\/+$/, \"\").toLowerCase(); } catch { return String(u).trim().toLowerCase(); } };\n const isMine = (l, t) => t[0] === l.tag && normURL(t[1] || \"\") === normURL(mineFor(l));\n const listHas = (list, l) => !!list && list.tags.some((t) => isMine(l, t));\n // mergeList is pure: the newest list with this relay put first or taken out, every other tag kept.\n function mergeList(list, l, include) {\n const tags = (list ? list.tags : []).filter((t) => !isMine(l, t));\n if (include) tags.unshift([l.tag, mineFor(l)]);\n return { kind: l.kind, created_at: Math.floor(Date.now() / 1000), content: list ? list.content : \"\", tags };\n }\n const relaysIn = (list) => list.tags.filter((t) => (t[0] === \"r\" || t[0] === \"relay\") && /^wss?:\\/\\//i.test(t[1] || \"\")).map((t) => t[1].trim());\n // overWS opens one socket, sends one message, feeds answers to onMessage until it says done or time runs out.\n function overWS(url, ms, first, onMessage) {\n return new Promise((res) => {\n let ws = null, done = false, out = null;\n const finish = () => { if (done) return; done = true; clearTimeout(timer); try { if (ws) ws.close(); } catch { /* closed */ } res(out); };\n const timer = setTimeout(finish, ms);\n try { ws = new WebSocket(url); } catch { return finish(); }\n ws.onopen = () => ws.send(JSON.stringify(first));\n ws.onmessage = (m) => { let d; try { d = JSON.parse(m.data); } catch { return; } if (!Array.isArray(d)) return; if (onMessage(d, (v) => { out = v; })) finish(); };\n ws.onerror = finish; ws.onclose = finish;\n });\n }\n function fetchNewest(url, filter, ms) {\n let best = null;\n return overWS(url, ms, [\"REQ\", \"w\", filter], (d, set) => {\n if (d[0] === \"EVENT\" && d[1] === \"w\" && d[2] && (!best || d[2].created_at > best.created_at)) { best = d[2]; set(best); }\n return (d[0] === \"EOSE\" || d[0] === \"CLOSED\") && d[1] === \"w\";\n });\n }\n function publishTo(url, event, ms) {\n return overWS(url, ms, [\"EVENT\", event], (d, set) => { if (d[0] === \"OK\" && d[1] === event.id) { set({ url, ok: !!d[2], msg: d[3] || \"\" }); return true; } return false; })\n .then((r) => r || { url, ok: false, msg: \"no answer\" });\n }\n // bridge signs a NIP-98 request to this relay's HTTP door, so the owner's own reads and writes pass any read rule.\n async function bridge(path, body) {\n const url = location.origin + path, raw = JSON.stringify(body);\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"POST\"], [\"payload\", await sha256hex(raw)]] });\n const resp = await fetch(url, { method: \"POST\", headers: { \"content-type\": \"application/json\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body: raw });\n const json = await resp.json();\n if (!resp.ok) throw new Error(json.error || \"HTTP \" + resp.status);\n return json;\n }\n const wire = {};\n function renderWire() {\n const pill = (state) => {\n const cls = state === \"listed\" ? \"on\" : state === \"failed\" ? \"bad\" : state === \"not listed\" ? \"off\" : \"\";\n const label = state === \"checking\" || state === \"publishing\" ? state + \"\\u2026\" : state;\n return '' + esc(label) + \"\";\n };\n $(\"#wire\").innerHTML = LISTS.map((l) => {\n const w = wire[l.kind] || { state: \"\", results: [], list: null };\n const busy = w.state === \"checking\" || w.state === \"publishing\";\n const btn = (act, cls, label) => '\";\n const buttons = w.state === \"listed\" ? btn(\"remove\", \"\", \"Remove this relay\") : btn(\"add\", \"pri\", \"Add this relay\") + btn(\"check\", \"\", w.state ? \"Check again\" : \"Check\");\n const n = w.list ? w.list.tags.filter((t) => t[0] === l.tag).length : 0;\n const from = w.list ? \"\" + (n === 1 ? \"1 entry\" : n + \" entries\") + \", \" + fmtTime(w.list.created_at) + \"\" : w.state && !busy ? \"no list found\" : \"\";\n const sent = (w.results || []).map((r) => '' + esc(r.url.replace(/^wss?:\\/\\//, \"\")) + (r.ok ? \"\" : \" failed\") + \"\").join(\"\");\n const meta = (w.state ? pill(w.state) : \"\") + from + sent;\n return '
                ' + l.title + ' ' + l.nip + \"
                \" + l.about + '
                ' + buttons + '
                ' + meta + \"
                \";\n }).join(\"\");\n }\n async function wireCheck(l) {\n const w = (wire[l.kind] = { state: \"checking\", list: null, results: [] }); renderWire();\n const filter = { kinds: [l.kind], authors: [me], limit: 1 };\n const found = [];\n try { found.push(...(await bridge(\"/query\", [filter]))); } catch { /* the relay may hold none */ }\n const remote = await Promise.all(INDEXERS.map((u) => fetchNewest(u, filter, 4000)));\n let lib = null; try { lib = await signerLib(); } catch { /* unverified lists are still the owner's own, by pubkey */ }\n for (const e of remote) if (e && e.pubkey === me && e.kind === l.kind && Array.isArray(e.tags) && (!lib || lib.verifyEvent(e))) found.push(e);\n w.list = found.sort((a, b) => b.created_at - a.created_at)[0] || null;\n w.state = listHas(w.list, l) ? \"listed\" : \"not listed\"; renderWire();\n }\n async function wirePublish(l, include) {\n if (!wire[l.kind] || !wire[l.kind].state || wire[l.kind].state === \"failed\") await wireCheck(l);\n const w = wire[l.kind]; w.state = \"publishing\"; w.results = []; renderWire();\n try {\n const signed = await signer.signEvent(mergeList(w.list, l, include));\n const here = await bridge(\"/events\", signed).then((r) => ({ url: wsURL, ok: !!r.accepted, msg: r.message || \"\" })).catch((e) => ({ url: wsURL, ok: false, msg: e.message }));\n const targets = [...new Set([...relaysIn(signed), ...INDEXERS])].filter((u) => normURL(u) !== normURL(wsURL));\n const rest = await Promise.all(targets.map((u) => publishTo(u, signed, 6000)));\n w.results = [here, ...rest]; w.list = signed; w.state = include ? \"listed\" : \"not listed\";\n toast((include ? \"Added to your \" : \"Removed from your \") + l.title.toLowerCase());\n } catch (e) { w.state = \"failed\"; w.results = [{ url: wsURL, ok: false, msg: e.message }]; toast(e.message); }\n renderWire();\n }\n $(\"#wire\").addEventListener(\"click\", (ev) => {\n const b = ev.target.closest(\"button[data-wire]\"); if (!b) return;\n const l = LISTS.find((x) => String(x.kind) === b.dataset.wire); if (!l) return;\n if (b.dataset.do === \"check\") wireCheck(l); else wirePublish(l, b.dataset.do === \"add\");\n });\n\n // ---- custom domains: this relay under a hostname the owner controls ----\n let domainSites = [];\n const domainOptions = (current = \"\") => [{ label: \"\", title: \"This relay\" }, ...domainSites.map((s) => ({ label: s.label, title: (s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + \" / \" + key(s.author) })), ...(current && !domainSites.some((s) => s.label === current) ? [{ label: current, title: \"Site \" + current }] : [])].map((s) => '\").join(\"\");\n let domains = null; // null: not enabled on this host\n function renderDomains() {\n $(\"#domains\").innerHTML = (domains || []).map((d) => {\n const state = d.ready ? \"active\" : \"hostname \" + d.status.replace(/_/g, \" \") + \", certificate \" + d.sslStatus.replace(/_/g, \" \");\n const btn = (act, cls, label) => '\";\n const rows = d.ready ? \"\" : '
                ' + d.records.map((r) => \"\").join(\"\") + \"
                TypeNameValue
                \" + esc(r.type) + '' + esc(r.name) + '' + esc(r.value) + \"\" + esc(r.note) + \"
                \";\n return '
                ' + esc(d.host) + '' + esc(state) + \"\" + btn(\"check\", \"\", \"Check\") + btn(\"remove\", \"danger\", \"Remove\") + '
                \" + rows;\n }).join(\"\");\n }\n async function loadDomains() {\n try { [domains, domainSites] = await Promise.all([rpc(\"listdomains\"), rpc(\"listsites\")]); $(\"#adddomain select[name=site]\").innerHTML = domainOptions(); $(\"#domainnote\").textContent = domains.length ? \"\" : \"No custom domain yet.\"; $(\"#adddomain\").classList.remove(\"hidden\"); }\n catch (e) { domains = null; $(\"#domainnote\").textContent = /^unsupported/.test(e.message) ? \"Custom domains are not enabled on this host.\" : e.message; $(\"#adddomain\").classList.add(\"hidden\"); }\n renderDomains();\n }\n $(\"#adddomain\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"adddomain\", f.host.value.trim(), f.site.value); f.reset(); toast(\"Domain added; now create the CNAME\"); await loadDomains();\n });\n $(\"#domains\").addEventListener(\"change\", guard(async (ev) => {\n const select = ev.target.closest(\"select[data-domain-site]\"); if (!select) return;\n await rpc(\"setdomainsite\", select.dataset.domainSite, select.value);\n toast(\"Domain destination saved\"); await loadDomains();\n }));\n $(\"#domains\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-domain]\"); if (!b) return;\n const host = b.dataset.domain;\n if (b.dataset.do === \"remove\" && !confirm(\"Remove \" + host + \"? Its certificate goes with it and the name stops answering.\")) return;\n b.disabled = true;\n try {\n if (b.dataset.do === \"remove\") { await rpc(\"removedomain\", host); toast(\"Removed\"); }\n else { const d = await rpc(\"checkdomain\", host); toast(d.ready ? host + \" is live\" : \"Not yet: \" + (d.status === \"active\" ? \"certificate pending\" : \"waiting for the CNAME\")); }\n await loadDomains();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n $(\"#access\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const openKinds = f.openKinds.value.split(/[\\s,]+/).filter(Boolean).map(Number);\n if (openKinds.some((k) => !Number.isInteger(k) || k < 0 || k > 65535)) throw new Error(\"Open kinds must be whole numbers.\");\n policy = await rpc(\"setpolicy\", { writes: f.writes.value, reads: f.reads.value, openKinds, guestReplies: f.guestReplies.checked, minPow: +f.minPow.value, maxFuture: +f.maxFuture.value, maxLimit: +f.maxLimit.value, maxSubs: +f.maxSubs.value, maxMessageKB: +f.maxMessageKB.value, eventsPerMinute: +f.eventsPerMinute.value, reqsPerMinute: +f.reqsPerMinute.value, maxBlobMB: +f.maxBlobMB.value });\n toast(\"Rules saved\"); await loadInfo();\n });\n $(\"#notify\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { notify: { reports: f.reports.checked, fuel: f.fuel.checked, jobs: f.jobs.checked, succession: f.succession.checked, digest: f.digest.checked } });\n toast(\"Notifications saved\");\n });\n $(\"#notifytest\").onclick = guard(async () => {\n const r = await rpc(\"notifytest\");\n toast(r.sent ? \"Sent. Look for a message from the relay in your DMs.\" : \"Could not send.\");\n });\n $(\"#identity\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const list = (v) => v.split(\",\").map((s) => s.trim()).filter(Boolean);\n policy = await rpc(\"setpolicy\", { name: f.name.value, contact: f.contact.value, description: f.description.value, icon: f.icon.value,\n banner: f.banner.value, postingPolicy: f.postingPolicy.value, privacyPolicy: f.privacyPolicy.value, tags: list(f.tags.value), languageTags: list(f.languageTags.value), relayCountries: list(f.relayCountries.value) });\n toast(\"Identity saved\"); await loadInfo(); await loadPeople(); loadCard();\n });\n $(\"#addmember\").onsubmit = guard(async (ev) => {\n const f = ev.target; const pk = npubToHex(f.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"setmember\", pk, { name: f.name.value.trim() || null, note: f.note.value }); f.reset(); toast(\"Member added\"); await loadLists();\n });\n $(\"#addblock\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"blockip\", f.ip.value.trim(), f.reason.value); f.reset(); toast(\"Blocked\"); await loadLists();\n });\n $(\"#wordsform\").onsubmit = guard(async (ev) => {\n const words = ev.target.words.value.split(\"\\n\").map((s) => s.trim()).filter(Boolean);\n const kept = await rpc(\"setblockedwords\", words);\n policy = await rpc(\"setpolicy\", { blockedWordsInTags: ev.target.inTags.checked });\n ev.target.words.value = kept.join(\"\\n\"); toast(kept.length ? kept.length + \" words blocked\" : \"No words blocked\");\n });\n $(\"#thresholdform\").onsubmit = guard(async (ev) => {\n policy = await rpc(\"setpolicy\", { reportThreshold: +ev.target.reportThreshold.value });\n toast(policy.reportThreshold ? \"Hidden after \" + policy.reportThreshold + \" reports\" : \"Never hidden by reports\"); await loadLists();\n });\n $(\"#addban\").onsubmit = guard(async (ev) => {\n const pk = npubToHex(ev.target.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"banpubkey\", pk, ev.target.reason.value, ev.target.erase.checked); ev.target.reset(); toast(ev.target.erase.checked ? \"Banned and erased\" : \"Banned\"); await loadLists();\n });\n $(\"#mintinvite\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const inv = await rpc(\"createinvite\", +f.ttl.value, +f.max.value, f.note.value);\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n f.note.value = \"\"; toast(\"Invite created and copied\"); await loadLists();\n });\n $(\"#kindform\").onsubmit = guard(async (ev) => {\n const rule = ev.submitter.value; const k = +ev.target.kind.value;\n await rpc(rule === \"allow\" ? \"allowkind\" : \"disallowkind\", k); ev.target.reset(); toast((rule === \"allow\" ? \"Allowed kind \" : \"Blocked kind \") + k); await loadLists();\n });\n $(\"#more\").onclick = guard(() => loadEvents(false));\n $(\"#dumpform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { dumps: f.dumps.value, dumpsKeep: Math.max(1, Math.min(60, Math.floor(+f.keep.value || 7))) });\n toast(policy.dumps === \"off\" ? \"Dumps off\" : \"Dumping \" + policy.dumps); await loadDumps();\n });\n $(\"#importform\").onsubmit = guard(async (ev) => {\n const file = ev.target.file.files[0]; if (!file) return;\n if (!signer.ready()) throw new Error(NO_SIGNER);\n if (file.size > 64 * 1024 * 1024) throw new Error(\"At most 64 MB per import.\");\n const body = await file.text();\n const url = location.origin + \"/import?name=\" + encodeURIComponent(file.name);\n const token = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"PUT\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(url, { method: \"PUT\", headers: { authorization: \"Nostr \" + btoa(JSON.stringify(token)), \"content-type\": \"application/x-ndjson\" }, body });\n const r = await resp.json();\n if (!resp.ok) throw new Error(r.error || \"import failed\");\n ev.target.reset(); toast(\"Importing \" + fmtBytes(r.bytes)); await pollJobs();\n });\n $(\"#dumpnow\").onclick = guard(async () => { const d = await rpc(\"dumpnow\"); toast(\"Dumped \" + d.events.toLocaleString() + \" events\"); await loadStorage(); });\n $(\"#treeform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { memberInvites: { depth: Math.max(0, Math.floor(+f.depth.value || 0)), quota: Math.max(0, Math.floor(+f.quota.value || 0)) } });\n toast(policy.memberInvites.depth ? \"Members may invite\" : \"Only you and moderators invite\");\n });\n $(\"#mintmine\").onclick = guard(async () => {\n const inv = await rpc(\"createinvite\", 259200, 1, \"\");\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n toast(\"Invite created and copied\"); await loadMine();\n });\n $(\"#exportcfg\").onclick = guard(async () => {\n const cfg = await rpc(\"exportconfig\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(new Blob([JSON.stringify(cfg, null, 2)], { type: \"application/json\" }));\n a.download = host.split(\".\")[0] + \".bind.ws.config.json\";\n a.click(); URL.revokeObjectURL(a.href);\n });\n $(\"#importcfg\").onclick = () => $(\"#cfgfile\").click();\n $(\"#cfgfile\").onchange = guard(async (ev) => {\n const file = ev.target.files[0]; if (!file) return;\n const cfg = JSON.parse(await file.text());\n const check = await rpc(\"importconfig\", cfg, { dryRun: true });\n const lines = check.changes.summary.length ? check.changes.summary.join(\"\\n\") : \"Nothing would change.\";\n const dropped = check.warnings.length ? \"\\n\\nNot taken:\\n\" + check.warnings.join(\"\\n\") : \"\";\n if (!confirm(\"Apply \" + file.name + \" to this relay?\\n\\n\" + lines + dropped)) { ev.target.value = \"\"; return; }\n await rpc(\"importconfig\", cfg); ev.target.value = \"\"; toast(\"Configuration imported\"); await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // Succession: the status line under the heir form, from successionstatus.\n async function renderSuccession() {\n const el = $(\"#successionnote\"), f = $(\"#succession\");\n let st;\n try { st = await rpc(\"successionstatus\"); } catch { el.textContent = \"\"; return; }\n const sc = st.succession;\n $(\"#clearsuccession\").classList.toggle(\"hidden\", !sc);\n f.querySelector(\"button.btn:not(#clearsuccession)\").textContent = sc ? \"Change heir\" : \"Set heir\";\n if (!sc) { el.textContent = \"No heir named. Last signed in \" + fmtTime(st.ownerSeenAt) + \".\"; return; }\n if (f.heir.querySelector('[value=\"' + sc.heir + '\"]')) f.heir.value = sc.heir;\n f.afterDays.value = String(sc.afterDays);\n const who = (window.__members || []).find((m) => m.pubkey === sc.heir);\n const heir = who && who.name ? who.name + \"@\" + host : short(sc.heir);\n el.textContent = \"Heir: \" + heir + \". Last signed in \" + fmtTime(st.ownerSeenAt) + (st.silentDays ? \" (\" + st.silentDays + \" days ago)\" : \"\") + \". \" +\n (st.warning ? \"The warning month is running: the relay goes to \" + heir + \" on \" + fmtDay(st.handoverAt) + \" unless you sign in.\" : \"If you stay away, it goes to \" + heir + \" around \" + fmtDay(st.handoverAt) + \".\") +\n (st.log && st.log.length ? \" Handed over before: \" + st.log.map((l) => fmtDay(l.at) + \" to \" + short(l.to)).join(\", \") + \".\" : \"\");\n }\n $(\"#succession\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"setsuccession\", { heir: f.heir.value, afterDays: +f.afterDays.value });\n toast(\"Heir set\"); await loadAdmin();\n });\n $(\"#clearsuccession\").onclick = guard(async () => {\n await rpc(\"clearsuccession\");\n toast(\"Heir cleared\"); await loadAdmin();\n });\n $(\"#transfer\").onsubmit = guard(async (ev) => {\n const pk = ev.target.pubkey.value; if (!pk) return;\n const name = host.split(\".\")[0];\n const typed = prompt(\"This hands \" + host + \" to \" + short(pk) + \" for good. You stay on as a moderator. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing changed.\"); return; }\n await rpc(\"transferowner\", pk);\n toast(\"Transferred\");\n await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // ---- fork: a new name with this relay's events, claim reserved for a key ----\n $(\"#forkform\").scope.onchange = (ev) => $(\"#forkkinds\").classList.toggle(\"hidden\", ev.target.value !== \"kinds\");\n $(\"#joinform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { joinTerms: f.joinTerms.value, directoryPublic: f.directoryPublic.checked });\n toast(\"Saved\"); await loadInfo(); await loadPeople();\n });\n $(\"#forkform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const opts = { people: f.people.checked };\n if (f.name.value.trim()) opts.name = f.name.value.trim().toLowerCase();\n if (f.holder.value.trim()) { const pk = npubToHex(f.holder.value); if (!pk) throw new Error(\"That is not a pubkey.\"); opts.holder = pk; }\n if (f.scope.value === \"mine\") opts.filter = { authors: [me] };\n if (f.scope.value === \"kinds\") { const kinds = f.kinds.value.split(/[\\s,]+/).filter(Boolean).map(Number); if (!kinds.length || kinds.some((k) => !Number.isInteger(k) || k < 0)) throw new Error(\"Give kinds as numbers.\"); opts.filter = { kinds }; }\n if (!confirm(\"Fork this relay into a new name\" + (opts.name ? \" (\" + opts.name + \")\" : \"\") + \"? It pulls \" + (f.scope.value === \"all\" ? \"everything\" : f.scope.value === \"mine\" ? \"your events\" : \"the chosen kinds\") + (opts.people ? \" and the people\" : \"\") + \", and only \" + (opts.holder ? \"that key\" : \"you\") + \" can claim it.\")) return;\n const r = await rpc(\"forkrelay\", opts);\n $(\"#forknote\").textContent = r.handover + \" Expires \" + fmtTime(r.expires_at) + \".\";\n $(\"#forkurl\").textContent = r.console;\n $(\"#forkresult\").classList.remove(\"hidden\");\n toast(\"Forked to \" + r.name);\n });\n $(\"#forkcopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#forkurl\").textContent); toast(\"copied\"); };\n $(\"#deleterelay\").onclick = guard(async () => {\n const name = host.split(\".\")[0];\n const typed = prompt(\"This deletes everything on \" + host + \" and gives the name up. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing was deleted.\"); return; }\n await rpc(\"deleterelay\", name);\n localStorage.removeItem(\"me\");\n location.href = \"/\";\n });\n $(\"#topup\").onsubmit = guard((ev) => topUp(+ev.target.sats.value));\n $(\"#inv-copy\").onclick = async () => { await navigator.clipboard.writeText($(\"#inv-text\").value); toast(\"invoice copied\"); };\n $$(\"button[data-copy]\").forEach((b) => { b.onclick = async () => { await navigator.clipboard.writeText(b.dataset.copy === \"ws\" ? wsURL : location.origin); toast(\"copied\"); }; });\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-act]\"); if (!b) return;\n const act = b.dataset.act, id = b.dataset.id;\n if (act === \"copy\") { await navigator.clipboard.writeText(id); toast(\"copied\"); return; }\n if ((act === \"banpubkey\" || act === \"banevent\" || act === \"resolve:ban\") && !confirm(act === \"banevent\" ? \"Delete this event and refuse it forever?\" : \"Ban this author and refuse everything they post?\")) return;\n const erase = (act === \"banpubkey\" || act === \"resolve:ban\") && confirm(\"Also erase everything they wrote and uploaded here?\");\n if (act === \"deleteblob\" && !confirm(\"Delete this file for good?\")) return;\n if (act === \"deletedump\" && !confirm(\"Delete this dump?\")) return;\n if (act === \"downloaddump\") { b.disabled = true; try { await downloadDump(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }\n if ((act === \"removemember\" || act === \"banpubkey\") && (window.__members || []).some((m) => m.invited_by === id) && confirm(\"Also remove everyone this member invited, and everyone they invited in turn?\")) {\n b.disabled = true;\n try { const r = await rpc(\"removesubtree\", id); if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase); toast(\"Removed \" + r.removed.length); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"removejob\" && !confirm(\"Remove this job?\")) return;\n if (act === \"purgekind\") {\n const kind = id === \"\" ? null : +id;\n const label = kind === null ? \"everything without its own rule\" : kindName(kind) + \" (kind \" + kind + \")\";\n const typed = prompt(\"Purge \" + label + \" older than how many days? 0 purges all of them. This cannot be undone.\", \"30\");\n if (typed === null) return;\n const days = Math.max(0, Math.floor(+typed || 0));\n b.disabled = true;\n try { const r = await rpc(\"purgekind\", kind, days); toast(\"Purged \" + r.deleted.toLocaleString() + \" events\"); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"saveretention\") {\n const kind = id === \"\" ? null : +id;\n const days = Math.max(0, Math.floor(+b.closest(\"tr\").querySelector(\".days\").value || 0));\n b.disabled = true;\n try { await rpc(\"setretention\", kind, days); toast(days ? \"Kept for \" + days + \" days\" : \"Kept forever\"); await loadStorage(); await loadInfo(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n b.disabled = true;\n try {\n if (act === \"savemember\") { const tr = b.closest(\"tr\"), role = tr.querySelector(\"select.role\"), keep = tr.querySelector(\".keep\"), cap = tr.querySelector(\".cap\"); await rpc(\"setmember\", id, { name: tr.querySelector(\".name\").value.trim() || null, note: tr.querySelector(\".note\").value, ...(role ? { role: role.value } : {}), ...(keep ? { keepDays: Math.max(0, Math.floor(+keep.value || 0)), maxBytes: Math.max(0, Math.floor(+cap.value || 0)) * 1024 } : {}) }); }\n else if (act.startsWith(\"resolve:\")) await rpc(\"resolvereport\", id, act.slice(8), erase);\n else if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase);\n else await rpc(act, act === \"unrulekind\" ? +id : id, \"\");\n toast(\"Done\"); await (myRole ? refresh() : loadMine());\n if (act === \"pinevent\" || act === \"unpinevent\") await loadPins();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // The share block: the card picture, the group naddr and its QR.\n let card = null;\n async function loadCard() {\n try {\n card = await (await fetch(\"/card.json\", { cache: \"no-store\" })).json();\n $(\"#cardimg\").src = \"/card.svg?t=\" + Date.now();\n $(\"#naddr\").textContent = card.naddr || \"\";\n const q = $(\"#naddrqr\");\n if (card.naddr) { q.src = \"/qr.svg?text=\" + encodeURIComponent(card.naddr); q.classList.remove(\"hidden\"); } else q.classList.add(\"hidden\");\n } catch { /* the card is decoration */ }\n renderApps();\n }\n // The app rows on the Connect section: one per client people actually use,\n // with the link that lands on this relay in it. Relay apps take the relay\n // or the group address; feed apps take the owner's profile with this relay\n // as the hint, since they have no notion of opening a relay.\n function renderApps() {\n const el = $(\"#apps\"); if (!el) return;\n const enc = encodeURIComponent;\n const nprofile = card && card.nprofile ? card.nprofile : \"\";\n const naddr = card && card.naddr ? card.naddr : \"\";\n const whose = me && owner && me === owner ? \"your\" : \"the owner's\";\n const link = (label, href) => '' + label + \"\";\n const app = (label, uri) => '' + label + \"\";\n const copy = (label, text) => '\";\n const row = (name, where, note, acts) => '
                ' + name + \"\" + where + \"

                \" + note + '

                ' + acts.filter(Boolean).join(\"\") + \"
                \";\n const profileNote = \"Opens \" + whose + \" profile with this relay attached.\";\n const groups = [\n [\"As a place\", \"These open the relay itself: its feed, its people, its group.\", [\n row(\"Jumble\", \"web\", \"A feed of everything on this relay.\", [link(\"Open\", \"https://jumble.social/?r=\" + enc(wsURL))]),\n row(\"Coracle\", \"web\", \"The relay's page: its feed and its people.\", [link(\"Open\", \"https://coracle.social/relays/\" + enc(host))]),\n row(\"Flotilla\", \"web, phone\", \"The relay as a space, with the group as a room.\", [link(\"Open\", \"https://app.flotilla.social/spaces/\" + enc(host)), naddr && app(\"Open group\", \"nostr:\" + naddr)]),\n row(\"0xchat\", \"phone\", \"The group, in a chat app.\", [naddr && app(\"Open group\", \"nostr:\" + naddr), naddr && copy(\"Copy naddr\", naddr)]),\n row(\"noStrudel\", \"web\", \"Relays, add this one, then open its page.\", [copy(\"Copy relay URL\", wsURL)]),\n ]],\n [\"Find me here\", \"Feed apps have no relay pages. They meet this relay through a profile link that names it, then keep it once it is in the relay settings.\", [\n row(\"Primal\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://primal.net/p/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"YakiHonne\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://yakihonne.com/profile/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"Damus\", \"iPhone\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Amethyst\", \"Android\", profileNote + \" Then Relays in the drawer.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Nostur\", \"iPhone, Mac\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n ]],\n ];\n if (info?.supported_grasps?.includes(\"GRASP-01\")) groups.push([\n \"Git repositories\", \"Use this relay with a Git client.\", [\n row(\"GitWorkshop\", \"web\", \"Browse this relay's Git repositories.\", [link(\"Open in app\", \"https://gitworkshop.dev/relay/\" + enc((wsURL.startsWith(\"ws://\") ? \"ws:\" : \"\") + host)), copy(\"Copy relay URL\", wsURL)]),\n ],\n ]);\n el.innerHTML = groups.map(([h, note, rows]) => '

                ' + h + '

                ' + note + '

                ' + rows.join(\"\") + \"
                \").join(\"\");\n $(\"#apps-ws\").textContent = wsURL;\n const tile = (label, text) => '
                ' + label + '\"QR
                ';\n $(\"#phones\").innerHTML = [nprofile && tile(\"Find me here, for a phone\", \"nostr:\" + nprofile), naddr && tile(\"The group, for a phone\", \"nostr:\" + naddr)].filter(Boolean).join(\"\");\n }\n // The folds are a group: opening one closes the rest. Browsers with the\n // details name attribute do this themselves; this covers the others.\n $$(\".folds details\").forEach((d) => d.addEventListener(\"toggle\", () => { if (d.open) $$(\".folds details\").forEach((o) => { if (o !== d && o.open) o.open = false; }); }));\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-copytext]\"); if (!b) return;\n try { await navigator.clipboard.writeText(b.dataset.copytext); toast(\"copied\"); } catch { /* no clipboard */ }\n });\n $(\"#copynaddr\").onclick = async () => { if (!card || !card.naddr) return; await navigator.clipboard.writeText(card.naddr); toast(\"copied naddr\"); };\n $(\"#copyembed\").onclick = async () => { await navigator.clipboard.writeText('\"''); toast(\"copied embed\"); };\n\n // A click on a key copies it; a double-click selects the whole key so the usual copy shortcut takes it too.\n document.addEventListener(\"dblclick\", (ev) => {\n const k = ev.target.closest(\".key\"); if (!k) return;\n const s = window.getSelection(), r = document.createRange(); r.selectNodeContents(k.querySelector(\".full\")); s.removeAllRanges(); s.addRange(r);\n });\n document.addEventListener(\"click\", async (ev) => {\n const k = ev.target.closest(\".key\"); if (!k || ev.detail > 1 || String(window.getSelection())) return;\n try { await navigator.clipboard.writeText(k.title); toast(\"copied \" + k.dataset.short); } catch { /* no clipboard */ }\n });\n // ---- boot ----\n try { await loadInfo(); } catch { renderHeader(); }\n await loadFuel();\n await loadCard();\n me = localStorage.getItem(\"me\");\n if (me && await resumeRemote()) { /* a remote session answers for itself when first used */ }\n else if (me && window.nostr) { try { const pk = await window.nostr.getPublicKey(); if (pk !== me) me = null; } catch { me = null; } }\n else if (me) me = null;\n renderHeader();\n renderApps();\n await loadAdmin();\n await loadPeople();\n})();\n"; diff --git a/src/jobs.ts b/src/jobs.ts index 621c493..6cd9c1f 100644 --- a/src/jobs.ts +++ b/src/jobs.ts @@ -6,7 +6,7 @@ // fuel, which is the right signal for work nobody asked a client for. import { hasTag, isPrivate, now, type Event } from "./event.ts"; import type { Filter } from "./filter.ts"; -import { Socket, dial, checkPullURL, runPullRound, type PullFilter, type PullJob, type PullResult } from "./pull.ts"; +import { Socket, dial, checkPullURL, runPullRound, newPullProgress, type PullConnect, type PullSource, type PullFilter, type PullJob, type PullResult } from "./pull.ts"; import type { Relay } from "./relay.ts"; import { runMirrorRound } from "./site-mirror.ts"; import { runImportRound } from "./imports.ts"; @@ -25,6 +25,7 @@ export interface JobResult { sent: number; refused: number; duplicates?: number; + sources?: PullSource[]; } export interface Job { @@ -54,6 +55,7 @@ export interface Job { size?: number; // import: the object's size in bytes carry?: string; // import: the partial last line of the previous round, base64 last: JobResult | null; + pullSources?: PullSource[]; } export const MAX_STANDING = 5; @@ -131,17 +133,32 @@ export async function runRound(relay: Relay, job: Job): Promise<{ more: boolean; // A pull job syncs its sources one after another; each source is a pull // in the sense of pull.ts, with the job's filter. -async function runPullSourceRound(relay: Relay, job: Job): Promise<{ more: boolean; error: string }> { +export async function runPullSourceRound(relay: Relay, job: Job, connect?: PullConnect): Promise<{ more: boolean; error: string }> { if (job.relayIndex >= job.relays.length) return { more: false, error: "" }; - const sub: PullJob = { url: job.relays[job.relayIndex], startedAt: job.startedAt, rounds: 0, stored: 0, skipped: 0, blobs: 0, failures: 0 }; + const sources = job.pullSources ??= job.relays.map((url) => ({ url, ...newPullProgress(), stored: 0, skipped: 0, blobs: 0 })); + const source = sources[job.relayIndex]; + const sub: PullJob = { url: source.url, startedAt: job.startedAt, rounds: 0, stored: 0, skipped: 0, blobs: 0, failures: 0, progress: source }; if (job.filter.authors || job.filter.kinds || job.filter.since) sub.filter = job.filter; - const r = await runPullRound(relay, sub); + const r = await runPullRound(relay, sub, connect); job.rounds++; job.stored += sub.stored; job.skipped += sub.skipped; job.blobs += sub.blobs; - if (r.error) return r; - if (!r.more) job.relayIndex++; + source.stored += sub.stored; + source.skipped += sub.skipped; + source.blobs += sub.blobs; + if (r.error) { + source.failures++; + const refused = /auth-required:|query refused:|files refused:/.test(r.error); + if (!refused && source.failures < 3) return r; + source.status = refused ? "refused" : "failed"; + source.error = r.error; + job.relayIndex++; + } else { + source.failures = 0; + source.error = ""; + if (!r.more) job.relayIndex++; + } return { more: job.relayIndex < job.relays.length, error: "" }; } @@ -230,11 +247,14 @@ export function startRun(job: Job, t: number) { job.sent = 0; job.refused = 0; job.duplicates = 0; + if (job.kind === "pull") job.pullSources = job.relays.map((url) => ({ url, ...newPullProgress(), stored: 0, skipped: 0, blobs: 0 })); } // finishRun closes the current run and schedules the next one. export function finishRun(job: Job, error: string, t: number) { - job.last = { finishedAt: t, error, rounds: job.rounds, stored: job.stored, skipped: job.skipped, blobs: job.blobs, sent: job.sent, refused: job.refused, duplicates: job.duplicates ?? 0 }; + const incomplete = job.pullSources?.filter((s) => ["partial", "refused", "failed"].includes(s.status)); + if (!error && incomplete?.length) error = `${incomplete.length} import source(s) incomplete; inspect Source results for details.`; + job.last = { finishedAt: t, error, rounds: job.rounds, stored: job.stored, skipped: job.skipped, blobs: job.blobs, sent: job.sent, refused: job.refused, duplicates: job.duplicates ?? 0, ...(job.pullSources ? { sources: structuredClone(job.pullSources) } : {}) }; job.running = false; job.nextRun = job.every > 0 ? t + job.every * 3600 : 0; } diff --git a/src/pull.ts b/src/pull.ts index e8ba8a1..65f1da4 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -1,6 +1,6 @@ -// Pull: this relay fetches what another relay has and it lacks. One round -// is one connection: reconcile with NIP-77, take a bounded batch of the -// missing events, and, for a relay on this host, a batch of its files. +// Pull: bounded history import uses NIP-77 when available and ordinary +// NIP-01 queries otherwise. Query windows survive between alarm rounds; +// their completion is best effort, since a relay may silently cap results. // The alarm calls rounds until nothing is missing (see jobs.ts), so a pull // survives the object sleeping, and running it again only fetches what is // new. An optional filter narrows the reconciliation: authors for a @@ -29,7 +29,32 @@ export interface PullJob { blobs: number; failures: number; filter?: PullFilter; + progress?: PullProgress; } +export interface PullProgress { + mode: "negentropy" | "query"; + status: "pending" | "running" | "complete" | "best-effort" | "partial" | "refused" | "failed"; + windows?: { since: number; until: number }[]; + pages: number; + failures: number; + error: string; + warning: string; + partial: boolean; +} +export interface PullSource extends PullProgress { + url: string; + stored: number; + skipped: number; + blobs: number; +} +export const newPullProgress = (): PullProgress => ({ mode: "negentropy", status: "pending", pages: 0, failures: 0, error: "", warning: "", partial: false }); +export interface PullSocket { + send(...msg: unknown[]): void; + recv(timeout?: number): Promise; + close(): void; +} +export type PullConnect = (relay: Relay, url: string) => Promise; +const connectPull: PullConnect = async (relay, url) => new Socket(await dial(relay, url)); export interface PullResult extends PullJob { finishedAt: number; error: string; @@ -40,6 +65,10 @@ const ROUND_BLOBS = 20; const IDS_PER_REQ = 100; const MAX_ITEMS = 100_000; const MESSAGE_TIMEOUT_MS = 20_000; +export const QUERY_PAGE = 500; +const QUERY_PAGES = 2048; +const ROUND_MESSAGES = 4096; +const ROUND_MS = 25_000; // localName is the relay name when the URL is one of ours, else "". export function localName(url: URL, domain: string): string { @@ -81,22 +110,29 @@ export async function dial(relay: Relay, raw: string): Promise { // Socket turns a websocket into a queue of parsed messages. export class Socket { - private queue: unknown[][] = []; + private queue: { message: unknown[]; size: number }[] = []; private waiters: { res: (m: unknown[]) => void; rej: (e: Error) => void }[] = []; private closed: Error | null = null; + private queuedBytes = 0; constructor(private ws: WebSocket) { ws.addEventListener("message", (e) => { + const raw = typeof e.data === "string" ? e.data : ""; + if (raw.length > 4 * 1024 * 1024 || this.queue.length >= 2048 || this.queuedBytes + raw.length > 16 * 1024 * 1024) { + end("relay response exceeded the import buffer limit"); + this.close(); + return; + } let m: unknown; try { - m = JSON.parse(typeof e.data === "string" ? e.data : ""); + m = JSON.parse(raw); } catch { return; } if (!Array.isArray(m)) return; const w = this.waiters.shift(); if (w) w.res(m); - else this.queue.push(m); + else { this.queue.push({ message: m, size: raw.length }); this.queuedBytes += raw.length; } }); const end = (why: string) => { this.closed = new Error(why); @@ -110,15 +146,15 @@ export class Socket { this.ws.send(JSON.stringify(msg)); } - recv(): Promise { + recv(timeout = MESSAGE_TIMEOUT_MS): Promise { const m = this.queue.shift(); - if (m) return Promise.resolve(m); + if (m) { this.queuedBytes -= m.size; return Promise.resolve(m.message); } if (this.closed) return Promise.reject(this.closed); return new Promise((res, rej) => { const timer = setTimeout(() => { this.waiters = this.waiters.filter((w) => w.res !== ok); rej(new Error("the relay stopped answering")); - }, MESSAGE_TIMEOUT_MS); + }, Math.max(1, Math.min(MESSAGE_TIMEOUT_MS, timeout))); const ok = (m: unknown[]) => { clearTimeout(timer); res(m); @@ -138,31 +174,140 @@ export class Socket { // runPullRound does one round of the job. more: call again. error: the // round failed and nothing more was learned. -export async function runPullRound(relay: Relay, job: PullJob): Promise<{ more: boolean; error: string }> { +export async function runPullRound(relay: Relay, job: PullJob, connect: PullConnect = connectPull): Promise<{ more: boolean; error: string }> { job.rounds++; - let sock: Socket | null = null; + const progress = job.progress ??= newPullProgress(); + progress.status = "running"; + let sock: PullSocket | null = null; try { - sock = new Socket(await dial(relay, job.url)); - const need = await reconcile(relay, sock, job.filter ?? {}); + sock = await connect(relay, job.url); + if (progress.mode === "query") return await queryRound(relay, sock, job); + if (progress.pages >= QUERY_PAGES) { + progress.status = "partial"; + progress.warning = "Import stopped at its round budget; narrow the filter and run again."; + return { more: false, error: "" }; + } + let need: string[]; + try { + need = await reconcile(relay, sock, job.filter ?? {}); + } catch (err) { + progress.mode = "query"; + progress.warning = "NIP-77 unavailable: " + reason(err); + progress.windows = [{ since: job.filter?.since ?? 0, until: job.startedAt }]; + return { more: true, error: "" }; + } + progress.pages++; + const storedBefore = job.stored; + const deadline = Date.now() + ROUND_MS; const want = need.slice(0, ROUND_EVENTS); - for (let i = 0; i < want.length; i += IDS_PER_REQ) await fetchEvents(relay, sock, want.slice(i, i + IDS_PER_REQ), job); + for (let i = 0; i < want.length; i += IDS_PER_REQ) await fetchEvents(relay, sock, want.slice(i, i + IDS_PER_REQ), job, deadline); let more = need.length > want.length; + if (want.length && job.stored === storedBefore) { + progress.partial = true; + progress.warning = "The source advertises missing events that it did not supply or this relay cannot admit."; + more = false; + } if (!more && !job.filter) { // Files come along on a whole-relay pull; a filtered pull is about events. const local = localName(new URL(job.url), relay.domain); if (local) more = await copyBlobs(relay, local, job); } + if (!more) progress.status = progress.partial ? "partial" : "complete"; return { more, error: "" }; } catch (err) { - return { more: false, error: err instanceof Error ? err.message : String(err) }; + const error = reason(err); + progress.error = error; + return { more: false, error }; } finally { sock?.close(); } } +const reason = (err: unknown) => (err instanceof Error ? err.message : String(err)).slice(0, 300); + +// queryRound stores one bounded time window. Full windows divide rather +// than skipping timestamps; a full single second remains explicitly partial. +async function queryRound(relay: Relay, sock: PullSocket, job: PullJob): Promise<{ more: boolean; error: string }> { + const p = job.progress!; + const windows = p.windows ??= [{ since: job.filter?.since ?? 0, until: job.startedAt }]; + if (!windows.length) return { more: false, error: "" }; + if (p.pages >= QUERY_PAGES) { + p.status = "partial"; + p.warning = "Import stopped at the query page budget; narrow the time or author filter and run again."; + return { more: false, error: "" }; + } + const window = windows[windows.length - 1]; + const sub = "history"; + const wire = { ...job.filter, ...window, limit: QUERY_PAGE }; + sock.send("REQ", sub, wire); + const ids = new Set(); + let received = 0; + let invalid = false; + let auth = false; + const deadline = Date.now() + ROUND_MS; + for (let messages = 0; ; messages++) { + if (messages >= ROUND_MESSAGES || Date.now() >= deadline) throw new Error(auth ? "auth-required: source needs authentication; no user key is shared for imports" : "source did not complete its query within the import limits"); + let m: unknown[]; + try { m = await sock.recv(deadline - Date.now()); } + catch (err) { if (auth) throw new Error("auth-required: source needs authentication; no user key is shared for imports"); throw err; } + if (m[0] === "AUTH") auth = true; + if (m[0] === "EOSE" && m[1] === sub) break; + if (m[0] === "CLOSED" && m[1] === sub) throw new Error("query refused: " + String(m[2]).slice(0, 200)); + if (m[0] !== "EVENT" || m[1] !== sub) continue; + received++; + if (received > QUERY_PAGE) throw new Error("source exceeded the requested event limit"); + const event = m[2]; + if (validate(event)) { job.skipped++; invalid = true; continue; } + const e = event as Event; + if (!matchesPull(e, job.filter) || e.created_at < window.since || e.created_at > window.until || ids.has(e.id)) { + job.skipped++; invalid = true; continue; + } + ids.add(e.id); + storePulled(relay, e, job); + } + sock.send("CLOSE", sub); + windows.pop(); + p.pages++; + if (invalid) { p.partial = true; p.warning = "Source returned invalid, duplicate or out-of-filter events; coverage is incomplete."; } + if (received >= QUERY_PAGE) { + if (window.since >= window.until) { + p.partial = true; + p.warning = "A one-second window reached the result limit; events sharing that timestamp may be missing."; + } else { + const middle = window.since + Math.floor((window.until - window.since) / 2); + windows.push({ since: window.since, until: middle }, { since: middle + 1, until: window.until }); + } + } + if (!windows.length) { + p.status = p.partial ? "partial" : "best-effort"; + if (!p.partial) p.warning = "Query scan finished; the source may silently cap or omit events, so complete history is not guaranteed."; + } + return { more: windows.length > 0, error: "" }; +} + +// matchesPull checks source-supplied events against the requested filter. +function matchesPull(e: Event, f?: PullFilter): boolean { + return !f || ((!f.authors?.length || f.authors.includes(e.pubkey)) && (!f.kinds?.length || f.kinds.includes(e.kind)) && (f.since === undefined || e.created_at >= f.since)); +} + +// storePulled applies normal host admission and counts each accepted event. +function storePulled(relay: Relay, e: Event, job: PullJob) { + if (!relay.settings.kindAllowed(e.kind)) { job.skipped++; markRejected(job); return; } + const r = relay.accept(e, null); + if (r.stored) { job.stored++; relay.broadcast(e); } + else if (r.msg !== ERR_DUPLICATE) { job.skipped++; markRejected(job); } +} + +// markRejected preserves admission gaps in the source result. +function markRejected(job: PullJob) { + if (!job.progress) return; + job.progress.partial = true; + job.progress.warning = "Some source events could not be admitted by this relay; coverage is incomplete."; +} + // reconcile runs NIP-77 as the initiator over the filter and returns the // ids the other side has that we do not. -async function reconcile(relay: Relay, sock: Socket, filter: PullFilter): Promise { +async function reconcile(relay: Relay, sock: PullSocket, filter: PullFilter): Promise { const f: Filter = { tags: {} }; const wire: Record = {}; if (filter.authors?.length) f.authors = wire.authors = filter.authors; @@ -174,12 +319,15 @@ async function reconcile(relay: Relay, sock: Socket, filter: PullFilter): Promis const id = "pull"; sock.send("NEG-OPEN", id, wire, bytesToHex(neg.initiate())); const need: string[] = []; - for (;;) { - const m = await sock.recv(); + const deadline = Date.now() + ROUND_MS; + for (let messages = 0; ; messages++) { + if (messages >= ROUND_MESSAGES || Date.now() >= deadline) throw new Error("sync exceeded its round budget"); + const m = await sock.recv(deadline - Date.now()); if (m[0] === "NEG-ERR" && m[1] === id) throw new Error("sync refused: " + String(m[2])); if (m[0] !== "NEG-MSG" || m[1] !== id) continue; // AUTH challenges, notices const r = neg.reconcile(hexToBytes(String(m[2]))); need.push(...r.need); + if (need.length > MAX_ITEMS) throw new Error("sync result exceeded the import limit"); if (r.reply === null) break; sock.send("NEG-MSG", id, bytesToHex(r.reply)); } @@ -190,28 +338,30 @@ async function reconcile(relay: Relay, sock: Socket, filter: PullFilter): Promis // fetchEvents asks for a batch by id and stores what checks out. Signatures // are verified, bans and kind rules apply, the write policy does not: the // owner asked for these. -async function fetchEvents(relay: Relay, sock: Socket, ids: string[], job: PullJob) { +async function fetchEvents(relay: Relay, sock: PullSocket, ids: string[], job: PullJob, deadline: number) { const sub = "pull-" + job.stored + "-" + job.skipped; sock.send("REQ", sub, { ids }); - for (;;) { - const m = await sock.recv(); + const seen = new Set(); + for (let messages = 0; ; messages++) { + if (messages >= ROUND_MESSAGES || Date.now() >= deadline) throw new Error("source exceeded its event response budget"); + const m = await sock.recv(deadline - Date.now()); if (m[0] === "EOSE" && m[1] === sub) break; if (m[0] === "CLOSED" && m[1] === sub) throw new Error("query refused: " + String(m[2])); if (m[0] !== "EVENT" || m[1] !== sub) continue; const e = m[2] as Event; - const f = job.filter; - const outside = !!f && ((f.authors?.length && !f.authors.includes(e.pubkey)) || (f.kinds?.length && !f.kinds.includes(e.kind)) || (f.since && e.created_at < f.since)); - if (validate(e) || !ids.includes(e.id) || outside || !relay.settings.kindAllowed(e.kind)) { + if (validate(e) || !ids.includes(e.id) || !matchesPull(e, job.filter) || seen.has(e.id)) { job.skipped++; + markRejected(job); continue; } - const r = relay.accept(e, null); - if (r.stored) { - job.stored++; - relay.broadcast(e); - } else if (r.msg !== ERR_DUPLICATE) job.skipped++; + seen.add(e.id); + storePulled(relay, e, job); } sock.send("CLOSE", sub); + if (seen.size < ids.length && job.progress) { + job.progress.partial = true; + job.progress.warning = "The source did not supply all events advertised by its sync response."; + } } // copyBlobs brings over a batch of the other relay's files. Returns true diff --git a/src/relay.ts b/src/relay.ts index f4319ec..f5b0b07 100644 --- a/src/relay.ts +++ b/src/relay.ts @@ -705,6 +705,7 @@ export class Relay extends DurableObject { // A finished run is worth a word to the owner, when they asked for one. const finish = (error: string) => { finishRun(job, error, now()); + error = job.last?.error ?? error; const where = (job.kind === "push" ? "to " : "from ") + job.relays.join(", "); const outcome = error ? `failed after ${job.rounds} rounds: ${error}` : job.kind === "push" ? `finished: ${job.sent} events sent${job.refused ? ", " + job.refused + " refused" : ""}` : `finished: ${job.stored} events${job.blobs ? " and " + job.blobs + " files" : ""}${job.skipped ? ", " + job.skipped + " skipped" : ""}`; void notify(this, "jobs", `${job.label} ${where} ${outcome}.`, "jobs on " + this.slug); diff --git a/test/object/pull.test.ts b/test/object/pull.test.ts new file mode 100644 index 0000000..7e402be --- /dev/null +++ b/test/object/pull.test.ts @@ -0,0 +1,225 @@ +import { env, runInDurableObject } from "cloudflare:test"; +import { describe, expect, it } from "vitest"; +import { generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure"; +import type { Relay } from "../../src/relay.ts"; +import { Socket, dial, runPullRound, type PullJob, type PullSocket } from "../../src/pull.ts"; +import { runPullSourceRound, type Job } from "../../src/jobs.ts"; +import { ev, now, rpc } from "../helpers/relay.ts"; +import { WS } from "../helpers/ws.ts"; + +async function claimed(host: string, owner: Uint8Array) { + await rpc(host, owner, "claim"); + return WS.connect(host); +} + +async function run(name: string, job: PullJob) { + return runInDurableObject(env.RELAY.getByName(name), async (relay: Relay) => runPullRound(relay, job)); +} + +class RefusingSocket implements PullSocket { + constructor(private query: boolean) {} + send() { /* scripted refusal */ } + async recv() { return (this.query ? ["CLOSED", "history", "auth-required: source refused history"] : ["NEG-ERR", "pull", "unsupported: sync is switched off"]) as unknown[]; } + close() { /* scripted socket */ } +} + +class ScriptedSocket implements PullSocket { + constructor(private messages: unknown[][]) {} + send() { /* scripted socket */ } + async recv() { return this.messages.shift() ?? ["EOSE", "history"]; } + close() { /* scripted socket */ } +} + +describe("history pull fallback", () => { + it("falls back from a refused NIP-77 sync and resumes ordinary queries", async () => { + const owner = generateSecretKey(); + const source = "pull-query-source.bind.ws"; + const destination = "pull-query-destination.bind.ws"; + const sourceClient = await claimed(source, owner); + await rpc(source, owner, "setpolicy", { features: { sync: false } }); + const first = ev(owner, 1, "first", [], now() - 20); + const second = ev(owner, 1, "second", [], now() - 10); + expect((await sourceClient.ok(first)).ok).toBe(true); + expect((await sourceClient.ok(second)).ok).toBe(true); + await claimed(destination, owner); + + const job: PullJob = { + url: "wss://" + source, + startedAt: now(), + rounds: 0, + stored: 0, + skipped: 0, + blobs: 0, + failures: 0, + filter: { authors: [getPublicKey(owner)] }, + }; + const firstRound = await run("pull-query-destination", job); + expect(firstRound).toEqual({ more: true, error: "" }); + expect(job.progress).toMatchObject({ mode: "query", status: "running", pages: 0 }); + expect(job.progress?.windows).toEqual([{ since: 0, until: job.startedAt }]); + + const secondRound = await run("pull-query-destination", job); + expect(secondRound).toEqual({ more: false, error: "" }); + expect(job.progress).toMatchObject({ mode: "query", status: "best-effort", pages: 1, partial: false }); + expect(job.stored).toBe(2); + }); + + it("marks a saturated one-second query window partial", async () => { + const owner = generateSecretKey(); + const source = "pull-query-saturated-source.bind.ws"; + const destination = "pull-query-saturated-destination.bind.ws"; + await claimed(source, owner); + await rpc(source, owner, "setpolicy", { features: { sync: false } }); + const timestamp = now() - 30; + const events: Event[] = []; + await runInDurableObject(env.RELAY.getByName("pull-query-saturated-source"), async (relay: Relay) => { + for (let i = 0; i < 501; i++) { + const event = ev(owner, 1, "same timestamp " + i, [], timestamp); + events.push(event); + expect(relay.store.save(event, now())).toBe(""); + } + }); + await claimed(destination, owner); + const job: PullJob = { + url: "wss://" + source, + startedAt: timestamp, + rounds: 0, + stored: 0, + skipped: 0, + blobs: 0, + failures: 0, + filter: { authors: [getPublicKey(owner)], since: timestamp }, + }; + expect((await run("pull-query-saturated-destination", job)).more).toBe(true); + const result = await run("pull-query-saturated-destination", job); + expect(result).toEqual({ more: false, error: "" }); + expect(job.progress).toMatchObject({ mode: "query", status: "partial", pages: 1, partial: true }); + expect(job.progress?.warning).toMatch(/one-second window reached/); + expect(job.stored).toBe(500); + expect(events).toHaveLength(501); + }); + + it("advances past a refused source and resumes the next source after JSON persistence", async () => { + const owner = generateSecretKey(); + const source = "pull-refused-source.bind.ws"; + const destination = "pull-refused-destination.bind.ws"; + await claimed(source, owner); + await rpc(source, owner, "setpolicy", { features: { sync: false } }); + const sourceClient = await WS.connect(source); + const note = ev(owner, 1, "from the second source", [], now() - 10); + expect((await sourceClient.ok(note)).ok).toBe(true); + await claimed(destination, owner); + const job = { + id: "pull-test", + kind: "pull", + label: "pull", + relays: ["wss://first.example", "wss://" + source], + filter: { authors: [getPublicKey(owner)] }, + every: 0, + createdAt: now(), + nextRun: 0, + running: true, + startedAt: now(), + rounds: 0, + failures: 0, + relayIndex: 0, + cursor: 0, + stored: 0, + skipped: 0, + blobs: 0, + sent: 0, + refused: 0, + last: null, + } as Job; + let refusalRounds = 0; + const connect = async (relay: Relay, url: string): Promise => { + if (url === "wss://first.example") return new RefusingSocket(refusalRounds++ > 0); + return new Socket(await dial(relay, url)); + }; + const first = await runInDurableObject(env.RELAY.getByName("pull-refused-destination"), async (relay: Relay) => runPullSourceRound(relay, job, connect)); + expect(first).toEqual({ more: true, error: "" }); + expect(job.relayIndex).toBe(0); + expect(job.pullSources?.[0]).toMatchObject({ mode: "query", status: "running" }); + const persisted = JSON.parse(JSON.stringify(job)) as Job; + const refused = await runInDurableObject(env.RELAY.getByName("pull-refused-destination"), async (relay: Relay) => runPullSourceRound(relay, persisted, connect)); + expect(refused).toEqual({ more: true, error: "" }); + expect(persisted.relayIndex).toBe(1); + expect(persisted.pullSources?.[0]).toMatchObject({ status: "refused", error: expect.stringContaining("auth-required") }); + const third = await runInDurableObject(env.RELAY.getByName("pull-refused-destination"), async (relay: Relay) => runPullSourceRound(relay, persisted)); + expect(third).toEqual({ more: true, error: "" }); + expect(persisted.relayIndex).toBe(1); + expect(persisted.pullSources?.[1]).toMatchObject({ mode: "query", status: "running" }); + const fourth = await runInDurableObject(env.RELAY.getByName("pull-refused-destination"), async (relay: Relay) => runPullSourceRound(relay, persisted)); + expect(fourth).toEqual({ more: false, error: "" }); + expect(persisted.relayIndex).toBe(2); + expect(persisted.pullSources?.[1]).toMatchObject({ status: "best-effort", pages: 1, stored: 1 }); + }); + + it("retries transient connection failures three times before advancing", async () => { + const owner = generateSecretKey(); + const destination = "pull-retry-destination.bind.ws"; + await claimed(destination, owner); + const job = { + id: "pull-retry-test", kind: "pull", label: "pull", relays: ["wss://flaky.example", "wss://next.example"], filter: {}, every: 0, + createdAt: now(), nextRun: 0, running: true, startedAt: now(), rounds: 0, failures: 0, relayIndex: 0, cursor: 0, + stored: 0, skipped: 0, blobs: 0, sent: 0, refused: 0, last: null, + } as Job; + let attempts = 0; + const connect = async (_relay: Relay, url: string): Promise => { + if (url === "wss://flaky.example") { attempts++; throw new Error("temporary network failure"); } + return new ScriptedSocket([["NEG-ERR", "pull", "unsupported"]]); + }; + for (let i = 0; i < 2; i++) { + const r = await runInDurableObject(env.RELAY.getByName("pull-retry-destination"), async (relay: Relay) => runPullSourceRound(relay, job, connect)); + expect(r.error).toMatch(/temporary network failure/); + expect(job.relayIndex).toBe(0); + } + const third = await runInDurableObject(env.RELAY.getByName("pull-retry-destination"), async (relay: Relay) => runPullSourceRound(relay, job, connect)); + expect(third).toEqual({ more: true, error: "" }); + expect(attempts).toBe(3); + expect(job.pullSources?.[0]).toMatchObject({ status: "failed", failures: 3 }); + expect(job.relayIndex).toBe(1); + }); + + it("skips invalid, wrong-author, and out-of-window source events", async () => { + const owner = generateSecretKey(); + const stranger = generateSecretKey(); + const destination = "pull-validation-destination.bind.ws"; + await claimed(destination, owner); + const good = ev(owner, 1, "in window", [], 100); + const wrongAuthor = ev(stranger, 1, "wrong author", [], 100); + const outOfWindow = ev(owner, 1, "too old", [], 50); + const invalid = { ...good, content: "tampered" }; + let round = 0; + const connect = async (): Promise => round++ === 0 + ? new ScriptedSocket([["NEG-ERR", "pull", "unsupported"]]) + : new ScriptedSocket([["EVENT", "history", invalid], ["EVENT", "history", wrongAuthor], ["EVENT", "history", outOfWindow], ["EVENT", "history", good], ["EOSE", "history"]]); + const job: PullJob = { url: "wss://scripted.example", startedAt: 100, rounds: 0, stored: 0, skipped: 0, blobs: 0, failures: 0, filter: { authors: [getPublicKey(owner)], since: 80 } }; + await runInDurableObject(env.RELAY.getByName("pull-validation-destination"), async (relay: Relay) => runPullRound(relay, job, connect)); + const result = await runInDurableObject(env.RELAY.getByName("pull-validation-destination"), async (relay: Relay) => runPullRound(relay, job, connect)); + expect(result).toEqual({ more: false, error: "" }); + expect(job.stored).toBe(1); + expect(job.skipped).toBe(3); + expect(job.progress).toMatchObject({ status: "partial", partial: true, pages: 1 }); + }); + + it("splits a saturated multi-second window and preserves the split after JSON cloning", async () => { + const owner = generateSecretKey(); + const destination = "pull-split-destination.bind.ws"; + await claimed(destination, owner); + const repeated = ev(owner, 1, "repeated", [], 50); + let round = 0; + const connect = async (): Promise => { + if (round++ === 0) return new ScriptedSocket([["NEG-ERR", "pull", "unsupported"]]); + return new ScriptedSocket([...Array.from({ length: 500 }, () => ["EVENT", "history", repeated] as unknown[]), ["EOSE", "history"]]); + }; + const job: PullJob = { url: "wss://scripted.example", startedAt: 100, rounds: 0, stored: 0, skipped: 0, blobs: 0, failures: 0, filter: { authors: [getPublicKey(owner)], since: 0 } }; + expect((await runInDurableObject(env.RELAY.getByName("pull-split-destination"), async (relay: Relay) => runPullRound(relay, job, connect))).more).toBe(true); + expect((await runInDurableObject(env.RELAY.getByName("pull-split-destination"), async (relay: Relay) => runPullRound(relay, job, connect))).more).toBe(true); + expect(job.progress?.windows).toEqual([{ since: 0, until: 50 }, { since: 51, until: 100 }]); + const persisted = JSON.parse(JSON.stringify(job)) as PullJob; + await runInDurableObject(env.RELAY.getByName("pull-split-destination"), async (relay: Relay) => runPullRound(relay, persisted, connect)); + expect(persisted.progress?.windows).toBeDefined(); + expect(persisted.progress?.pages).toBe(2); + }); +}); From 3c60fa663dd6e02559f6409ae776da2d16e3ae17 Mon Sep 17 00:00:00 2001 From: Dami Date: Fri, 4 Sep 2026 18:07:26 -0600 Subject: [PATCH 02/14] lists: retain bounded private versions so owners can restore follows relay lists and bookmarks --- README.md | 1 + docs/27-list-recovery.md | 10 ++++++ src/console/console.html | 5 +++ src/console/console.js | 19 ++++++++++ src/gen/console.ts | 4 +-- src/list-history.ts | 60 ++++++++++++++++++++++++++++++++ src/manage.ts | 17 ++++++++- src/store.ts | 11 ++++++ test/object/list-history.test.ts | 42 ++++++++++++++++++++++ 9 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 docs/27-list-recovery.md create mode 100644 src/list-history.ts create mode 100644 test/object/list-history.test.ts diff --git a/README.md b/README.md index 90d4dff..6f7f091 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ Site hosting and mirroring are on by default; Marmot and GRASP are off until ena - [NIP-86 membership claims](docs/24-nip86-claims.md): create, list and revoke invitation codes through the standard management methods. - [NIP-9a relay push](docs/25-nip-9a-relay-push.md): opt-in callback delivery, privacy, bounds and operator setup. - [NIP-11 identifier compatibility](docs/26-nip11-compatibility.md): lettered capabilities and concrete client parser behavior. +- [List recovery](docs/27-list-recovery.md): privately review and restore older follows, relay lists and bookmarks. ### Protocol guides diff --git a/docs/27-list-recovery.md b/docs/27-list-recovery.md new file mode 100644 index 0000000..3f44a48 --- /dev/null +++ b/docs/27-list-recovery.md @@ -0,0 +1,10 @@ +--- +title: List recovery +audience: user +--- + +Your relay keeps up to twelve older versions of your follows, relay lists and bookmark lists. The versions stay private to the key that published them and do not appear in ordinary queries, dumps or search. + +Open the Data tab in your relay console to see saved versions. Restore prepares the old tags and content as a new event with a current timestamp. Your browser extension or remote signer signs it, and the console publishes the signed event through the normal event door. The relay never receives or stores your private key. + +History starts when a list is replaced, so a newly claimed relay has no older version until you publish a newer list. Author deletion and NIP-62 vanish remove the saved versions as well. diff --git a/src/console/console.html b/src/console/console.html index 98a7b81..cc101c4 100644 --- a/src/console/console.html +++ b/src/console/console.html @@ -295,6 +295,11 @@

                Share

                Data

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

                +
                +

                Recover your lists

                +

                Older signed versions of your follows, relay lists and bookmarks stay private here. Restore a version to review it, then sign and publish it from this relay.

                +
                ListCreatedSaved
                +
                diff --git a/src/console/console.js b/src/console/console.js index 51341f0..4abbcec 100644 --- a/src/console/console.js +++ b/src/console/console.js @@ -517,6 +517,13 @@ return '' + pill + '' + (k.n === undefined ? "" : k.n.toLocaleString()) + '' + (k.bytes === undefined ? "" : fmtBytes(k.bytes)) + '' + (k.oldest ? esc(fmtDay(k.oldest)) : "") + "" + cells + ""; }; $("#kinds tbody").innerHTML = [...st.kinds, { kind: null, days: any ? any.days : 0 }].map(row).join(""); + loadListHistory().catch(() => {}); + } + + async function loadListHistory() { + const rows = await rpc("listlisthistory"); + const labels = { 3: "follows", 10002: "relay list", 10003: "bookmarks", 30003: "bookmark list" }; + $("#listhistory tbody").innerHTML = rows.length ? rows.map((r) => '' + esc(labels[r.kind] || ("kind " + r.kind) + (r.d ? " / " + r.d : "")) + '' + esc(fmtTime(r.created_at)) + '' + esc(fmtTime(r.saved_at)) + '' + ib("undo", "Restore this version", "restorelist", r.event_id) + '').join("") : 'no older list versions yet'; } let searchQuery = ""; @@ -964,6 +971,18 @@ if (act === "deleteblob" && !confirm("Delete this file for good?")) return; if (act === "deletedump" && !confirm("Delete this dump?")) return; if (act === "downloaddump") { b.disabled = true; try { await downloadDump(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; } + if (act === "restorelist") { + if (!signer.ready()) { toast(NO_SIGNER); return; } + if (!confirm("Restore this list version? It will be signed and published as the newest version.")) return; + try { + const draft = await rpc("restorelist", id); + const signed = await signer.signEvent(draft); + const result = await bridge("/events", signed); + if (!result.accepted) throw new Error(result.message || "The relay refused the restored list."); + toast("List restored"); await loadListHistory(); await loadStorage(); + } catch (e) { toast(e.message); } finally { b.disabled = false; } + return; + } if ((act === "removemember" || act === "banpubkey") && (window.__members || []).some((m) => m.invited_by === id) && confirm("Also remove everyone this member invited, and everyone they invited in turn?")) { b.disabled = true; try { const r = await rpc("removesubtree", id); if (act === "banpubkey") await rpc("banpubkey", id, "", erase); toast("Removed " + r.removed.length); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; } diff --git a/src/gen/console.ts b/src/gen/console.ts index a061299..0cae9bf 100644 --- a/src/gen/console.ts +++ b/src/gen/console.ts @@ -1,4 +1,4 @@ // Generated by scripts/build/build-console.mjs from src/console. Do not edit; run npm run build:console. -export const CONSOLE_HTML = "
                \n
                \n
                \"\"
                \n
                \"\"

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

                Nobody owns this relay yet.

                \n

                Claim it and it's yours: you decide who can post, who can read, and what stays. One signature with a nostr browser extension; no account, no email, no card.

                \n
                \n
                \n\n
                \n

                A temporary relay, for now.

                \n

                Anyone can read and write here until . Then everything on it is deleted and the name is freed. Claim it and it stays, events and files included: one signature with a nostr browser extension. Or claim a new name and pull this one into it from its Storage tab.

                \n
                \n
                \n\n
                \n

                Connect a remote signer.

                \n

                Your key stays in a signer app such as Amber or nsec.app; this page asks it to sign. On a phone, open the link and approve there. On a computer, paste the bunker:// URL the app gives you.

                \n \n
                \n \"QR\n

                \n
                \n\n
                \n
                \n

                \n

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

                Git repositories, ntig

                \n
                \n

                Use ordinary Git to clone a repository hosted here.

                \n
                Clone a repository
                \n

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

                \n

                To host your own, use a Nostr Git client to publish your repository and signed branch state to this relay before pushing to the same remote. Git hosting guide.

                \n
                \n
                \n
                \n

                Open it in an app

                \n

                Relay apps open this relay as a place. Feed apps do not: they find the owner here through a profile link that carries this relay as the hint, and learn the relay from that. Either way, add under the app's relay settings to post here.

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

                People

                \n

                Hidden from visitors. Only you see this list.

                \n
                \n
                \n\n
                \n

                Fuel

                \n

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

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

                \n
                sats
                \n
                \n

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

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

                Your invites

                \n

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

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

                  People

                  \n

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

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

                  Invites

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

                    Joining

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

                    Moderation

                    \n

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

                    \n
                    \n

                    Reports

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

                    Log

                    \n

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

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

                    Bans

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

                        Blocked addresses

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

                          Recent events

                          \n

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

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

                          Pinned

                          \n

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

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

                            Rules

                            \n

                            Bans apply regardless of these.

                            \n
                            \n

                            Presets

                            \n

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

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

                            \n
                            \n
                            \n
                            \n

                            Writes

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

                            Reads

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

                            Kinds

                            \n

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

                            \n
                            \n

                            Allowed:

                            \n

                            Blocked:

                            \n
                            \n
                            \n

                            Features

                            \n

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

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

                            Blocked words

                            \n

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

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

                            Identity

                            \n
                            \n

                            Profile

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

                            For directories

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

                            Your own domain

                            \n

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

                            \n
                            \n
                            \n

                            \n
                            \n
                            \n

                            Your relay lists

                            \n

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

                            \n
                            \n
                            \n
                            \n

                            Share

                            \n

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

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

                            Data

                            \n

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

                            \n
                            \n
                            \n
                            \n

                            By kind

                            \n
                            KindCountSizeOldestKeep for
                            \n
                            \n
                            \n

                            Files

                            \n
                            TimeFileSizeUploader
                            \n
                            \n
                            \n

                            Sites

                            \n

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

                            \n
                            AuthorNameURLFilesSizeExpiry
                            \n
                            \n
                            \n

                            Dumps

                            \n

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

                            \n
                            \n
                              \n
                              \n
                              \n

                              Import a file

                              \n

                              A JSONL of events, one per line, such as a dump or a strfry export, or a JSON array. Up to 64 MB. Signatures are checked; bans and kind rules apply; the write rule does not. Progress shows under Jobs.

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

                              Sync

                              \n

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

                              \n
                              \n

                              Jobs

                              \n

                              Work the relay does on its own, one round at a time. A pull copies what another relay has and this one lacks. Fetch my history pulls your own events from the relays in your relay list. Rebroadcast sends what is here to other relays. Bans and kind rules apply to what arrives.

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

                              Fork this relay

                              \n

                              A new name, temporary until claimed, filled from this relay. Yours to split a name by job, or somebody else's to hand them a community with its history. One fork an hour.

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

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

                              Views

                              \n

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

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

                              Health

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

                              Zaps received

                              \n
                              WhenFromSats
                              \n
                              \n
                              \n

                              Notifications

                              \n

                              The relay writes you a private message (NIP-17) with its own key. It lands here, in your inbox on your own relay, and on your DM relays when this relay holds your kind 10050.

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

                              Owner

                              \n

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

                              \n
                              \n

                              Configuration

                              \n

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

                              \n
                              \n
                              \n
                              \n

                              Transfer ownership

                              \n

                              Hands the relay to a member. You stay on as a moderator. The relay's key, events, files and fuel do not change. There is no undo.

                              \n
                              \n
                              \n
                              \n

                              If I lose my key

                              \n

                              Name a member as your heir. If you do not sign in here for the time you pick, the relay writes to you once a week for a month, then hands itself to the heir and keeps you on as a moderator. Any signed action on the relay resets the clock.

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

                              \n
                              \n
                              \n

                              Delete this relay

                              \n

                              Deletes every event, file, member, invite and setting, closes every connection, and returns the name to unclaimed for anyone to take. There is no undo.

                              \n \n
                              \n
                              \n
                              \n\n \n
                              \n"; +export const CONSOLE_HTML = "
                              \n
                              \n
                              \"\"
                              \n
                              \"\"

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

                              Nobody owns this relay yet.

                              \n

                              Claim it and it's yours: you decide who can post, who can read, and what stays. One signature with a nostr browser extension; no account, no email, no card.

                              \n
                              \n
                              \n\n
                              \n

                              A temporary relay, for now.

                              \n

                              Anyone can read and write here until . Then everything on it is deleted and the name is freed. Claim it and it stays, events and files included: one signature with a nostr browser extension. Or claim a new name and pull this one into it from its Storage tab.

                              \n
                              \n
                              \n\n
                              \n

                              Connect a remote signer.

                              \n

                              Your key stays in a signer app such as Amber or nsec.app; this page asks it to sign. On a phone, open the link and approve there. On a computer, paste the bunker:// URL the app gives you.

                              \n \n
                              \n \"QR\n

                              \n
                              \n\n
                              \n
                              \n

                              \n

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

                              Git repositories, ntig

                              \n
                              \n

                              Use ordinary Git to clone a repository hosted here.

                              \n
                              Clone a repository
                              \n

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

                              \n

                              To host your own, use a Nostr Git client to publish your repository and signed branch state to this relay before pushing to the same remote. Git hosting guide.

                              \n
                              \n
                              \n
                              \n

                              Open it in an app

                              \n

                              Relay apps open this relay as a place. Feed apps do not: they find the owner here through a profile link that carries this relay as the hint, and learn the relay from that. Either way, add under the app's relay settings to post here.

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

                              People

                              \n

                              Hidden from visitors. Only you see this list.

                              \n
                              \n
                              \n\n
                              \n

                              Fuel

                              \n

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

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

                              \n
                              sats
                              \n
                              \n

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

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

                              Your invites

                              \n

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

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

                                People

                                \n

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

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

                                Invites

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

                                  Joining

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

                                  Moderation

                                  \n

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

                                  \n
                                  \n

                                  Reports

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

                                  Log

                                  \n

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

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

                                  Bans

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

                                      Blocked addresses

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

                                        Recent events

                                        \n

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

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

                                        Pinned

                                        \n

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

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

                                          Rules

                                          \n

                                          Bans apply regardless of these.

                                          \n
                                          \n

                                          Presets

                                          \n

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

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

                                          \n
                                          \n
                                          \n
                                          \n

                                          Writes

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

                                          Reads

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

                                          Kinds

                                          \n

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

                                          \n
                                          \n

                                          Allowed:

                                          \n

                                          Blocked:

                                          \n
                                          \n
                                          \n

                                          Features

                                          \n

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

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

                                          Blocked words

                                          \n

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

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

                                          Identity

                                          \n
                                          \n

                                          Profile

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

                                          For directories

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

                                          Your own domain

                                          \n

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

                                          \n
                                          \n
                                          \n

                                          \n
                                          \n
                                          \n

                                          Your relay lists

                                          \n

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

                                          \n
                                          \n
                                          \n
                                          \n

                                          Share

                                          \n

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

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

                                          Data

                                          \n

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

                                          \n
                                          \n

                                          Recover your lists

                                          \n

                                          Older signed versions of your follows, relay lists and bookmarks stay private here. Restore a version to review it, then sign and publish it from this relay.

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

                                          By kind

                                          \n
                                          KindCountSizeOldestKeep for
                                          \n
                                          \n
                                          \n

                                          Files

                                          \n
                                          TimeFileSizeUploader
                                          \n
                                          \n
                                          \n

                                          Sites

                                          \n

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

                                          \n
                                          AuthorNameURLFilesSizeExpiry
                                          \n
                                          \n
                                          \n

                                          Dumps

                                          \n

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

                                          \n
                                          \n
                                            \n
                                            \n
                                            \n

                                            Import a file

                                            \n

                                            A JSONL of events, one per line, such as a dump or a strfry export, or a JSON array. Up to 64 MB. Signatures are checked; bans and kind rules apply; the write rule does not. Progress shows under Jobs.

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

                                            Sync

                                            \n

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

                                            \n
                                            \n

                                            Jobs

                                            \n

                                            Work the relay does on its own, one round at a time. A pull copies what another relay has and this one lacks. Fetch my history pulls your own events from the relays in your relay list. Rebroadcast sends what is here to other relays. Bans and kind rules apply to what arrives.

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

                                            Fork this relay

                                            \n

                                            A new name, temporary until claimed, filled from this relay. Yours to split a name by job, or somebody else's to hand them a community with its history. One fork an hour.

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

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

                                            Views

                                            \n

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

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

                                            Health

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

                                            Zaps received

                                            \n
                                            WhenFromSats
                                            \n
                                            \n
                                            \n

                                            Notifications

                                            \n

                                            The relay writes you a private message (NIP-17) with its own key. It lands here, in your inbox on your own relay, and on your DM relays when this relay holds your kind 10050.

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

                                            Owner

                                            \n

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

                                            \n
                                            \n

                                            Configuration

                                            \n

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

                                            \n
                                            \n
                                            \n
                                            \n

                                            Transfer ownership

                                            \n

                                            Hands the relay to a member. You stay on as a moderator. The relay's key, events, files and fuel do not change. There is no undo.

                                            \n
                                            \n
                                            \n
                                            \n

                                            If I lose my key

                                            \n

                                            Name a member as your heir. If you do not sign in here for the time you pick, the relay writes to you once a week for a month, then hands itself to the heir and keeps you on as a moderator. Any signed action on the relay resets the clock.

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

                                            \n
                                            \n
                                            \n

                                            Delete this relay

                                            \n

                                            Deletes every event, file, member, invite and setting, closes every connection, and returns the name to unclaimed for anyone to take. There is no undo.

                                            \n \n
                                            \n
                                            \n
                                            \n\n \n
                                            \n"; export const CONSOLE_CSS = "main { max-width: 64rem; }\n.mast { display: grid; grid-template-columns: 1fr auto; gap: 1.5rem 2rem; align-items: end; margin-bottom: 2rem; }\n.mast .wordmark { grid-column: 1 / -1; justify-self: center; margin-bottom: .5rem; }\n.mast .right { display: grid; gap: .7rem; justify-items: end; text-align: right; }\n.urlrow, .owner { display: inline-flex; align-items: center; gap: .4rem; color: var(--ink-2); }\n.owner b { color: var(--ink); } .owner em { font-style: normal; color: var(--forest); }\n.urlrow .ib, .owner .ib { width: 26px; height: 26px; margin-left: .2rem; box-shadow: 1px 1px 0 var(--ink); } .urlrow .ib svg, .owner .ib svg { width: 13px; height: 13px; }\n.care { display: flex; gap: .9rem; flex-wrap: wrap; }\n.care .g { display: grid; justify-items: center; gap: .3rem; width: 5.4rem; }\n.care .g i { display: grid; place-items: center; width: 44px; height: 44px; border: 2px solid var(--ink); border-radius: 10px; background: var(--paper); box-shadow: 2px 2px 0 var(--ink); position: relative; }\n.care .g i svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }\n.care .g.off i::after { content: \"\"; position: absolute; width: 3px; height: 40px; background: var(--ink); transform: rotate(45deg); border-radius: 2px; box-shadow: 0 0 0 2px var(--paper); }\n.care .g small { font: 500 10px/1.2 var(--mono); text-transform: uppercase; letter-spacing: .04em; color: var(--ink-2); text-align: center; }\n#unclaimed { background: var(--butter); }\n.doors { display: grid; grid-template-columns: repeat(4, 1fr); gap: .8rem; } @media (max-width: 52rem) { .doors { grid-template-columns: 1fr 1fr; } } @media (max-width: 30rem) { .doors { grid-template-columns: 1fr; } }\n.door { display: grid; grid-template-rows: auto 1fr; align-content: start; gap: .35rem; padding: .8rem .9rem; border: 2px solid var(--ink); border-radius: 12px; background: var(--paper); box-shadow: 3px 3px 0 var(--ink); min-width: 0; }\n.apps { display: grid; gap: 1.4rem; margin-top: 1rem; } .appgroup h4 { margin: 0 0 .2rem; font-size: 1rem; } .appgroup > .note { margin-top: 0; }\n.appgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: .8rem; } @media (max-width: 52rem) { .appgrid { grid-template-columns: 1fr 1fr; } } @media (max-width: 30rem) { .appgrid { grid-template-columns: 1fr; } }\n.app { display: grid; align-content: start; gap: .4rem; padding: .8rem .9rem; border: 2px solid var(--ink); border-radius: 12px; background: var(--paper); box-shadow: 3px 3px 0 var(--ink); min-width: 0; }\n.app-head { display: flex; align-items: baseline; gap: .5rem; flex-wrap: wrap; } .app-head b { font-size: 15px; } .app-head small { color: var(--ink-3); } .app p { margin: 0; font-size: 13px; color: var(--ink-2); }\n.app-acts { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .2rem; } .app-acts .btn { padding: .3rem .7rem; font-size: 13px; }\n.phones { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, max-content)); gap: .8rem; margin-top: 1rem; } .phones img { display: block; width: 150px; height: 150px; } .phones .door { justify-items: start; }\n.door small { font: 700 11px var(--sans); text-transform: uppercase; letter-spacing: .06em; color: var(--ink-2); }\n.door .v { display: grid; grid-template-columns: 1fr auto; align-items: start; gap: .5rem; font: 500 13px/1.45 var(--mono); } .door .v span { min-width: 0; overflow-wrap: anywhere; }\n.door .ib { width: 24px; height: 24px; box-shadow: 1px 1px 0 var(--ink); flex: 0 0 auto; } .door .ib svg { width: 12px; height: 12px; }\n#peoplesec { background: var(--peach); }\n.dir { display: flex; flex-wrap: wrap; gap: .5rem; }\n.who { display: inline-flex; align-items: center; gap: .1rem; padding: .3rem .7rem .3rem .4rem; border: 2px solid var(--ink); border-radius: 999px; background: var(--paper); box-shadow: 2px 2px 0 var(--ink); font-size: 14px; }\n.who .role { font: 700 10px var(--mono); text-transform: uppercase; letter-spacing: .05em; color: var(--forest); margin-left: .4rem; }\n.who.me { background: var(--sun); }\n#fuelsec { background: var(--mint); }\n.gauges { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }\n.gauge small { display: block; font-weight: 600; font-size: 13px; }\n.gauge .bar { position: relative; height: 22px; border: 2px solid var(--ink); border-radius: 999px; margin: .5rem 0 .35rem; overflow: hidden; padding: 3px; background: var(--paper) linear-gradient(90deg, transparent calc(25% - 1px), var(--line) calc(25% - 1px) 25%, transparent 25% calc(50% - 1px), var(--line) calc(50% - 1px) 50%, transparent 50% calc(75% - 1px), var(--line) calc(75% - 1px) 75%, transparent 75%); }\n.gauge .bar b { position: absolute; right: .6rem; top: 0; bottom: 0; display: flex; align-items: center; font: 600 11px var(--mono); color: var(--ink-3); pointer-events: none; }\n.gauge .bar i { display: block; height: 100%; width: 0; min-width: 14px; border-radius: 999px; background: repeating-linear-gradient(-45deg, var(--forest) 0 8px, var(--forest-2) 8px 16px); transition: width .4s; }\n.gauge .bar i.warm { background: repeating-linear-gradient(-45deg, #d9a52a 0 8px, var(--sun) 8px 16px); }\n.gauge .bar i.over { background: repeating-linear-gradient(-45deg, var(--red) 0 8px, #d4614c 8px 16px); }\n.gauge span { font-size: 13px; color: var(--ink-3); }\n.balance { margin: 1.2rem 0 0; color: var(--ink-2); } .balance b { color: var(--ink); font-weight: 700; }\n.sats { display: inline-flex; align-items: center; width: auto; } .sats input { font: 15px var(--sans); width: 6rem; text-align: right; border: 0; outline: none; padding: 0; background: transparent; color: var(--ink); } .sats span { color: var(--ink-3); padding-left: .4rem; }\n.topup { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; margin-top: .8rem; }\n.invoice { margin-top: 1rem; border: 2px dashed var(--ink); border-radius: 12px; padding: .9rem 1rem; background: var(--paper); }\n.invoice p { margin: 0 0 .6rem; color: var(--ink-2); font-size: 14px; } .invoice .state { font-size: 13px; color: var(--forest); }\n.invoice textarea { margin-top: .7rem; min-height: 3.4rem; font-family: var(--mono); font-size: 11.5px; color: var(--ink-3); }\ndetails.disclosure summary { cursor: pointer; font: 400 1.6rem/1 var(--display); list-style: none; display: flex; align-items: center; gap: .5rem; }\ndetails.disclosure summary::-webkit-details-marker { display: none; }\n.folds details + details, .folds #frontsec:not(.hidden) + details { border-top: 2px solid var(--line); margin-top: 1.2rem; padding-top: 1.2rem; } .folds #frontsec .metarow { margin-bottom: 0; } .folds .doors { margin-top: 1rem; } .folds > details > .block:first-of-type { margin-top: 1.4rem; }\ndetails.disclosure summary::before { content: \"+\"; font: 700 1.4rem var(--mono); width: 1.4rem; } details.disclosure[open] summary::before { content: \"–\"; }\n.about { display: grid; grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); gap: .6rem 1.2rem; margin-top: 1rem; font-size: 14px; }\n.about small { display: block; color: var(--ink-3); font-size: 12px; } .about div > div { overflow-wrap: anywhere; }\n.console { margin-top: 3rem; }\n.tabs { display: flex; gap: .45rem; flex-wrap: nowrap; align-items: flex-end; margin: 0 0 -2px 1rem; padding: 2px 3rem 2px 0; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; }\n.tabs.fade-r { -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 2.5rem), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 2.5rem), transparent); }\n.tabs.fade-l { -webkit-mask-image: linear-gradient(90deg, transparent, #000 2.5rem); mask-image: linear-gradient(90deg, transparent, #000 2.5rem); }\n.tabs.fade-l.fade-r { -webkit-mask-image: linear-gradient(90deg, transparent, #000 2.5rem, #000 calc(100% - 2.5rem), transparent); mask-image: linear-gradient(90deg, transparent, #000 2.5rem, #000 calc(100% - 2.5rem), transparent); } .tabs::-webkit-scrollbar { display: none; } .tabs a { flex: 0 0 auto; white-space: nowrap; }\n.tabs a { font: 600 14px var(--sans); padding: .55rem 1rem; border: 2px solid var(--ink); border-bottom: 0; border-radius: 12px 12px 0 0; background: var(--head); color: var(--ink-2); text-decoration: none; margin-bottom: -2px; position: relative; z-index: 1; }\n.tabs a.on { color: var(--ink); z-index: 3; padding-top: .7rem; } .tabs a.on:nth-child(4n+1) { background: var(--sun); } .tabs a.on:nth-child(4n+2) { background: var(--peach); } .tabs a.on:nth-child(4n+3) { background: var(--sky); } .tabs a.on:nth-child(4n+4) { background: var(--mint); }\n.count { display: inline-grid; place-items: center; min-width: 1.5em; height: 1.5em; padding: 0 .4em; margin-left: .45rem; border: 1.5px solid var(--ink); border-radius: 999px; background: var(--sun); color: var(--ink); font: 700 11px/1 var(--mono); vertical-align: middle; box-shadow: 1px 1px 0 var(--ink); } .count:empty { display: none; } h2 .count { font-size: 12px; vertical-align: .45em; }\n.panel { display: none; margin: 0; border-radius: 0 18px 18px 18px; position: relative; z-index: 2; }\n.panel.on { display: block; }\n.panel h2 { font-size: 2rem; }\n.block { margin-top: 1.8rem; }\n.two { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; } .two > * { min-width: 0; }\n.actions { display: flex; justify-content: flex-end; margin-top: 1.1rem; } .actions.left { justify-content: flex-start; }\n.addrow { display: flex; gap: .5rem; align-items: center; margin-bottom: .9rem; } .addrow input { flex: 1; min-width: 0; } .addrow label { display: inline-flex; align-items: center; gap: .35rem; flex: 0 0 auto; white-space: nowrap; font-size: 14px; color: var(--ink-2); cursor: pointer; } .addrow label input { flex: none; width: auto; margin: 0; accent-color: var(--forest); } .addrow .ib { width: 38px; height: 38px; flex: 0 0 auto; border-radius: 10px; }\n.choices { display: grid; gap: .5rem; }\n.choices label { display: grid; grid-template-columns: auto 1fr; gap: 0 .7rem; cursor: pointer; padding: .7rem .8rem; border: 2px solid var(--line-2); border-radius: 12px; background: var(--paper); }\n.choices label:has(:checked) { border-color: var(--ink); box-shadow: 3px 3px 0 var(--ink); }\n.choices input { grid-row: span 2; margin: .2rem 0 0; accent-color: var(--forest); }\n.choices b { font-weight: 600; } .choices small { color: var(--ink-2); font-size: 13px; }\n.limits { display: grid; grid-template-columns: 1fr 1fr; gap: .6rem 1.5rem; margin-top: 1.3rem; }\n.limits label { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: .6rem; } .limits label small { color: var(--ink-3); }\n.form { display: grid; grid-template-columns: 1fr 1fr; gap: .9rem 1.5rem; } .form label { display: grid; gap: .3rem; font-weight: 600; font-size: 14px; } .form .wide { grid-column: 1 / -1; }\n.danger-zone { border: 2px solid var(--red); border-radius: 12px; padding: 1rem 1.1rem; background: var(--red-soft); } .danger-zone h3 { color: var(--red); } .form label.switch { display: flex; flex-direction: row; align-items: center; gap: .6rem; font-weight: 600; font-size: 14px; } .switch input { accent-color: var(--forest); width: 18px; height: 18px; }\ntable { width: 100%; border-collapse: collapse; }\nth { text-align: left; font-size: 12px; font-weight: 600; color: var(--ink-3); padding: .4rem .5rem; border-bottom: 2px solid var(--ink); white-space: nowrap; }\ntd { padding: .5rem .5rem; vertical-align: middle; border-bottom: 1px solid var(--line); }\ntbody tr:nth-child(even) td { background: rgba(255,255,255,.55); }\nth.r, td.r { text-align: right; white-space: nowrap; } td.mono, td.dim { white-space: nowrap; } .events td.c:empty::before { content: \"(no content)\"; color: var(--ink-3); }\ntd .ib { width: 26px; height: 26px; box-shadow: 1px 1px 0 var(--ink); } td .ib svg { width: 13px; height: 13px; }\ntd input.txt { padding: .25rem .5rem; font-size: 14px; } td select.role { width: auto; min-width: 7rem; padding: .25rem .4rem; font-size: 13px; } .people-table input.name { width: 7rem; } .people-table input.note { width: 9.5rem; }\n.kind { font-family: var(--mono); font-size: 12px; background: var(--sun); border: 1.5px solid var(--ink); padding: 0 .5rem; border-radius: 999px; font-weight: 500; white-space: nowrap; }\n.events td.c { max-width: 0; width: 100%; overflow-x: auto; white-space: nowrap; scrollbar-width: thin; -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 2rem), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 2rem), transparent); }\n.people-table td.name { width: 12rem; }\n.ev { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 4px; vertical-align: -5px; margin-right: .45rem; font: 500 9px var(--mono); background: var(--line); color: var(--ink-2); font-style: normal; }\n.plain { list-style: none; margin: 0; padding: 0; } .plain li { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: .5rem; padding: .5rem 0; border-bottom: 2px dotted var(--line-2); } .plain li > span { min-width: 0; overflow-wrap: anywhere; } .plain li:last-child { border-bottom: 0; } .plain li.empty { grid-template-columns: 1fr; color: var(--ink-3); }\n.kindline { margin: .4rem 0; color: var(--ink-2); display: flex; flex-wrap: wrap; align-items: center; gap: .35rem; }\n.tag { display: inline-flex; align-items: center; gap: .2rem; font-family: var(--mono); font-size: 13px; padding: .05rem .2rem .05rem .55rem; border: 2px solid var(--ink); border-radius: 999px; background: var(--paper); box-shadow: 1px 1px 0 var(--ink); }\n.tag.plain { padding-right: .55rem; box-shadow: none; border-color: var(--line-2); color: var(--ink-3); } .tag.blk { color: var(--red); }\n.tag .ib { width: 18px; height: 18px; border-width: 1.5px; box-shadow: none; margin-left: .2rem; } .tag .ib svg { width: 9px; height: 9px; }\n.counters { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1.2rem; margin-bottom: 1.6rem; }\n.counter { display: grid; justify-items: center; align-content: center; gap: .15rem; min-height: 7.2rem; text-align: center; padding: 1rem .8rem; border: 2px solid var(--ink); border-radius: 16px; box-shadow: 4px 4px 0 var(--ink); background: var(--paper); }\n.counter:nth-child(1) { background: var(--sun); } .counter:nth-child(2) { background: var(--peach); } .counter:nth-child(3) { background: var(--sky); } .counter:nth-child(4) { background: var(--mint); }\n.counter .big { font: 400 2.6rem/1 var(--display); white-space: nowrap; } .counter small { display: block; margin-top: .35rem; font: 700 12px var(--sans); text-transform: uppercase; letter-spacing: .08em; } .counter .sub { font-size: 12px; color: var(--ink-2); }\n.kbar { display: flex; width: 100%; height: 22px; border: 2px solid var(--ink); border-radius: 999px; overflow: hidden; background: var(--paper); margin-bottom: .35rem; } .kbar i { display: block; height: 100%; border-right: 2px solid var(--ink); } .kbar i:last-child { border-right: 0; }\n.k1 { background: var(--ink); } .k2 { background: var(--forest); } .k3 { background: var(--id-3); } .k4 { background: var(--id-2); } .k5 { background: var(--line-2); }\n.legend { display: flex; flex-wrap: wrap; justify-content: center; gap: .1rem .6rem; font-family: var(--mono); font-size: 10.5px; text-transform: uppercase; } .legend i { display: inline-block; width: 9px; height: 9px; border: 1.5px solid var(--ink); border-radius: 2px; margin-right: .3rem; vertical-align: -1px; }\n.usage { display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: .8rem; } .usage div { padding: .7rem .9rem; border: 2px solid var(--ink); border-radius: 12px; background: var(--paper); } .usage small { display: block; color: var(--ink-3); font-size: 12px; } .usage b { font: 400 1.5rem/1.2 var(--display); }\n@media (max-width: 52rem) { .two, .gauges, .limits, .form, .counters { grid-template-columns: 1fr !important; } .addrow { flex-wrap: wrap; } .mast { grid-template-columns: 1fr; } .mast .right { justify-items: start; text-align: left; } }\n@media (max-width: 40rem) { .events:not(#kinds):not(#jobs) th:first-child, .events:not(#kinds):not(#jobs) td:first-child { display: none; } }\n\n.slegend { justify-content: flex-start; margin: 0 0 1.2rem; }\n#s-totals { margin-bottom: .4rem; }\ntd.keep { white-space: nowrap; } td.keep input { width: 5.5rem; display: inline-block; margin-right: .3rem; } td.keep input::placeholder { color: var(--ink-3); }\n#kinds tr.any td:first-child { font-weight: 600; }\n#kinds td:first-child .kind { display: inline-block; min-width: 5.2em; text-align: center; margin-right: .35rem; }\n/* Tables keep their columns and scroll inside the card on narrow screens instead of pushing the page sideways. */\n.scroll { overflow-x: auto; max-width: 100%; scrollbar-width: thin; } .scroll table { min-width: 100%; }\nsection, .card { overflow-x: clip; }\n.kind.sys { background: var(--mint); border-color: var(--forest); color: var(--forest); }\ntd.keep.sys { color: var(--ink-3); font-size: 13px; white-space: normal; }\n\n#console.mod .tabs a:not([data-tab=people]):not([data-tab=moderation]) { display: none; } #console.mod #thresholdform { display: none; }\n#members select.role { width: auto; padding: .1rem .3rem; font-size: 12px; margin-left: .3rem; }\n.wire-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .5rem 1.2rem; padding: .8rem 0; border-bottom: 1px solid var(--line); align-items: start; } .wire-row:first-child { border-top: 2px solid var(--ink); }\n.wire-main { min-width: 0; } .wire-side { display: grid; justify-items: end; gap: .45rem; max-width: 26rem; }\n.wire-acts { display: flex; gap: .4rem; white-space: nowrap; } .wire-acts .btn { padding: .35rem .75rem; font-size: 13px; }\n.wire-meta { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .35rem .5rem; align-items: center; font-size: 13px; color: var(--ink-2); text-align: right; } .wire-meta:empty { display: none; }\n.pill { display: inline-block; padding: .1rem .6rem; border: 1.5px solid var(--ink); border-radius: 999px; font: 600 12px var(--sans); color: var(--ink); background: var(--paper); } .pill.on { background: var(--mint); } .pill.off { color: var(--ink-3); } .pill.bad { color: var(--red); }\n.chip { display: inline-block; padding: .05rem .45rem; border: 1px solid var(--forest); border-radius: 6px; font: 12px var(--mono); color: var(--forest); } .chip.bad { border-color: var(--red); color: var(--red); }\n.share { display: grid; grid-template-columns: minmax(0, 3fr) minmax(14rem, 2fr); gap: 1.4rem; align-items: start; } .share #cardimg { width: 100%; height: auto; display: block; }\n.share-side { display: grid; gap: .8rem; justify-items: start; } .naddr { display: block; font-size: 12px; line-height: 1.5; word-break: break-all; color: var(--ink-2); }\n@media (max-width: 52rem) { .share { grid-template-columns: 1fr; } .wire-side { display: contents; } .wire-acts { justify-self: end; } .wire-meta { grid-column: 1 / -1; justify-content: flex-start; text-align: left; } }\n#presets .btn { margin: 0 .4rem .4rem 0; }\n.panel form h3.gap { margin-top: 1.4rem; }\n.key { position: relative; display: inline-block; font-family: var(--mono); } .key::before { content: attr(data-short); } .key .full { position: absolute; left: 0; top: 0; width: 1px; height: 1px; overflow: hidden; opacity: 0; white-space: nowrap; }\n.mast .banner { grid-column: 1 / -1; width: 100%; aspect-ratio: 4 / 1; overflow: hidden; border: 2px solid var(--ink); border-radius: 18px; box-shadow: 4px 4px 0 var(--ink); background: var(--paper); margin-bottom: .5rem; } .mast .banner img { width: 100%; height: 100%; object-fit: cover; display: block; }\n.mast .wordmark { display: flex; align-items: center; gap: 1rem; } .mast .icon { width: 64px; height: 64px; border-radius: 50%; border: 2px solid var(--ink); box-shadow: 3px 3px 0 var(--ink); background: var(--paper); object-fit: cover; }\n.metarow { display: flex; flex-wrap: wrap; gap: .4rem .6rem; align-items: center; margin: -.4rem 0 1rem; font-size: 13px; color: var(--ink-2); }\n.metarow .tag { display: inline-block; padding: .1rem .6rem; border: 1.5px solid var(--ink); border-radius: 999px; font: 600 12px var(--sans); color: var(--ink); background: var(--sun); }\n.metarow .sep { color: var(--ink-3); } .metarow a { color: var(--forest); text-decoration: none; border-bottom: 1.5px solid var(--forest); } .metarow a:hover { border-bottom-width: 2px; }\n.about a { color: var(--forest); text-decoration: none; border-bottom: 1.5px solid var(--forest); }\n.wire-acts label { display: inline-flex; align-items: center; gap: .35rem; font-size: 14px; color: var(--ink-2); cursor: pointer; } .wire-acts label input { margin: 0; accent-color: var(--forest); } .wire-row.dim .wire-main { color: var(--ink-3); }\n"; -export const CONSOLE_JS = "(async () => {\n const $ = (s) => document.querySelector(s);\n const $$ = (s) => document.querySelectorAll(s);\n const host = location.host;\n const wsURL = (location.protocol === \"https:\" ? \"wss://\" : \"ws://\") + host;\n const rpcURL = location.origin + \"/\";\n let info = null, me = null, owner = \"\", policy = null, fuel = null, people = null, myRole = \"\";\n\n const toast = (msg) => { const t = $(\"#toast\"); t.textContent = msg; t.classList.add(\"show\"); clearTimeout(t._t); t._t = setTimeout(() => t.classList.remove(\"show\"), 2600); };\n // guard wraps a handler: disables the button, shows errors as toasts. Defined first, since handlers below use it.\n const guard = (fn) => async (ev) => { ev.preventDefault(); const b = ev.submitter || ev.target; if (b) b.disabled = true; try { await fn(ev); } catch (e) { toast(e.message); } finally { if (b) b.disabled = false; } };\n const short = (hex) => hex ? hex.slice(0, 8) + \"…\" + hex.slice(-4) : \"\";\n // key shows a key short but keeps the whole of it in the DOM: selecting, double-clicking or clicking it copies the full hex.\n const key = (hex) => hex ? '' + hex + '' : '';\n const hue = (hex) => (parseInt(hex.slice(0, 2), 16) * 360 / 256).toFixed(0) + \"deg\";\n const av = (hex) => '';\n const fmtBytes = (n) => n < 1e6 ? (n / 1e3).toFixed(0) + \" KB\" : n < 1e9 ? (n / 1e6).toFixed(1) + \" MB\" : (n / 1e9).toFixed(2) + \" GB\";\n const fmtHours = (ms) => ms < 3600e3 ? Math.round(ms / 60e3) + \" min\" : (ms / 3600e3).toFixed(ms < 36e6 ? 1 : 0) + \" h\";\n const fuelOver = () => !!fuel && (fuel.eventBytes > fuel.freeEventBytes || fuel.mediaBytes > fuel.freeMediaBytes || fuel.activeMs > fuel.freeActiveMs || fuel.rowsWritten > fuel.freeRowsWritten);\n const fmtTime = (t) => t ? new Date(t * 1000).toLocaleString(undefined, { month: \"short\", day: \"numeric\", hour: \"2-digit\", minute: \"2-digit\" }) : \"–\";\n const fmtDay = (t) => t ? new Date(t * 1000).toLocaleDateString(undefined, { month: \"short\", day: \"numeric\" }) : \"–\";\n const ago = (t) => { const s = Math.max(0, Math.floor(Date.now() / 1000) - t); return s < 60 ? \"just now\" : s < 3600 ? Math.floor(s / 60) + \" min\" : s < 86400 ? Math.floor(s / 3600) + \" h\" : Math.floor(s / 86400) + \" d\"; };\n const esc = (s) => String(s ?? \"\").replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c]);\n\n const IC = {\n x: '',\n undo: '',\n check: '',\n trash: '',\n ban: '',\n banuser: '',\n eye: '',\n lock: '',\n pen: '',\n people: '',\n person: '',\n gauge: '',\n bolt: '',\n copy: '',\n pin: '',\n };\n const ib = (icon, label, act, id, extra) => '\";\n\n const CH = \"qpzry9x8gf2tvdw0s3jn54khce6mua7l\";\n function npubToHex(s) {\n s = s.trim().toLowerCase();\n if (/^[0-9a-f]{64}$/.test(s)) return s;\n if (!s.startsWith(\"npub1\")) return null;\n const data = s.slice(5, -6).split(\"\").map((c) => CH.indexOf(c));\n if (data.some((d) => d < 0)) return null;\n let bits = 0, acc = 0, out = [];\n for (const d of data) { acc = (acc << 5) | d; bits += 5; if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 255); } }\n return out.length === 32 ? out.map((b) => b.toString(16).padStart(2, \"0\")).join(\"\") : null;\n }\n\n async function sha256hex(s) {\n const b = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(s));\n return [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, \"0\")).join(\"\");\n }\n // ---- signing: a NIP-07 extension, or a NIP-46 remote signer ----\n // The remote path loads the bundled library on first use; its session\n // (client key, signer pubkey, relays) lives in localStorage until sign-out.\n let remote = null, lib = null, pendingNote = null;\n const NO_SIGNER = \"Install a nostr extension (Alby, nos2x, …) or connect a remote signer.\";\n const withTimeout = (p, ms, what) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(what + \" did not answer; is the signer app open?\")), ms))]);\n const signer = {\n ready: () => !!remote || !!window.nostr,\n async getPublicKey() { if (remote) return withTimeout(remote.getPublicKey(), 20000, \"The remote signer\"); if (window.nostr) return window.nostr.getPublicKey(); throw new Error(NO_SIGNER); },\n async signEvent(ev) { if (remote) return withTimeout(remote.signEvent(ev), 60000, \"The remote signer\"); if (window.nostr) return window.nostr.signEvent(ev); throw new Error(NO_SIGNER); },\n };\n async function signerLib() {\n if (lib) return lib;\n if (!window.NostrSigner) await new Promise((res, rej) => { const s = document.createElement(\"script\"); s.src = window.SIGNER_URL || \"/signer.js\"; s.onload = res; s.onerror = () => rej(new Error(\"Could not load the signer library.\")); document.head.appendChild(s); });\n return (lib = window.NostrSigner);\n }\n const onauth = (u) => window.open(u, \"_blank\");\n // The signer conversation rides this relay, and a relay may ask the socket to\n // authenticate before it delivers kind 24133 to its parties; answer with the\n // session's own key.\n const authPool = (L, sk) => new L.SimplePool({ automaticallyAuth: () => (evt) => Promise.resolve(L.finalizeEvent(evt, sk)) });\n const saveSession = (sk, s, secret) => localStorage.setItem(\"nip46\", JSON.stringify({ sk: lib.bytesToHex(sk), pubkey: s.bp.pubkey, relays: s.bp.relays, secret: secret || null }));\n async function resumeRemote() {\n const raw = localStorage.getItem(\"nip46\");\n if (!raw) return false;\n try {\n const s = JSON.parse(raw); const L = await signerLib();\n remote = L.BunkerSigner.fromBunker(L.hexToBytes(s.sk), { pubkey: s.pubkey, relays: s.relays, secret: s.secret }, { onauth, pool: authPool(L, L.hexToBytes(s.sk)) });\n return true;\n } catch { localStorage.removeItem(\"nip46\"); return false; }\n }\n async function connectBunker(input) {\n const L = await signerLib();\n const bp = await L.parseBunkerInput(input.trim());\n if (!bp) throw new Error(\"That is not a bunker:// URL.\");\n if (!bp.relays.length) throw new Error(\"The bunker URL names no relay.\");\n const sk = L.generateSecretKey();\n const s = L.BunkerSigner.fromBunker(sk, bp, { onauth, pool: authPool(L, sk) });\n await withTimeout(s.connect({ name: host, url: location.origin }), 60000, \"The signer\");\n await Promise.race([s.switchRelays(), new Promise((r) => setTimeout(r, 3000))]);\n remote = s; saveSession(sk, s, bp.secret);\n }\n // The nostrconnect:// flow: this relay carries the traffic, so no third relay is involved.\n let ncPending = null;\n async function offerNostrConnect() {\n if (ncPending) return ncPending;\n const L = await signerLib();\n const sk = L.generateSecretKey();\n const secret = L.bytesToHex(crypto.getRandomValues(new Uint8Array(8)));\n const uri = L.createNostrConnectURI({ clientPubkey: L.getPublicKey(sk), relays: [wsURL], secret, name: host, url: location.origin, perms: [\"sign_event:27235\", \"sign_event:9734\"] });\n $(\"#nclink\").href = uri; $(\"#ncnote\").textContent = \"\";\n $(\"#ncqr\").src = \"/qr.svg?text=\" + encodeURIComponent(uri); $(\"#ncqr\").classList.remove(\"hidden\");\n ncPending = L.BunkerSigner.fromURI(sk, uri, { onauth, pool: authPool(L, sk) }, 600000).then((s) => { remote = s; saveSession(sk, s, secret); ncPending = null; return s; }, (e) => { ncPending = null; throw e; });\n return ncPending;\n }\n async function remoteDone() {\n me = await signer.getPublicKey();\n localStorage.setItem(\"me\", me);\n $(\"#remotesec\").classList.add(\"hidden\");\n toast(\"Remote signer connected\");\n if (pendingNote && !owner) { const n = pendingNote; pendingNote = null; await claimNow(n); return; }\n pendingNote = null;\n renderHeader(); await loadAdmin(); await loadPeople();\n }\n function showRemote(note) {\n pendingNote = note || null;\n $(\"#remotesec\").classList.remove(\"hidden\");\n $(\"#remotesec\").scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n offerNostrConnect().then(remoteDone).catch((e) => { $(\"#ncnote\").textContent = e.message; });\n }\n\n async function rpc(method, ...params) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const body = JSON.stringify({ method, params });\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", rpcURL], [\"method\", \"POST\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(rpcURL, { method: \"POST\", headers: { \"content-type\": \"application/nostr+json+rpc\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body });\n const json = await resp.json();\n if (json.error) throw new Error(json.error);\n return json.result;\n }\n\n // ---- front of house ----\n // urlish shows a URL as a short link: the host and the start of the path, the whole thing on hover and as the target.\n class Html { constructor(s) { this.s = s; } }\n const urlish = (u) => {\n if (!u) return \"\";\n let label = u; try { const x = new URL(u); label = x.host + (x.pathname.length > 1 ? x.pathname.slice(0, 18) + (x.pathname.length > 18 ? \"\\u2026\" : \"\") : \"\"); } catch { /* not a URL */ }\n if (!/^https?:\\/\\//.test(u)) return u;\n return new Html('' + esc(label) + \"\");\n };\n function renderHeader() {\n const name = (info && info.name) || host.split(\".\")[0];\n $(\"#title\").textContent = name;\n document.title = name + \" - relay\";\n $(\"#url\").textContent = wsURL;\n $(\"#d-ws\").textContent = wsURL; $(\"#d-blossom\").textContent = location.origin; $(\"#d-nip05\").textContent = \"you@\" + host;\n $(\"#nip05-example\").textContent = \"alice@\" + host;\n const desc = (info && info.description) || \"\";\n $(\"#desc\").textContent = desc; $(\"#desc\").classList.toggle(\"hidden\", !desc);\n // What the owner declared about the relay, on the page, not only in the document clients read.\n const img = (id, url) => { const el = $(id); if (url) el.src = url; el.classList.toggle(\"hidden\", !url); };\n img(\"#bannerimg\", info?.banner); $(\"#banner\").classList.toggle(\"hidden\", !info?.banner);\n img(\"#iconimg\", info?.icon);\n const meta = [];\n for (const t of info?.tags || []) meta.push('' + esc(t) + \"\");\n const where = [...(info?.language_tags || []), ...(info?.relay_countries || [])];\n if (where.length) meta.push(\"\" + esc(where.join(\", \")) + \"\");\n const link = (label, href) => href ? '' + label + \"\" : \"\";\n const contact = info?.contact ? (/^(mailto:|https?:\\/\\/)/.test(info.contact) ? link(\"Contact\", info.contact) : \"\" + esc(info.contact) + \"\") : \"\";\n for (const x of [contact, link(\"Terms\", info?.terms_of_service), link(\"Posting policy\", info?.posting_policy), link(\"Privacy\", info?.privacy_policy)]) if (x) meta.push(x);\n $(\"#metarow\").innerHTML = meta.join('|'); $(\"#metarow\").classList.toggle(\"hidden\", meta.length === 0);\n $(\"#frontsec\").classList.toggle(\"hidden\", !desc && meta.length === 0);\n owner = (info && info.pubkey) || \"\";\n const isOwner = !!me && me === owner;\n const lease = !owner && info && info.lease ? info.lease : null;\n $(\"#ownerline\").innerHTML = owner ? 'run by ' + key(owner) + \"\" : lease ? \"temporary until \" + fmtDay(lease.expires_at) : \"unclaimed\";\n $(\"#owner-av\").classList.toggle(\"hidden\", !owner);\n if (owner) $(\"#owner-av\").style.setProperty(\"--h\", hue(owner));\n $(\"#who\").textContent = me ? (isOwner ? \"(that's you)\" : \"\") : \"\";\n $(\"#unclaimed\").classList.toggle(\"hidden\", !!owner || !!lease);\n $(\"#leased\").classList.toggle(\"hidden\", !lease);\n if (lease) {\n $(\"#lease-until\").textContent = fmtTime(lease.expires_at);\n $(\"#leasenote\").textContent = lease.holder ? \"Reserved for the key that asked for it; sign with that key.\" : \"\";\n }\n $(\"#signin\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signin46\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signout\").classList.toggle(\"hidden\", !me);\n $(\"#about\").innerHTML = [\n [\"Name\", info?.name], [\"Description\", info?.description], [\"Contact\", info?.contact], [\"Owner\", owner ? short(owner) : \"\"], [\"Relay key\", info?.self ? short(info.self) : \"\"], [\"Software\", urlish(info?.software)], [\"Version\", info?.version],\n [\"Max query\", info?.limitation?.max_limit], [\"Auth required\", String(!!info?.limitation?.auth_required)], [\"Restricted writes\", String(!!info?.limitation?.restricted_writes)], [\"Min PoW\", info?.limitation?.min_pow_difficulty || 0],\n [\"Tags\", info?.tags?.join(\", \")], [\"Languages\", info?.language_tags?.join(\", \")], [\"Countries\", info?.relay_countries?.join(\", \")],\n [\"Terms\", urlish(info?.terms_of_service)], [\"Posting policy\", urlish(info?.posting_policy)], [\"Privacy policy\", urlish(info?.privacy_policy)], [\"Icon\", urlish(info?.icon)], [\"Banner\", urlish(info?.banner)],\n [\"NIPs\", info?.supported_nips?.join(\" \")],\n ].map(([k, v]) => \"
                                            \" + k + \"\" + (v === undefined || v === \"\" ? '' : v instanceof Html ? v.s : esc(v)) + \"
                                            \").join(\"\");\n renderCare();\n }\n\n function renderCare() {\n const g = (icon, label, off) => '' + IC[icon] + \"\" + label + \"\";\n const writes = policy ? policy.writes : (info?.limitation?.restricted_writes ? \"allowlist\" : \"open\");\n const reads = policy ? policy.reads : (info?.limitation?.auth_required ? \"auth\" : \"open\");\n let out = writes === \"owner\" ? g(\"person\", \"only owner writes\") : writes === \"allowlist\" ? g(\"people\", \"members write\") : writes === \"wot\" ? g(\"people\", \"members and follows write\") : g(\"pen\", \"anyone writes\");\n out += reads === \"members\" ? g(\"people\", \"members read\") : reads === \"auth\" ? g(\"lock\", \"sign in to read\") : g(\"eye\", \"anyone reads\");\n if (fuel) {\n const over = fuelOver();\n out += fuel.outOfFuel ? g(\"gauge\", \"out of fuel\", true) : over ? g(\"bolt\", \"burning sats\") : g(\"gauge\", \"on free allowance\");\n }\n $(\"#care\").innerHTML = out;\n }\n\n async function loadPeople() {\n try { people = await (await fetch(\"/people\")).json(); } catch { return; }\n const isOwner = !!me && me === owner;\n let list = people.people || [];\n if (!people.public && isOwner && policy) list = (window.__members || []);\n $(\"#peoplesec\").classList.toggle(\"hidden\", !owner || list.length === 0);\n $(\"#people-note\").classList.toggle(\"hidden\", people.public);\n $(\"#people-count\").textContent = list.length || \"\";\n $(\"#dir\").innerHTML = list.map((m) => '' + av(m.pubkey) + '' + (m.name ? esc(m.name) + \"@\" + host : key(m.pubkey)) + \"\" + (m.role === \"owner\" ? 'owner' : \"\") + \"\").join(\"\");\n }\n\n async function loadInfo() {\n info = await (await fetch(\"/\", { headers: { accept: \"application/nostr+json\" } })).json();\n renderHeader();\n const git = info.supported_grasps?.includes(\"GRASP-01\");\n $(\"#git-connect\").classList.toggle(\"hidden\", !git);\n const clone = \"git clone '\" + location.origin + \"//.git'\";\n $(\"#git-clone\").textContent = clone;\n $(\"#git-copy\").dataset.copytext = clone;\n renderApps();\n }\n\n async function loadFuel() {\n try { fuel = await (await fetch(\"/fuel\")).json(); } catch { return; }\n $(\"#fuelsec\").classList.toggle(\"hidden\", !owner);\n const pct = (used, free) => Math.min(100, Math.round((used / Math.max(free, 1)) * 100));\n const gauge = (id, used, free, text) => { const g = $(\"#g-\" + id), share = free ? used / free : 0; g.style.width = pct(used, free) + \"%\"; g.classList.toggle(\"over\", share > 1); g.classList.toggle(\"warm\", share > 0.75 && share <= 1); $(\"#p-\" + id).textContent = share > 1 ? \"over\" : Math.round(share * 100) + \"%\"; $(\"#t-\" + id).textContent = text; };\n gauge(\"events\", fuel.eventBytes, fuel.freeEventBytes, fmtBytes(fuel.eventBytes) + \" of \" + fmtBytes(fuel.freeEventBytes) + \" free\");\n gauge(\"media\", fuel.mediaBytes, fuel.freeMediaBytes, fmtBytes(fuel.mediaBytes) + \" of \" + fmtBytes(fuel.freeMediaBytes) + \" free\");\n gauge(\"active\", fuel.activeMs, fuel.freeActiveMs, fmtHours(fuel.activeMs) + \" of \" + fmtHours(fuel.freeActiveMs) + \" free\");\n gauge(\"rows\", fuel.rowsWritten, fuel.freeRowsWritten, fuel.rowsWritten.toLocaleString() + \" of \" + fuel.freeRowsWritten.toLocaleString() + \" free\");\n const sats = Math.floor(fuel.balanceMsats / 1000);\n const r = fuel.rates;\n $(\"#fuel-balance\").innerHTML = fuel.outOfFuel\n ? 'out of fuel Writes are paused until someone tops up.'\n : \"Balance \" + sats.toLocaleString() + \" sats. Past the allowances, prices track what the hosting costs: \" + r.satsPerGBMonthEvents.toLocaleString() + \" sats per GB-month of events, \" + r.satsPerGBMonthMedia.toLocaleString() + \" per GB-month of files, \" + r.satsPerActiveHour.toLocaleString() + \" per hour awake, \" + r.satsPerMillionRows.toLocaleString() + \" per million rows written. Traffic is free.\";\n $(\"#topup\").classList.toggle(\"hidden\", !fuel.enabled);\n // Who paid is the owner's to see: it comes with the signed stats call.\n let credits = [];\n if (owner && signer.ready()) { try { credits = (await rpc(\"stats\")).credits || []; } catch { credits = []; } }\n $(\"#credits tbody\").innerHTML = credits.length ? credits.map((c) => '' + esc(fmtTime(c.at)) + '' + av(c.payer) + key(c.payer) + '' + Math.floor(c.msats / 1000).toLocaleString() + \"\").join(\"\") : 'no zaps yet';\n if (!fuel.enabled) $(\"#topup-note\").textContent = \"Top-ups are not enabled on this service yet.\";\n renderCare();\n renderFuelTile();\n }\n\n async function topUp(sats) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const msats = Math.round(sats * 1000);\n const zapRequest = await signer.signEvent({ kind: 9734, created_at: Math.floor(Date.now() / 1000), content: \"fuel for \" + host, tags: [[\"p\", fuel.servicePubkey], [\"amount\", String(msats)], [\"relays\", wsURL]] });\n const r = await (await fetch(\"/fuel/invoice\", { method: \"POST\", body: JSON.stringify({ zapRequest }) })).json();\n if (r.error) throw new Error(r.error);\n const inv = r.invoice;\n $(\"#invoice\").classList.remove(\"hidden\");\n $(\"#inv-text\").value = inv; $(\"#inv-link\").href = \"lightning:\" + inv; $(\"#inv-state\").textContent = \"waiting for payment…\";\n if (window.webln) { try { await window.webln.enable(); await window.webln.sendPayment(inv); } catch (e) { toast(e.message || \"wallet declined\"); } }\n const before = fuel.creditedMsats;\n for (let i = 0; i < 120; i++) {\n await new Promise((r) => setTimeout(r, 2500));\n await loadFuel();\n if (fuel.creditedMsats > before) { $(\"#inv-state\").textContent = \"paid, thank you\"; toast(\"Fuel credited\"); return; }\n }\n $(\"#inv-state\").textContent = \"no receipt yet; it can take a minute after paying\";\n }\n\n // ---- console ----\n function showTab(name) {\n if (name === \"content\" || name === \"storage\") name = \"data\";\n const known = myRole === \"moderator\" ? [\"people\", \"moderation\"] : [\"people\", \"moderation\", \"rules\", \"identity\", \"data\", \"sync\", \"views\", \"health\", \"owner\"];\n if (!known.includes(name)) name = \"people\";\n $$(\".tabs a\").forEach((a) => a.classList.toggle(\"on\", a.dataset.tab === name));\n $$(\".panel\").forEach((p) => p.classList.toggle(\"on\", p.dataset.panel === name));\n }\n // Tabs switch in place. A fragment still opens a tab when someone arrives with one,\n // but clicking never writes one, and any fragment already there is dropped.\n $$(\".tabs a\").forEach((a) => a.addEventListener(\"click\", (ev) => { ev.preventDefault(); showTab(a.dataset.tab); if (location.hash) history.replaceState(null, \"\", location.pathname + location.search); }));\n window.addEventListener(\"hashchange\", () => showTab(location.hash.slice(1)));\n // The strip fades on whichever side has more tabs off screen.\n const tabsFade = () => { const t = $(\"#tabs\"); t.classList.toggle(\"fade-l\", t.scrollLeft > 4); t.classList.toggle(\"fade-r\", t.scrollLeft + t.clientWidth < t.scrollWidth - 4); };\n $(\"#tabs\").addEventListener(\"scroll\", tabsFade); window.addEventListener(\"resize\", tabsFade); new ResizeObserver(tabsFade).observe($(\"#tabs\"));\n\n function renderFuelTile() {\n if (!fuel) return;\n const over = fuelOver();\n const sats = Math.max(0, Math.floor(fuel.balanceMsats / 1000));\n const d = new Date(), end = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1), days = Math.max(1, Math.ceil((end - Date.now()) / 86400000));\n if (fuel.outOfFuel) { $(\"#h-fuel\").textContent = \"0 sats\"; $(\"#h-fuel-label\").textContent = \"out of fuel\"; $(\"#h-fuel-sub\").textContent = \"writes are paused\"; }\n else if (over) { $(\"#h-fuel\").textContent = sats.toLocaleString() + \" sats\"; $(\"#h-fuel-label\").textContent = \"fuel left\"; $(\"#h-fuel-sub\").textContent = \"past the free allowance\"; }\n else { $(\"#h-fuel\").textContent = days + (days === 1 ? \" day\" : \" days\"); $(\"#h-fuel-label\").textContent = \"free allowance left\"; $(\"#h-fuel-sub\").textContent = sats ? \"then \" + sats.toLocaleString() + \" sats\" : \"then a top-up is needed\"; }\n $(\"#usage\").innerHTML = [[\"events stored\", fmtBytes(fuel.eventBytes)], [\"files stored\", fmtBytes(fuel.mediaBytes)], [\"awake this month\", fmtHours(fuel.activeMs)], [\"rows written\", fuel.rowsWritten.toLocaleString()], [\"rows read\", fuel.rowsRead.toLocaleString()], [\"received this month\", fmtBytes(fuel.bytesIn)], [\"served this month\", fmtBytes(fuel.bytesOut)], [\"charged\", Math.floor(fuel.chargedMsats / 1000).toLocaleString() + \" sats\"]]\n .map(([k, v]) => \"
                                            \" + k + \"\" + v + \"
                                            \").join(\"\");\n }\n\n function renderHealth(stats) {\n $(\"#h-last\").textContent = stats.newest ? ago(stats.newest) : \"none\";\n $(\"#h-last-sub\").textContent = stats.newest ? fmtTime(stats.newest) : \"no events yet\";\n $(\"#h-conns\").textContent = stats.connections;\n const buckets = [[\"notes\", \"k1\", [1]], [\"reactions\", \"k2\", [7]], [\"DMs\", \"k3\", [4, 14, 1059]], [\"long-form\", \"k4\", [30023]], [\"other\", \"k5\", null]];\n const sums = buckets.map(() => 0); let total = 0;\n for (const { kind, n } of stats.kinds || []) { total += n; const i = buckets.findIndex((b) => b[2] && b[2].includes(kind)); sums[i < 0 ? 4 : i] += n; }\n const parts = buckets.map((b, i) => [b[0], b[1], sums[i]]).filter((p) => p[2] > 0);\n $(\"#h-kinds\").innerHTML = total ? parts.map(([name, cls, n]) => '').join(\"\") : '';\n $(\"#h-kinds-legend\").innerHTML = total ? parts.map(([name, cls, n]) => '' + name + \" \" + Math.round(n * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n renderFuelTile();\n }\n\n async function loadAdmin() {\n const isOwner = !!me && me === owner;\n myRole = isOwner ? \"owner\" : \"\";\n let stats = null, p = null;\n // A signed-in member may be a moderator; the relay says by answering stats.\n if (me && owner && !isOwner) { try { [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]); myRole = \"moderator\"; } catch { myRole = \"\"; } }\n $(\"#console\").classList.toggle(\"hidden\", !myRole);\n loadMine();\n $(\"#console\").classList.toggle(\"mod\", myRole === \"moderator\");\n if (!myRole) { policy = null; renderCare(); return; }\n if (isOwner) [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]);\n policy = p;\n renderHealth(stats);\n renderCare();\n const fa = $(\"#access\"), fi = $(\"#identity\");\n fa.writes.value = p.writes; fa.reads.value = p.reads; fa.openKinds.value = (p.openKinds || []).join(\", \"); fa.guestReplies.checked = !!p.guestReplies;\n $(\"#wordsform\").words.value = (p.blockedWords || []).join(\"\\n\"); $(\"#wordsform\").inTags.checked = !!p.blockedWordsInTags; $(\"#thresholdform\").reportThreshold.value = p.reportThreshold || 0; fa.minPow.value = p.minPow; fa.maxFuture.value = p.maxFuture; fa.maxLimit.value = p.maxLimit; fa.maxSubs.value = p.maxSubs; fa.maxMessageKB.value = p.maxMessageKB;\n fa.eventsPerMinute.value = p.eventsPerMinute; fa.reqsPerMinute.value = p.reqsPerMinute; fa.maxBlobMB.value = p.maxBlobMB;\n renderFeatures(p.features || {});\n $(\"#push-policy-form\").elements.origins.value = (p.pushCallbacks || []).join(\"\\n\");\n $(\"#push-policy-form\").elements.lettered.checked = !!p.letteredNips;\n fi.name.value = p.name; fi.contact.value = p.contact; fi.description.value = p.description; fi.icon.value = p.icon;\n const fj = $(\"#joinform\"); fj.joinTerms.value = p.joinTerms; fj.directoryPublic.checked = !!p.directoryPublic;\n loadCard();\n fi.banner.value = p.banner || \"\"; fi.postingPolicy.value = p.postingPolicy || \"\"; fi.privacyPolicy.value = p.privacyPolicy || \"\";\n fi.tags.value = (p.tags || []).join(\", \"); fi.languageTags.value = (p.languageTags || []).join(\", \"); fi.relayCountries.value = (p.relayCountries || []).join(\", \");\n const fn = $(\"#notify\"), nt = p.notify || {};\n fn.reports.checked = !!nt.reports; fn.fuel.checked = !!nt.fuel; fn.jobs.checked = !!nt.jobs; fn.succession.checked = !!nt.succession; fn.digest.checked = !!nt.digest;\n showTab(location.hash.slice(1));\n if (isOwner) { await renderPresets(); renderWire(); renderSuccession(); loadDomains(); }\n await Promise.all([loadLists(), loadEvents(true), loadPins(), ...(isOwner ? [loadStorage()] : [])]);\n }\n\n async function loadAudit(before) {\n const rows = await rpc(\"listaudit\", before || 0);\n const html = rows.map((r) => '' + esc(fmtTime(r.at)) + '' + av(r.actor) + key(r.actor) + '' + esc(r.action) + '' + (/^[0-9a-f]{64}$/.test(r.target) ? key(r.target) : esc(r.target)) + '' + esc(r.detail) + '').join(\"\");\n const tb = $(\"#audit tbody\");\n if (before) tb.insertAdjacentHTML(\"beforeend\", html); else tb.innerHTML = html || 'Nothing yet';\n $(\"#audit-more\").style.display = rows.length < 100 ? \"none\" : \"\";\n }\n $(\"#audit-more\").onclick = guard(async () => { const last = $(\"#audit tbody tr:last-child\"); await loadAudit(last ? +last.dataset.seq : 0); });\n async function loadLists() {\n loadAudit(0);\n const [mem, bans, bannedEvents, allow, block, invites, reports, blobs, blocks, sites] = await Promise.all([rpc(\"listmembers\"), rpc(\"listbannedpubkeys\"), rpc(\"listbannedevents\"), rpc(\"listallowedkinds\"), rpc(\"listblockedkinds\"), rpc(\"listinvites\"), rpc(\"listreports\"), rpc(\"listblobs\", 100), rpc(\"listblockedips\"), rpc(\"listsites\")]);\n const members = mem.members;\n window.__members = members;\n const roleCell = (m) => m.role === \"owner\" ? ' owner' : myRole === \"owner\" ? ' ' : m.role === \"moderator\" ? ' moderator' : \"\";\n const untouchable = (m) => m.role === \"owner\" || (myRole !== \"owner\" && m.role === \"moderator\");\n // The tree: everyone under whoever invited them, the owner and the owner's own additions at the root.\n const known = new Set(members.map((m) => m.pubkey));\n const byInviter = new Map();\n for (const m of members) { const k = m.role !== \"owner\" && known.has(m.invited_by) ? m.invited_by : \"\"; if (!byInviter.has(k)) byInviter.set(k, []); byInviter.get(k).push(m); }\n const ordered = [], placed = new Set();\n const walk = (k, depth) => { for (const m of byInviter.get(k) || []) { if (placed.has(m.pubkey)) continue; placed.add(m.pubkey); m.depth = depth; ordered.push(m); walk(m.pubkey, depth + 1); } };\n walk(\"\", 0);\n for (const m of members) if (!placed.has(m.pubkey)) { m.depth = 0; ordered.push(m); }\n const nameOf = (pk) => { const x = members.find((m) => m.pubkey === pk); return x && x.name ? x.name : short(pk); };\n const limits = (m) => m.role === \"owner\" ? \"\" : myRole === \"owner\"\n ? ''\n : '' + (m.keep_days ? m.keep_days + \" d\" : \"\") + (m.max_bytes ? \" \" + fmtBytes(m.max_bytes) : \"\") + \"\";\n $(\"#members tbody\").innerHTML = ordered.map((m) => '' + av(m.pubkey) + key(m.pubkey) + roleCell(m) + (m.depth ? ' via ' + esc(nameOf(m.invited_by)) + \"\" : \"\") + (m.invites ? ' ' + m.invites + \" inv\" : \"\") + '' + limits(m) + '' + esc(fmtDay(m.joined_at)) + \", \" + esc(m.via) + '' + ib(\"check\", \"Save\", \"savemember\", m.pubkey) + (untouchable(m) ? \"\" : ib(\"x\", \"Remove\", \"removemember\", m.pubkey) + ib(\"banuser\", \"Ban\", \"banpubkey\", m.pubkey, \"danger\")) + \"\").join(\"\");\n const tf = $(\"#treeform\");\n tf.classList.toggle(\"hidden\", myRole !== \"owner\");\n if (policy && policy.memberInvites) { tf.depth.value = policy.memberInvites.depth; tf.quota.value = policy.memberInvites.quota; }\n $(\"#transfer [name=pubkey]\").innerHTML = members.filter((m) => m.role !== \"owner\").map((m) => '\").join(\"\");\n $(\"#succession [name=heir]\").innerHTML = $(\"#transfer [name=pubkey]\").innerHTML;\n $(\"#tc-people\").textContent = members.length;\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#invites\").innerHTML = invites.length ? invites.map((i) => '
                                          • inv…' + i.code.slice(-8) + \" \" + esc(i.note || \"\") + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
                                          • \").join(\"\") : '
                                          • no invites
                                          • ';\n const person = (r, icon, label, act) => \"
                                          • \" + av(r.pubkey) + '' + short(r.pubkey) + \" \" + esc(r.reason || \"\") + \"\" + ib(icon, label, act, r.pubkey) + \"
                                          • \";\n $(\"#bans\").innerHTML = bans.length ? bans.map((r) => person(r, \"undo\", \"Unban\", \"unrulepubkey\")).join(\"\") : '
                                          • nobody banned
                                          • ';\n $(\"#blocks\").innerHTML = blocks.length ? blocks.map((r) => '
                                          • ip' + esc(r.ip) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"undo\", \"Unblock\", \"unblockip\", r.ip) + \"
                                          • \").join(\"\") : '
                                          • no addresses blocked
                                          • ';\n $(\"#banned-events\").innerHTML = bannedEvents.map((r) => '
                                          • ev' + key(r.id) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"check\", \"Allow again\", \"allowevent\", r.id) + \"
                                          • \").join(\"\");\n $(\"#reports tbody\").innerHTML = reports.length ? reports.map((r) => '' + esc(fmtTime(r.at)) + '' + esc(r.type || \"report\") + '' + av(r.target_pubkey) + key(r.target_pubkey) + (r.target_event ? (r.blob ? ' file ' + key(r.target_event) + \"\" : ' ev ' + key(r.target_event) + (r.hidden ? ' hidden' : \"\") + \"\") : \"\") + '' + esc(r.content) + '' + ib(\"check\", \"Dismiss\", \"resolve:dismiss\", r.id) + ib(\"trash\", r.blob ? \"Delete the file\" : \"Delete the event\", \"resolve:delete\", r.id) + ib(\"banuser\", \"Ban the author\", \"resolve:ban\", r.id, \"danger\") + \"\").join(\"\") : 'nothing reported';\n $(\"#reports-count\").textContent = reports.length || \"\"; $(\"#tc-reports\").textContent = reports.length || \"\";\n $(\"#blobs tbody\").innerHTML = blobs.length ? blobs.map((b) => '' + esc(fmtTime(b.uploaded)) + '' + key(b.sha256) + ' ' + esc(b.type) + '' + fmtBytes(b.size) + '' + av(b.uploader) + key(b.uploader) + '' + ib(\"trash\", \"Delete file\", \"deleteblob\", b.sha256, \"danger\") + \"\").join(\"\") : 'no uploads';\n $(\"#blobs-count\").textContent = blobs.length || \"\";\n $(\"#sites tbody\").innerHTML = sites.length ? sites.map((s) => '' + av(s.author) + key(s.author) + '' + esc(s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + '' + esc(s.url) + '' + esc(s.paths) + (s.missing ? ' ' + esc(s.missing) + ' missing' : '') + '' + fmtBytes(s.size) + '' + esc(s.expires_at ? fmtTime(s.expires_at) : \"never\") + '' + ib(\"trash\", \"Delete site\", \"deleteevent\", s.id, \"danger\") + '').join(\"\") : 'no sites yet';\n $(\"#sites-count\").textContent = sites.length || \"\";\n const tag = (k, cls) => '' + k + ib(\"x\", \"Remove rule\", \"unrulekind\", String(k)) + \"\";\n $(\"#kinds-allow\").innerHTML = allow.length ? allow.map((k) => tag(k, \"ok\")).join(\"\") : 'all';\n $(\"#kinds-block\").innerHTML = block.length ? block.map((k) => tag(k, \"blk\")).join(\"\") : 'none';\n await loadPeople();\n }\n\n const KIND_NAMES = { 0: \"profiles\", 1: \"notes\", 3: \"contacts\", 4: \"DMs\", 5: \"deletions\", 6: \"reposts\", 7: \"reactions\", 16: \"reposts\", 1059: \"gift wraps\", 1063: \"file headers\", 1111: \"comments\", 1984: \"reports\", 9734: \"zap requests\", 9735: \"zap receipts\", 9802: \"highlights\", 10002: \"relay lists\", 13534: \"roster\", 9000: \"group adds\", 9001: \"group removals\", 9021: \"join requests\", 9022: \"leave requests\", 39000: \"group info\", 39001: \"group admins\", 39002: \"group members\", 39003: \"group roles\", 30023: \"articles\", 30024: \"drafts\", 30078: \"app data\", 30311: \"live events\", 30818: \"wiki\" };\n const kindName = (k) => KIND_NAMES[k] || (k >= 20000 && k < 30000 ? \"ephemeral\" : k >= 30000 && k < 40000 ? \"addressable\" : k >= 10000 && k < 20000 ? \"replaceable\" : \"kind \" + k);\n const SYS_KINDS = new Set([0, 3, 10002, 9735, 13534, 8000, 8001, 9000, 9001, 39000, 39001, 39002, 39003]);\n let storage = null;\n\n // Features (settings.ts): a select each; search has three modes, the rest on or off.\n const FEATURES = [\n [\"search\", \"Search\", \"NIP-50. Prose indexes notes, threads, comments, highlights, articles and wiki pages; full indexes every public kind with content. A change applies to events from then on.\", [\"prose:prose\", \"full:full\", \"off:off\"]],\n [\"sync\", \"Sync\", \"NIP-77 reconciliation, which reads the whole matching set per sync.\"],\n [\"count\", \"Counts\", \"NIP-45 COUNT, with HLL sketches.\"],\n [\"discovery\", \"Discovery record\", \"NIP-66: the record the relay signs about itself, for crawlers.\"],\n [\"names\", \"Names\", \"NIP-05 addresses under this relay's domain.\"],\n [\"files\", \"Files\", \"Blossom and NIP-96: uploads, downloads and listings.\"],\n [\"pages\", \"Pages and feed\", \"Notes and articles as pages, and the Atom feed.\"],\n [\"sites\", \"Static websites\", \"NIP-5A sites on their own hostnames. Mirroring copies missing files into this relay and costs fuel.\", [\"mirror:on, mirror files\", \"proxy:on, fetch as needed\", \"off:off\"]],\n [\"marmot\", \"Marmot transport\", \"Signed KeyPackages and encrypted group messages, with account admission for ephemeral authors.\"],\n [\"grasp\", \"Git repositories\", \"GRASP Git hosting with admitted repository state. The prototype backend has bounded storage and compute limits.\"],\n [\"push\", \"Relay push\", \"NIP-9a callback delivery for members and the owner. Requires approved callback origins; advertises lettered NIP identifiers.\"],\n [\"signer\", \"Signer traffic\", \"NIP-46 remote signing carried for anyone, never stored.\"],\n ];\n function renderFeatures(f) {\n $(\"#features\").innerHTML = FEATURES.map(([k, title, about, modes]) => {\n const cur = k === \"sites\" ? (f.sites?.enabled === false ? \"off\" : f.sites?.mirror === false ? \"proxy\" : \"mirror\") : modes ? String(f[k] || \"prose\") : String(f[k] !== false);\n const opts = (modes || [\"true:on\", \"false:off\"]).map((m) => { const [v, l] = m.split(\":\"); return '\"; }).join(\"\");\n return \"\";\n }).join(\"\");\n }\n $(\"#features\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-feature]\"); if (!sel) return;\n const k = sel.dataset.feature, v = k === \"search\" || k === \"sites\" ? sel.value : sel.value === \"true\";\n policy = await rpc(\"setpolicy\", { features: { [k]: k === \"sites\" ? { enabled: v !== \"off\", mirror: v === \"mirror\" } : v } });\n toast(k === \"search\" ? \"Search: \" + v : (v ? \"Switched on \" : \"Switched off \") + k); await loadInfo();\n }));\n\n $(\"#push-policy-form\").addEventListener(\"submit\", guard(async (ev) => {\n ev.preventDefault();\n const form = ev.target;\n const origins = form.elements.origins.value.split(/\\s+/).filter(Boolean);\n const updated = await rpc(\"setpolicy\", { pushCallbacks: origins, letteredNips: form.elements.lettered.checked });\n if (JSON.stringify(updated.pushCallbacks) !== JSON.stringify([...new Set(origins.map((s) => s.replace(/\\/$/, \"\")))])) throw new Error(\"Use up to sixteen exact HTTPS origins, with no path or credentials.\");\n policy = updated;\n toast(\"Saved callback policy\"); await loadInfo();\n }));\n\n async function loadViews() {\n let views;\n try { views = await rpc(\"listviews\"); } catch { return; }\n $(\"#views\").innerHTML = views.map((v) => {\n const runs = v.trigger === \"off\" ? \"off\" : v.trigger === \"live\" ? \"live, from memory\" : v.trigger === \"write\" ? \"on write and daily\" : v.trigger;\n const label = (c) => (c === \"off\" ? \"off\" : c === \"write\" ? \"on write\" : c === \"live\" ? \"on\" : c);\n const choices = [...v.choices, v.default].filter((c, i, a) => a.indexOf(c) === i);\n const pick = '\";\n const who = v.audience === \"members\" ? \"members\" + (v.stored ? \"\" : \", on request\") : \"anyone\";\n const last = v.trigger === \"live\" ? \"\" : v.last ? \"last run \" + fmtTime(v.last.at) : \"not run yet\";\n const rows = v.trigger === \"live\" ? \"no rows\" : v.last ? v.last.rows.toLocaleString() + \" rows\" : \"\";\n const meta = [runs, who, last, rows].filter(Boolean).map((x) => \"\" + esc(x) + \"\").join('|');\n return '
                                            ' + esc(v.name) + '
                                            ' + esc(v.about) + '
                                            ' + pick + (v.on ? 'Open' : \"\") + '
                                            ' + meta + \"
                                            \";\n }).join(\"\");\n }\n $(\"#views\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-view]\"); if (!sel) return;\n const value = sel.value === \"live\" ? true : sel.value;\n await rpc(\"setpolicy\", { views: { [sel.dataset.view]: value } });\n toast(sel.value === \"off\" ? \"Took down \" + sel.dataset.view : sel.dataset.view + \": \" + (sel.value === \"live\" ? \"on\" : sel.value === \"write\" ? \"on write\" : sel.value)); await loadViews(); await loadInfo();\n }));\n async function loadDumps() {\n const list = await rpc(\"listdumps\");\n const f = $(\"#dumpform\");\n if (policy) { f.dumps.value = policy.dumps || \"off\"; f.keep.value = policy.dumpsKeep || 7; }\n $(\"#dumps-count\").textContent = list.length || \"\";\n $(\"#dumps\").innerHTML = list.length ? list.map((d) => '
                                          • jsonl' + esc(d.name) + ' ' + d.events.toLocaleString() + \" events, \" + fmtBytes(d.bytes) + \"\" + ib(\"copy\", \"Download\", \"downloaddump\", d.name) + ib(\"trash\", \"Delete\", \"deletedump\", d.name, \"danger\") + \"
                                          • \").join(\"\") : '
                                          • no dumps yet
                                          • ';\n }\n // A dump is fetched with a signed request and handed to the browser as a file.\n async function downloadDump(name) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const url = location.origin + \"/dumps/\" + name;\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"GET\"]] });\n const resp = await fetch(url, { headers: { authorization: \"Nostr \" + btoa(JSON.stringify(ev)) } });\n if (!resp.ok) throw new Error((await resp.json()).error || \"download failed\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(\".\")[0] + \"-\" + name; a.click(); URL.revokeObjectURL(a.href);\n }\n // A plain member's own invites, when the owner lets members invite.\n async function loadMine() {\n const sec = $(\"#myinvites\");\n if (!me || !owner || myRole || !signer.ready()) { sec.classList.add(\"hidden\"); return; }\n let mine;\n try { mine = await rpc(\"listinvites\"); } catch { sec.classList.add(\"hidden\"); return; }\n sec.classList.remove(\"hidden\");\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#mine\").innerHTML = mine.length ? mine.map((i) => '
                                          • inv…' + i.code.slice(-8) + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
                                          • \").join(\"\") : '
                                          • no invites yet
                                          • ';\n }\n async function loadStorage() {\n storage = await rpc(\"storagestats\");\n pollJobs();\n loadViews();\n loadDumps().catch(() => {});\n const st = storage;\n const top = st.kinds.slice(0, 4), rest = st.kinds.slice(4).reduce((a, k) => a + k.bytes, 0);\n const parts = [...top.map((k, i) => [k.kind + \" \" + kindName(k.kind), \"k\" + (i + 1), k.bytes]), ...(rest ? [[\"other\", \"k5\", rest]] : [])];\n const total = st.eventBytes || 1;\n $(\"#s-bar\").innerHTML = parts.length ? parts.map(([n, c, b]) => '').join(\"\") : '';\n $(\"#s-legend\").innerHTML = parts.length ? parts.map(([n, c, b]) => '' + esc(n) + \" \" + Math.round(b * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n const overhead = Math.max(0, st.databaseBytes - st.eventBytes);\n $(\"#s-totals\").innerHTML = [[\"events\", st.events.toLocaleString() + \" \" + fmtBytes(st.eventBytes) + \"\"], [\"index and overhead\", fmtBytes(overhead)], [\"files\", st.blobs.toLocaleString() + \" \" + fmtBytes(st.mediaBytes) + \"\"]]\n .map(([k, v]) => \"
                                            \" + k + \"\" + v + \"
                                            \").join(\"\");\n const any = st.retention.find((r) => r.kind === null);\n const own = (kind) => { const r = st.retention.find((x) => x.kind === kind); return r ? r.days : \"\"; };\n const row = (k) => {\n const key = k.kind === null ? \"\" : String(k.kind);\n const placeholder = k.kind === null ? \"forever\" : k.replaceable || !any ? \"forever\" : any.days + \" (everything else)\";\n const pill = k.kind === null ? \"everything else\" : '' + k.kind + \" \" + esc(kindName(k.kind));\n const cells = k.protected\n ? 'required'\n : ' days' + ib(\"check\", \"Save keep-for rule\", \"saveretention\", key) + ib(\"trash\", \"Purge\", \"purgekind\", key, \"danger\") + \"\";\n return '' + pill + '' + (k.n === undefined ? \"\" : k.n.toLocaleString()) + '' + (k.bytes === undefined ? \"\" : fmtBytes(k.bytes)) + '' + (k.oldest ? esc(fmtDay(k.oldest)) : \"\") + \"\" + cells + \"\";\n };\n $(\"#kinds tbody\").innerHTML = [...st.kinds, { kind: null, days: any ? any.days : 0 }].map(row).join(\"\");\n }\n\n let searchQuery = \"\";\n async function loadEvents(reset) {\n const list = searchQuery ? await rpc(\"searchevents\", searchQuery, 200) : await rpc(\"listrecentevents\", reset ? 50 : 200);\n $(\"#events tbody\").innerHTML = list.length ? list.map((e) => '' + esc(fmtTime(e.created_at)) + '' + e.kind + '' + av(e.pubkey) + key(e.pubkey) + '' + esc(e.content) + '' + ib(\"pin\", \"Pin\", \"pinevent\", e.id) + ib(\"trash\", \"Delete event\", \"deleteevent\", e.id) + ib(\"ban\", \"Ban event\", \"banevent\", e.id, \"danger\") + ib(\"banuser\", \"Ban author\", \"banpubkey\", e.pubkey, \"danger\") + \"\").join(\"\") : 'no events yet';\n $(\"#more\").classList.toggle(\"hidden\", searchQuery || list.length < 50);\n }\n $(\"#searchform\").onsubmit = guard(async (ev) => {\n searchQuery = ev.target.q.value.trim();\n $(\"#searchclear\").classList.toggle(\"hidden\", !searchQuery);\n await loadEvents(true);\n });\n $(\"#searchclear\").onclick = guard(async () => {\n searchQuery = \"\"; $(\"#searchform\").reset(); $(\"#searchclear\").classList.add(\"hidden\");\n await loadEvents(true);\n });\n async function loadPins() {\n const pins = await rpc(\"listpins\");\n $(\"#pins-count\").textContent = pins.length || \"\";\n $(\"#pins\").innerHTML = pins.length ? pins.map((t) => '
                                          • ' + (t[0] === \"e\" ? \"event\" : \"address\") + '' + esc(t[1].length > 40 ? t[1].slice(0, 16) + \"\\u2026\" + t[1].slice(-8) : t[1]) + \"\" + ib(\"copy\", \"Copy\", \"copy\", t[1]) + ib(\"x\", \"Unpin\", \"unpinevent\", t[1]) + \"
                                          • \").join(\"\") : '
                                          • nothing pinned
                                          • ';\n }\n $(\"#pinform\").onsubmit = guard(async (ev) => {\n await rpc(\"pinevent\", ev.target.id.value.trim()); ev.target.reset(); toast(\"Pinned\"); await loadPins();\n });\n\n // ---- actions ----\n const refresh = () => Promise.all([loadLists(), loadEvents(true), loadStorage(), loadPins()]);\n\n $(\"#copy\").onclick = async () => { await navigator.clipboard.writeText(wsURL); toast(\"copied \" + wsURL); };\n $(\"#signin\").onclick = guard(async () => {\n if (!window.nostr) throw new Error(\"No nostr extension found. Install one (Alby, nos2x, …) and reload, or use a remote signer.\");\n me = await window.nostr.getPublicKey();\n localStorage.setItem(\"me\", me);\n renderHeader(); await loadAdmin(); await loadPeople();\n });\n $(\"#signin46\").onclick = () => showRemote(null);\n $$(\".remote\").forEach((b) => { b.onclick = () => showRemote($(\"#\" + b.dataset.note)); });\n $(\"#nccopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#nclink\").href); toast(\"Link copied; paste it into your signer app\"); };\n $(\"#bunkerform\").onsubmit = guard(async (ev) => {\n $(\"#remotenote\").textContent = \"Connecting…\";\n try { await connectBunker(ev.target.url.value); } catch (e) { $(\"#remotenote\").textContent = e.message; throw e; }\n $(\"#remotenote\").textContent = \"\"; ev.target.reset();\n await remoteDone();\n });\n $(\"#signout\").onclick = async () => {\n if (remote) { const r = remote; remote = null; try { await withTimeout(r.logout(), 5000, \"The signer\"); } catch { /* the session is gone either way */ } }\n localStorage.removeItem(\"nip46\");\n me = null; localStorage.removeItem(\"me\"); renderHeader(); await loadAdmin(); await loadPeople();\n };\n const claimNow = async (note) => {\n if (!signer.ready()) { note.textContent = \"Sign the claim with a nostr extension or a remote signer.\"; showRemote(note); return; }\n me = await signer.getPublicKey();\n const r = await rpc(\"claim\");\n if (!r.claimed) throw new Error(\"Somebody else claimed it first.\");\n localStorage.setItem(\"me\", me);\n toast(\"It's yours.\");\n await loadInfo(); await loadFuel(); await loadAdmin(); await loadPeople();\n if (r.converted && confirm(\"This relay began as a temporary one: anyone can write, and everything is deleted after 14 days. Switch to the default rules and keep everything from now on? Each rule can be changed later on the Rules and Storage tabs.\")) {\n await rpc(\"resetrules\"); toast(\"Rules reset\"); await loadInfo(); await loadAdmin();\n }\n };\n $(\"#claim\").onclick = guard(() => claimNow($(\"#claimnote\")));\n $(\"#claimlease\").onclick = guard(() => claimNow($(\"#leasenote\")));\n // Jobs run in the background; the table follows them while the tab is open.\n let jobsTimer = 0;\n const fmtJob = (j) => {\n const what = j.kind === \"mirror\" ? \"mirror site\" : j.kind === \"import\" ? \"import\" : j.label === \"backfill\" ? \"fetch my history\" : j.kind === \"pull\" ? \"pull\" : \"rebroadcast\";\n const f = [];\n if (j.filter.authors) f.push(j.filter.authors.length === 1 && j.filter.authors[0] === me ? \"my events\" : j.filter.authors.length + \" authors\");\n if (j.filter.kinds) f.push(\"kinds \" + j.filter.kinds.join(\", \"));\n if (j.filter.since) f.push(\"since \" + fmtDay(j.filter.since));\n const when = j.every ? \"every \" + (j.every === 24 ? \"day\" : j.every + \" h\") + (j.nextRun ? \", next \" + fmtTime(j.nextRun) : \"\") : \"once\";\n const l = j.last;\n const count = (stored, blobs, sent, refused) => (j.kind === \"mirror\" ? blobs.toLocaleString() + \" files mirrored\" : j.kind === \"import\" ? stored.toLocaleString() + \" events\" + ((j.last ? j.last.duplicates : j.duplicates) ? \", \" + (j.last ? j.last.duplicates : j.duplicates) + \" already here\" : \"\") : j.kind === \"pull\" ? stored.toLocaleString() + \" events\" + (blobs ? \", \" + blobs + \" files\" : \"\") : sent.toLocaleString() + \" sent\" + (refused ? \", \" + refused + \" refused\" : \"\"));\n const res = j.running ? \"running: \" + count(j.stored, j.blobs, j.sent, j.refused) + \"...\" : !l ? \"waiting\" : l.error ? \"failed: \" + l.error : count(l.stored, l.blobs, l.sent, l.refused) + (l.skipped ? \", \" + l.skipped + \" skipped\" : \"\") + \", \" + fmtTime(l.finishedAt);\n const sources = j.running ? j.pullSources : l?.sources;\n const details = sources?.length ? '
                                            Source results' + sources.map((s) => '

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

                                            ').join('') + '
                                            ' : '';\n return \"\" + what + \"\" + j.relays.map(esc).join(\"
                                            \") + \"\" + (f.join(\", \") || \"everything\") + \"\" + when + \"\" + esc(res) + details + \"\" + (j.running ? \"\" : ib(\"undo\", \"Run now\", \"runjob\", j.id)) + ib(\"x\", \"Remove\", \"removejob\", j.id) + \"\";\n };\n async function pollJobs() {\n clearTimeout(jobsTimer);\n let jobs;\n try { jobs = await rpc(\"listjobs\"); } catch { return; }\n $(\"#jobs tbody\").innerHTML = jobs.length ? jobs.map(fmtJob).join(\"\") : 'no jobs yet';\n if (jobs.some((j) => j.running || (j.nextRun && j.nextRun <= Math.floor(Date.now() / 1000) + 1))) jobsTimer = setTimeout(pollJobs, 3000);\n }\n const urls = (s) => s.split(/[\\s,]+/).map((u) => u.trim()).filter(Boolean);\n const kindsOf = (s) => s.split(/[\\s,]+/).map((k) => parseInt(k, 10)).filter((k) => Number.isInteger(k) && k >= 0);\n $(\"#pullform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"addjob\", { kind: \"pull\", relays: [f.url.value.trim()], every: +f.every.value });\n toast(+f.every.value ? \"Mirror scheduled\" : \"Pull started\"); f.reset(); await pollJobs();\n });\n $(\"#backfillform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"backfill\", urls(f.relays.value));\n toast(\"Fetching your history\"); f.reset(); await pollJobs();\n });\n $(\"#pushform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const filter = {};\n const kinds = kindsOf(f.kinds.value); if (kinds.length) filter.kinds = kinds;\n const days = +f.days.value; if (days > 0) filter.since = Math.floor(Date.now() / 1000) - days * 86400;\n await rpc(\"addjob\", { kind: \"push\", relays: urls(f.targets.value), filter, every: +f.every.value });\n toast(\"Rebroadcast started\"); f.reset(); await pollJobs();\n });\n // ---- presets: writes, reads, kinds and keep-for rules in one click ----\n let presets = null;\n async function renderPresets() {\n if (!presets) { try { presets = await rpc(\"listpresets\"); } catch { presets = []; } }\n $(\"#presets\").innerHTML = presets.map((p) => '\").join(\"\");\n $(\"#presetsourcerow\").classList.toggle(\"hidden\", !presets.some((p) => p.source));\n }\n $(\"#presets\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-preset]\"); if (!b) return;\n const p = (presets || []).find((x) => x.name === b.dataset.preset); if (!p) return;\n if (!confirm(p.title + \": \" + p.about + \"\\n\\nThis replaces the writes and reads rules, the directory setting, the kind rules and the keep-for rules. Limits, identity, people and bans stay.\")) return;\n const source = $(\"#presetsource\").value.trim();\n if (p.source === \"required\" && !source) { $(\"#presetnote\").textContent = p.title + \" needs a source relay to mirror; enter its wss:// URL first.\"; return; }\n b.disabled = true;\n try { policy = await rpc(\"applypreset\", p.name, p.source && source ? { source } : undefined); $(\"#presetnote\").textContent = \"Now: \" + p.about + (policy.job ? \" Mirroring \" + source + \" every \" + policy.job.every + \" h.\" : \"\"); toast(p.title + \" applied\"); await loadInfo(); await loadAdmin(); }\n catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // ---- wire me in: this relay in the owner's own lists ----\n // The lists are replaceable, so a fresh list with only this relay would\n // clobber the real one once it spread. Every publish starts from the\n // newest copy found here or on the indexers, verified when the signer\n // library is around, and adds or removes this relay in it.\n const INDEXERS = [\"wss://purplepag.es\", \"wss://relay.nostr.band\", \"wss://relay.damus.io\", \"wss://nos.lol\"];\n const LISTS = [\n { kind: 10002, tag: \"r\", title: \"Relay list\", nip: \"NIP-65\", about: \"where clients read your notes and send you mentions\" },\n { kind: 10050, tag: \"relay\", title: \"DM inbox\", nip: \"NIP-17\", about: \"where people send you private messages\" },\n { kind: 10007, tag: \"relay\", title: \"Search relays\", nip: \"NIP-51\", about: \"where clients run your searches\" },\n { kind: 10063, tag: \"server\", title: \"Blossom servers\", nip: \"BUD-03\", about: \"where clients upload and look for your files\" },\n ];\n const mineFor = (l) => (l.tag === \"server\" ? location.origin : wsURL);\n const normURL = (u) => { try { const x = new URL(String(u).trim()); return (x.host + x.pathname).replace(/\\/+$/, \"\").toLowerCase(); } catch { return String(u).trim().toLowerCase(); } };\n const isMine = (l, t) => t[0] === l.tag && normURL(t[1] || \"\") === normURL(mineFor(l));\n const listHas = (list, l) => !!list && list.tags.some((t) => isMine(l, t));\n // mergeList is pure: the newest list with this relay put first or taken out, every other tag kept.\n function mergeList(list, l, include) {\n const tags = (list ? list.tags : []).filter((t) => !isMine(l, t));\n if (include) tags.unshift([l.tag, mineFor(l)]);\n return { kind: l.kind, created_at: Math.floor(Date.now() / 1000), content: list ? list.content : \"\", tags };\n }\n const relaysIn = (list) => list.tags.filter((t) => (t[0] === \"r\" || t[0] === \"relay\") && /^wss?:\\/\\//i.test(t[1] || \"\")).map((t) => t[1].trim());\n // overWS opens one socket, sends one message, feeds answers to onMessage until it says done or time runs out.\n function overWS(url, ms, first, onMessage) {\n return new Promise((res) => {\n let ws = null, done = false, out = null;\n const finish = () => { if (done) return; done = true; clearTimeout(timer); try { if (ws) ws.close(); } catch { /* closed */ } res(out); };\n const timer = setTimeout(finish, ms);\n try { ws = new WebSocket(url); } catch { return finish(); }\n ws.onopen = () => ws.send(JSON.stringify(first));\n ws.onmessage = (m) => { let d; try { d = JSON.parse(m.data); } catch { return; } if (!Array.isArray(d)) return; if (onMessage(d, (v) => { out = v; })) finish(); };\n ws.onerror = finish; ws.onclose = finish;\n });\n }\n function fetchNewest(url, filter, ms) {\n let best = null;\n return overWS(url, ms, [\"REQ\", \"w\", filter], (d, set) => {\n if (d[0] === \"EVENT\" && d[1] === \"w\" && d[2] && (!best || d[2].created_at > best.created_at)) { best = d[2]; set(best); }\n return (d[0] === \"EOSE\" || d[0] === \"CLOSED\") && d[1] === \"w\";\n });\n }\n function publishTo(url, event, ms) {\n return overWS(url, ms, [\"EVENT\", event], (d, set) => { if (d[0] === \"OK\" && d[1] === event.id) { set({ url, ok: !!d[2], msg: d[3] || \"\" }); return true; } return false; })\n .then((r) => r || { url, ok: false, msg: \"no answer\" });\n }\n // bridge signs a NIP-98 request to this relay's HTTP door, so the owner's own reads and writes pass any read rule.\n async function bridge(path, body) {\n const url = location.origin + path, raw = JSON.stringify(body);\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"POST\"], [\"payload\", await sha256hex(raw)]] });\n const resp = await fetch(url, { method: \"POST\", headers: { \"content-type\": \"application/json\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body: raw });\n const json = await resp.json();\n if (!resp.ok) throw new Error(json.error || \"HTTP \" + resp.status);\n return json;\n }\n const wire = {};\n function renderWire() {\n const pill = (state) => {\n const cls = state === \"listed\" ? \"on\" : state === \"failed\" ? \"bad\" : state === \"not listed\" ? \"off\" : \"\";\n const label = state === \"checking\" || state === \"publishing\" ? state + \"\\u2026\" : state;\n return '' + esc(label) + \"\";\n };\n $(\"#wire\").innerHTML = LISTS.map((l) => {\n const w = wire[l.kind] || { state: \"\", results: [], list: null };\n const busy = w.state === \"checking\" || w.state === \"publishing\";\n const btn = (act, cls, label) => '\";\n const buttons = w.state === \"listed\" ? btn(\"remove\", \"\", \"Remove this relay\") : btn(\"add\", \"pri\", \"Add this relay\") + btn(\"check\", \"\", w.state ? \"Check again\" : \"Check\");\n const n = w.list ? w.list.tags.filter((t) => t[0] === l.tag).length : 0;\n const from = w.list ? \"\" + (n === 1 ? \"1 entry\" : n + \" entries\") + \", \" + fmtTime(w.list.created_at) + \"\" : w.state && !busy ? \"no list found\" : \"\";\n const sent = (w.results || []).map((r) => '' + esc(r.url.replace(/^wss?:\\/\\//, \"\")) + (r.ok ? \"\" : \" failed\") + \"\").join(\"\");\n const meta = (w.state ? pill(w.state) : \"\") + from + sent;\n return '
                                            ' + l.title + ' ' + l.nip + \"
                                            \" + l.about + '
                                            ' + buttons + '
                                            ' + meta + \"
                                            \";\n }).join(\"\");\n }\n async function wireCheck(l) {\n const w = (wire[l.kind] = { state: \"checking\", list: null, results: [] }); renderWire();\n const filter = { kinds: [l.kind], authors: [me], limit: 1 };\n const found = [];\n try { found.push(...(await bridge(\"/query\", [filter]))); } catch { /* the relay may hold none */ }\n const remote = await Promise.all(INDEXERS.map((u) => fetchNewest(u, filter, 4000)));\n let lib = null; try { lib = await signerLib(); } catch { /* unverified lists are still the owner's own, by pubkey */ }\n for (const e of remote) if (e && e.pubkey === me && e.kind === l.kind && Array.isArray(e.tags) && (!lib || lib.verifyEvent(e))) found.push(e);\n w.list = found.sort((a, b) => b.created_at - a.created_at)[0] || null;\n w.state = listHas(w.list, l) ? \"listed\" : \"not listed\"; renderWire();\n }\n async function wirePublish(l, include) {\n if (!wire[l.kind] || !wire[l.kind].state || wire[l.kind].state === \"failed\") await wireCheck(l);\n const w = wire[l.kind]; w.state = \"publishing\"; w.results = []; renderWire();\n try {\n const signed = await signer.signEvent(mergeList(w.list, l, include));\n const here = await bridge(\"/events\", signed).then((r) => ({ url: wsURL, ok: !!r.accepted, msg: r.message || \"\" })).catch((e) => ({ url: wsURL, ok: false, msg: e.message }));\n const targets = [...new Set([...relaysIn(signed), ...INDEXERS])].filter((u) => normURL(u) !== normURL(wsURL));\n const rest = await Promise.all(targets.map((u) => publishTo(u, signed, 6000)));\n w.results = [here, ...rest]; w.list = signed; w.state = include ? \"listed\" : \"not listed\";\n toast((include ? \"Added to your \" : \"Removed from your \") + l.title.toLowerCase());\n } catch (e) { w.state = \"failed\"; w.results = [{ url: wsURL, ok: false, msg: e.message }]; toast(e.message); }\n renderWire();\n }\n $(\"#wire\").addEventListener(\"click\", (ev) => {\n const b = ev.target.closest(\"button[data-wire]\"); if (!b) return;\n const l = LISTS.find((x) => String(x.kind) === b.dataset.wire); if (!l) return;\n if (b.dataset.do === \"check\") wireCheck(l); else wirePublish(l, b.dataset.do === \"add\");\n });\n\n // ---- custom domains: this relay under a hostname the owner controls ----\n let domainSites = [];\n const domainOptions = (current = \"\") => [{ label: \"\", title: \"This relay\" }, ...domainSites.map((s) => ({ label: s.label, title: (s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + \" / \" + key(s.author) })), ...(current && !domainSites.some((s) => s.label === current) ? [{ label: current, title: \"Site \" + current }] : [])].map((s) => '\").join(\"\");\n let domains = null; // null: not enabled on this host\n function renderDomains() {\n $(\"#domains\").innerHTML = (domains || []).map((d) => {\n const state = d.ready ? \"active\" : \"hostname \" + d.status.replace(/_/g, \" \") + \", certificate \" + d.sslStatus.replace(/_/g, \" \");\n const btn = (act, cls, label) => '\";\n const rows = d.ready ? \"\" : '
                                            ' + d.records.map((r) => \"\").join(\"\") + \"
                                            TypeNameValue
                                            \" + esc(r.type) + '' + esc(r.name) + '' + esc(r.value) + \"\" + esc(r.note) + \"
                                            \";\n return '
                                            ' + esc(d.host) + '' + esc(state) + \"\" + btn(\"check\", \"\", \"Check\") + btn(\"remove\", \"danger\", \"Remove\") + '
                                            \" + rows;\n }).join(\"\");\n }\n async function loadDomains() {\n try { [domains, domainSites] = await Promise.all([rpc(\"listdomains\"), rpc(\"listsites\")]); $(\"#adddomain select[name=site]\").innerHTML = domainOptions(); $(\"#domainnote\").textContent = domains.length ? \"\" : \"No custom domain yet.\"; $(\"#adddomain\").classList.remove(\"hidden\"); }\n catch (e) { domains = null; $(\"#domainnote\").textContent = /^unsupported/.test(e.message) ? \"Custom domains are not enabled on this host.\" : e.message; $(\"#adddomain\").classList.add(\"hidden\"); }\n renderDomains();\n }\n $(\"#adddomain\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"adddomain\", f.host.value.trim(), f.site.value); f.reset(); toast(\"Domain added; now create the CNAME\"); await loadDomains();\n });\n $(\"#domains\").addEventListener(\"change\", guard(async (ev) => {\n const select = ev.target.closest(\"select[data-domain-site]\"); if (!select) return;\n await rpc(\"setdomainsite\", select.dataset.domainSite, select.value);\n toast(\"Domain destination saved\"); await loadDomains();\n }));\n $(\"#domains\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-domain]\"); if (!b) return;\n const host = b.dataset.domain;\n if (b.dataset.do === \"remove\" && !confirm(\"Remove \" + host + \"? Its certificate goes with it and the name stops answering.\")) return;\n b.disabled = true;\n try {\n if (b.dataset.do === \"remove\") { await rpc(\"removedomain\", host); toast(\"Removed\"); }\n else { const d = await rpc(\"checkdomain\", host); toast(d.ready ? host + \" is live\" : \"Not yet: \" + (d.status === \"active\" ? \"certificate pending\" : \"waiting for the CNAME\")); }\n await loadDomains();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n $(\"#access\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const openKinds = f.openKinds.value.split(/[\\s,]+/).filter(Boolean).map(Number);\n if (openKinds.some((k) => !Number.isInteger(k) || k < 0 || k > 65535)) throw new Error(\"Open kinds must be whole numbers.\");\n policy = await rpc(\"setpolicy\", { writes: f.writes.value, reads: f.reads.value, openKinds, guestReplies: f.guestReplies.checked, minPow: +f.minPow.value, maxFuture: +f.maxFuture.value, maxLimit: +f.maxLimit.value, maxSubs: +f.maxSubs.value, maxMessageKB: +f.maxMessageKB.value, eventsPerMinute: +f.eventsPerMinute.value, reqsPerMinute: +f.reqsPerMinute.value, maxBlobMB: +f.maxBlobMB.value });\n toast(\"Rules saved\"); await loadInfo();\n });\n $(\"#notify\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { notify: { reports: f.reports.checked, fuel: f.fuel.checked, jobs: f.jobs.checked, succession: f.succession.checked, digest: f.digest.checked } });\n toast(\"Notifications saved\");\n });\n $(\"#notifytest\").onclick = guard(async () => {\n const r = await rpc(\"notifytest\");\n toast(r.sent ? \"Sent. Look for a message from the relay in your DMs.\" : \"Could not send.\");\n });\n $(\"#identity\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const list = (v) => v.split(\",\").map((s) => s.trim()).filter(Boolean);\n policy = await rpc(\"setpolicy\", { name: f.name.value, contact: f.contact.value, description: f.description.value, icon: f.icon.value,\n banner: f.banner.value, postingPolicy: f.postingPolicy.value, privacyPolicy: f.privacyPolicy.value, tags: list(f.tags.value), languageTags: list(f.languageTags.value), relayCountries: list(f.relayCountries.value) });\n toast(\"Identity saved\"); await loadInfo(); await loadPeople(); loadCard();\n });\n $(\"#addmember\").onsubmit = guard(async (ev) => {\n const f = ev.target; const pk = npubToHex(f.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"setmember\", pk, { name: f.name.value.trim() || null, note: f.note.value }); f.reset(); toast(\"Member added\"); await loadLists();\n });\n $(\"#addblock\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"blockip\", f.ip.value.trim(), f.reason.value); f.reset(); toast(\"Blocked\"); await loadLists();\n });\n $(\"#wordsform\").onsubmit = guard(async (ev) => {\n const words = ev.target.words.value.split(\"\\n\").map((s) => s.trim()).filter(Boolean);\n const kept = await rpc(\"setblockedwords\", words);\n policy = await rpc(\"setpolicy\", { blockedWordsInTags: ev.target.inTags.checked });\n ev.target.words.value = kept.join(\"\\n\"); toast(kept.length ? kept.length + \" words blocked\" : \"No words blocked\");\n });\n $(\"#thresholdform\").onsubmit = guard(async (ev) => {\n policy = await rpc(\"setpolicy\", { reportThreshold: +ev.target.reportThreshold.value });\n toast(policy.reportThreshold ? \"Hidden after \" + policy.reportThreshold + \" reports\" : \"Never hidden by reports\"); await loadLists();\n });\n $(\"#addban\").onsubmit = guard(async (ev) => {\n const pk = npubToHex(ev.target.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"banpubkey\", pk, ev.target.reason.value, ev.target.erase.checked); ev.target.reset(); toast(ev.target.erase.checked ? \"Banned and erased\" : \"Banned\"); await loadLists();\n });\n $(\"#mintinvite\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const inv = await rpc(\"createinvite\", +f.ttl.value, +f.max.value, f.note.value);\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n f.note.value = \"\"; toast(\"Invite created and copied\"); await loadLists();\n });\n $(\"#kindform\").onsubmit = guard(async (ev) => {\n const rule = ev.submitter.value; const k = +ev.target.kind.value;\n await rpc(rule === \"allow\" ? \"allowkind\" : \"disallowkind\", k); ev.target.reset(); toast((rule === \"allow\" ? \"Allowed kind \" : \"Blocked kind \") + k); await loadLists();\n });\n $(\"#more\").onclick = guard(() => loadEvents(false));\n $(\"#dumpform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { dumps: f.dumps.value, dumpsKeep: Math.max(1, Math.min(60, Math.floor(+f.keep.value || 7))) });\n toast(policy.dumps === \"off\" ? \"Dumps off\" : \"Dumping \" + policy.dumps); await loadDumps();\n });\n $(\"#importform\").onsubmit = guard(async (ev) => {\n const file = ev.target.file.files[0]; if (!file) return;\n if (!signer.ready()) throw new Error(NO_SIGNER);\n if (file.size > 64 * 1024 * 1024) throw new Error(\"At most 64 MB per import.\");\n const body = await file.text();\n const url = location.origin + \"/import?name=\" + encodeURIComponent(file.name);\n const token = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"PUT\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(url, { method: \"PUT\", headers: { authorization: \"Nostr \" + btoa(JSON.stringify(token)), \"content-type\": \"application/x-ndjson\" }, body });\n const r = await resp.json();\n if (!resp.ok) throw new Error(r.error || \"import failed\");\n ev.target.reset(); toast(\"Importing \" + fmtBytes(r.bytes)); await pollJobs();\n });\n $(\"#dumpnow\").onclick = guard(async () => { const d = await rpc(\"dumpnow\"); toast(\"Dumped \" + d.events.toLocaleString() + \" events\"); await loadStorage(); });\n $(\"#treeform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { memberInvites: { depth: Math.max(0, Math.floor(+f.depth.value || 0)), quota: Math.max(0, Math.floor(+f.quota.value || 0)) } });\n toast(policy.memberInvites.depth ? \"Members may invite\" : \"Only you and moderators invite\");\n });\n $(\"#mintmine\").onclick = guard(async () => {\n const inv = await rpc(\"createinvite\", 259200, 1, \"\");\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n toast(\"Invite created and copied\"); await loadMine();\n });\n $(\"#exportcfg\").onclick = guard(async () => {\n const cfg = await rpc(\"exportconfig\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(new Blob([JSON.stringify(cfg, null, 2)], { type: \"application/json\" }));\n a.download = host.split(\".\")[0] + \".bind.ws.config.json\";\n a.click(); URL.revokeObjectURL(a.href);\n });\n $(\"#importcfg\").onclick = () => $(\"#cfgfile\").click();\n $(\"#cfgfile\").onchange = guard(async (ev) => {\n const file = ev.target.files[0]; if (!file) return;\n const cfg = JSON.parse(await file.text());\n const check = await rpc(\"importconfig\", cfg, { dryRun: true });\n const lines = check.changes.summary.length ? check.changes.summary.join(\"\\n\") : \"Nothing would change.\";\n const dropped = check.warnings.length ? \"\\n\\nNot taken:\\n\" + check.warnings.join(\"\\n\") : \"\";\n if (!confirm(\"Apply \" + file.name + \" to this relay?\\n\\n\" + lines + dropped)) { ev.target.value = \"\"; return; }\n await rpc(\"importconfig\", cfg); ev.target.value = \"\"; toast(\"Configuration imported\"); await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // Succession: the status line under the heir form, from successionstatus.\n async function renderSuccession() {\n const el = $(\"#successionnote\"), f = $(\"#succession\");\n let st;\n try { st = await rpc(\"successionstatus\"); } catch { el.textContent = \"\"; return; }\n const sc = st.succession;\n $(\"#clearsuccession\").classList.toggle(\"hidden\", !sc);\n f.querySelector(\"button.btn:not(#clearsuccession)\").textContent = sc ? \"Change heir\" : \"Set heir\";\n if (!sc) { el.textContent = \"No heir named. Last signed in \" + fmtTime(st.ownerSeenAt) + \".\"; return; }\n if (f.heir.querySelector('[value=\"' + sc.heir + '\"]')) f.heir.value = sc.heir;\n f.afterDays.value = String(sc.afterDays);\n const who = (window.__members || []).find((m) => m.pubkey === sc.heir);\n const heir = who && who.name ? who.name + \"@\" + host : short(sc.heir);\n el.textContent = \"Heir: \" + heir + \". Last signed in \" + fmtTime(st.ownerSeenAt) + (st.silentDays ? \" (\" + st.silentDays + \" days ago)\" : \"\") + \". \" +\n (st.warning ? \"The warning month is running: the relay goes to \" + heir + \" on \" + fmtDay(st.handoverAt) + \" unless you sign in.\" : \"If you stay away, it goes to \" + heir + \" around \" + fmtDay(st.handoverAt) + \".\") +\n (st.log && st.log.length ? \" Handed over before: \" + st.log.map((l) => fmtDay(l.at) + \" to \" + short(l.to)).join(\", \") + \".\" : \"\");\n }\n $(\"#succession\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"setsuccession\", { heir: f.heir.value, afterDays: +f.afterDays.value });\n toast(\"Heir set\"); await loadAdmin();\n });\n $(\"#clearsuccession\").onclick = guard(async () => {\n await rpc(\"clearsuccession\");\n toast(\"Heir cleared\"); await loadAdmin();\n });\n $(\"#transfer\").onsubmit = guard(async (ev) => {\n const pk = ev.target.pubkey.value; if (!pk) return;\n const name = host.split(\".\")[0];\n const typed = prompt(\"This hands \" + host + \" to \" + short(pk) + \" for good. You stay on as a moderator. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing changed.\"); return; }\n await rpc(\"transferowner\", pk);\n toast(\"Transferred\");\n await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // ---- fork: a new name with this relay's events, claim reserved for a key ----\n $(\"#forkform\").scope.onchange = (ev) => $(\"#forkkinds\").classList.toggle(\"hidden\", ev.target.value !== \"kinds\");\n $(\"#joinform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { joinTerms: f.joinTerms.value, directoryPublic: f.directoryPublic.checked });\n toast(\"Saved\"); await loadInfo(); await loadPeople();\n });\n $(\"#forkform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const opts = { people: f.people.checked };\n if (f.name.value.trim()) opts.name = f.name.value.trim().toLowerCase();\n if (f.holder.value.trim()) { const pk = npubToHex(f.holder.value); if (!pk) throw new Error(\"That is not a pubkey.\"); opts.holder = pk; }\n if (f.scope.value === \"mine\") opts.filter = { authors: [me] };\n if (f.scope.value === \"kinds\") { const kinds = f.kinds.value.split(/[\\s,]+/).filter(Boolean).map(Number); if (!kinds.length || kinds.some((k) => !Number.isInteger(k) || k < 0)) throw new Error(\"Give kinds as numbers.\"); opts.filter = { kinds }; }\n if (!confirm(\"Fork this relay into a new name\" + (opts.name ? \" (\" + opts.name + \")\" : \"\") + \"? It pulls \" + (f.scope.value === \"all\" ? \"everything\" : f.scope.value === \"mine\" ? \"your events\" : \"the chosen kinds\") + (opts.people ? \" and the people\" : \"\") + \", and only \" + (opts.holder ? \"that key\" : \"you\") + \" can claim it.\")) return;\n const r = await rpc(\"forkrelay\", opts);\n $(\"#forknote\").textContent = r.handover + \" Expires \" + fmtTime(r.expires_at) + \".\";\n $(\"#forkurl\").textContent = r.console;\n $(\"#forkresult\").classList.remove(\"hidden\");\n toast(\"Forked to \" + r.name);\n });\n $(\"#forkcopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#forkurl\").textContent); toast(\"copied\"); };\n $(\"#deleterelay\").onclick = guard(async () => {\n const name = host.split(\".\")[0];\n const typed = prompt(\"This deletes everything on \" + host + \" and gives the name up. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing was deleted.\"); return; }\n await rpc(\"deleterelay\", name);\n localStorage.removeItem(\"me\");\n location.href = \"/\";\n });\n $(\"#topup\").onsubmit = guard((ev) => topUp(+ev.target.sats.value));\n $(\"#inv-copy\").onclick = async () => { await navigator.clipboard.writeText($(\"#inv-text\").value); toast(\"invoice copied\"); };\n $$(\"button[data-copy]\").forEach((b) => { b.onclick = async () => { await navigator.clipboard.writeText(b.dataset.copy === \"ws\" ? wsURL : location.origin); toast(\"copied\"); }; });\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-act]\"); if (!b) return;\n const act = b.dataset.act, id = b.dataset.id;\n if (act === \"copy\") { await navigator.clipboard.writeText(id); toast(\"copied\"); return; }\n if ((act === \"banpubkey\" || act === \"banevent\" || act === \"resolve:ban\") && !confirm(act === \"banevent\" ? \"Delete this event and refuse it forever?\" : \"Ban this author and refuse everything they post?\")) return;\n const erase = (act === \"banpubkey\" || act === \"resolve:ban\") && confirm(\"Also erase everything they wrote and uploaded here?\");\n if (act === \"deleteblob\" && !confirm(\"Delete this file for good?\")) return;\n if (act === \"deletedump\" && !confirm(\"Delete this dump?\")) return;\n if (act === \"downloaddump\") { b.disabled = true; try { await downloadDump(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }\n if ((act === \"removemember\" || act === \"banpubkey\") && (window.__members || []).some((m) => m.invited_by === id) && confirm(\"Also remove everyone this member invited, and everyone they invited in turn?\")) {\n b.disabled = true;\n try { const r = await rpc(\"removesubtree\", id); if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase); toast(\"Removed \" + r.removed.length); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"removejob\" && !confirm(\"Remove this job?\")) return;\n if (act === \"purgekind\") {\n const kind = id === \"\" ? null : +id;\n const label = kind === null ? \"everything without its own rule\" : kindName(kind) + \" (kind \" + kind + \")\";\n const typed = prompt(\"Purge \" + label + \" older than how many days? 0 purges all of them. This cannot be undone.\", \"30\");\n if (typed === null) return;\n const days = Math.max(0, Math.floor(+typed || 0));\n b.disabled = true;\n try { const r = await rpc(\"purgekind\", kind, days); toast(\"Purged \" + r.deleted.toLocaleString() + \" events\"); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"saveretention\") {\n const kind = id === \"\" ? null : +id;\n const days = Math.max(0, Math.floor(+b.closest(\"tr\").querySelector(\".days\").value || 0));\n b.disabled = true;\n try { await rpc(\"setretention\", kind, days); toast(days ? \"Kept for \" + days + \" days\" : \"Kept forever\"); await loadStorage(); await loadInfo(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n b.disabled = true;\n try {\n if (act === \"savemember\") { const tr = b.closest(\"tr\"), role = tr.querySelector(\"select.role\"), keep = tr.querySelector(\".keep\"), cap = tr.querySelector(\".cap\"); await rpc(\"setmember\", id, { name: tr.querySelector(\".name\").value.trim() || null, note: tr.querySelector(\".note\").value, ...(role ? { role: role.value } : {}), ...(keep ? { keepDays: Math.max(0, Math.floor(+keep.value || 0)), maxBytes: Math.max(0, Math.floor(+cap.value || 0)) * 1024 } : {}) }); }\n else if (act.startsWith(\"resolve:\")) await rpc(\"resolvereport\", id, act.slice(8), erase);\n else if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase);\n else await rpc(act, act === \"unrulekind\" ? +id : id, \"\");\n toast(\"Done\"); await (myRole ? refresh() : loadMine());\n if (act === \"pinevent\" || act === \"unpinevent\") await loadPins();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // The share block: the card picture, the group naddr and its QR.\n let card = null;\n async function loadCard() {\n try {\n card = await (await fetch(\"/card.json\", { cache: \"no-store\" })).json();\n $(\"#cardimg\").src = \"/card.svg?t=\" + Date.now();\n $(\"#naddr\").textContent = card.naddr || \"\";\n const q = $(\"#naddrqr\");\n if (card.naddr) { q.src = \"/qr.svg?text=\" + encodeURIComponent(card.naddr); q.classList.remove(\"hidden\"); } else q.classList.add(\"hidden\");\n } catch { /* the card is decoration */ }\n renderApps();\n }\n // The app rows on the Connect section: one per client people actually use,\n // with the link that lands on this relay in it. Relay apps take the relay\n // or the group address; feed apps take the owner's profile with this relay\n // as the hint, since they have no notion of opening a relay.\n function renderApps() {\n const el = $(\"#apps\"); if (!el) return;\n const enc = encodeURIComponent;\n const nprofile = card && card.nprofile ? card.nprofile : \"\";\n const naddr = card && card.naddr ? card.naddr : \"\";\n const whose = me && owner && me === owner ? \"your\" : \"the owner's\";\n const link = (label, href) => '' + label + \"\";\n const app = (label, uri) => '' + label + \"\";\n const copy = (label, text) => '\";\n const row = (name, where, note, acts) => '
                                            ' + name + \"\" + where + \"

                                            \" + note + '

                                            ' + acts.filter(Boolean).join(\"\") + \"
                                            \";\n const profileNote = \"Opens \" + whose + \" profile with this relay attached.\";\n const groups = [\n [\"As a place\", \"These open the relay itself: its feed, its people, its group.\", [\n row(\"Jumble\", \"web\", \"A feed of everything on this relay.\", [link(\"Open\", \"https://jumble.social/?r=\" + enc(wsURL))]),\n row(\"Coracle\", \"web\", \"The relay's page: its feed and its people.\", [link(\"Open\", \"https://coracle.social/relays/\" + enc(host))]),\n row(\"Flotilla\", \"web, phone\", \"The relay as a space, with the group as a room.\", [link(\"Open\", \"https://app.flotilla.social/spaces/\" + enc(host)), naddr && app(\"Open group\", \"nostr:\" + naddr)]),\n row(\"0xchat\", \"phone\", \"The group, in a chat app.\", [naddr && app(\"Open group\", \"nostr:\" + naddr), naddr && copy(\"Copy naddr\", naddr)]),\n row(\"noStrudel\", \"web\", \"Relays, add this one, then open its page.\", [copy(\"Copy relay URL\", wsURL)]),\n ]],\n [\"Find me here\", \"Feed apps have no relay pages. They meet this relay through a profile link that names it, then keep it once it is in the relay settings.\", [\n row(\"Primal\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://primal.net/p/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"YakiHonne\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://yakihonne.com/profile/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"Damus\", \"iPhone\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Amethyst\", \"Android\", profileNote + \" Then Relays in the drawer.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Nostur\", \"iPhone, Mac\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n ]],\n ];\n if (info?.supported_grasps?.includes(\"GRASP-01\")) groups.push([\n \"Git repositories\", \"Use this relay with a Git client.\", [\n row(\"GitWorkshop\", \"web\", \"Browse this relay's Git repositories.\", [link(\"Open in app\", \"https://gitworkshop.dev/relay/\" + enc((wsURL.startsWith(\"ws://\") ? \"ws:\" : \"\") + host)), copy(\"Copy relay URL\", wsURL)]),\n ],\n ]);\n el.innerHTML = groups.map(([h, note, rows]) => '

                                            ' + h + '

                                            ' + note + '

                                            ' + rows.join(\"\") + \"
                                            \").join(\"\");\n $(\"#apps-ws\").textContent = wsURL;\n const tile = (label, text) => '
                                            ' + label + '\"QR
                                            ';\n $(\"#phones\").innerHTML = [nprofile && tile(\"Find me here, for a phone\", \"nostr:\" + nprofile), naddr && tile(\"The group, for a phone\", \"nostr:\" + naddr)].filter(Boolean).join(\"\");\n }\n // The folds are a group: opening one closes the rest. Browsers with the\n // details name attribute do this themselves; this covers the others.\n $$(\".folds details\").forEach((d) => d.addEventListener(\"toggle\", () => { if (d.open) $$(\".folds details\").forEach((o) => { if (o !== d && o.open) o.open = false; }); }));\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-copytext]\"); if (!b) return;\n try { await navigator.clipboard.writeText(b.dataset.copytext); toast(\"copied\"); } catch { /* no clipboard */ }\n });\n $(\"#copynaddr\").onclick = async () => { if (!card || !card.naddr) return; await navigator.clipboard.writeText(card.naddr); toast(\"copied naddr\"); };\n $(\"#copyembed\").onclick = async () => { await navigator.clipboard.writeText('\"''); toast(\"copied embed\"); };\n\n // A click on a key copies it; a double-click selects the whole key so the usual copy shortcut takes it too.\n document.addEventListener(\"dblclick\", (ev) => {\n const k = ev.target.closest(\".key\"); if (!k) return;\n const s = window.getSelection(), r = document.createRange(); r.selectNodeContents(k.querySelector(\".full\")); s.removeAllRanges(); s.addRange(r);\n });\n document.addEventListener(\"click\", async (ev) => {\n const k = ev.target.closest(\".key\"); if (!k || ev.detail > 1 || String(window.getSelection())) return;\n try { await navigator.clipboard.writeText(k.title); toast(\"copied \" + k.dataset.short); } catch { /* no clipboard */ }\n });\n // ---- boot ----\n try { await loadInfo(); } catch { renderHeader(); }\n await loadFuel();\n await loadCard();\n me = localStorage.getItem(\"me\");\n if (me && await resumeRemote()) { /* a remote session answers for itself when first used */ }\n else if (me && window.nostr) { try { const pk = await window.nostr.getPublicKey(); if (pk !== me) me = null; } catch { me = null; } }\n else if (me) me = null;\n renderHeader();\n renderApps();\n await loadAdmin();\n await loadPeople();\n})();\n"; +export const CONSOLE_JS = "(async () => {\n const $ = (s) => document.querySelector(s);\n const $$ = (s) => document.querySelectorAll(s);\n const host = location.host;\n const wsURL = (location.protocol === \"https:\" ? \"wss://\" : \"ws://\") + host;\n const rpcURL = location.origin + \"/\";\n let info = null, me = null, owner = \"\", policy = null, fuel = null, people = null, myRole = \"\";\n\n const toast = (msg) => { const t = $(\"#toast\"); t.textContent = msg; t.classList.add(\"show\"); clearTimeout(t._t); t._t = setTimeout(() => t.classList.remove(\"show\"), 2600); };\n // guard wraps a handler: disables the button, shows errors as toasts. Defined first, since handlers below use it.\n const guard = (fn) => async (ev) => { ev.preventDefault(); const b = ev.submitter || ev.target; if (b) b.disabled = true; try { await fn(ev); } catch (e) { toast(e.message); } finally { if (b) b.disabled = false; } };\n const short = (hex) => hex ? hex.slice(0, 8) + \"…\" + hex.slice(-4) : \"\";\n // key shows a key short but keeps the whole of it in the DOM: selecting, double-clicking or clicking it copies the full hex.\n const key = (hex) => hex ? '' + hex + '' : '';\n const hue = (hex) => (parseInt(hex.slice(0, 2), 16) * 360 / 256).toFixed(0) + \"deg\";\n const av = (hex) => '';\n const fmtBytes = (n) => n < 1e6 ? (n / 1e3).toFixed(0) + \" KB\" : n < 1e9 ? (n / 1e6).toFixed(1) + \" MB\" : (n / 1e9).toFixed(2) + \" GB\";\n const fmtHours = (ms) => ms < 3600e3 ? Math.round(ms / 60e3) + \" min\" : (ms / 3600e3).toFixed(ms < 36e6 ? 1 : 0) + \" h\";\n const fuelOver = () => !!fuel && (fuel.eventBytes > fuel.freeEventBytes || fuel.mediaBytes > fuel.freeMediaBytes || fuel.activeMs > fuel.freeActiveMs || fuel.rowsWritten > fuel.freeRowsWritten);\n const fmtTime = (t) => t ? new Date(t * 1000).toLocaleString(undefined, { month: \"short\", day: \"numeric\", hour: \"2-digit\", minute: \"2-digit\" }) : \"–\";\n const fmtDay = (t) => t ? new Date(t * 1000).toLocaleDateString(undefined, { month: \"short\", day: \"numeric\" }) : \"–\";\n const ago = (t) => { const s = Math.max(0, Math.floor(Date.now() / 1000) - t); return s < 60 ? \"just now\" : s < 3600 ? Math.floor(s / 60) + \" min\" : s < 86400 ? Math.floor(s / 3600) + \" h\" : Math.floor(s / 86400) + \" d\"; };\n const esc = (s) => String(s ?? \"\").replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c]);\n\n const IC = {\n x: '',\n undo: '',\n check: '',\n trash: '',\n ban: '',\n banuser: '',\n eye: '',\n lock: '',\n pen: '',\n people: '',\n person: '',\n gauge: '',\n bolt: '',\n copy: '',\n pin: '',\n };\n const ib = (icon, label, act, id, extra) => '\";\n\n const CH = \"qpzry9x8gf2tvdw0s3jn54khce6mua7l\";\n function npubToHex(s) {\n s = s.trim().toLowerCase();\n if (/^[0-9a-f]{64}$/.test(s)) return s;\n if (!s.startsWith(\"npub1\")) return null;\n const data = s.slice(5, -6).split(\"\").map((c) => CH.indexOf(c));\n if (data.some((d) => d < 0)) return null;\n let bits = 0, acc = 0, out = [];\n for (const d of data) { acc = (acc << 5) | d; bits += 5; if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 255); } }\n return out.length === 32 ? out.map((b) => b.toString(16).padStart(2, \"0\")).join(\"\") : null;\n }\n\n async function sha256hex(s) {\n const b = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(s));\n return [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, \"0\")).join(\"\");\n }\n // ---- signing: a NIP-07 extension, or a NIP-46 remote signer ----\n // The remote path loads the bundled library on first use; its session\n // (client key, signer pubkey, relays) lives in localStorage until sign-out.\n let remote = null, lib = null, pendingNote = null;\n const NO_SIGNER = \"Install a nostr extension (Alby, nos2x, …) or connect a remote signer.\";\n const withTimeout = (p, ms, what) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(what + \" did not answer; is the signer app open?\")), ms))]);\n const signer = {\n ready: () => !!remote || !!window.nostr,\n async getPublicKey() { if (remote) return withTimeout(remote.getPublicKey(), 20000, \"The remote signer\"); if (window.nostr) return window.nostr.getPublicKey(); throw new Error(NO_SIGNER); },\n async signEvent(ev) { if (remote) return withTimeout(remote.signEvent(ev), 60000, \"The remote signer\"); if (window.nostr) return window.nostr.signEvent(ev); throw new Error(NO_SIGNER); },\n };\n async function signerLib() {\n if (lib) return lib;\n if (!window.NostrSigner) await new Promise((res, rej) => { const s = document.createElement(\"script\"); s.src = window.SIGNER_URL || \"/signer.js\"; s.onload = res; s.onerror = () => rej(new Error(\"Could not load the signer library.\")); document.head.appendChild(s); });\n return (lib = window.NostrSigner);\n }\n const onauth = (u) => window.open(u, \"_blank\");\n // The signer conversation rides this relay, and a relay may ask the socket to\n // authenticate before it delivers kind 24133 to its parties; answer with the\n // session's own key.\n const authPool = (L, sk) => new L.SimplePool({ automaticallyAuth: () => (evt) => Promise.resolve(L.finalizeEvent(evt, sk)) });\n const saveSession = (sk, s, secret) => localStorage.setItem(\"nip46\", JSON.stringify({ sk: lib.bytesToHex(sk), pubkey: s.bp.pubkey, relays: s.bp.relays, secret: secret || null }));\n async function resumeRemote() {\n const raw = localStorage.getItem(\"nip46\");\n if (!raw) return false;\n try {\n const s = JSON.parse(raw); const L = await signerLib();\n remote = L.BunkerSigner.fromBunker(L.hexToBytes(s.sk), { pubkey: s.pubkey, relays: s.relays, secret: s.secret }, { onauth, pool: authPool(L, L.hexToBytes(s.sk)) });\n return true;\n } catch { localStorage.removeItem(\"nip46\"); return false; }\n }\n async function connectBunker(input) {\n const L = await signerLib();\n const bp = await L.parseBunkerInput(input.trim());\n if (!bp) throw new Error(\"That is not a bunker:// URL.\");\n if (!bp.relays.length) throw new Error(\"The bunker URL names no relay.\");\n const sk = L.generateSecretKey();\n const s = L.BunkerSigner.fromBunker(sk, bp, { onauth, pool: authPool(L, sk) });\n await withTimeout(s.connect({ name: host, url: location.origin }), 60000, \"The signer\");\n await Promise.race([s.switchRelays(), new Promise((r) => setTimeout(r, 3000))]);\n remote = s; saveSession(sk, s, bp.secret);\n }\n // The nostrconnect:// flow: this relay carries the traffic, so no third relay is involved.\n let ncPending = null;\n async function offerNostrConnect() {\n if (ncPending) return ncPending;\n const L = await signerLib();\n const sk = L.generateSecretKey();\n const secret = L.bytesToHex(crypto.getRandomValues(new Uint8Array(8)));\n const uri = L.createNostrConnectURI({ clientPubkey: L.getPublicKey(sk), relays: [wsURL], secret, name: host, url: location.origin, perms: [\"sign_event:27235\", \"sign_event:9734\"] });\n $(\"#nclink\").href = uri; $(\"#ncnote\").textContent = \"\";\n $(\"#ncqr\").src = \"/qr.svg?text=\" + encodeURIComponent(uri); $(\"#ncqr\").classList.remove(\"hidden\");\n ncPending = L.BunkerSigner.fromURI(sk, uri, { onauth, pool: authPool(L, sk) }, 600000).then((s) => { remote = s; saveSession(sk, s, secret); ncPending = null; return s; }, (e) => { ncPending = null; throw e; });\n return ncPending;\n }\n async function remoteDone() {\n me = await signer.getPublicKey();\n localStorage.setItem(\"me\", me);\n $(\"#remotesec\").classList.add(\"hidden\");\n toast(\"Remote signer connected\");\n if (pendingNote && !owner) { const n = pendingNote; pendingNote = null; await claimNow(n); return; }\n pendingNote = null;\n renderHeader(); await loadAdmin(); await loadPeople();\n }\n function showRemote(note) {\n pendingNote = note || null;\n $(\"#remotesec\").classList.remove(\"hidden\");\n $(\"#remotesec\").scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n offerNostrConnect().then(remoteDone).catch((e) => { $(\"#ncnote\").textContent = e.message; });\n }\n\n async function rpc(method, ...params) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const body = JSON.stringify({ method, params });\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", rpcURL], [\"method\", \"POST\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(rpcURL, { method: \"POST\", headers: { \"content-type\": \"application/nostr+json+rpc\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body });\n const json = await resp.json();\n if (json.error) throw new Error(json.error);\n return json.result;\n }\n\n // ---- front of house ----\n // urlish shows a URL as a short link: the host and the start of the path, the whole thing on hover and as the target.\n class Html { constructor(s) { this.s = s; } }\n const urlish = (u) => {\n if (!u) return \"\";\n let label = u; try { const x = new URL(u); label = x.host + (x.pathname.length > 1 ? x.pathname.slice(0, 18) + (x.pathname.length > 18 ? \"\\u2026\" : \"\") : \"\"); } catch { /* not a URL */ }\n if (!/^https?:\\/\\//.test(u)) return u;\n return new Html('' + esc(label) + \"\");\n };\n function renderHeader() {\n const name = (info && info.name) || host.split(\".\")[0];\n $(\"#title\").textContent = name;\n document.title = name + \" - relay\";\n $(\"#url\").textContent = wsURL;\n $(\"#d-ws\").textContent = wsURL; $(\"#d-blossom\").textContent = location.origin; $(\"#d-nip05\").textContent = \"you@\" + host;\n $(\"#nip05-example\").textContent = \"alice@\" + host;\n const desc = (info && info.description) || \"\";\n $(\"#desc\").textContent = desc; $(\"#desc\").classList.toggle(\"hidden\", !desc);\n // What the owner declared about the relay, on the page, not only in the document clients read.\n const img = (id, url) => { const el = $(id); if (url) el.src = url; el.classList.toggle(\"hidden\", !url); };\n img(\"#bannerimg\", info?.banner); $(\"#banner\").classList.toggle(\"hidden\", !info?.banner);\n img(\"#iconimg\", info?.icon);\n const meta = [];\n for (const t of info?.tags || []) meta.push('' + esc(t) + \"\");\n const where = [...(info?.language_tags || []), ...(info?.relay_countries || [])];\n if (where.length) meta.push(\"\" + esc(where.join(\", \")) + \"\");\n const link = (label, href) => href ? '' + label + \"\" : \"\";\n const contact = info?.contact ? (/^(mailto:|https?:\\/\\/)/.test(info.contact) ? link(\"Contact\", info.contact) : \"\" + esc(info.contact) + \"\") : \"\";\n for (const x of [contact, link(\"Terms\", info?.terms_of_service), link(\"Posting policy\", info?.posting_policy), link(\"Privacy\", info?.privacy_policy)]) if (x) meta.push(x);\n $(\"#metarow\").innerHTML = meta.join('|'); $(\"#metarow\").classList.toggle(\"hidden\", meta.length === 0);\n $(\"#frontsec\").classList.toggle(\"hidden\", !desc && meta.length === 0);\n owner = (info && info.pubkey) || \"\";\n const isOwner = !!me && me === owner;\n const lease = !owner && info && info.lease ? info.lease : null;\n $(\"#ownerline\").innerHTML = owner ? 'run by ' + key(owner) + \"\" : lease ? \"temporary until \" + fmtDay(lease.expires_at) : \"unclaimed\";\n $(\"#owner-av\").classList.toggle(\"hidden\", !owner);\n if (owner) $(\"#owner-av\").style.setProperty(\"--h\", hue(owner));\n $(\"#who\").textContent = me ? (isOwner ? \"(that's you)\" : \"\") : \"\";\n $(\"#unclaimed\").classList.toggle(\"hidden\", !!owner || !!lease);\n $(\"#leased\").classList.toggle(\"hidden\", !lease);\n if (lease) {\n $(\"#lease-until\").textContent = fmtTime(lease.expires_at);\n $(\"#leasenote\").textContent = lease.holder ? \"Reserved for the key that asked for it; sign with that key.\" : \"\";\n }\n $(\"#signin\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signin46\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signout\").classList.toggle(\"hidden\", !me);\n $(\"#about\").innerHTML = [\n [\"Name\", info?.name], [\"Description\", info?.description], [\"Contact\", info?.contact], [\"Owner\", owner ? short(owner) : \"\"], [\"Relay key\", info?.self ? short(info.self) : \"\"], [\"Software\", urlish(info?.software)], [\"Version\", info?.version],\n [\"Max query\", info?.limitation?.max_limit], [\"Auth required\", String(!!info?.limitation?.auth_required)], [\"Restricted writes\", String(!!info?.limitation?.restricted_writes)], [\"Min PoW\", info?.limitation?.min_pow_difficulty || 0],\n [\"Tags\", info?.tags?.join(\", \")], [\"Languages\", info?.language_tags?.join(\", \")], [\"Countries\", info?.relay_countries?.join(\", \")],\n [\"Terms\", urlish(info?.terms_of_service)], [\"Posting policy\", urlish(info?.posting_policy)], [\"Privacy policy\", urlish(info?.privacy_policy)], [\"Icon\", urlish(info?.icon)], [\"Banner\", urlish(info?.banner)],\n [\"NIPs\", info?.supported_nips?.join(\" \")],\n ].map(([k, v]) => \"
                                            \" + k + \"\" + (v === undefined || v === \"\" ? '' : v instanceof Html ? v.s : esc(v)) + \"
                                            \").join(\"\");\n renderCare();\n }\n\n function renderCare() {\n const g = (icon, label, off) => '' + IC[icon] + \"\" + label + \"\";\n const writes = policy ? policy.writes : (info?.limitation?.restricted_writes ? \"allowlist\" : \"open\");\n const reads = policy ? policy.reads : (info?.limitation?.auth_required ? \"auth\" : \"open\");\n let out = writes === \"owner\" ? g(\"person\", \"only owner writes\") : writes === \"allowlist\" ? g(\"people\", \"members write\") : writes === \"wot\" ? g(\"people\", \"members and follows write\") : g(\"pen\", \"anyone writes\");\n out += reads === \"members\" ? g(\"people\", \"members read\") : reads === \"auth\" ? g(\"lock\", \"sign in to read\") : g(\"eye\", \"anyone reads\");\n if (fuel) {\n const over = fuelOver();\n out += fuel.outOfFuel ? g(\"gauge\", \"out of fuel\", true) : over ? g(\"bolt\", \"burning sats\") : g(\"gauge\", \"on free allowance\");\n }\n $(\"#care\").innerHTML = out;\n }\n\n async function loadPeople() {\n try { people = await (await fetch(\"/people\")).json(); } catch { return; }\n const isOwner = !!me && me === owner;\n let list = people.people || [];\n if (!people.public && isOwner && policy) list = (window.__members || []);\n $(\"#peoplesec\").classList.toggle(\"hidden\", !owner || list.length === 0);\n $(\"#people-note\").classList.toggle(\"hidden\", people.public);\n $(\"#people-count\").textContent = list.length || \"\";\n $(\"#dir\").innerHTML = list.map((m) => '' + av(m.pubkey) + '' + (m.name ? esc(m.name) + \"@\" + host : key(m.pubkey)) + \"\" + (m.role === \"owner\" ? 'owner' : \"\") + \"\").join(\"\");\n }\n\n async function loadInfo() {\n info = await (await fetch(\"/\", { headers: { accept: \"application/nostr+json\" } })).json();\n renderHeader();\n const git = info.supported_grasps?.includes(\"GRASP-01\");\n $(\"#git-connect\").classList.toggle(\"hidden\", !git);\n const clone = \"git clone '\" + location.origin + \"//.git'\";\n $(\"#git-clone\").textContent = clone;\n $(\"#git-copy\").dataset.copytext = clone;\n renderApps();\n }\n\n async function loadFuel() {\n try { fuel = await (await fetch(\"/fuel\")).json(); } catch { return; }\n $(\"#fuelsec\").classList.toggle(\"hidden\", !owner);\n const pct = (used, free) => Math.min(100, Math.round((used / Math.max(free, 1)) * 100));\n const gauge = (id, used, free, text) => { const g = $(\"#g-\" + id), share = free ? used / free : 0; g.style.width = pct(used, free) + \"%\"; g.classList.toggle(\"over\", share > 1); g.classList.toggle(\"warm\", share > 0.75 && share <= 1); $(\"#p-\" + id).textContent = share > 1 ? \"over\" : Math.round(share * 100) + \"%\"; $(\"#t-\" + id).textContent = text; };\n gauge(\"events\", fuel.eventBytes, fuel.freeEventBytes, fmtBytes(fuel.eventBytes) + \" of \" + fmtBytes(fuel.freeEventBytes) + \" free\");\n gauge(\"media\", fuel.mediaBytes, fuel.freeMediaBytes, fmtBytes(fuel.mediaBytes) + \" of \" + fmtBytes(fuel.freeMediaBytes) + \" free\");\n gauge(\"active\", fuel.activeMs, fuel.freeActiveMs, fmtHours(fuel.activeMs) + \" of \" + fmtHours(fuel.freeActiveMs) + \" free\");\n gauge(\"rows\", fuel.rowsWritten, fuel.freeRowsWritten, fuel.rowsWritten.toLocaleString() + \" of \" + fuel.freeRowsWritten.toLocaleString() + \" free\");\n const sats = Math.floor(fuel.balanceMsats / 1000);\n const r = fuel.rates;\n $(\"#fuel-balance\").innerHTML = fuel.outOfFuel\n ? 'out of fuel Writes are paused until someone tops up.'\n : \"Balance \" + sats.toLocaleString() + \" sats. Past the allowances, prices track what the hosting costs: \" + r.satsPerGBMonthEvents.toLocaleString() + \" sats per GB-month of events, \" + r.satsPerGBMonthMedia.toLocaleString() + \" per GB-month of files, \" + r.satsPerActiveHour.toLocaleString() + \" per hour awake, \" + r.satsPerMillionRows.toLocaleString() + \" per million rows written. Traffic is free.\";\n $(\"#topup\").classList.toggle(\"hidden\", !fuel.enabled);\n // Who paid is the owner's to see: it comes with the signed stats call.\n let credits = [];\n if (owner && signer.ready()) { try { credits = (await rpc(\"stats\")).credits || []; } catch { credits = []; } }\n $(\"#credits tbody\").innerHTML = credits.length ? credits.map((c) => '' + esc(fmtTime(c.at)) + '' + av(c.payer) + key(c.payer) + '' + Math.floor(c.msats / 1000).toLocaleString() + \"\").join(\"\") : 'no zaps yet';\n if (!fuel.enabled) $(\"#topup-note\").textContent = \"Top-ups are not enabled on this service yet.\";\n renderCare();\n renderFuelTile();\n }\n\n async function topUp(sats) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const msats = Math.round(sats * 1000);\n const zapRequest = await signer.signEvent({ kind: 9734, created_at: Math.floor(Date.now() / 1000), content: \"fuel for \" + host, tags: [[\"p\", fuel.servicePubkey], [\"amount\", String(msats)], [\"relays\", wsURL]] });\n const r = await (await fetch(\"/fuel/invoice\", { method: \"POST\", body: JSON.stringify({ zapRequest }) })).json();\n if (r.error) throw new Error(r.error);\n const inv = r.invoice;\n $(\"#invoice\").classList.remove(\"hidden\");\n $(\"#inv-text\").value = inv; $(\"#inv-link\").href = \"lightning:\" + inv; $(\"#inv-state\").textContent = \"waiting for payment…\";\n if (window.webln) { try { await window.webln.enable(); await window.webln.sendPayment(inv); } catch (e) { toast(e.message || \"wallet declined\"); } }\n const before = fuel.creditedMsats;\n for (let i = 0; i < 120; i++) {\n await new Promise((r) => setTimeout(r, 2500));\n await loadFuel();\n if (fuel.creditedMsats > before) { $(\"#inv-state\").textContent = \"paid, thank you\"; toast(\"Fuel credited\"); return; }\n }\n $(\"#inv-state\").textContent = \"no receipt yet; it can take a minute after paying\";\n }\n\n // ---- console ----\n function showTab(name) {\n if (name === \"content\" || name === \"storage\") name = \"data\";\n const known = myRole === \"moderator\" ? [\"people\", \"moderation\"] : [\"people\", \"moderation\", \"rules\", \"identity\", \"data\", \"sync\", \"views\", \"health\", \"owner\"];\n if (!known.includes(name)) name = \"people\";\n $$(\".tabs a\").forEach((a) => a.classList.toggle(\"on\", a.dataset.tab === name));\n $$(\".panel\").forEach((p) => p.classList.toggle(\"on\", p.dataset.panel === name));\n }\n // Tabs switch in place. A fragment still opens a tab when someone arrives with one,\n // but clicking never writes one, and any fragment already there is dropped.\n $$(\".tabs a\").forEach((a) => a.addEventListener(\"click\", (ev) => { ev.preventDefault(); showTab(a.dataset.tab); if (location.hash) history.replaceState(null, \"\", location.pathname + location.search); }));\n window.addEventListener(\"hashchange\", () => showTab(location.hash.slice(1)));\n // The strip fades on whichever side has more tabs off screen.\n const tabsFade = () => { const t = $(\"#tabs\"); t.classList.toggle(\"fade-l\", t.scrollLeft > 4); t.classList.toggle(\"fade-r\", t.scrollLeft + t.clientWidth < t.scrollWidth - 4); };\n $(\"#tabs\").addEventListener(\"scroll\", tabsFade); window.addEventListener(\"resize\", tabsFade); new ResizeObserver(tabsFade).observe($(\"#tabs\"));\n\n function renderFuelTile() {\n if (!fuel) return;\n const over = fuelOver();\n const sats = Math.max(0, Math.floor(fuel.balanceMsats / 1000));\n const d = new Date(), end = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1), days = Math.max(1, Math.ceil((end - Date.now()) / 86400000));\n if (fuel.outOfFuel) { $(\"#h-fuel\").textContent = \"0 sats\"; $(\"#h-fuel-label\").textContent = \"out of fuel\"; $(\"#h-fuel-sub\").textContent = \"writes are paused\"; }\n else if (over) { $(\"#h-fuel\").textContent = sats.toLocaleString() + \" sats\"; $(\"#h-fuel-label\").textContent = \"fuel left\"; $(\"#h-fuel-sub\").textContent = \"past the free allowance\"; }\n else { $(\"#h-fuel\").textContent = days + (days === 1 ? \" day\" : \" days\"); $(\"#h-fuel-label\").textContent = \"free allowance left\"; $(\"#h-fuel-sub\").textContent = sats ? \"then \" + sats.toLocaleString() + \" sats\" : \"then a top-up is needed\"; }\n $(\"#usage\").innerHTML = [[\"events stored\", fmtBytes(fuel.eventBytes)], [\"files stored\", fmtBytes(fuel.mediaBytes)], [\"awake this month\", fmtHours(fuel.activeMs)], [\"rows written\", fuel.rowsWritten.toLocaleString()], [\"rows read\", fuel.rowsRead.toLocaleString()], [\"received this month\", fmtBytes(fuel.bytesIn)], [\"served this month\", fmtBytes(fuel.bytesOut)], [\"charged\", Math.floor(fuel.chargedMsats / 1000).toLocaleString() + \" sats\"]]\n .map(([k, v]) => \"
                                            \" + k + \"\" + v + \"
                                            \").join(\"\");\n }\n\n function renderHealth(stats) {\n $(\"#h-last\").textContent = stats.newest ? ago(stats.newest) : \"none\";\n $(\"#h-last-sub\").textContent = stats.newest ? fmtTime(stats.newest) : \"no events yet\";\n $(\"#h-conns\").textContent = stats.connections;\n const buckets = [[\"notes\", \"k1\", [1]], [\"reactions\", \"k2\", [7]], [\"DMs\", \"k3\", [4, 14, 1059]], [\"long-form\", \"k4\", [30023]], [\"other\", \"k5\", null]];\n const sums = buckets.map(() => 0); let total = 0;\n for (const { kind, n } of stats.kinds || []) { total += n; const i = buckets.findIndex((b) => b[2] && b[2].includes(kind)); sums[i < 0 ? 4 : i] += n; }\n const parts = buckets.map((b, i) => [b[0], b[1], sums[i]]).filter((p) => p[2] > 0);\n $(\"#h-kinds\").innerHTML = total ? parts.map(([name, cls, n]) => '').join(\"\") : '';\n $(\"#h-kinds-legend\").innerHTML = total ? parts.map(([name, cls, n]) => '' + name + \" \" + Math.round(n * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n renderFuelTile();\n }\n\n async function loadAdmin() {\n const isOwner = !!me && me === owner;\n myRole = isOwner ? \"owner\" : \"\";\n let stats = null, p = null;\n // A signed-in member may be a moderator; the relay says by answering stats.\n if (me && owner && !isOwner) { try { [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]); myRole = \"moderator\"; } catch { myRole = \"\"; } }\n $(\"#console\").classList.toggle(\"hidden\", !myRole);\n loadMine();\n $(\"#console\").classList.toggle(\"mod\", myRole === \"moderator\");\n if (!myRole) { policy = null; renderCare(); return; }\n if (isOwner) [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]);\n policy = p;\n renderHealth(stats);\n renderCare();\n const fa = $(\"#access\"), fi = $(\"#identity\");\n fa.writes.value = p.writes; fa.reads.value = p.reads; fa.openKinds.value = (p.openKinds || []).join(\", \"); fa.guestReplies.checked = !!p.guestReplies;\n $(\"#wordsform\").words.value = (p.blockedWords || []).join(\"\\n\"); $(\"#wordsform\").inTags.checked = !!p.blockedWordsInTags; $(\"#thresholdform\").reportThreshold.value = p.reportThreshold || 0; fa.minPow.value = p.minPow; fa.maxFuture.value = p.maxFuture; fa.maxLimit.value = p.maxLimit; fa.maxSubs.value = p.maxSubs; fa.maxMessageKB.value = p.maxMessageKB;\n fa.eventsPerMinute.value = p.eventsPerMinute; fa.reqsPerMinute.value = p.reqsPerMinute; fa.maxBlobMB.value = p.maxBlobMB;\n renderFeatures(p.features || {});\n $(\"#push-policy-form\").elements.origins.value = (p.pushCallbacks || []).join(\"\\n\");\n $(\"#push-policy-form\").elements.lettered.checked = !!p.letteredNips;\n fi.name.value = p.name; fi.contact.value = p.contact; fi.description.value = p.description; fi.icon.value = p.icon;\n const fj = $(\"#joinform\"); fj.joinTerms.value = p.joinTerms; fj.directoryPublic.checked = !!p.directoryPublic;\n loadCard();\n fi.banner.value = p.banner || \"\"; fi.postingPolicy.value = p.postingPolicy || \"\"; fi.privacyPolicy.value = p.privacyPolicy || \"\";\n fi.tags.value = (p.tags || []).join(\", \"); fi.languageTags.value = (p.languageTags || []).join(\", \"); fi.relayCountries.value = (p.relayCountries || []).join(\", \");\n const fn = $(\"#notify\"), nt = p.notify || {};\n fn.reports.checked = !!nt.reports; fn.fuel.checked = !!nt.fuel; fn.jobs.checked = !!nt.jobs; fn.succession.checked = !!nt.succession; fn.digest.checked = !!nt.digest;\n showTab(location.hash.slice(1));\n if (isOwner) { await renderPresets(); renderWire(); renderSuccession(); loadDomains(); }\n await Promise.all([loadLists(), loadEvents(true), loadPins(), ...(isOwner ? [loadStorage()] : [])]);\n }\n\n async function loadAudit(before) {\n const rows = await rpc(\"listaudit\", before || 0);\n const html = rows.map((r) => '' + esc(fmtTime(r.at)) + '' + av(r.actor) + key(r.actor) + '' + esc(r.action) + '' + (/^[0-9a-f]{64}$/.test(r.target) ? key(r.target) : esc(r.target)) + '' + esc(r.detail) + '').join(\"\");\n const tb = $(\"#audit tbody\");\n if (before) tb.insertAdjacentHTML(\"beforeend\", html); else tb.innerHTML = html || 'Nothing yet';\n $(\"#audit-more\").style.display = rows.length < 100 ? \"none\" : \"\";\n }\n $(\"#audit-more\").onclick = guard(async () => { const last = $(\"#audit tbody tr:last-child\"); await loadAudit(last ? +last.dataset.seq : 0); });\n async function loadLists() {\n loadAudit(0);\n const [mem, bans, bannedEvents, allow, block, invites, reports, blobs, blocks, sites] = await Promise.all([rpc(\"listmembers\"), rpc(\"listbannedpubkeys\"), rpc(\"listbannedevents\"), rpc(\"listallowedkinds\"), rpc(\"listblockedkinds\"), rpc(\"listinvites\"), rpc(\"listreports\"), rpc(\"listblobs\", 100), rpc(\"listblockedips\"), rpc(\"listsites\")]);\n const members = mem.members;\n window.__members = members;\n const roleCell = (m) => m.role === \"owner\" ? ' owner' : myRole === \"owner\" ? ' ' : m.role === \"moderator\" ? ' moderator' : \"\";\n const untouchable = (m) => m.role === \"owner\" || (myRole !== \"owner\" && m.role === \"moderator\");\n // The tree: everyone under whoever invited them, the owner and the owner's own additions at the root.\n const known = new Set(members.map((m) => m.pubkey));\n const byInviter = new Map();\n for (const m of members) { const k = m.role !== \"owner\" && known.has(m.invited_by) ? m.invited_by : \"\"; if (!byInviter.has(k)) byInviter.set(k, []); byInviter.get(k).push(m); }\n const ordered = [], placed = new Set();\n const walk = (k, depth) => { for (const m of byInviter.get(k) || []) { if (placed.has(m.pubkey)) continue; placed.add(m.pubkey); m.depth = depth; ordered.push(m); walk(m.pubkey, depth + 1); } };\n walk(\"\", 0);\n for (const m of members) if (!placed.has(m.pubkey)) { m.depth = 0; ordered.push(m); }\n const nameOf = (pk) => { const x = members.find((m) => m.pubkey === pk); return x && x.name ? x.name : short(pk); };\n const limits = (m) => m.role === \"owner\" ? \"\" : myRole === \"owner\"\n ? ''\n : '' + (m.keep_days ? m.keep_days + \" d\" : \"\") + (m.max_bytes ? \" \" + fmtBytes(m.max_bytes) : \"\") + \"\";\n $(\"#members tbody\").innerHTML = ordered.map((m) => '' + av(m.pubkey) + key(m.pubkey) + roleCell(m) + (m.depth ? ' via ' + esc(nameOf(m.invited_by)) + \"\" : \"\") + (m.invites ? ' ' + m.invites + \" inv\" : \"\") + '' + limits(m) + '' + esc(fmtDay(m.joined_at)) + \", \" + esc(m.via) + '' + ib(\"check\", \"Save\", \"savemember\", m.pubkey) + (untouchable(m) ? \"\" : ib(\"x\", \"Remove\", \"removemember\", m.pubkey) + ib(\"banuser\", \"Ban\", \"banpubkey\", m.pubkey, \"danger\")) + \"\").join(\"\");\n const tf = $(\"#treeform\");\n tf.classList.toggle(\"hidden\", myRole !== \"owner\");\n if (policy && policy.memberInvites) { tf.depth.value = policy.memberInvites.depth; tf.quota.value = policy.memberInvites.quota; }\n $(\"#transfer [name=pubkey]\").innerHTML = members.filter((m) => m.role !== \"owner\").map((m) => '\").join(\"\");\n $(\"#succession [name=heir]\").innerHTML = $(\"#transfer [name=pubkey]\").innerHTML;\n $(\"#tc-people\").textContent = members.length;\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#invites\").innerHTML = invites.length ? invites.map((i) => '
                                          • inv…' + i.code.slice(-8) + \" \" + esc(i.note || \"\") + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
                                          • \").join(\"\") : '
                                          • no invites
                                          • ';\n const person = (r, icon, label, act) => \"
                                          • \" + av(r.pubkey) + '' + short(r.pubkey) + \" \" + esc(r.reason || \"\") + \"\" + ib(icon, label, act, r.pubkey) + \"
                                          • \";\n $(\"#bans\").innerHTML = bans.length ? bans.map((r) => person(r, \"undo\", \"Unban\", \"unrulepubkey\")).join(\"\") : '
                                          • nobody banned
                                          • ';\n $(\"#blocks\").innerHTML = blocks.length ? blocks.map((r) => '
                                          • ip' + esc(r.ip) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"undo\", \"Unblock\", \"unblockip\", r.ip) + \"
                                          • \").join(\"\") : '
                                          • no addresses blocked
                                          • ';\n $(\"#banned-events\").innerHTML = bannedEvents.map((r) => '
                                          • ev' + key(r.id) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"check\", \"Allow again\", \"allowevent\", r.id) + \"
                                          • \").join(\"\");\n $(\"#reports tbody\").innerHTML = reports.length ? reports.map((r) => '' + esc(fmtTime(r.at)) + '' + esc(r.type || \"report\") + '' + av(r.target_pubkey) + key(r.target_pubkey) + (r.target_event ? (r.blob ? ' file ' + key(r.target_event) + \"\" : ' ev ' + key(r.target_event) + (r.hidden ? ' hidden' : \"\") + \"\") : \"\") + '' + esc(r.content) + '' + ib(\"check\", \"Dismiss\", \"resolve:dismiss\", r.id) + ib(\"trash\", r.blob ? \"Delete the file\" : \"Delete the event\", \"resolve:delete\", r.id) + ib(\"banuser\", \"Ban the author\", \"resolve:ban\", r.id, \"danger\") + \"\").join(\"\") : 'nothing reported';\n $(\"#reports-count\").textContent = reports.length || \"\"; $(\"#tc-reports\").textContent = reports.length || \"\";\n $(\"#blobs tbody\").innerHTML = blobs.length ? blobs.map((b) => '' + esc(fmtTime(b.uploaded)) + '' + key(b.sha256) + ' ' + esc(b.type) + '' + fmtBytes(b.size) + '' + av(b.uploader) + key(b.uploader) + '' + ib(\"trash\", \"Delete file\", \"deleteblob\", b.sha256, \"danger\") + \"\").join(\"\") : 'no uploads';\n $(\"#blobs-count\").textContent = blobs.length || \"\";\n $(\"#sites tbody\").innerHTML = sites.length ? sites.map((s) => '' + av(s.author) + key(s.author) + '' + esc(s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + '' + esc(s.url) + '' + esc(s.paths) + (s.missing ? ' ' + esc(s.missing) + ' missing' : '') + '' + fmtBytes(s.size) + '' + esc(s.expires_at ? fmtTime(s.expires_at) : \"never\") + '' + ib(\"trash\", \"Delete site\", \"deleteevent\", s.id, \"danger\") + '').join(\"\") : 'no sites yet';\n $(\"#sites-count\").textContent = sites.length || \"\";\n const tag = (k, cls) => '' + k + ib(\"x\", \"Remove rule\", \"unrulekind\", String(k)) + \"\";\n $(\"#kinds-allow\").innerHTML = allow.length ? allow.map((k) => tag(k, \"ok\")).join(\"\") : 'all';\n $(\"#kinds-block\").innerHTML = block.length ? block.map((k) => tag(k, \"blk\")).join(\"\") : 'none';\n await loadPeople();\n }\n\n const KIND_NAMES = { 0: \"profiles\", 1: \"notes\", 3: \"contacts\", 4: \"DMs\", 5: \"deletions\", 6: \"reposts\", 7: \"reactions\", 16: \"reposts\", 1059: \"gift wraps\", 1063: \"file headers\", 1111: \"comments\", 1984: \"reports\", 9734: \"zap requests\", 9735: \"zap receipts\", 9802: \"highlights\", 10002: \"relay lists\", 13534: \"roster\", 9000: \"group adds\", 9001: \"group removals\", 9021: \"join requests\", 9022: \"leave requests\", 39000: \"group info\", 39001: \"group admins\", 39002: \"group members\", 39003: \"group roles\", 30023: \"articles\", 30024: \"drafts\", 30078: \"app data\", 30311: \"live events\", 30818: \"wiki\" };\n const kindName = (k) => KIND_NAMES[k] || (k >= 20000 && k < 30000 ? \"ephemeral\" : k >= 30000 && k < 40000 ? \"addressable\" : k >= 10000 && k < 20000 ? \"replaceable\" : \"kind \" + k);\n const SYS_KINDS = new Set([0, 3, 10002, 9735, 13534, 8000, 8001, 9000, 9001, 39000, 39001, 39002, 39003]);\n let storage = null;\n\n // Features (settings.ts): a select each; search has three modes, the rest on or off.\n const FEATURES = [\n [\"search\", \"Search\", \"NIP-50. Prose indexes notes, threads, comments, highlights, articles and wiki pages; full indexes every public kind with content. A change applies to events from then on.\", [\"prose:prose\", \"full:full\", \"off:off\"]],\n [\"sync\", \"Sync\", \"NIP-77 reconciliation, which reads the whole matching set per sync.\"],\n [\"count\", \"Counts\", \"NIP-45 COUNT, with HLL sketches.\"],\n [\"discovery\", \"Discovery record\", \"NIP-66: the record the relay signs about itself, for crawlers.\"],\n [\"names\", \"Names\", \"NIP-05 addresses under this relay's domain.\"],\n [\"files\", \"Files\", \"Blossom and NIP-96: uploads, downloads and listings.\"],\n [\"pages\", \"Pages and feed\", \"Notes and articles as pages, and the Atom feed.\"],\n [\"sites\", \"Static websites\", \"NIP-5A sites on their own hostnames. Mirroring copies missing files into this relay and costs fuel.\", [\"mirror:on, mirror files\", \"proxy:on, fetch as needed\", \"off:off\"]],\n [\"marmot\", \"Marmot transport\", \"Signed KeyPackages and encrypted group messages, with account admission for ephemeral authors.\"],\n [\"grasp\", \"Git repositories\", \"GRASP Git hosting with admitted repository state. The prototype backend has bounded storage and compute limits.\"],\n [\"push\", \"Relay push\", \"NIP-9a callback delivery for members and the owner. Requires approved callback origins; advertises lettered NIP identifiers.\"],\n [\"signer\", \"Signer traffic\", \"NIP-46 remote signing carried for anyone, never stored.\"],\n ];\n function renderFeatures(f) {\n $(\"#features\").innerHTML = FEATURES.map(([k, title, about, modes]) => {\n const cur = k === \"sites\" ? (f.sites?.enabled === false ? \"off\" : f.sites?.mirror === false ? \"proxy\" : \"mirror\") : modes ? String(f[k] || \"prose\") : String(f[k] !== false);\n const opts = (modes || [\"true:on\", \"false:off\"]).map((m) => { const [v, l] = m.split(\":\"); return '\"; }).join(\"\");\n return \"\";\n }).join(\"\");\n }\n $(\"#features\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-feature]\"); if (!sel) return;\n const k = sel.dataset.feature, v = k === \"search\" || k === \"sites\" ? sel.value : sel.value === \"true\";\n policy = await rpc(\"setpolicy\", { features: { [k]: k === \"sites\" ? { enabled: v !== \"off\", mirror: v === \"mirror\" } : v } });\n toast(k === \"search\" ? \"Search: \" + v : (v ? \"Switched on \" : \"Switched off \") + k); await loadInfo();\n }));\n\n $(\"#push-policy-form\").addEventListener(\"submit\", guard(async (ev) => {\n ev.preventDefault();\n const form = ev.target;\n const origins = form.elements.origins.value.split(/\\s+/).filter(Boolean);\n const updated = await rpc(\"setpolicy\", { pushCallbacks: origins, letteredNips: form.elements.lettered.checked });\n if (JSON.stringify(updated.pushCallbacks) !== JSON.stringify([...new Set(origins.map((s) => s.replace(/\\/$/, \"\")))])) throw new Error(\"Use up to sixteen exact HTTPS origins, with no path or credentials.\");\n policy = updated;\n toast(\"Saved callback policy\"); await loadInfo();\n }));\n\n async function loadViews() {\n let views;\n try { views = await rpc(\"listviews\"); } catch { return; }\n $(\"#views\").innerHTML = views.map((v) => {\n const runs = v.trigger === \"off\" ? \"off\" : v.trigger === \"live\" ? \"live, from memory\" : v.trigger === \"write\" ? \"on write and daily\" : v.trigger;\n const label = (c) => (c === \"off\" ? \"off\" : c === \"write\" ? \"on write\" : c === \"live\" ? \"on\" : c);\n const choices = [...v.choices, v.default].filter((c, i, a) => a.indexOf(c) === i);\n const pick = '\";\n const who = v.audience === \"members\" ? \"members\" + (v.stored ? \"\" : \", on request\") : \"anyone\";\n const last = v.trigger === \"live\" ? \"\" : v.last ? \"last run \" + fmtTime(v.last.at) : \"not run yet\";\n const rows = v.trigger === \"live\" ? \"no rows\" : v.last ? v.last.rows.toLocaleString() + \" rows\" : \"\";\n const meta = [runs, who, last, rows].filter(Boolean).map((x) => \"\" + esc(x) + \"\").join('|');\n return '
                                            ' + esc(v.name) + '
                                            ' + esc(v.about) + '
                                            ' + pick + (v.on ? 'Open' : \"\") + '
                                            ' + meta + \"
                                            \";\n }).join(\"\");\n }\n $(\"#views\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-view]\"); if (!sel) return;\n const value = sel.value === \"live\" ? true : sel.value;\n await rpc(\"setpolicy\", { views: { [sel.dataset.view]: value } });\n toast(sel.value === \"off\" ? \"Took down \" + sel.dataset.view : sel.dataset.view + \": \" + (sel.value === \"live\" ? \"on\" : sel.value === \"write\" ? \"on write\" : sel.value)); await loadViews(); await loadInfo();\n }));\n async function loadDumps() {\n const list = await rpc(\"listdumps\");\n const f = $(\"#dumpform\");\n if (policy) { f.dumps.value = policy.dumps || \"off\"; f.keep.value = policy.dumpsKeep || 7; }\n $(\"#dumps-count\").textContent = list.length || \"\";\n $(\"#dumps\").innerHTML = list.length ? list.map((d) => '
                                          • jsonl' + esc(d.name) + ' ' + d.events.toLocaleString() + \" events, \" + fmtBytes(d.bytes) + \"\" + ib(\"copy\", \"Download\", \"downloaddump\", d.name) + ib(\"trash\", \"Delete\", \"deletedump\", d.name, \"danger\") + \"
                                          • \").join(\"\") : '
                                          • no dumps yet
                                          • ';\n }\n // A dump is fetched with a signed request and handed to the browser as a file.\n async function downloadDump(name) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const url = location.origin + \"/dumps/\" + name;\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"GET\"]] });\n const resp = await fetch(url, { headers: { authorization: \"Nostr \" + btoa(JSON.stringify(ev)) } });\n if (!resp.ok) throw new Error((await resp.json()).error || \"download failed\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(\".\")[0] + \"-\" + name; a.click(); URL.revokeObjectURL(a.href);\n }\n // A plain member's own invites, when the owner lets members invite.\n async function loadMine() {\n const sec = $(\"#myinvites\");\n if (!me || !owner || myRole || !signer.ready()) { sec.classList.add(\"hidden\"); return; }\n let mine;\n try { mine = await rpc(\"listinvites\"); } catch { sec.classList.add(\"hidden\"); return; }\n sec.classList.remove(\"hidden\");\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#mine\").innerHTML = mine.length ? mine.map((i) => '
                                          • inv…' + i.code.slice(-8) + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
                                          • \").join(\"\") : '
                                          • no invites yet
                                          • ';\n }\n async function loadStorage() {\n storage = await rpc(\"storagestats\");\n pollJobs();\n loadViews();\n loadDumps().catch(() => {});\n const st = storage;\n const top = st.kinds.slice(0, 4), rest = st.kinds.slice(4).reduce((a, k) => a + k.bytes, 0);\n const parts = [...top.map((k, i) => [k.kind + \" \" + kindName(k.kind), \"k\" + (i + 1), k.bytes]), ...(rest ? [[\"other\", \"k5\", rest]] : [])];\n const total = st.eventBytes || 1;\n $(\"#s-bar\").innerHTML = parts.length ? parts.map(([n, c, b]) => '').join(\"\") : '';\n $(\"#s-legend\").innerHTML = parts.length ? parts.map(([n, c, b]) => '' + esc(n) + \" \" + Math.round(b * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n const overhead = Math.max(0, st.databaseBytes - st.eventBytes);\n $(\"#s-totals\").innerHTML = [[\"events\", st.events.toLocaleString() + \" \" + fmtBytes(st.eventBytes) + \"\"], [\"index and overhead\", fmtBytes(overhead)], [\"files\", st.blobs.toLocaleString() + \" \" + fmtBytes(st.mediaBytes) + \"\"]]\n .map(([k, v]) => \"
                                            \" + k + \"\" + v + \"
                                            \").join(\"\");\n const any = st.retention.find((r) => r.kind === null);\n const own = (kind) => { const r = st.retention.find((x) => x.kind === kind); return r ? r.days : \"\"; };\n const row = (k) => {\n const key = k.kind === null ? \"\" : String(k.kind);\n const placeholder = k.kind === null ? \"forever\" : k.replaceable || !any ? \"forever\" : any.days + \" (everything else)\";\n const pill = k.kind === null ? \"everything else\" : '' + k.kind + \" \" + esc(kindName(k.kind));\n const cells = k.protected\n ? 'required'\n : ' days' + ib(\"check\", \"Save keep-for rule\", \"saveretention\", key) + ib(\"trash\", \"Purge\", \"purgekind\", key, \"danger\") + \"\";\n return '' + pill + '' + (k.n === undefined ? \"\" : k.n.toLocaleString()) + '' + (k.bytes === undefined ? \"\" : fmtBytes(k.bytes)) + '' + (k.oldest ? esc(fmtDay(k.oldest)) : \"\") + \"\" + cells + \"\";\n };\n $(\"#kinds tbody\").innerHTML = [...st.kinds, { kind: null, days: any ? any.days : 0 }].map(row).join(\"\");\n loadListHistory().catch(() => {});\n }\n\n async function loadListHistory() {\n const rows = await rpc(\"listlisthistory\");\n const labels = { 3: \"follows\", 10002: \"relay list\", 10003: \"bookmarks\", 30003: \"bookmark list\" };\n $(\"#listhistory tbody\").innerHTML = rows.length ? rows.map((r) => '' + esc(labels[r.kind] || (\"kind \" + r.kind) + (r.d ? \" / \" + r.d : \"\")) + '' + esc(fmtTime(r.created_at)) + '' + esc(fmtTime(r.saved_at)) + '' + ib(\"undo\", \"Restore this version\", \"restorelist\", r.event_id) + '').join(\"\") : 'no older list versions yet';\n }\n\n let searchQuery = \"\";\n async function loadEvents(reset) {\n const list = searchQuery ? await rpc(\"searchevents\", searchQuery, 200) : await rpc(\"listrecentevents\", reset ? 50 : 200);\n $(\"#events tbody\").innerHTML = list.length ? list.map((e) => '' + esc(fmtTime(e.created_at)) + '' + e.kind + '' + av(e.pubkey) + key(e.pubkey) + '' + esc(e.content) + '' + ib(\"pin\", \"Pin\", \"pinevent\", e.id) + ib(\"trash\", \"Delete event\", \"deleteevent\", e.id) + ib(\"ban\", \"Ban event\", \"banevent\", e.id, \"danger\") + ib(\"banuser\", \"Ban author\", \"banpubkey\", e.pubkey, \"danger\") + \"\").join(\"\") : 'no events yet';\n $(\"#more\").classList.toggle(\"hidden\", searchQuery || list.length < 50);\n }\n $(\"#searchform\").onsubmit = guard(async (ev) => {\n searchQuery = ev.target.q.value.trim();\n $(\"#searchclear\").classList.toggle(\"hidden\", !searchQuery);\n await loadEvents(true);\n });\n $(\"#searchclear\").onclick = guard(async () => {\n searchQuery = \"\"; $(\"#searchform\").reset(); $(\"#searchclear\").classList.add(\"hidden\");\n await loadEvents(true);\n });\n async function loadPins() {\n const pins = await rpc(\"listpins\");\n $(\"#pins-count\").textContent = pins.length || \"\";\n $(\"#pins\").innerHTML = pins.length ? pins.map((t) => '
                                          • ' + (t[0] === \"e\" ? \"event\" : \"address\") + '' + esc(t[1].length > 40 ? t[1].slice(0, 16) + \"\\u2026\" + t[1].slice(-8) : t[1]) + \"\" + ib(\"copy\", \"Copy\", \"copy\", t[1]) + ib(\"x\", \"Unpin\", \"unpinevent\", t[1]) + \"
                                          • \").join(\"\") : '
                                          • nothing pinned
                                          • ';\n }\n $(\"#pinform\").onsubmit = guard(async (ev) => {\n await rpc(\"pinevent\", ev.target.id.value.trim()); ev.target.reset(); toast(\"Pinned\"); await loadPins();\n });\n\n // ---- actions ----\n const refresh = () => Promise.all([loadLists(), loadEvents(true), loadStorage(), loadPins()]);\n\n $(\"#copy\").onclick = async () => { await navigator.clipboard.writeText(wsURL); toast(\"copied \" + wsURL); };\n $(\"#signin\").onclick = guard(async () => {\n if (!window.nostr) throw new Error(\"No nostr extension found. Install one (Alby, nos2x, …) and reload, or use a remote signer.\");\n me = await window.nostr.getPublicKey();\n localStorage.setItem(\"me\", me);\n renderHeader(); await loadAdmin(); await loadPeople();\n });\n $(\"#signin46\").onclick = () => showRemote(null);\n $$(\".remote\").forEach((b) => { b.onclick = () => showRemote($(\"#\" + b.dataset.note)); });\n $(\"#nccopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#nclink\").href); toast(\"Link copied; paste it into your signer app\"); };\n $(\"#bunkerform\").onsubmit = guard(async (ev) => {\n $(\"#remotenote\").textContent = \"Connecting…\";\n try { await connectBunker(ev.target.url.value); } catch (e) { $(\"#remotenote\").textContent = e.message; throw e; }\n $(\"#remotenote\").textContent = \"\"; ev.target.reset();\n await remoteDone();\n });\n $(\"#signout\").onclick = async () => {\n if (remote) { const r = remote; remote = null; try { await withTimeout(r.logout(), 5000, \"The signer\"); } catch { /* the session is gone either way */ } }\n localStorage.removeItem(\"nip46\");\n me = null; localStorage.removeItem(\"me\"); renderHeader(); await loadAdmin(); await loadPeople();\n };\n const claimNow = async (note) => {\n if (!signer.ready()) { note.textContent = \"Sign the claim with a nostr extension or a remote signer.\"; showRemote(note); return; }\n me = await signer.getPublicKey();\n const r = await rpc(\"claim\");\n if (!r.claimed) throw new Error(\"Somebody else claimed it first.\");\n localStorage.setItem(\"me\", me);\n toast(\"It's yours.\");\n await loadInfo(); await loadFuel(); await loadAdmin(); await loadPeople();\n if (r.converted && confirm(\"This relay began as a temporary one: anyone can write, and everything is deleted after 14 days. Switch to the default rules and keep everything from now on? Each rule can be changed later on the Rules and Storage tabs.\")) {\n await rpc(\"resetrules\"); toast(\"Rules reset\"); await loadInfo(); await loadAdmin();\n }\n };\n $(\"#claim\").onclick = guard(() => claimNow($(\"#claimnote\")));\n $(\"#claimlease\").onclick = guard(() => claimNow($(\"#leasenote\")));\n // Jobs run in the background; the table follows them while the tab is open.\n let jobsTimer = 0;\n const fmtJob = (j) => {\n const what = j.kind === \"mirror\" ? \"mirror site\" : j.kind === \"import\" ? \"import\" : j.label === \"backfill\" ? \"fetch my history\" : j.kind === \"pull\" ? \"pull\" : \"rebroadcast\";\n const f = [];\n if (j.filter.authors) f.push(j.filter.authors.length === 1 && j.filter.authors[0] === me ? \"my events\" : j.filter.authors.length + \" authors\");\n if (j.filter.kinds) f.push(\"kinds \" + j.filter.kinds.join(\", \"));\n if (j.filter.since) f.push(\"since \" + fmtDay(j.filter.since));\n const when = j.every ? \"every \" + (j.every === 24 ? \"day\" : j.every + \" h\") + (j.nextRun ? \", next \" + fmtTime(j.nextRun) : \"\") : \"once\";\n const l = j.last;\n const count = (stored, blobs, sent, refused) => (j.kind === \"mirror\" ? blobs.toLocaleString() + \" files mirrored\" : j.kind === \"import\" ? stored.toLocaleString() + \" events\" + ((j.last ? j.last.duplicates : j.duplicates) ? \", \" + (j.last ? j.last.duplicates : j.duplicates) + \" already here\" : \"\") : j.kind === \"pull\" ? stored.toLocaleString() + \" events\" + (blobs ? \", \" + blobs + \" files\" : \"\") : sent.toLocaleString() + \" sent\" + (refused ? \", \" + refused + \" refused\" : \"\"));\n const res = j.running ? \"running: \" + count(j.stored, j.blobs, j.sent, j.refused) + \"...\" : !l ? \"waiting\" : l.error ? \"failed: \" + l.error : count(l.stored, l.blobs, l.sent, l.refused) + (l.skipped ? \", \" + l.skipped + \" skipped\" : \"\") + \", \" + fmtTime(l.finishedAt);\n const sources = j.running ? j.pullSources : l?.sources;\n const details = sources?.length ? '
                                            Source results' + sources.map((s) => '

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

                                            ').join('') + '
                                            ' : '';\n return \"\" + what + \"\" + j.relays.map(esc).join(\"
                                            \") + \"\" + (f.join(\", \") || \"everything\") + \"\" + when + \"\" + esc(res) + details + \"\" + (j.running ? \"\" : ib(\"undo\", \"Run now\", \"runjob\", j.id)) + ib(\"x\", \"Remove\", \"removejob\", j.id) + \"\";\n };\n async function pollJobs() {\n clearTimeout(jobsTimer);\n let jobs;\n try { jobs = await rpc(\"listjobs\"); } catch { return; }\n $(\"#jobs tbody\").innerHTML = jobs.length ? jobs.map(fmtJob).join(\"\") : 'no jobs yet';\n if (jobs.some((j) => j.running || (j.nextRun && j.nextRun <= Math.floor(Date.now() / 1000) + 1))) jobsTimer = setTimeout(pollJobs, 3000);\n }\n const urls = (s) => s.split(/[\\s,]+/).map((u) => u.trim()).filter(Boolean);\n const kindsOf = (s) => s.split(/[\\s,]+/).map((k) => parseInt(k, 10)).filter((k) => Number.isInteger(k) && k >= 0);\n $(\"#pullform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"addjob\", { kind: \"pull\", relays: [f.url.value.trim()], every: +f.every.value });\n toast(+f.every.value ? \"Mirror scheduled\" : \"Pull started\"); f.reset(); await pollJobs();\n });\n $(\"#backfillform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"backfill\", urls(f.relays.value));\n toast(\"Fetching your history\"); f.reset(); await pollJobs();\n });\n $(\"#pushform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const filter = {};\n const kinds = kindsOf(f.kinds.value); if (kinds.length) filter.kinds = kinds;\n const days = +f.days.value; if (days > 0) filter.since = Math.floor(Date.now() / 1000) - days * 86400;\n await rpc(\"addjob\", { kind: \"push\", relays: urls(f.targets.value), filter, every: +f.every.value });\n toast(\"Rebroadcast started\"); f.reset(); await pollJobs();\n });\n // ---- presets: writes, reads, kinds and keep-for rules in one click ----\n let presets = null;\n async function renderPresets() {\n if (!presets) { try { presets = await rpc(\"listpresets\"); } catch { presets = []; } }\n $(\"#presets\").innerHTML = presets.map((p) => '\").join(\"\");\n $(\"#presetsourcerow\").classList.toggle(\"hidden\", !presets.some((p) => p.source));\n }\n $(\"#presets\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-preset]\"); if (!b) return;\n const p = (presets || []).find((x) => x.name === b.dataset.preset); if (!p) return;\n if (!confirm(p.title + \": \" + p.about + \"\\n\\nThis replaces the writes and reads rules, the directory setting, the kind rules and the keep-for rules. Limits, identity, people and bans stay.\")) return;\n const source = $(\"#presetsource\").value.trim();\n if (p.source === \"required\" && !source) { $(\"#presetnote\").textContent = p.title + \" needs a source relay to mirror; enter its wss:// URL first.\"; return; }\n b.disabled = true;\n try { policy = await rpc(\"applypreset\", p.name, p.source && source ? { source } : undefined); $(\"#presetnote\").textContent = \"Now: \" + p.about + (policy.job ? \" Mirroring \" + source + \" every \" + policy.job.every + \" h.\" : \"\"); toast(p.title + \" applied\"); await loadInfo(); await loadAdmin(); }\n catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // ---- wire me in: this relay in the owner's own lists ----\n // The lists are replaceable, so a fresh list with only this relay would\n // clobber the real one once it spread. Every publish starts from the\n // newest copy found here or on the indexers, verified when the signer\n // library is around, and adds or removes this relay in it.\n const INDEXERS = [\"wss://purplepag.es\", \"wss://relay.nostr.band\", \"wss://relay.damus.io\", \"wss://nos.lol\"];\n const LISTS = [\n { kind: 10002, tag: \"r\", title: \"Relay list\", nip: \"NIP-65\", about: \"where clients read your notes and send you mentions\" },\n { kind: 10050, tag: \"relay\", title: \"DM inbox\", nip: \"NIP-17\", about: \"where people send you private messages\" },\n { kind: 10007, tag: \"relay\", title: \"Search relays\", nip: \"NIP-51\", about: \"where clients run your searches\" },\n { kind: 10063, tag: \"server\", title: \"Blossom servers\", nip: \"BUD-03\", about: \"where clients upload and look for your files\" },\n ];\n const mineFor = (l) => (l.tag === \"server\" ? location.origin : wsURL);\n const normURL = (u) => { try { const x = new URL(String(u).trim()); return (x.host + x.pathname).replace(/\\/+$/, \"\").toLowerCase(); } catch { return String(u).trim().toLowerCase(); } };\n const isMine = (l, t) => t[0] === l.tag && normURL(t[1] || \"\") === normURL(mineFor(l));\n const listHas = (list, l) => !!list && list.tags.some((t) => isMine(l, t));\n // mergeList is pure: the newest list with this relay put first or taken out, every other tag kept.\n function mergeList(list, l, include) {\n const tags = (list ? list.tags : []).filter((t) => !isMine(l, t));\n if (include) tags.unshift([l.tag, mineFor(l)]);\n return { kind: l.kind, created_at: Math.floor(Date.now() / 1000), content: list ? list.content : \"\", tags };\n }\n const relaysIn = (list) => list.tags.filter((t) => (t[0] === \"r\" || t[0] === \"relay\") && /^wss?:\\/\\//i.test(t[1] || \"\")).map((t) => t[1].trim());\n // overWS opens one socket, sends one message, feeds answers to onMessage until it says done or time runs out.\n function overWS(url, ms, first, onMessage) {\n return new Promise((res) => {\n let ws = null, done = false, out = null;\n const finish = () => { if (done) return; done = true; clearTimeout(timer); try { if (ws) ws.close(); } catch { /* closed */ } res(out); };\n const timer = setTimeout(finish, ms);\n try { ws = new WebSocket(url); } catch { return finish(); }\n ws.onopen = () => ws.send(JSON.stringify(first));\n ws.onmessage = (m) => { let d; try { d = JSON.parse(m.data); } catch { return; } if (!Array.isArray(d)) return; if (onMessage(d, (v) => { out = v; })) finish(); };\n ws.onerror = finish; ws.onclose = finish;\n });\n }\n function fetchNewest(url, filter, ms) {\n let best = null;\n return overWS(url, ms, [\"REQ\", \"w\", filter], (d, set) => {\n if (d[0] === \"EVENT\" && d[1] === \"w\" && d[2] && (!best || d[2].created_at > best.created_at)) { best = d[2]; set(best); }\n return (d[0] === \"EOSE\" || d[0] === \"CLOSED\") && d[1] === \"w\";\n });\n }\n function publishTo(url, event, ms) {\n return overWS(url, ms, [\"EVENT\", event], (d, set) => { if (d[0] === \"OK\" && d[1] === event.id) { set({ url, ok: !!d[2], msg: d[3] || \"\" }); return true; } return false; })\n .then((r) => r || { url, ok: false, msg: \"no answer\" });\n }\n // bridge signs a NIP-98 request to this relay's HTTP door, so the owner's own reads and writes pass any read rule.\n async function bridge(path, body) {\n const url = location.origin + path, raw = JSON.stringify(body);\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"POST\"], [\"payload\", await sha256hex(raw)]] });\n const resp = await fetch(url, { method: \"POST\", headers: { \"content-type\": \"application/json\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body: raw });\n const json = await resp.json();\n if (!resp.ok) throw new Error(json.error || \"HTTP \" + resp.status);\n return json;\n }\n const wire = {};\n function renderWire() {\n const pill = (state) => {\n const cls = state === \"listed\" ? \"on\" : state === \"failed\" ? \"bad\" : state === \"not listed\" ? \"off\" : \"\";\n const label = state === \"checking\" || state === \"publishing\" ? state + \"\\u2026\" : state;\n return '' + esc(label) + \"\";\n };\n $(\"#wire\").innerHTML = LISTS.map((l) => {\n const w = wire[l.kind] || { state: \"\", results: [], list: null };\n const busy = w.state === \"checking\" || w.state === \"publishing\";\n const btn = (act, cls, label) => '\";\n const buttons = w.state === \"listed\" ? btn(\"remove\", \"\", \"Remove this relay\") : btn(\"add\", \"pri\", \"Add this relay\") + btn(\"check\", \"\", w.state ? \"Check again\" : \"Check\");\n const n = w.list ? w.list.tags.filter((t) => t[0] === l.tag).length : 0;\n const from = w.list ? \"\" + (n === 1 ? \"1 entry\" : n + \" entries\") + \", \" + fmtTime(w.list.created_at) + \"\" : w.state && !busy ? \"no list found\" : \"\";\n const sent = (w.results || []).map((r) => '' + esc(r.url.replace(/^wss?:\\/\\//, \"\")) + (r.ok ? \"\" : \" failed\") + \"\").join(\"\");\n const meta = (w.state ? pill(w.state) : \"\") + from + sent;\n return '
                                            ' + l.title + ' ' + l.nip + \"
                                            \" + l.about + '
                                            ' + buttons + '
                                            ' + meta + \"
                                            \";\n }).join(\"\");\n }\n async function wireCheck(l) {\n const w = (wire[l.kind] = { state: \"checking\", list: null, results: [] }); renderWire();\n const filter = { kinds: [l.kind], authors: [me], limit: 1 };\n const found = [];\n try { found.push(...(await bridge(\"/query\", [filter]))); } catch { /* the relay may hold none */ }\n const remote = await Promise.all(INDEXERS.map((u) => fetchNewest(u, filter, 4000)));\n let lib = null; try { lib = await signerLib(); } catch { /* unverified lists are still the owner's own, by pubkey */ }\n for (const e of remote) if (e && e.pubkey === me && e.kind === l.kind && Array.isArray(e.tags) && (!lib || lib.verifyEvent(e))) found.push(e);\n w.list = found.sort((a, b) => b.created_at - a.created_at)[0] || null;\n w.state = listHas(w.list, l) ? \"listed\" : \"not listed\"; renderWire();\n }\n async function wirePublish(l, include) {\n if (!wire[l.kind] || !wire[l.kind].state || wire[l.kind].state === \"failed\") await wireCheck(l);\n const w = wire[l.kind]; w.state = \"publishing\"; w.results = []; renderWire();\n try {\n const signed = await signer.signEvent(mergeList(w.list, l, include));\n const here = await bridge(\"/events\", signed).then((r) => ({ url: wsURL, ok: !!r.accepted, msg: r.message || \"\" })).catch((e) => ({ url: wsURL, ok: false, msg: e.message }));\n const targets = [...new Set([...relaysIn(signed), ...INDEXERS])].filter((u) => normURL(u) !== normURL(wsURL));\n const rest = await Promise.all(targets.map((u) => publishTo(u, signed, 6000)));\n w.results = [here, ...rest]; w.list = signed; w.state = include ? \"listed\" : \"not listed\";\n toast((include ? \"Added to your \" : \"Removed from your \") + l.title.toLowerCase());\n } catch (e) { w.state = \"failed\"; w.results = [{ url: wsURL, ok: false, msg: e.message }]; toast(e.message); }\n renderWire();\n }\n $(\"#wire\").addEventListener(\"click\", (ev) => {\n const b = ev.target.closest(\"button[data-wire]\"); if (!b) return;\n const l = LISTS.find((x) => String(x.kind) === b.dataset.wire); if (!l) return;\n if (b.dataset.do === \"check\") wireCheck(l); else wirePublish(l, b.dataset.do === \"add\");\n });\n\n // ---- custom domains: this relay under a hostname the owner controls ----\n let domainSites = [];\n const domainOptions = (current = \"\") => [{ label: \"\", title: \"This relay\" }, ...domainSites.map((s) => ({ label: s.label, title: (s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + \" / \" + key(s.author) })), ...(current && !domainSites.some((s) => s.label === current) ? [{ label: current, title: \"Site \" + current }] : [])].map((s) => '\").join(\"\");\n let domains = null; // null: not enabled on this host\n function renderDomains() {\n $(\"#domains\").innerHTML = (domains || []).map((d) => {\n const state = d.ready ? \"active\" : \"hostname \" + d.status.replace(/_/g, \" \") + \", certificate \" + d.sslStatus.replace(/_/g, \" \");\n const btn = (act, cls, label) => '\";\n const rows = d.ready ? \"\" : '
                                            ' + d.records.map((r) => \"\").join(\"\") + \"
                                            TypeNameValue
                                            \" + esc(r.type) + '' + esc(r.name) + '' + esc(r.value) + \"\" + esc(r.note) + \"
                                            \";\n return '
                                            ' + esc(d.host) + '' + esc(state) + \"\" + btn(\"check\", \"\", \"Check\") + btn(\"remove\", \"danger\", \"Remove\") + '
                                            \" + rows;\n }).join(\"\");\n }\n async function loadDomains() {\n try { [domains, domainSites] = await Promise.all([rpc(\"listdomains\"), rpc(\"listsites\")]); $(\"#adddomain select[name=site]\").innerHTML = domainOptions(); $(\"#domainnote\").textContent = domains.length ? \"\" : \"No custom domain yet.\"; $(\"#adddomain\").classList.remove(\"hidden\"); }\n catch (e) { domains = null; $(\"#domainnote\").textContent = /^unsupported/.test(e.message) ? \"Custom domains are not enabled on this host.\" : e.message; $(\"#adddomain\").classList.add(\"hidden\"); }\n renderDomains();\n }\n $(\"#adddomain\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"adddomain\", f.host.value.trim(), f.site.value); f.reset(); toast(\"Domain added; now create the CNAME\"); await loadDomains();\n });\n $(\"#domains\").addEventListener(\"change\", guard(async (ev) => {\n const select = ev.target.closest(\"select[data-domain-site]\"); if (!select) return;\n await rpc(\"setdomainsite\", select.dataset.domainSite, select.value);\n toast(\"Domain destination saved\"); await loadDomains();\n }));\n $(\"#domains\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-domain]\"); if (!b) return;\n const host = b.dataset.domain;\n if (b.dataset.do === \"remove\" && !confirm(\"Remove \" + host + \"? Its certificate goes with it and the name stops answering.\")) return;\n b.disabled = true;\n try {\n if (b.dataset.do === \"remove\") { await rpc(\"removedomain\", host); toast(\"Removed\"); }\n else { const d = await rpc(\"checkdomain\", host); toast(d.ready ? host + \" is live\" : \"Not yet: \" + (d.status === \"active\" ? \"certificate pending\" : \"waiting for the CNAME\")); }\n await loadDomains();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n $(\"#access\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const openKinds = f.openKinds.value.split(/[\\s,]+/).filter(Boolean).map(Number);\n if (openKinds.some((k) => !Number.isInteger(k) || k < 0 || k > 65535)) throw new Error(\"Open kinds must be whole numbers.\");\n policy = await rpc(\"setpolicy\", { writes: f.writes.value, reads: f.reads.value, openKinds, guestReplies: f.guestReplies.checked, minPow: +f.minPow.value, maxFuture: +f.maxFuture.value, maxLimit: +f.maxLimit.value, maxSubs: +f.maxSubs.value, maxMessageKB: +f.maxMessageKB.value, eventsPerMinute: +f.eventsPerMinute.value, reqsPerMinute: +f.reqsPerMinute.value, maxBlobMB: +f.maxBlobMB.value });\n toast(\"Rules saved\"); await loadInfo();\n });\n $(\"#notify\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { notify: { reports: f.reports.checked, fuel: f.fuel.checked, jobs: f.jobs.checked, succession: f.succession.checked, digest: f.digest.checked } });\n toast(\"Notifications saved\");\n });\n $(\"#notifytest\").onclick = guard(async () => {\n const r = await rpc(\"notifytest\");\n toast(r.sent ? \"Sent. Look for a message from the relay in your DMs.\" : \"Could not send.\");\n });\n $(\"#identity\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const list = (v) => v.split(\",\").map((s) => s.trim()).filter(Boolean);\n policy = await rpc(\"setpolicy\", { name: f.name.value, contact: f.contact.value, description: f.description.value, icon: f.icon.value,\n banner: f.banner.value, postingPolicy: f.postingPolicy.value, privacyPolicy: f.privacyPolicy.value, tags: list(f.tags.value), languageTags: list(f.languageTags.value), relayCountries: list(f.relayCountries.value) });\n toast(\"Identity saved\"); await loadInfo(); await loadPeople(); loadCard();\n });\n $(\"#addmember\").onsubmit = guard(async (ev) => {\n const f = ev.target; const pk = npubToHex(f.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"setmember\", pk, { name: f.name.value.trim() || null, note: f.note.value }); f.reset(); toast(\"Member added\"); await loadLists();\n });\n $(\"#addblock\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"blockip\", f.ip.value.trim(), f.reason.value); f.reset(); toast(\"Blocked\"); await loadLists();\n });\n $(\"#wordsform\").onsubmit = guard(async (ev) => {\n const words = ev.target.words.value.split(\"\\n\").map((s) => s.trim()).filter(Boolean);\n const kept = await rpc(\"setblockedwords\", words);\n policy = await rpc(\"setpolicy\", { blockedWordsInTags: ev.target.inTags.checked });\n ev.target.words.value = kept.join(\"\\n\"); toast(kept.length ? kept.length + \" words blocked\" : \"No words blocked\");\n });\n $(\"#thresholdform\").onsubmit = guard(async (ev) => {\n policy = await rpc(\"setpolicy\", { reportThreshold: +ev.target.reportThreshold.value });\n toast(policy.reportThreshold ? \"Hidden after \" + policy.reportThreshold + \" reports\" : \"Never hidden by reports\"); await loadLists();\n });\n $(\"#addban\").onsubmit = guard(async (ev) => {\n const pk = npubToHex(ev.target.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"banpubkey\", pk, ev.target.reason.value, ev.target.erase.checked); ev.target.reset(); toast(ev.target.erase.checked ? \"Banned and erased\" : \"Banned\"); await loadLists();\n });\n $(\"#mintinvite\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const inv = await rpc(\"createinvite\", +f.ttl.value, +f.max.value, f.note.value);\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n f.note.value = \"\"; toast(\"Invite created and copied\"); await loadLists();\n });\n $(\"#kindform\").onsubmit = guard(async (ev) => {\n const rule = ev.submitter.value; const k = +ev.target.kind.value;\n await rpc(rule === \"allow\" ? \"allowkind\" : \"disallowkind\", k); ev.target.reset(); toast((rule === \"allow\" ? \"Allowed kind \" : \"Blocked kind \") + k); await loadLists();\n });\n $(\"#more\").onclick = guard(() => loadEvents(false));\n $(\"#dumpform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { dumps: f.dumps.value, dumpsKeep: Math.max(1, Math.min(60, Math.floor(+f.keep.value || 7))) });\n toast(policy.dumps === \"off\" ? \"Dumps off\" : \"Dumping \" + policy.dumps); await loadDumps();\n });\n $(\"#importform\").onsubmit = guard(async (ev) => {\n const file = ev.target.file.files[0]; if (!file) return;\n if (!signer.ready()) throw new Error(NO_SIGNER);\n if (file.size > 64 * 1024 * 1024) throw new Error(\"At most 64 MB per import.\");\n const body = await file.text();\n const url = location.origin + \"/import?name=\" + encodeURIComponent(file.name);\n const token = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"PUT\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(url, { method: \"PUT\", headers: { authorization: \"Nostr \" + btoa(JSON.stringify(token)), \"content-type\": \"application/x-ndjson\" }, body });\n const r = await resp.json();\n if (!resp.ok) throw new Error(r.error || \"import failed\");\n ev.target.reset(); toast(\"Importing \" + fmtBytes(r.bytes)); await pollJobs();\n });\n $(\"#dumpnow\").onclick = guard(async () => { const d = await rpc(\"dumpnow\"); toast(\"Dumped \" + d.events.toLocaleString() + \" events\"); await loadStorage(); });\n $(\"#treeform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { memberInvites: { depth: Math.max(0, Math.floor(+f.depth.value || 0)), quota: Math.max(0, Math.floor(+f.quota.value || 0)) } });\n toast(policy.memberInvites.depth ? \"Members may invite\" : \"Only you and moderators invite\");\n });\n $(\"#mintmine\").onclick = guard(async () => {\n const inv = await rpc(\"createinvite\", 259200, 1, \"\");\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n toast(\"Invite created and copied\"); await loadMine();\n });\n $(\"#exportcfg\").onclick = guard(async () => {\n const cfg = await rpc(\"exportconfig\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(new Blob([JSON.stringify(cfg, null, 2)], { type: \"application/json\" }));\n a.download = host.split(\".\")[0] + \".bind.ws.config.json\";\n a.click(); URL.revokeObjectURL(a.href);\n });\n $(\"#importcfg\").onclick = () => $(\"#cfgfile\").click();\n $(\"#cfgfile\").onchange = guard(async (ev) => {\n const file = ev.target.files[0]; if (!file) return;\n const cfg = JSON.parse(await file.text());\n const check = await rpc(\"importconfig\", cfg, { dryRun: true });\n const lines = check.changes.summary.length ? check.changes.summary.join(\"\\n\") : \"Nothing would change.\";\n const dropped = check.warnings.length ? \"\\n\\nNot taken:\\n\" + check.warnings.join(\"\\n\") : \"\";\n if (!confirm(\"Apply \" + file.name + \" to this relay?\\n\\n\" + lines + dropped)) { ev.target.value = \"\"; return; }\n await rpc(\"importconfig\", cfg); ev.target.value = \"\"; toast(\"Configuration imported\"); await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // Succession: the status line under the heir form, from successionstatus.\n async function renderSuccession() {\n const el = $(\"#successionnote\"), f = $(\"#succession\");\n let st;\n try { st = await rpc(\"successionstatus\"); } catch { el.textContent = \"\"; return; }\n const sc = st.succession;\n $(\"#clearsuccession\").classList.toggle(\"hidden\", !sc);\n f.querySelector(\"button.btn:not(#clearsuccession)\").textContent = sc ? \"Change heir\" : \"Set heir\";\n if (!sc) { el.textContent = \"No heir named. Last signed in \" + fmtTime(st.ownerSeenAt) + \".\"; return; }\n if (f.heir.querySelector('[value=\"' + sc.heir + '\"]')) f.heir.value = sc.heir;\n f.afterDays.value = String(sc.afterDays);\n const who = (window.__members || []).find((m) => m.pubkey === sc.heir);\n const heir = who && who.name ? who.name + \"@\" + host : short(sc.heir);\n el.textContent = \"Heir: \" + heir + \". Last signed in \" + fmtTime(st.ownerSeenAt) + (st.silentDays ? \" (\" + st.silentDays + \" days ago)\" : \"\") + \". \" +\n (st.warning ? \"The warning month is running: the relay goes to \" + heir + \" on \" + fmtDay(st.handoverAt) + \" unless you sign in.\" : \"If you stay away, it goes to \" + heir + \" around \" + fmtDay(st.handoverAt) + \".\") +\n (st.log && st.log.length ? \" Handed over before: \" + st.log.map((l) => fmtDay(l.at) + \" to \" + short(l.to)).join(\", \") + \".\" : \"\");\n }\n $(\"#succession\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"setsuccession\", { heir: f.heir.value, afterDays: +f.afterDays.value });\n toast(\"Heir set\"); await loadAdmin();\n });\n $(\"#clearsuccession\").onclick = guard(async () => {\n await rpc(\"clearsuccession\");\n toast(\"Heir cleared\"); await loadAdmin();\n });\n $(\"#transfer\").onsubmit = guard(async (ev) => {\n const pk = ev.target.pubkey.value; if (!pk) return;\n const name = host.split(\".\")[0];\n const typed = prompt(\"This hands \" + host + \" to \" + short(pk) + \" for good. You stay on as a moderator. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing changed.\"); return; }\n await rpc(\"transferowner\", pk);\n toast(\"Transferred\");\n await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // ---- fork: a new name with this relay's events, claim reserved for a key ----\n $(\"#forkform\").scope.onchange = (ev) => $(\"#forkkinds\").classList.toggle(\"hidden\", ev.target.value !== \"kinds\");\n $(\"#joinform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { joinTerms: f.joinTerms.value, directoryPublic: f.directoryPublic.checked });\n toast(\"Saved\"); await loadInfo(); await loadPeople();\n });\n $(\"#forkform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const opts = { people: f.people.checked };\n if (f.name.value.trim()) opts.name = f.name.value.trim().toLowerCase();\n if (f.holder.value.trim()) { const pk = npubToHex(f.holder.value); if (!pk) throw new Error(\"That is not a pubkey.\"); opts.holder = pk; }\n if (f.scope.value === \"mine\") opts.filter = { authors: [me] };\n if (f.scope.value === \"kinds\") { const kinds = f.kinds.value.split(/[\\s,]+/).filter(Boolean).map(Number); if (!kinds.length || kinds.some((k) => !Number.isInteger(k) || k < 0)) throw new Error(\"Give kinds as numbers.\"); opts.filter = { kinds }; }\n if (!confirm(\"Fork this relay into a new name\" + (opts.name ? \" (\" + opts.name + \")\" : \"\") + \"? It pulls \" + (f.scope.value === \"all\" ? \"everything\" : f.scope.value === \"mine\" ? \"your events\" : \"the chosen kinds\") + (opts.people ? \" and the people\" : \"\") + \", and only \" + (opts.holder ? \"that key\" : \"you\") + \" can claim it.\")) return;\n const r = await rpc(\"forkrelay\", opts);\n $(\"#forknote\").textContent = r.handover + \" Expires \" + fmtTime(r.expires_at) + \".\";\n $(\"#forkurl\").textContent = r.console;\n $(\"#forkresult\").classList.remove(\"hidden\");\n toast(\"Forked to \" + r.name);\n });\n $(\"#forkcopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#forkurl\").textContent); toast(\"copied\"); };\n $(\"#deleterelay\").onclick = guard(async () => {\n const name = host.split(\".\")[0];\n const typed = prompt(\"This deletes everything on \" + host + \" and gives the name up. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing was deleted.\"); return; }\n await rpc(\"deleterelay\", name);\n localStorage.removeItem(\"me\");\n location.href = \"/\";\n });\n $(\"#topup\").onsubmit = guard((ev) => topUp(+ev.target.sats.value));\n $(\"#inv-copy\").onclick = async () => { await navigator.clipboard.writeText($(\"#inv-text\").value); toast(\"invoice copied\"); };\n $$(\"button[data-copy]\").forEach((b) => { b.onclick = async () => { await navigator.clipboard.writeText(b.dataset.copy === \"ws\" ? wsURL : location.origin); toast(\"copied\"); }; });\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-act]\"); if (!b) return;\n const act = b.dataset.act, id = b.dataset.id;\n if (act === \"copy\") { await navigator.clipboard.writeText(id); toast(\"copied\"); return; }\n if ((act === \"banpubkey\" || act === \"banevent\" || act === \"resolve:ban\") && !confirm(act === \"banevent\" ? \"Delete this event and refuse it forever?\" : \"Ban this author and refuse everything they post?\")) return;\n const erase = (act === \"banpubkey\" || act === \"resolve:ban\") && confirm(\"Also erase everything they wrote and uploaded here?\");\n if (act === \"deleteblob\" && !confirm(\"Delete this file for good?\")) return;\n if (act === \"deletedump\" && !confirm(\"Delete this dump?\")) return;\n if (act === \"downloaddump\") { b.disabled = true; try { await downloadDump(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }\n if (act === \"restorelist\") {\n if (!signer.ready()) { toast(NO_SIGNER); return; }\n if (!confirm(\"Restore this list version? It will be signed and published as the newest version.\")) return;\n try {\n const draft = await rpc(\"restorelist\", id);\n const signed = await signer.signEvent(draft);\n const result = await bridge(\"/events\", signed);\n if (!result.accepted) throw new Error(result.message || \"The relay refused the restored list.\");\n toast(\"List restored\"); await loadListHistory(); await loadStorage();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if ((act === \"removemember\" || act === \"banpubkey\") && (window.__members || []).some((m) => m.invited_by === id) && confirm(\"Also remove everyone this member invited, and everyone they invited in turn?\")) {\n b.disabled = true;\n try { const r = await rpc(\"removesubtree\", id); if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase); toast(\"Removed \" + r.removed.length); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"removejob\" && !confirm(\"Remove this job?\")) return;\n if (act === \"purgekind\") {\n const kind = id === \"\" ? null : +id;\n const label = kind === null ? \"everything without its own rule\" : kindName(kind) + \" (kind \" + kind + \")\";\n const typed = prompt(\"Purge \" + label + \" older than how many days? 0 purges all of them. This cannot be undone.\", \"30\");\n if (typed === null) return;\n const days = Math.max(0, Math.floor(+typed || 0));\n b.disabled = true;\n try { const r = await rpc(\"purgekind\", kind, days); toast(\"Purged \" + r.deleted.toLocaleString() + \" events\"); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"saveretention\") {\n const kind = id === \"\" ? null : +id;\n const days = Math.max(0, Math.floor(+b.closest(\"tr\").querySelector(\".days\").value || 0));\n b.disabled = true;\n try { await rpc(\"setretention\", kind, days); toast(days ? \"Kept for \" + days + \" days\" : \"Kept forever\"); await loadStorage(); await loadInfo(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n b.disabled = true;\n try {\n if (act === \"savemember\") { const tr = b.closest(\"tr\"), role = tr.querySelector(\"select.role\"), keep = tr.querySelector(\".keep\"), cap = tr.querySelector(\".cap\"); await rpc(\"setmember\", id, { name: tr.querySelector(\".name\").value.trim() || null, note: tr.querySelector(\".note\").value, ...(role ? { role: role.value } : {}), ...(keep ? { keepDays: Math.max(0, Math.floor(+keep.value || 0)), maxBytes: Math.max(0, Math.floor(+cap.value || 0)) * 1024 } : {}) }); }\n else if (act.startsWith(\"resolve:\")) await rpc(\"resolvereport\", id, act.slice(8), erase);\n else if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase);\n else await rpc(act, act === \"unrulekind\" ? +id : id, \"\");\n toast(\"Done\"); await (myRole ? refresh() : loadMine());\n if (act === \"pinevent\" || act === \"unpinevent\") await loadPins();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // The share block: the card picture, the group naddr and its QR.\n let card = null;\n async function loadCard() {\n try {\n card = await (await fetch(\"/card.json\", { cache: \"no-store\" })).json();\n $(\"#cardimg\").src = \"/card.svg?t=\" + Date.now();\n $(\"#naddr\").textContent = card.naddr || \"\";\n const q = $(\"#naddrqr\");\n if (card.naddr) { q.src = \"/qr.svg?text=\" + encodeURIComponent(card.naddr); q.classList.remove(\"hidden\"); } else q.classList.add(\"hidden\");\n } catch { /* the card is decoration */ }\n renderApps();\n }\n // The app rows on the Connect section: one per client people actually use,\n // with the link that lands on this relay in it. Relay apps take the relay\n // or the group address; feed apps take the owner's profile with this relay\n // as the hint, since they have no notion of opening a relay.\n function renderApps() {\n const el = $(\"#apps\"); if (!el) return;\n const enc = encodeURIComponent;\n const nprofile = card && card.nprofile ? card.nprofile : \"\";\n const naddr = card && card.naddr ? card.naddr : \"\";\n const whose = me && owner && me === owner ? \"your\" : \"the owner's\";\n const link = (label, href) => '' + label + \"\";\n const app = (label, uri) => '' + label + \"\";\n const copy = (label, text) => '\";\n const row = (name, where, note, acts) => '
                                            ' + name + \"\" + where + \"

                                            \" + note + '

                                            ' + acts.filter(Boolean).join(\"\") + \"
                                            \";\n const profileNote = \"Opens \" + whose + \" profile with this relay attached.\";\n const groups = [\n [\"As a place\", \"These open the relay itself: its feed, its people, its group.\", [\n row(\"Jumble\", \"web\", \"A feed of everything on this relay.\", [link(\"Open\", \"https://jumble.social/?r=\" + enc(wsURL))]),\n row(\"Coracle\", \"web\", \"The relay's page: its feed and its people.\", [link(\"Open\", \"https://coracle.social/relays/\" + enc(host))]),\n row(\"Flotilla\", \"web, phone\", \"The relay as a space, with the group as a room.\", [link(\"Open\", \"https://app.flotilla.social/spaces/\" + enc(host)), naddr && app(\"Open group\", \"nostr:\" + naddr)]),\n row(\"0xchat\", \"phone\", \"The group, in a chat app.\", [naddr && app(\"Open group\", \"nostr:\" + naddr), naddr && copy(\"Copy naddr\", naddr)]),\n row(\"noStrudel\", \"web\", \"Relays, add this one, then open its page.\", [copy(\"Copy relay URL\", wsURL)]),\n ]],\n [\"Find me here\", \"Feed apps have no relay pages. They meet this relay through a profile link that names it, then keep it once it is in the relay settings.\", [\n row(\"Primal\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://primal.net/p/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"YakiHonne\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://yakihonne.com/profile/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"Damus\", \"iPhone\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Amethyst\", \"Android\", profileNote + \" Then Relays in the drawer.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Nostur\", \"iPhone, Mac\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n ]],\n ];\n if (info?.supported_grasps?.includes(\"GRASP-01\")) groups.push([\n \"Git repositories\", \"Use this relay with a Git client.\", [\n row(\"GitWorkshop\", \"web\", \"Browse this relay's Git repositories.\", [link(\"Open in app\", \"https://gitworkshop.dev/relay/\" + enc((wsURL.startsWith(\"ws://\") ? \"ws:\" : \"\") + host)), copy(\"Copy relay URL\", wsURL)]),\n ],\n ]);\n el.innerHTML = groups.map(([h, note, rows]) => '

                                            ' + h + '

                                            ' + note + '

                                            ' + rows.join(\"\") + \"
                                            \").join(\"\");\n $(\"#apps-ws\").textContent = wsURL;\n const tile = (label, text) => '
                                            ' + label + '\"QR
                                            ';\n $(\"#phones\").innerHTML = [nprofile && tile(\"Find me here, for a phone\", \"nostr:\" + nprofile), naddr && tile(\"The group, for a phone\", \"nostr:\" + naddr)].filter(Boolean).join(\"\");\n }\n // The folds are a group: opening one closes the rest. Browsers with the\n // details name attribute do this themselves; this covers the others.\n $$(\".folds details\").forEach((d) => d.addEventListener(\"toggle\", () => { if (d.open) $$(\".folds details\").forEach((o) => { if (o !== d && o.open) o.open = false; }); }));\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-copytext]\"); if (!b) return;\n try { await navigator.clipboard.writeText(b.dataset.copytext); toast(\"copied\"); } catch { /* no clipboard */ }\n });\n $(\"#copynaddr\").onclick = async () => { if (!card || !card.naddr) return; await navigator.clipboard.writeText(card.naddr); toast(\"copied naddr\"); };\n $(\"#copyembed\").onclick = async () => { await navigator.clipboard.writeText('\"''); toast(\"copied embed\"); };\n\n // A click on a key copies it; a double-click selects the whole key so the usual copy shortcut takes it too.\n document.addEventListener(\"dblclick\", (ev) => {\n const k = ev.target.closest(\".key\"); if (!k) return;\n const s = window.getSelection(), r = document.createRange(); r.selectNodeContents(k.querySelector(\".full\")); s.removeAllRanges(); s.addRange(r);\n });\n document.addEventListener(\"click\", async (ev) => {\n const k = ev.target.closest(\".key\"); if (!k || ev.detail > 1 || String(window.getSelection())) return;\n try { await navigator.clipboard.writeText(k.title); toast(\"copied \" + k.dataset.short); } catch { /* no clipboard */ }\n });\n // ---- boot ----\n try { await loadInfo(); } catch { renderHeader(); }\n await loadFuel();\n await loadCard();\n me = localStorage.getItem(\"me\");\n if (me && await resumeRemote()) { /* a remote session answers for itself when first used */ }\n else if (me && window.nostr) { try { const pk = await window.nostr.getPublicKey(); if (pk !== me) me = null; } catch { me = null; } }\n else if (me) me = null;\n renderHeader();\n renderApps();\n await loadAdmin();\n await loadPeople();\n})();\n"; diff --git a/src/list-history.ts b/src/list-history.ts new file mode 100644 index 0000000..1ef29f0 --- /dev/null +++ b/src/list-history.ts @@ -0,0 +1,60 @@ +import type { Event } from "./event.ts"; + +// List history keeps a small private undo trail for the replaceable lists that +// are difficult to recreate. It is keyed by the signing pubkey, never indexed +// as events, and only the same signer can ask for a version back. +export const LIST_KINDS = [3, 10002, 10003, 30003] as const; +export const LIST_HISTORY_LIMIT = 12; +export const LIST_HISTORY_SCHEMA = ` +CREATE TABLE IF NOT EXISTS list_history ( + owner TEXT NOT NULL, + kind INTEGER NOT NULL, + d TEXT NOT NULL DEFAULT '', + event_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + saved_at INTEGER NOT NULL, + raw TEXT NOT NULL, + PRIMARY KEY (owner, kind, d, event_id) +); +CREATE INDEX IF NOT EXISTS list_history_owner ON list_history(owner, saved_at DESC); +CREATE INDEX IF NOT EXISTS list_history_list ON list_history(owner, kind, d, created_at DESC); +`; + +const listKind = (kind: number) => (LIST_KINDS as readonly number[]).includes(kind); +const listD = (e: Pick) => e.kind >= 30000 ? e.tags.find((t: Event["tags"][number]) => t[0] === "d")?.[1] ?? "" : ""; + +// archiveCurrent records the version about to be replaced, then trims the +// owner's trail. The current event remains available through ordinary NIP-01 +// queries; history is an internal recovery aid. +export const archiveCurrent = (sql: SqlStorage, e: Event, savedAt: number) => { + if (!listKind(e.kind)) return; + sql.exec(`INSERT OR IGNORE INTO list_history(owner,kind,d,event_id,created_at,saved_at,raw) VALUES(?,?,?,?,?,?,?)`, e.pubkey, e.kind, listD(e), e.id, e.created_at, savedAt, JSON.stringify(e)); + sql.exec(`DELETE FROM list_history WHERE owner=? AND kind=? AND d=? AND event_id NOT IN (SELECT event_id FROM list_history WHERE owner=? AND kind=? AND d=? ORDER BY created_at DESC, event_id ASC LIMIT ?)`, e.pubkey, e.kind, listD(e), e.pubkey, e.kind, listD(e), LIST_HISTORY_LIMIT); +}; + +// clearForDelete removes versions an author's NIP-09 deletion names. A list +// deletion must not leave private old tags recoverable through this feature. +export const clearForDelete = (sql: SqlStorage, author: string, eventID: string) => { + sql.exec(`DELETE FROM list_history WHERE owner=? AND event_id=?`, author, eventID); +}; + +export const clearList = (sql: SqlStorage, owner: string, kind: number, d: string, before?: number) => { + if (before === undefined) sql.exec(`DELETE FROM list_history WHERE owner=? AND kind=? AND d=?`, owner, kind, d); + else sql.exec(`DELETE FROM list_history WHERE owner=? AND kind=? AND d=? AND created_at<=?`, owner, kind, d, before); +}; + +export interface ListHistoryRow { owner: string; kind: number; d: string; event_id: string; created_at: number; saved_at: number; raw: string; } + +export const listHistory = (sql: SqlStorage, owner: string): Omit[] => + sql.exec>(`SELECT kind,d,event_id,created_at,saved_at FROM list_history WHERE owner=? ORDER BY saved_at DESC LIMIT 100`, owner).toArray(); + +export const restoreHistory = (sql: SqlStorage, owner: string, eventID: string): (Event & { sig: string }) | string => { + const row = sql.exec<{ raw: string }>(`SELECT raw FROM list_history WHERE owner=? AND event_id=?`, owner, eventID).toArray()[0]; + if (!row) return "not found"; + try { + const event = JSON.parse(row.raw) as Event; + return { kind: event.kind, created_at: Math.floor(Date.now() / 1000), tags: event.tags, content: event.content } as Event & { sig: string }; + } catch { + return "error: saved list is unreadable"; + } +}; diff --git a/src/manage.ts b/src/manage.ts index a42eed5..42caa1a 100644 --- a/src/manage.ts +++ b/src/manage.ts @@ -26,6 +26,7 @@ import { addDomain, checkDomain, listDomains, removeDomain, setDomainSite } from import { verifyNIP98 } from "./auth.ts"; import { SITE_KINDS, checkSite, siteLabel, sitePaths } from "./sites.ts"; import { gitStorage } from "./git-storage.ts"; +import { listHistory, restoreHistory } from "./list-history.ts"; // A call: the relay and the request, who is calling and as what, the // parameters with their readers, and how to answer. @@ -56,6 +57,7 @@ interface Method { reads?: true; // Plain members may manage their own invitations when the tree is open. ownInvites?: true; + ownListHistory?: true; run: (c: Call) => Response | Promise; } @@ -256,6 +258,18 @@ export const METHODS: Record = { return reply({ result: listClaims(relay.sql, t, role === "member" ? caller : "") }); }, }, + listlisthistory: { + action: "read", reads: true, ownListHistory: true, + run: ({ relay, caller, reply }) => reply({ result: listHistory(relay.sql, caller) }), + }, + restorelist: { + action: "read", reads: true, ownListHistory: true, + run: ({ relay, caller, params, str, hex64, reply }) => { + if (params.length !== 1 || !hex64(str(0))) return reply({ error: "invalid: give one saved list event id" }, 400); + const restored = restoreHistory(relay.sql, caller, str(0)); + return typeof restored === "string" ? reply({ error: restored }, 404) : reply({ result: restored }); + }, + }, createclaim: { action: "invites", ownInvites: true, run: ({ relay, s, t, caller, role, params, str, reply }) => { @@ -794,8 +808,9 @@ export async function manage(relay: Relay, req: Request): Promise { // A plain member reaches their own invites when the owner opened the // invite tree (memberInvites); the invite methods keep them to their own. const ownInvites = role === "member" && p.memberInvites.depth > 0 && m.ownInvites; + const ownListHistory = role !== null && m.ownListHistory; if (role === "owner") void relay.succession.seen(caller); - if (!ownInvites && !can(role, m.action)) { + if (!ownInvites && !ownListHistory && !can(role, m.action)) { 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"; return reply({ error: why }, 403); } diff --git a/src/store.ts b/src/store.ts index c459fcf..9a9fb1e 100644 --- a/src/store.ts +++ b/src/store.ts @@ -7,6 +7,7 @@ import { ftsQuery, searchTerms, type Filter } from "./filter.ts"; import { SITE_SCHEMA, SITE_KINDS } from "./sites.ts"; import { HLL } from "./hll.ts"; import { hexToBytes, type SyncItem } from "./negentropy.ts"; +import { archiveCurrent, clearForDelete, clearList, LIST_HISTORY_SCHEMA } from "./list-history.ts"; export const SCHEMA = ` CREATE TABLE IF NOT EXISTS events ( @@ -86,6 +87,7 @@ export class Store { init() { this.sql.exec(SCHEMA); + this.sql.exec(LIST_HISTORY_SCHEMA); this.sql.exec(SITE_SCHEMA); this.sql.exec(GRASP_SCHEMA); } @@ -136,11 +138,15 @@ export class Store { if (isReplaceable(e.kind)) { if (has(`SELECT 1 FROM events WHERE pubkey=? AND kind=? AND (created_at>? OR (created_at=? AND id(`SELECT raw FROM events WHERE pubkey=? AND kind=?`, e.pubkey, e.kind).toArray()[0]; + if (current) archiveCurrent(this.sql, JSON.parse(current.raw) as Event, now); this.x(`DELETE FROM events WHERE pubkey=? AND kind=?`, e.pubkey, e.kind); } else if (isAddressable(e.kind)) { d = tag(e, "d"); if (has(`SELECT 1 FROM events WHERE pubkey=? AND kind=? AND d=? AND (created_at>? OR (created_at=? AND id(`SELECT raw FROM events WHERE pubkey=? AND kind=? AND d=?`, e.pubkey, e.kind, d).toArray()[0]; + if (current) archiveCurrent(this.sql, JSON.parse(current.raw) as Event, now); this.x(`DELETE FROM events WHERE pubkey=? AND kind=? AND d=?`, e.pubkey, e.kind, d); } else if (e.kind === 5) { for (const t of e.tags) { @@ -150,10 +156,12 @@ export class Store { `DELETE FROM events WHERE id=? AND (pubkey=? OR (kind=1059 AND EXISTS (SELECT 1 FROM tags WHERE tags.event_id=events.id AND name='p' AND value=?)))`, t[1], e.pubkey, e.pubkey, ); + clearForDelete(this.sql, e.pubkey, t[1]); } else if (t[0] === "a") { const parts = t[1].split(":"); if (parts.length === 3 && parts[1] === e.pubkey) { this.x(`DELETE FROM events WHERE kind=? AND pubkey=? AND d=? AND created_at<=?`, parseInt(parts[0], 10) || 0, e.pubkey, parts[2], e.created_at); + clearList(this.sql, e.pubkey, parseInt(parts[0], 10) || 0, parts[2], e.created_at); } } } @@ -178,6 +186,7 @@ export class Store { this.x(`INSERT INTO vanished(pubkey,until) VALUES(?,?) ON CONFLICT(pubkey) DO UPDATE SET until=max(until,excluded.until)`, pubkey, until); this.x(`DELETE FROM events WHERE pubkey=? AND created_at<=?`, pubkey, until); this.x(`DELETE FROM events WHERE kind=1059 AND id IN (SELECT event_id FROM tags WHERE name='p' AND value=?)`, pubkey); + this.x(`DELETE FROM list_history WHERE owner=?`, pubkey); } // sweepExpired deletes NIP-40 expired rows and returns the next expiry, or 0. @@ -364,6 +373,7 @@ export class Store { this.x(`DELETE FROM events WHERE pubkey=?`, pubkey); this.bytesByAuthor.delete(pubkey); } + this.x(`DELETE FROM list_history WHERE owner=?`, pubkey); return n; } @@ -376,6 +386,7 @@ export class Store { this.x(`DELETE FROM events WHERE pubkey=? AND created_at { + it("keeps bounded private versions and returns an unsigned owner restore", async () => { + const host = "list-history.bind.ws"; + const owner = generateSecretKey(); + const other = generateSecretKey(); + await rpc(host, owner, "claim"); + await runInDurableObject(env.RELAY.getByName("list-history"), (relay: Relay) => { + const base = now(); + for (let i = 0; i < 14; i++) expect(relay.store.save(ev(owner, 10002, "v" + i, [["r", "wss://relay" + i]], base + i), base + i)).toBe(""); + expect(relay.store.save(ev(other, 10002, "other", [], base + 20), base + 20)).toBe(""); + expect(relay.store.save(ev(owner, 10003, "bookmarks", [], base + 21), base + 21)).toBe(""); + }); + const history = (await rpc(host, owner, "listlisthistory")).result as any[]; + expect(history.length).toBe(12); + expect(history.every((x) => x.owner === undefined && x.kind === 10002 || x.kind === 10003)).toBe(true); + expect((await rpc(host, other, "listlisthistory")).status).toBe(403); + const chosen = history.find((x) => x.kind === 10002); + const restored = (await rpc(host, owner, "restorelist", chosen.event_id)).result; + expect(restored).toMatchObject({ kind: 10002, content: expect.stringMatching(/^v/) }); + expect(restored).not.toHaveProperty("sig"); + expect(restored).not.toHaveProperty("pubkey"); + expect((await rpc(host, other, "restorelist", chosen.event_id)).status).toBe(403); + }); + + it("clears prior versions when the author vanishes", async () => { + const host = "list-history-vanish.bind.ws"; + const owner = generateSecretKey(); + await rpc(host, owner, "claim"); + await runInDurableObject(env.RELAY.getByName("list-history-vanish"), (relay: Relay) => { + relay.store.save(ev(owner, 3, "old"), now()); + relay.store.save(ev(owner, 3, "new"), now() + 1); + relay.store.vanish(pk(owner), now() + 100); + }); + expect((await rpc(host, owner, "listlisthistory")).result).toEqual([]); + }); +}); From 0c1847ac59cf7f0605b8a16bc4f0833c8e323357 Mon Sep 17 00:00:00 2001 From: Dami Date: Fri, 4 Sep 2026 18:10:35 -0600 Subject: [PATCH 03/14] lists: show restore diffs, supersede future versions, and bound history storage --- src/console/console.js | 10 +++++--- src/gen/console.ts | 2 +- src/list-history.ts | 43 ++++++++++++++++++++++---------- src/manage.ts | 5 ++-- src/store.ts | 23 +++++++++++++---- test/object/list-history.test.ts | 13 +++++++--- 6 files changed, 67 insertions(+), 29 deletions(-) diff --git a/src/console/console.js b/src/console/console.js index 4abbcec..365d129 100644 --- a/src/console/console.js +++ b/src/console/console.js @@ -973,10 +973,14 @@ if (act === "downloaddump") { b.disabled = true; try { await downloadDump(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; } if (act === "restorelist") { if (!signer.ready()) { toast(NO_SIGNER); return; } - if (!confirm("Restore this list version? It will be signed and published as the newest version.")) return; try { - const draft = await rpc("restorelist", id); - const signed = await signer.signEvent(draft); + const preview = await rpc("restorelist", id); + const d = preview.diff || {}; + const added = (d.addedTags || []).map((t) => "+ " + JSON.stringify(t)).join("\n"); + const removed = (d.removedTags || []).map((t) => "- " + JSON.stringify(t)).join("\n"); + const changes = [added, removed, d.contentChanged ? "content changed" : "content unchanged"].filter(Boolean).join("\n"); + if (!confirm("Restore this list version?\n\n" + (changes || "No tag or content changes") + "\n\nIt will be signed and published as the newest version.")) return; + const signed = await signer.signEvent(preview.draft); const result = await bridge("/events", signed); if (!result.accepted) throw new Error(result.message || "The relay refused the restored list."); toast("List restored"); await loadListHistory(); await loadStorage(); diff --git a/src/gen/console.ts b/src/gen/console.ts index 0cae9bf..fb2d1d3 100644 --- a/src/gen/console.ts +++ b/src/gen/console.ts @@ -1,4 +1,4 @@ // Generated by scripts/build/build-console.mjs from src/console. Do not edit; run npm run build:console. export const CONSOLE_HTML = "
                                            \n
                                            \n
                                            \"\"
                                            \n
                                            \"\"

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

                                            Nobody owns this relay yet.

                                            \n

                                            Claim it and it's yours: you decide who can post, who can read, and what stays. One signature with a nostr browser extension; no account, no email, no card.

                                            \n
                                            \n
                                            \n\n
                                            \n

                                            A temporary relay, for now.

                                            \n

                                            Anyone can read and write here until . Then everything on it is deleted and the name is freed. Claim it and it stays, events and files included: one signature with a nostr browser extension. Or claim a new name and pull this one into it from its Storage tab.

                                            \n
                                            \n
                                            \n\n
                                            \n

                                            Connect a remote signer.

                                            \n

                                            Your key stays in a signer app such as Amber or nsec.app; this page asks it to sign. On a phone, open the link and approve there. On a computer, paste the bunker:// URL the app gives you.

                                            \n \n
                                            \n \"QR\n

                                            \n
                                            \n\n
                                            \n
                                            \n

                                            \n

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

                                            Git repositories, ntig

                                            \n
                                            \n

                                            Use ordinary Git to clone a repository hosted here.

                                            \n
                                            Clone a repository
                                            \n

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

                                            \n

                                            To host your own, use a Nostr Git client to publish your repository and signed branch state to this relay before pushing to the same remote. Git hosting guide.

                                            \n
                                            \n
                                            \n
                                            \n

                                            Open it in an app

                                            \n

                                            Relay apps open this relay as a place. Feed apps do not: they find the owner here through a profile link that carries this relay as the hint, and learn the relay from that. Either way, add under the app's relay settings to post here.

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

                                            People

                                            \n

                                            Hidden from visitors. Only you see this list.

                                            \n
                                            \n
                                            \n\n
                                            \n

                                            Fuel

                                            \n

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

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

                                            \n
                                            sats
                                            \n
                                            \n

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

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

                                            Your invites

                                            \n

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

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

                                              People

                                              \n

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

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

                                              Invites

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

                                                Joining

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

                                                Moderation

                                                \n

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

                                                \n
                                                \n

                                                Reports

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

                                                Log

                                                \n

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

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

                                                Bans

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

                                                    Blocked addresses

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

                                                      Recent events

                                                      \n

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

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

                                                      Pinned

                                                      \n

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

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

                                                        Rules

                                                        \n

                                                        Bans apply regardless of these.

                                                        \n
                                                        \n

                                                        Presets

                                                        \n

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

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

                                                        \n
                                                        \n
                                                        \n
                                                        \n

                                                        Writes

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

                                                        Reads

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

                                                        Kinds

                                                        \n

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

                                                        \n
                                                        \n

                                                        Allowed:

                                                        \n

                                                        Blocked:

                                                        \n
                                                        \n
                                                        \n

                                                        Features

                                                        \n

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

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

                                                        Blocked words

                                                        \n

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

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

                                                        Identity

                                                        \n
                                                        \n

                                                        Profile

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

                                                        For directories

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

                                                        Your own domain

                                                        \n

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

                                                        \n
                                                        \n
                                                        \n

                                                        \n
                                                        \n
                                                        \n

                                                        Your relay lists

                                                        \n

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

                                                        \n
                                                        \n
                                                        \n
                                                        \n

                                                        Share

                                                        \n

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

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

                                                        Data

                                                        \n

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

                                                        \n
                                                        \n

                                                        Recover your lists

                                                        \n

                                                        Older signed versions of your follows, relay lists and bookmarks stay private here. Restore a version to review it, then sign and publish it from this relay.

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

                                                        By kind

                                                        \n
                                                        KindCountSizeOldestKeep for
                                                        \n
                                                        \n
                                                        \n

                                                        Files

                                                        \n
                                                        TimeFileSizeUploader
                                                        \n
                                                        \n
                                                        \n

                                                        Sites

                                                        \n

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

                                                        \n
                                                        AuthorNameURLFilesSizeExpiry
                                                        \n
                                                        \n
                                                        \n

                                                        Dumps

                                                        \n

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

                                                        \n
                                                        \n
                                                          \n
                                                          \n
                                                          \n

                                                          Import a file

                                                          \n

                                                          A JSONL of events, one per line, such as a dump or a strfry export, or a JSON array. Up to 64 MB. Signatures are checked; bans and kind rules apply; the write rule does not. Progress shows under Jobs.

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

                                                          Sync

                                                          \n

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

                                                          \n
                                                          \n

                                                          Jobs

                                                          \n

                                                          Work the relay does on its own, one round at a time. A pull copies what another relay has and this one lacks. Fetch my history pulls your own events from the relays in your relay list. Rebroadcast sends what is here to other relays. Bans and kind rules apply to what arrives.

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

                                                          Fork this relay

                                                          \n

                                                          A new name, temporary until claimed, filled from this relay. Yours to split a name by job, or somebody else's to hand them a community with its history. One fork an hour.

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

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

                                                          Views

                                                          \n

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

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

                                                          Health

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

                                                          Zaps received

                                                          \n
                                                          WhenFromSats
                                                          \n
                                                          \n
                                                          \n

                                                          Notifications

                                                          \n

                                                          The relay writes you a private message (NIP-17) with its own key. It lands here, in your inbox on your own relay, and on your DM relays when this relay holds your kind 10050.

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

                                                          Owner

                                                          \n

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

                                                          \n
                                                          \n

                                                          Configuration

                                                          \n

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

                                                          \n
                                                          \n
                                                          \n
                                                          \n

                                                          Transfer ownership

                                                          \n

                                                          Hands the relay to a member. You stay on as a moderator. The relay's key, events, files and fuel do not change. There is no undo.

                                                          \n
                                                          \n
                                                          \n
                                                          \n

                                                          If I lose my key

                                                          \n

                                                          Name a member as your heir. If you do not sign in here for the time you pick, the relay writes to you once a week for a month, then hands itself to the heir and keeps you on as a moderator. Any signed action on the relay resets the clock.

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

                                                          \n
                                                          \n
                                                          \n

                                                          Delete this relay

                                                          \n

                                                          Deletes every event, file, member, invite and setting, closes every connection, and returns the name to unclaimed for anyone to take. There is no undo.

                                                          \n \n
                                                          \n
                                                          \n
                                                          \n\n \n
                                                          \n"; export const CONSOLE_CSS = "main { max-width: 64rem; }\n.mast { display: grid; grid-template-columns: 1fr auto; gap: 1.5rem 2rem; align-items: end; margin-bottom: 2rem; }\n.mast .wordmark { grid-column: 1 / -1; justify-self: center; margin-bottom: .5rem; }\n.mast .right { display: grid; gap: .7rem; justify-items: end; text-align: right; }\n.urlrow, .owner { display: inline-flex; align-items: center; gap: .4rem; color: var(--ink-2); }\n.owner b { color: var(--ink); } .owner em { font-style: normal; color: var(--forest); }\n.urlrow .ib, .owner .ib { width: 26px; height: 26px; margin-left: .2rem; box-shadow: 1px 1px 0 var(--ink); } .urlrow .ib svg, .owner .ib svg { width: 13px; height: 13px; }\n.care { display: flex; gap: .9rem; flex-wrap: wrap; }\n.care .g { display: grid; justify-items: center; gap: .3rem; width: 5.4rem; }\n.care .g i { display: grid; place-items: center; width: 44px; height: 44px; border: 2px solid var(--ink); border-radius: 10px; background: var(--paper); box-shadow: 2px 2px 0 var(--ink); position: relative; }\n.care .g i svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }\n.care .g.off i::after { content: \"\"; position: absolute; width: 3px; height: 40px; background: var(--ink); transform: rotate(45deg); border-radius: 2px; box-shadow: 0 0 0 2px var(--paper); }\n.care .g small { font: 500 10px/1.2 var(--mono); text-transform: uppercase; letter-spacing: .04em; color: var(--ink-2); text-align: center; }\n#unclaimed { background: var(--butter); }\n.doors { display: grid; grid-template-columns: repeat(4, 1fr); gap: .8rem; } @media (max-width: 52rem) { .doors { grid-template-columns: 1fr 1fr; } } @media (max-width: 30rem) { .doors { grid-template-columns: 1fr; } }\n.door { display: grid; grid-template-rows: auto 1fr; align-content: start; gap: .35rem; padding: .8rem .9rem; border: 2px solid var(--ink); border-radius: 12px; background: var(--paper); box-shadow: 3px 3px 0 var(--ink); min-width: 0; }\n.apps { display: grid; gap: 1.4rem; margin-top: 1rem; } .appgroup h4 { margin: 0 0 .2rem; font-size: 1rem; } .appgroup > .note { margin-top: 0; }\n.appgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: .8rem; } @media (max-width: 52rem) { .appgrid { grid-template-columns: 1fr 1fr; } } @media (max-width: 30rem) { .appgrid { grid-template-columns: 1fr; } }\n.app { display: grid; align-content: start; gap: .4rem; padding: .8rem .9rem; border: 2px solid var(--ink); border-radius: 12px; background: var(--paper); box-shadow: 3px 3px 0 var(--ink); min-width: 0; }\n.app-head { display: flex; align-items: baseline; gap: .5rem; flex-wrap: wrap; } .app-head b { font-size: 15px; } .app-head small { color: var(--ink-3); } .app p { margin: 0; font-size: 13px; color: var(--ink-2); }\n.app-acts { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .2rem; } .app-acts .btn { padding: .3rem .7rem; font-size: 13px; }\n.phones { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, max-content)); gap: .8rem; margin-top: 1rem; } .phones img { display: block; width: 150px; height: 150px; } .phones .door { justify-items: start; }\n.door small { font: 700 11px var(--sans); text-transform: uppercase; letter-spacing: .06em; color: var(--ink-2); }\n.door .v { display: grid; grid-template-columns: 1fr auto; align-items: start; gap: .5rem; font: 500 13px/1.45 var(--mono); } .door .v span { min-width: 0; overflow-wrap: anywhere; }\n.door .ib { width: 24px; height: 24px; box-shadow: 1px 1px 0 var(--ink); flex: 0 0 auto; } .door .ib svg { width: 12px; height: 12px; }\n#peoplesec { background: var(--peach); }\n.dir { display: flex; flex-wrap: wrap; gap: .5rem; }\n.who { display: inline-flex; align-items: center; gap: .1rem; padding: .3rem .7rem .3rem .4rem; border: 2px solid var(--ink); border-radius: 999px; background: var(--paper); box-shadow: 2px 2px 0 var(--ink); font-size: 14px; }\n.who .role { font: 700 10px var(--mono); text-transform: uppercase; letter-spacing: .05em; color: var(--forest); margin-left: .4rem; }\n.who.me { background: var(--sun); }\n#fuelsec { background: var(--mint); }\n.gauges { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }\n.gauge small { display: block; font-weight: 600; font-size: 13px; }\n.gauge .bar { position: relative; height: 22px; border: 2px solid var(--ink); border-radius: 999px; margin: .5rem 0 .35rem; overflow: hidden; padding: 3px; background: var(--paper) linear-gradient(90deg, transparent calc(25% - 1px), var(--line) calc(25% - 1px) 25%, transparent 25% calc(50% - 1px), var(--line) calc(50% - 1px) 50%, transparent 50% calc(75% - 1px), var(--line) calc(75% - 1px) 75%, transparent 75%); }\n.gauge .bar b { position: absolute; right: .6rem; top: 0; bottom: 0; display: flex; align-items: center; font: 600 11px var(--mono); color: var(--ink-3); pointer-events: none; }\n.gauge .bar i { display: block; height: 100%; width: 0; min-width: 14px; border-radius: 999px; background: repeating-linear-gradient(-45deg, var(--forest) 0 8px, var(--forest-2) 8px 16px); transition: width .4s; }\n.gauge .bar i.warm { background: repeating-linear-gradient(-45deg, #d9a52a 0 8px, var(--sun) 8px 16px); }\n.gauge .bar i.over { background: repeating-linear-gradient(-45deg, var(--red) 0 8px, #d4614c 8px 16px); }\n.gauge span { font-size: 13px; color: var(--ink-3); }\n.balance { margin: 1.2rem 0 0; color: var(--ink-2); } .balance b { color: var(--ink); font-weight: 700; }\n.sats { display: inline-flex; align-items: center; width: auto; } .sats input { font: 15px var(--sans); width: 6rem; text-align: right; border: 0; outline: none; padding: 0; background: transparent; color: var(--ink); } .sats span { color: var(--ink-3); padding-left: .4rem; }\n.topup { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; margin-top: .8rem; }\n.invoice { margin-top: 1rem; border: 2px dashed var(--ink); border-radius: 12px; padding: .9rem 1rem; background: var(--paper); }\n.invoice p { margin: 0 0 .6rem; color: var(--ink-2); font-size: 14px; } .invoice .state { font-size: 13px; color: var(--forest); }\n.invoice textarea { margin-top: .7rem; min-height: 3.4rem; font-family: var(--mono); font-size: 11.5px; color: var(--ink-3); }\ndetails.disclosure summary { cursor: pointer; font: 400 1.6rem/1 var(--display); list-style: none; display: flex; align-items: center; gap: .5rem; }\ndetails.disclosure summary::-webkit-details-marker { display: none; }\n.folds details + details, .folds #frontsec:not(.hidden) + details { border-top: 2px solid var(--line); margin-top: 1.2rem; padding-top: 1.2rem; } .folds #frontsec .metarow { margin-bottom: 0; } .folds .doors { margin-top: 1rem; } .folds > details > .block:first-of-type { margin-top: 1.4rem; }\ndetails.disclosure summary::before { content: \"+\"; font: 700 1.4rem var(--mono); width: 1.4rem; } details.disclosure[open] summary::before { content: \"–\"; }\n.about { display: grid; grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); gap: .6rem 1.2rem; margin-top: 1rem; font-size: 14px; }\n.about small { display: block; color: var(--ink-3); font-size: 12px; } .about div > div { overflow-wrap: anywhere; }\n.console { margin-top: 3rem; }\n.tabs { display: flex; gap: .45rem; flex-wrap: nowrap; align-items: flex-end; margin: 0 0 -2px 1rem; padding: 2px 3rem 2px 0; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; }\n.tabs.fade-r { -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 2.5rem), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 2.5rem), transparent); }\n.tabs.fade-l { -webkit-mask-image: linear-gradient(90deg, transparent, #000 2.5rem); mask-image: linear-gradient(90deg, transparent, #000 2.5rem); }\n.tabs.fade-l.fade-r { -webkit-mask-image: linear-gradient(90deg, transparent, #000 2.5rem, #000 calc(100% - 2.5rem), transparent); mask-image: linear-gradient(90deg, transparent, #000 2.5rem, #000 calc(100% - 2.5rem), transparent); } .tabs::-webkit-scrollbar { display: none; } .tabs a { flex: 0 0 auto; white-space: nowrap; }\n.tabs a { font: 600 14px var(--sans); padding: .55rem 1rem; border: 2px solid var(--ink); border-bottom: 0; border-radius: 12px 12px 0 0; background: var(--head); color: var(--ink-2); text-decoration: none; margin-bottom: -2px; position: relative; z-index: 1; }\n.tabs a.on { color: var(--ink); z-index: 3; padding-top: .7rem; } .tabs a.on:nth-child(4n+1) { background: var(--sun); } .tabs a.on:nth-child(4n+2) { background: var(--peach); } .tabs a.on:nth-child(4n+3) { background: var(--sky); } .tabs a.on:nth-child(4n+4) { background: var(--mint); }\n.count { display: inline-grid; place-items: center; min-width: 1.5em; height: 1.5em; padding: 0 .4em; margin-left: .45rem; border: 1.5px solid var(--ink); border-radius: 999px; background: var(--sun); color: var(--ink); font: 700 11px/1 var(--mono); vertical-align: middle; box-shadow: 1px 1px 0 var(--ink); } .count:empty { display: none; } h2 .count { font-size: 12px; vertical-align: .45em; }\n.panel { display: none; margin: 0; border-radius: 0 18px 18px 18px; position: relative; z-index: 2; }\n.panel.on { display: block; }\n.panel h2 { font-size: 2rem; }\n.block { margin-top: 1.8rem; }\n.two { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; } .two > * { min-width: 0; }\n.actions { display: flex; justify-content: flex-end; margin-top: 1.1rem; } .actions.left { justify-content: flex-start; }\n.addrow { display: flex; gap: .5rem; align-items: center; margin-bottom: .9rem; } .addrow input { flex: 1; min-width: 0; } .addrow label { display: inline-flex; align-items: center; gap: .35rem; flex: 0 0 auto; white-space: nowrap; font-size: 14px; color: var(--ink-2); cursor: pointer; } .addrow label input { flex: none; width: auto; margin: 0; accent-color: var(--forest); } .addrow .ib { width: 38px; height: 38px; flex: 0 0 auto; border-radius: 10px; }\n.choices { display: grid; gap: .5rem; }\n.choices label { display: grid; grid-template-columns: auto 1fr; gap: 0 .7rem; cursor: pointer; padding: .7rem .8rem; border: 2px solid var(--line-2); border-radius: 12px; background: var(--paper); }\n.choices label:has(:checked) { border-color: var(--ink); box-shadow: 3px 3px 0 var(--ink); }\n.choices input { grid-row: span 2; margin: .2rem 0 0; accent-color: var(--forest); }\n.choices b { font-weight: 600; } .choices small { color: var(--ink-2); font-size: 13px; }\n.limits { display: grid; grid-template-columns: 1fr 1fr; gap: .6rem 1.5rem; margin-top: 1.3rem; }\n.limits label { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: .6rem; } .limits label small { color: var(--ink-3); }\n.form { display: grid; grid-template-columns: 1fr 1fr; gap: .9rem 1.5rem; } .form label { display: grid; gap: .3rem; font-weight: 600; font-size: 14px; } .form .wide { grid-column: 1 / -1; }\n.danger-zone { border: 2px solid var(--red); border-radius: 12px; padding: 1rem 1.1rem; background: var(--red-soft); } .danger-zone h3 { color: var(--red); } .form label.switch { display: flex; flex-direction: row; align-items: center; gap: .6rem; font-weight: 600; font-size: 14px; } .switch input { accent-color: var(--forest); width: 18px; height: 18px; }\ntable { width: 100%; border-collapse: collapse; }\nth { text-align: left; font-size: 12px; font-weight: 600; color: var(--ink-3); padding: .4rem .5rem; border-bottom: 2px solid var(--ink); white-space: nowrap; }\ntd { padding: .5rem .5rem; vertical-align: middle; border-bottom: 1px solid var(--line); }\ntbody tr:nth-child(even) td { background: rgba(255,255,255,.55); }\nth.r, td.r { text-align: right; white-space: nowrap; } td.mono, td.dim { white-space: nowrap; } .events td.c:empty::before { content: \"(no content)\"; color: var(--ink-3); }\ntd .ib { width: 26px; height: 26px; box-shadow: 1px 1px 0 var(--ink); } td .ib svg { width: 13px; height: 13px; }\ntd input.txt { padding: .25rem .5rem; font-size: 14px; } td select.role { width: auto; min-width: 7rem; padding: .25rem .4rem; font-size: 13px; } .people-table input.name { width: 7rem; } .people-table input.note { width: 9.5rem; }\n.kind { font-family: var(--mono); font-size: 12px; background: var(--sun); border: 1.5px solid var(--ink); padding: 0 .5rem; border-radius: 999px; font-weight: 500; white-space: nowrap; }\n.events td.c { max-width: 0; width: 100%; overflow-x: auto; white-space: nowrap; scrollbar-width: thin; -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 2rem), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 2rem), transparent); }\n.people-table td.name { width: 12rem; }\n.ev { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 4px; vertical-align: -5px; margin-right: .45rem; font: 500 9px var(--mono); background: var(--line); color: var(--ink-2); font-style: normal; }\n.plain { list-style: none; margin: 0; padding: 0; } .plain li { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: .5rem; padding: .5rem 0; border-bottom: 2px dotted var(--line-2); } .plain li > span { min-width: 0; overflow-wrap: anywhere; } .plain li:last-child { border-bottom: 0; } .plain li.empty { grid-template-columns: 1fr; color: var(--ink-3); }\n.kindline { margin: .4rem 0; color: var(--ink-2); display: flex; flex-wrap: wrap; align-items: center; gap: .35rem; }\n.tag { display: inline-flex; align-items: center; gap: .2rem; font-family: var(--mono); font-size: 13px; padding: .05rem .2rem .05rem .55rem; border: 2px solid var(--ink); border-radius: 999px; background: var(--paper); box-shadow: 1px 1px 0 var(--ink); }\n.tag.plain { padding-right: .55rem; box-shadow: none; border-color: var(--line-2); color: var(--ink-3); } .tag.blk { color: var(--red); }\n.tag .ib { width: 18px; height: 18px; border-width: 1.5px; box-shadow: none; margin-left: .2rem; } .tag .ib svg { width: 9px; height: 9px; }\n.counters { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1.2rem; margin-bottom: 1.6rem; }\n.counter { display: grid; justify-items: center; align-content: center; gap: .15rem; min-height: 7.2rem; text-align: center; padding: 1rem .8rem; border: 2px solid var(--ink); border-radius: 16px; box-shadow: 4px 4px 0 var(--ink); background: var(--paper); }\n.counter:nth-child(1) { background: var(--sun); } .counter:nth-child(2) { background: var(--peach); } .counter:nth-child(3) { background: var(--sky); } .counter:nth-child(4) { background: var(--mint); }\n.counter .big { font: 400 2.6rem/1 var(--display); white-space: nowrap; } .counter small { display: block; margin-top: .35rem; font: 700 12px var(--sans); text-transform: uppercase; letter-spacing: .08em; } .counter .sub { font-size: 12px; color: var(--ink-2); }\n.kbar { display: flex; width: 100%; height: 22px; border: 2px solid var(--ink); border-radius: 999px; overflow: hidden; background: var(--paper); margin-bottom: .35rem; } .kbar i { display: block; height: 100%; border-right: 2px solid var(--ink); } .kbar i:last-child { border-right: 0; }\n.k1 { background: var(--ink); } .k2 { background: var(--forest); } .k3 { background: var(--id-3); } .k4 { background: var(--id-2); } .k5 { background: var(--line-2); }\n.legend { display: flex; flex-wrap: wrap; justify-content: center; gap: .1rem .6rem; font-family: var(--mono); font-size: 10.5px; text-transform: uppercase; } .legend i { display: inline-block; width: 9px; height: 9px; border: 1.5px solid var(--ink); border-radius: 2px; margin-right: .3rem; vertical-align: -1px; }\n.usage { display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: .8rem; } .usage div { padding: .7rem .9rem; border: 2px solid var(--ink); border-radius: 12px; background: var(--paper); } .usage small { display: block; color: var(--ink-3); font-size: 12px; } .usage b { font: 400 1.5rem/1.2 var(--display); }\n@media (max-width: 52rem) { .two, .gauges, .limits, .form, .counters { grid-template-columns: 1fr !important; } .addrow { flex-wrap: wrap; } .mast { grid-template-columns: 1fr; } .mast .right { justify-items: start; text-align: left; } }\n@media (max-width: 40rem) { .events:not(#kinds):not(#jobs) th:first-child, .events:not(#kinds):not(#jobs) td:first-child { display: none; } }\n\n.slegend { justify-content: flex-start; margin: 0 0 1.2rem; }\n#s-totals { margin-bottom: .4rem; }\ntd.keep { white-space: nowrap; } td.keep input { width: 5.5rem; display: inline-block; margin-right: .3rem; } td.keep input::placeholder { color: var(--ink-3); }\n#kinds tr.any td:first-child { font-weight: 600; }\n#kinds td:first-child .kind { display: inline-block; min-width: 5.2em; text-align: center; margin-right: .35rem; }\n/* Tables keep their columns and scroll inside the card on narrow screens instead of pushing the page sideways. */\n.scroll { overflow-x: auto; max-width: 100%; scrollbar-width: thin; } .scroll table { min-width: 100%; }\nsection, .card { overflow-x: clip; }\n.kind.sys { background: var(--mint); border-color: var(--forest); color: var(--forest); }\ntd.keep.sys { color: var(--ink-3); font-size: 13px; white-space: normal; }\n\n#console.mod .tabs a:not([data-tab=people]):not([data-tab=moderation]) { display: none; } #console.mod #thresholdform { display: none; }\n#members select.role { width: auto; padding: .1rem .3rem; font-size: 12px; margin-left: .3rem; }\n.wire-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .5rem 1.2rem; padding: .8rem 0; border-bottom: 1px solid var(--line); align-items: start; } .wire-row:first-child { border-top: 2px solid var(--ink); }\n.wire-main { min-width: 0; } .wire-side { display: grid; justify-items: end; gap: .45rem; max-width: 26rem; }\n.wire-acts { display: flex; gap: .4rem; white-space: nowrap; } .wire-acts .btn { padding: .35rem .75rem; font-size: 13px; }\n.wire-meta { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .35rem .5rem; align-items: center; font-size: 13px; color: var(--ink-2); text-align: right; } .wire-meta:empty { display: none; }\n.pill { display: inline-block; padding: .1rem .6rem; border: 1.5px solid var(--ink); border-radius: 999px; font: 600 12px var(--sans); color: var(--ink); background: var(--paper); } .pill.on { background: var(--mint); } .pill.off { color: var(--ink-3); } .pill.bad { color: var(--red); }\n.chip { display: inline-block; padding: .05rem .45rem; border: 1px solid var(--forest); border-radius: 6px; font: 12px var(--mono); color: var(--forest); } .chip.bad { border-color: var(--red); color: var(--red); }\n.share { display: grid; grid-template-columns: minmax(0, 3fr) minmax(14rem, 2fr); gap: 1.4rem; align-items: start; } .share #cardimg { width: 100%; height: auto; display: block; }\n.share-side { display: grid; gap: .8rem; justify-items: start; } .naddr { display: block; font-size: 12px; line-height: 1.5; word-break: break-all; color: var(--ink-2); }\n@media (max-width: 52rem) { .share { grid-template-columns: 1fr; } .wire-side { display: contents; } .wire-acts { justify-self: end; } .wire-meta { grid-column: 1 / -1; justify-content: flex-start; text-align: left; } }\n#presets .btn { margin: 0 .4rem .4rem 0; }\n.panel form h3.gap { margin-top: 1.4rem; }\n.key { position: relative; display: inline-block; font-family: var(--mono); } .key::before { content: attr(data-short); } .key .full { position: absolute; left: 0; top: 0; width: 1px; height: 1px; overflow: hidden; opacity: 0; white-space: nowrap; }\n.mast .banner { grid-column: 1 / -1; width: 100%; aspect-ratio: 4 / 1; overflow: hidden; border: 2px solid var(--ink); border-radius: 18px; box-shadow: 4px 4px 0 var(--ink); background: var(--paper); margin-bottom: .5rem; } .mast .banner img { width: 100%; height: 100%; object-fit: cover; display: block; }\n.mast .wordmark { display: flex; align-items: center; gap: 1rem; } .mast .icon { width: 64px; height: 64px; border-radius: 50%; border: 2px solid var(--ink); box-shadow: 3px 3px 0 var(--ink); background: var(--paper); object-fit: cover; }\n.metarow { display: flex; flex-wrap: wrap; gap: .4rem .6rem; align-items: center; margin: -.4rem 0 1rem; font-size: 13px; color: var(--ink-2); }\n.metarow .tag { display: inline-block; padding: .1rem .6rem; border: 1.5px solid var(--ink); border-radius: 999px; font: 600 12px var(--sans); color: var(--ink); background: var(--sun); }\n.metarow .sep { color: var(--ink-3); } .metarow a { color: var(--forest); text-decoration: none; border-bottom: 1.5px solid var(--forest); } .metarow a:hover { border-bottom-width: 2px; }\n.about a { color: var(--forest); text-decoration: none; border-bottom: 1.5px solid var(--forest); }\n.wire-acts label { display: inline-flex; align-items: center; gap: .35rem; font-size: 14px; color: var(--ink-2); cursor: pointer; } .wire-acts label input { margin: 0; accent-color: var(--forest); } .wire-row.dim .wire-main { color: var(--ink-3); }\n"; -export const CONSOLE_JS = "(async () => {\n const $ = (s) => document.querySelector(s);\n const $$ = (s) => document.querySelectorAll(s);\n const host = location.host;\n const wsURL = (location.protocol === \"https:\" ? \"wss://\" : \"ws://\") + host;\n const rpcURL = location.origin + \"/\";\n let info = null, me = null, owner = \"\", policy = null, fuel = null, people = null, myRole = \"\";\n\n const toast = (msg) => { const t = $(\"#toast\"); t.textContent = msg; t.classList.add(\"show\"); clearTimeout(t._t); t._t = setTimeout(() => t.classList.remove(\"show\"), 2600); };\n // guard wraps a handler: disables the button, shows errors as toasts. Defined first, since handlers below use it.\n const guard = (fn) => async (ev) => { ev.preventDefault(); const b = ev.submitter || ev.target; if (b) b.disabled = true; try { await fn(ev); } catch (e) { toast(e.message); } finally { if (b) b.disabled = false; } };\n const short = (hex) => hex ? hex.slice(0, 8) + \"…\" + hex.slice(-4) : \"\";\n // key shows a key short but keeps the whole of it in the DOM: selecting, double-clicking or clicking it copies the full hex.\n const key = (hex) => hex ? '' + hex + '' : '';\n const hue = (hex) => (parseInt(hex.slice(0, 2), 16) * 360 / 256).toFixed(0) + \"deg\";\n const av = (hex) => '';\n const fmtBytes = (n) => n < 1e6 ? (n / 1e3).toFixed(0) + \" KB\" : n < 1e9 ? (n / 1e6).toFixed(1) + \" MB\" : (n / 1e9).toFixed(2) + \" GB\";\n const fmtHours = (ms) => ms < 3600e3 ? Math.round(ms / 60e3) + \" min\" : (ms / 3600e3).toFixed(ms < 36e6 ? 1 : 0) + \" h\";\n const fuelOver = () => !!fuel && (fuel.eventBytes > fuel.freeEventBytes || fuel.mediaBytes > fuel.freeMediaBytes || fuel.activeMs > fuel.freeActiveMs || fuel.rowsWritten > fuel.freeRowsWritten);\n const fmtTime = (t) => t ? new Date(t * 1000).toLocaleString(undefined, { month: \"short\", day: \"numeric\", hour: \"2-digit\", minute: \"2-digit\" }) : \"–\";\n const fmtDay = (t) => t ? new Date(t * 1000).toLocaleDateString(undefined, { month: \"short\", day: \"numeric\" }) : \"–\";\n const ago = (t) => { const s = Math.max(0, Math.floor(Date.now() / 1000) - t); return s < 60 ? \"just now\" : s < 3600 ? Math.floor(s / 60) + \" min\" : s < 86400 ? Math.floor(s / 3600) + \" h\" : Math.floor(s / 86400) + \" d\"; };\n const esc = (s) => String(s ?? \"\").replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c]);\n\n const IC = {\n x: '',\n undo: '',\n check: '',\n trash: '',\n ban: '',\n banuser: '',\n eye: '',\n lock: '',\n pen: '',\n people: '',\n person: '',\n gauge: '',\n bolt: '',\n copy: '',\n pin: '',\n };\n const ib = (icon, label, act, id, extra) => '\";\n\n const CH = \"qpzry9x8gf2tvdw0s3jn54khce6mua7l\";\n function npubToHex(s) {\n s = s.trim().toLowerCase();\n if (/^[0-9a-f]{64}$/.test(s)) return s;\n if (!s.startsWith(\"npub1\")) return null;\n const data = s.slice(5, -6).split(\"\").map((c) => CH.indexOf(c));\n if (data.some((d) => d < 0)) return null;\n let bits = 0, acc = 0, out = [];\n for (const d of data) { acc = (acc << 5) | d; bits += 5; if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 255); } }\n return out.length === 32 ? out.map((b) => b.toString(16).padStart(2, \"0\")).join(\"\") : null;\n }\n\n async function sha256hex(s) {\n const b = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(s));\n return [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, \"0\")).join(\"\");\n }\n // ---- signing: a NIP-07 extension, or a NIP-46 remote signer ----\n // The remote path loads the bundled library on first use; its session\n // (client key, signer pubkey, relays) lives in localStorage until sign-out.\n let remote = null, lib = null, pendingNote = null;\n const NO_SIGNER = \"Install a nostr extension (Alby, nos2x, …) or connect a remote signer.\";\n const withTimeout = (p, ms, what) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(what + \" did not answer; is the signer app open?\")), ms))]);\n const signer = {\n ready: () => !!remote || !!window.nostr,\n async getPublicKey() { if (remote) return withTimeout(remote.getPublicKey(), 20000, \"The remote signer\"); if (window.nostr) return window.nostr.getPublicKey(); throw new Error(NO_SIGNER); },\n async signEvent(ev) { if (remote) return withTimeout(remote.signEvent(ev), 60000, \"The remote signer\"); if (window.nostr) return window.nostr.signEvent(ev); throw new Error(NO_SIGNER); },\n };\n async function signerLib() {\n if (lib) return lib;\n if (!window.NostrSigner) await new Promise((res, rej) => { const s = document.createElement(\"script\"); s.src = window.SIGNER_URL || \"/signer.js\"; s.onload = res; s.onerror = () => rej(new Error(\"Could not load the signer library.\")); document.head.appendChild(s); });\n return (lib = window.NostrSigner);\n }\n const onauth = (u) => window.open(u, \"_blank\");\n // The signer conversation rides this relay, and a relay may ask the socket to\n // authenticate before it delivers kind 24133 to its parties; answer with the\n // session's own key.\n const authPool = (L, sk) => new L.SimplePool({ automaticallyAuth: () => (evt) => Promise.resolve(L.finalizeEvent(evt, sk)) });\n const saveSession = (sk, s, secret) => localStorage.setItem(\"nip46\", JSON.stringify({ sk: lib.bytesToHex(sk), pubkey: s.bp.pubkey, relays: s.bp.relays, secret: secret || null }));\n async function resumeRemote() {\n const raw = localStorage.getItem(\"nip46\");\n if (!raw) return false;\n try {\n const s = JSON.parse(raw); const L = await signerLib();\n remote = L.BunkerSigner.fromBunker(L.hexToBytes(s.sk), { pubkey: s.pubkey, relays: s.relays, secret: s.secret }, { onauth, pool: authPool(L, L.hexToBytes(s.sk)) });\n return true;\n } catch { localStorage.removeItem(\"nip46\"); return false; }\n }\n async function connectBunker(input) {\n const L = await signerLib();\n const bp = await L.parseBunkerInput(input.trim());\n if (!bp) throw new Error(\"That is not a bunker:// URL.\");\n if (!bp.relays.length) throw new Error(\"The bunker URL names no relay.\");\n const sk = L.generateSecretKey();\n const s = L.BunkerSigner.fromBunker(sk, bp, { onauth, pool: authPool(L, sk) });\n await withTimeout(s.connect({ name: host, url: location.origin }), 60000, \"The signer\");\n await Promise.race([s.switchRelays(), new Promise((r) => setTimeout(r, 3000))]);\n remote = s; saveSession(sk, s, bp.secret);\n }\n // The nostrconnect:// flow: this relay carries the traffic, so no third relay is involved.\n let ncPending = null;\n async function offerNostrConnect() {\n if (ncPending) return ncPending;\n const L = await signerLib();\n const sk = L.generateSecretKey();\n const secret = L.bytesToHex(crypto.getRandomValues(new Uint8Array(8)));\n const uri = L.createNostrConnectURI({ clientPubkey: L.getPublicKey(sk), relays: [wsURL], secret, name: host, url: location.origin, perms: [\"sign_event:27235\", \"sign_event:9734\"] });\n $(\"#nclink\").href = uri; $(\"#ncnote\").textContent = \"\";\n $(\"#ncqr\").src = \"/qr.svg?text=\" + encodeURIComponent(uri); $(\"#ncqr\").classList.remove(\"hidden\");\n ncPending = L.BunkerSigner.fromURI(sk, uri, { onauth, pool: authPool(L, sk) }, 600000).then((s) => { remote = s; saveSession(sk, s, secret); ncPending = null; return s; }, (e) => { ncPending = null; throw e; });\n return ncPending;\n }\n async function remoteDone() {\n me = await signer.getPublicKey();\n localStorage.setItem(\"me\", me);\n $(\"#remotesec\").classList.add(\"hidden\");\n toast(\"Remote signer connected\");\n if (pendingNote && !owner) { const n = pendingNote; pendingNote = null; await claimNow(n); return; }\n pendingNote = null;\n renderHeader(); await loadAdmin(); await loadPeople();\n }\n function showRemote(note) {\n pendingNote = note || null;\n $(\"#remotesec\").classList.remove(\"hidden\");\n $(\"#remotesec\").scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n offerNostrConnect().then(remoteDone).catch((e) => { $(\"#ncnote\").textContent = e.message; });\n }\n\n async function rpc(method, ...params) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const body = JSON.stringify({ method, params });\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", rpcURL], [\"method\", \"POST\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(rpcURL, { method: \"POST\", headers: { \"content-type\": \"application/nostr+json+rpc\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body });\n const json = await resp.json();\n if (json.error) throw new Error(json.error);\n return json.result;\n }\n\n // ---- front of house ----\n // urlish shows a URL as a short link: the host and the start of the path, the whole thing on hover and as the target.\n class Html { constructor(s) { this.s = s; } }\n const urlish = (u) => {\n if (!u) return \"\";\n let label = u; try { const x = new URL(u); label = x.host + (x.pathname.length > 1 ? x.pathname.slice(0, 18) + (x.pathname.length > 18 ? \"\\u2026\" : \"\") : \"\"); } catch { /* not a URL */ }\n if (!/^https?:\\/\\//.test(u)) return u;\n return new Html('' + esc(label) + \"\");\n };\n function renderHeader() {\n const name = (info && info.name) || host.split(\".\")[0];\n $(\"#title\").textContent = name;\n document.title = name + \" - relay\";\n $(\"#url\").textContent = wsURL;\n $(\"#d-ws\").textContent = wsURL; $(\"#d-blossom\").textContent = location.origin; $(\"#d-nip05\").textContent = \"you@\" + host;\n $(\"#nip05-example\").textContent = \"alice@\" + host;\n const desc = (info && info.description) || \"\";\n $(\"#desc\").textContent = desc; $(\"#desc\").classList.toggle(\"hidden\", !desc);\n // What the owner declared about the relay, on the page, not only in the document clients read.\n const img = (id, url) => { const el = $(id); if (url) el.src = url; el.classList.toggle(\"hidden\", !url); };\n img(\"#bannerimg\", info?.banner); $(\"#banner\").classList.toggle(\"hidden\", !info?.banner);\n img(\"#iconimg\", info?.icon);\n const meta = [];\n for (const t of info?.tags || []) meta.push('' + esc(t) + \"\");\n const where = [...(info?.language_tags || []), ...(info?.relay_countries || [])];\n if (where.length) meta.push(\"\" + esc(where.join(\", \")) + \"\");\n const link = (label, href) => href ? '' + label + \"\" : \"\";\n const contact = info?.contact ? (/^(mailto:|https?:\\/\\/)/.test(info.contact) ? link(\"Contact\", info.contact) : \"\" + esc(info.contact) + \"\") : \"\";\n for (const x of [contact, link(\"Terms\", info?.terms_of_service), link(\"Posting policy\", info?.posting_policy), link(\"Privacy\", info?.privacy_policy)]) if (x) meta.push(x);\n $(\"#metarow\").innerHTML = meta.join('|'); $(\"#metarow\").classList.toggle(\"hidden\", meta.length === 0);\n $(\"#frontsec\").classList.toggle(\"hidden\", !desc && meta.length === 0);\n owner = (info && info.pubkey) || \"\";\n const isOwner = !!me && me === owner;\n const lease = !owner && info && info.lease ? info.lease : null;\n $(\"#ownerline\").innerHTML = owner ? 'run by ' + key(owner) + \"\" : lease ? \"temporary until \" + fmtDay(lease.expires_at) : \"unclaimed\";\n $(\"#owner-av\").classList.toggle(\"hidden\", !owner);\n if (owner) $(\"#owner-av\").style.setProperty(\"--h\", hue(owner));\n $(\"#who\").textContent = me ? (isOwner ? \"(that's you)\" : \"\") : \"\";\n $(\"#unclaimed\").classList.toggle(\"hidden\", !!owner || !!lease);\n $(\"#leased\").classList.toggle(\"hidden\", !lease);\n if (lease) {\n $(\"#lease-until\").textContent = fmtTime(lease.expires_at);\n $(\"#leasenote\").textContent = lease.holder ? \"Reserved for the key that asked for it; sign with that key.\" : \"\";\n }\n $(\"#signin\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signin46\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signout\").classList.toggle(\"hidden\", !me);\n $(\"#about\").innerHTML = [\n [\"Name\", info?.name], [\"Description\", info?.description], [\"Contact\", info?.contact], [\"Owner\", owner ? short(owner) : \"\"], [\"Relay key\", info?.self ? short(info.self) : \"\"], [\"Software\", urlish(info?.software)], [\"Version\", info?.version],\n [\"Max query\", info?.limitation?.max_limit], [\"Auth required\", String(!!info?.limitation?.auth_required)], [\"Restricted writes\", String(!!info?.limitation?.restricted_writes)], [\"Min PoW\", info?.limitation?.min_pow_difficulty || 0],\n [\"Tags\", info?.tags?.join(\", \")], [\"Languages\", info?.language_tags?.join(\", \")], [\"Countries\", info?.relay_countries?.join(\", \")],\n [\"Terms\", urlish(info?.terms_of_service)], [\"Posting policy\", urlish(info?.posting_policy)], [\"Privacy policy\", urlish(info?.privacy_policy)], [\"Icon\", urlish(info?.icon)], [\"Banner\", urlish(info?.banner)],\n [\"NIPs\", info?.supported_nips?.join(\" \")],\n ].map(([k, v]) => \"
                                                          \" + k + \"\" + (v === undefined || v === \"\" ? '' : v instanceof Html ? v.s : esc(v)) + \"
                                                          \").join(\"\");\n renderCare();\n }\n\n function renderCare() {\n const g = (icon, label, off) => '' + IC[icon] + \"\" + label + \"\";\n const writes = policy ? policy.writes : (info?.limitation?.restricted_writes ? \"allowlist\" : \"open\");\n const reads = policy ? policy.reads : (info?.limitation?.auth_required ? \"auth\" : \"open\");\n let out = writes === \"owner\" ? g(\"person\", \"only owner writes\") : writes === \"allowlist\" ? g(\"people\", \"members write\") : writes === \"wot\" ? g(\"people\", \"members and follows write\") : g(\"pen\", \"anyone writes\");\n out += reads === \"members\" ? g(\"people\", \"members read\") : reads === \"auth\" ? g(\"lock\", \"sign in to read\") : g(\"eye\", \"anyone reads\");\n if (fuel) {\n const over = fuelOver();\n out += fuel.outOfFuel ? g(\"gauge\", \"out of fuel\", true) : over ? g(\"bolt\", \"burning sats\") : g(\"gauge\", \"on free allowance\");\n }\n $(\"#care\").innerHTML = out;\n }\n\n async function loadPeople() {\n try { people = await (await fetch(\"/people\")).json(); } catch { return; }\n const isOwner = !!me && me === owner;\n let list = people.people || [];\n if (!people.public && isOwner && policy) list = (window.__members || []);\n $(\"#peoplesec\").classList.toggle(\"hidden\", !owner || list.length === 0);\n $(\"#people-note\").classList.toggle(\"hidden\", people.public);\n $(\"#people-count\").textContent = list.length || \"\";\n $(\"#dir\").innerHTML = list.map((m) => '' + av(m.pubkey) + '' + (m.name ? esc(m.name) + \"@\" + host : key(m.pubkey)) + \"\" + (m.role === \"owner\" ? 'owner' : \"\") + \"\").join(\"\");\n }\n\n async function loadInfo() {\n info = await (await fetch(\"/\", { headers: { accept: \"application/nostr+json\" } })).json();\n renderHeader();\n const git = info.supported_grasps?.includes(\"GRASP-01\");\n $(\"#git-connect\").classList.toggle(\"hidden\", !git);\n const clone = \"git clone '\" + location.origin + \"//.git'\";\n $(\"#git-clone\").textContent = clone;\n $(\"#git-copy\").dataset.copytext = clone;\n renderApps();\n }\n\n async function loadFuel() {\n try { fuel = await (await fetch(\"/fuel\")).json(); } catch { return; }\n $(\"#fuelsec\").classList.toggle(\"hidden\", !owner);\n const pct = (used, free) => Math.min(100, Math.round((used / Math.max(free, 1)) * 100));\n const gauge = (id, used, free, text) => { const g = $(\"#g-\" + id), share = free ? used / free : 0; g.style.width = pct(used, free) + \"%\"; g.classList.toggle(\"over\", share > 1); g.classList.toggle(\"warm\", share > 0.75 && share <= 1); $(\"#p-\" + id).textContent = share > 1 ? \"over\" : Math.round(share * 100) + \"%\"; $(\"#t-\" + id).textContent = text; };\n gauge(\"events\", fuel.eventBytes, fuel.freeEventBytes, fmtBytes(fuel.eventBytes) + \" of \" + fmtBytes(fuel.freeEventBytes) + \" free\");\n gauge(\"media\", fuel.mediaBytes, fuel.freeMediaBytes, fmtBytes(fuel.mediaBytes) + \" of \" + fmtBytes(fuel.freeMediaBytes) + \" free\");\n gauge(\"active\", fuel.activeMs, fuel.freeActiveMs, fmtHours(fuel.activeMs) + \" of \" + fmtHours(fuel.freeActiveMs) + \" free\");\n gauge(\"rows\", fuel.rowsWritten, fuel.freeRowsWritten, fuel.rowsWritten.toLocaleString() + \" of \" + fuel.freeRowsWritten.toLocaleString() + \" free\");\n const sats = Math.floor(fuel.balanceMsats / 1000);\n const r = fuel.rates;\n $(\"#fuel-balance\").innerHTML = fuel.outOfFuel\n ? 'out of fuel Writes are paused until someone tops up.'\n : \"Balance \" + sats.toLocaleString() + \" sats. Past the allowances, prices track what the hosting costs: \" + r.satsPerGBMonthEvents.toLocaleString() + \" sats per GB-month of events, \" + r.satsPerGBMonthMedia.toLocaleString() + \" per GB-month of files, \" + r.satsPerActiveHour.toLocaleString() + \" per hour awake, \" + r.satsPerMillionRows.toLocaleString() + \" per million rows written. Traffic is free.\";\n $(\"#topup\").classList.toggle(\"hidden\", !fuel.enabled);\n // Who paid is the owner's to see: it comes with the signed stats call.\n let credits = [];\n if (owner && signer.ready()) { try { credits = (await rpc(\"stats\")).credits || []; } catch { credits = []; } }\n $(\"#credits tbody\").innerHTML = credits.length ? credits.map((c) => '' + esc(fmtTime(c.at)) + '' + av(c.payer) + key(c.payer) + '' + Math.floor(c.msats / 1000).toLocaleString() + \"\").join(\"\") : 'no zaps yet';\n if (!fuel.enabled) $(\"#topup-note\").textContent = \"Top-ups are not enabled on this service yet.\";\n renderCare();\n renderFuelTile();\n }\n\n async function topUp(sats) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const msats = Math.round(sats * 1000);\n const zapRequest = await signer.signEvent({ kind: 9734, created_at: Math.floor(Date.now() / 1000), content: \"fuel for \" + host, tags: [[\"p\", fuel.servicePubkey], [\"amount\", String(msats)], [\"relays\", wsURL]] });\n const r = await (await fetch(\"/fuel/invoice\", { method: \"POST\", body: JSON.stringify({ zapRequest }) })).json();\n if (r.error) throw new Error(r.error);\n const inv = r.invoice;\n $(\"#invoice\").classList.remove(\"hidden\");\n $(\"#inv-text\").value = inv; $(\"#inv-link\").href = \"lightning:\" + inv; $(\"#inv-state\").textContent = \"waiting for payment…\";\n if (window.webln) { try { await window.webln.enable(); await window.webln.sendPayment(inv); } catch (e) { toast(e.message || \"wallet declined\"); } }\n const before = fuel.creditedMsats;\n for (let i = 0; i < 120; i++) {\n await new Promise((r) => setTimeout(r, 2500));\n await loadFuel();\n if (fuel.creditedMsats > before) { $(\"#inv-state\").textContent = \"paid, thank you\"; toast(\"Fuel credited\"); return; }\n }\n $(\"#inv-state\").textContent = \"no receipt yet; it can take a minute after paying\";\n }\n\n // ---- console ----\n function showTab(name) {\n if (name === \"content\" || name === \"storage\") name = \"data\";\n const known = myRole === \"moderator\" ? [\"people\", \"moderation\"] : [\"people\", \"moderation\", \"rules\", \"identity\", \"data\", \"sync\", \"views\", \"health\", \"owner\"];\n if (!known.includes(name)) name = \"people\";\n $$(\".tabs a\").forEach((a) => a.classList.toggle(\"on\", a.dataset.tab === name));\n $$(\".panel\").forEach((p) => p.classList.toggle(\"on\", p.dataset.panel === name));\n }\n // Tabs switch in place. A fragment still opens a tab when someone arrives with one,\n // but clicking never writes one, and any fragment already there is dropped.\n $$(\".tabs a\").forEach((a) => a.addEventListener(\"click\", (ev) => { ev.preventDefault(); showTab(a.dataset.tab); if (location.hash) history.replaceState(null, \"\", location.pathname + location.search); }));\n window.addEventListener(\"hashchange\", () => showTab(location.hash.slice(1)));\n // The strip fades on whichever side has more tabs off screen.\n const tabsFade = () => { const t = $(\"#tabs\"); t.classList.toggle(\"fade-l\", t.scrollLeft > 4); t.classList.toggle(\"fade-r\", t.scrollLeft + t.clientWidth < t.scrollWidth - 4); };\n $(\"#tabs\").addEventListener(\"scroll\", tabsFade); window.addEventListener(\"resize\", tabsFade); new ResizeObserver(tabsFade).observe($(\"#tabs\"));\n\n function renderFuelTile() {\n if (!fuel) return;\n const over = fuelOver();\n const sats = Math.max(0, Math.floor(fuel.balanceMsats / 1000));\n const d = new Date(), end = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1), days = Math.max(1, Math.ceil((end - Date.now()) / 86400000));\n if (fuel.outOfFuel) { $(\"#h-fuel\").textContent = \"0 sats\"; $(\"#h-fuel-label\").textContent = \"out of fuel\"; $(\"#h-fuel-sub\").textContent = \"writes are paused\"; }\n else if (over) { $(\"#h-fuel\").textContent = sats.toLocaleString() + \" sats\"; $(\"#h-fuel-label\").textContent = \"fuel left\"; $(\"#h-fuel-sub\").textContent = \"past the free allowance\"; }\n else { $(\"#h-fuel\").textContent = days + (days === 1 ? \" day\" : \" days\"); $(\"#h-fuel-label\").textContent = \"free allowance left\"; $(\"#h-fuel-sub\").textContent = sats ? \"then \" + sats.toLocaleString() + \" sats\" : \"then a top-up is needed\"; }\n $(\"#usage\").innerHTML = [[\"events stored\", fmtBytes(fuel.eventBytes)], [\"files stored\", fmtBytes(fuel.mediaBytes)], [\"awake this month\", fmtHours(fuel.activeMs)], [\"rows written\", fuel.rowsWritten.toLocaleString()], [\"rows read\", fuel.rowsRead.toLocaleString()], [\"received this month\", fmtBytes(fuel.bytesIn)], [\"served this month\", fmtBytes(fuel.bytesOut)], [\"charged\", Math.floor(fuel.chargedMsats / 1000).toLocaleString() + \" sats\"]]\n .map(([k, v]) => \"
                                                          \" + k + \"\" + v + \"
                                                          \").join(\"\");\n }\n\n function renderHealth(stats) {\n $(\"#h-last\").textContent = stats.newest ? ago(stats.newest) : \"none\";\n $(\"#h-last-sub\").textContent = stats.newest ? fmtTime(stats.newest) : \"no events yet\";\n $(\"#h-conns\").textContent = stats.connections;\n const buckets = [[\"notes\", \"k1\", [1]], [\"reactions\", \"k2\", [7]], [\"DMs\", \"k3\", [4, 14, 1059]], [\"long-form\", \"k4\", [30023]], [\"other\", \"k5\", null]];\n const sums = buckets.map(() => 0); let total = 0;\n for (const { kind, n } of stats.kinds || []) { total += n; const i = buckets.findIndex((b) => b[2] && b[2].includes(kind)); sums[i < 0 ? 4 : i] += n; }\n const parts = buckets.map((b, i) => [b[0], b[1], sums[i]]).filter((p) => p[2] > 0);\n $(\"#h-kinds\").innerHTML = total ? parts.map(([name, cls, n]) => '').join(\"\") : '';\n $(\"#h-kinds-legend\").innerHTML = total ? parts.map(([name, cls, n]) => '' + name + \" \" + Math.round(n * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n renderFuelTile();\n }\n\n async function loadAdmin() {\n const isOwner = !!me && me === owner;\n myRole = isOwner ? \"owner\" : \"\";\n let stats = null, p = null;\n // A signed-in member may be a moderator; the relay says by answering stats.\n if (me && owner && !isOwner) { try { [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]); myRole = \"moderator\"; } catch { myRole = \"\"; } }\n $(\"#console\").classList.toggle(\"hidden\", !myRole);\n loadMine();\n $(\"#console\").classList.toggle(\"mod\", myRole === \"moderator\");\n if (!myRole) { policy = null; renderCare(); return; }\n if (isOwner) [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]);\n policy = p;\n renderHealth(stats);\n renderCare();\n const fa = $(\"#access\"), fi = $(\"#identity\");\n fa.writes.value = p.writes; fa.reads.value = p.reads; fa.openKinds.value = (p.openKinds || []).join(\", \"); fa.guestReplies.checked = !!p.guestReplies;\n $(\"#wordsform\").words.value = (p.blockedWords || []).join(\"\\n\"); $(\"#wordsform\").inTags.checked = !!p.blockedWordsInTags; $(\"#thresholdform\").reportThreshold.value = p.reportThreshold || 0; fa.minPow.value = p.minPow; fa.maxFuture.value = p.maxFuture; fa.maxLimit.value = p.maxLimit; fa.maxSubs.value = p.maxSubs; fa.maxMessageKB.value = p.maxMessageKB;\n fa.eventsPerMinute.value = p.eventsPerMinute; fa.reqsPerMinute.value = p.reqsPerMinute; fa.maxBlobMB.value = p.maxBlobMB;\n renderFeatures(p.features || {});\n $(\"#push-policy-form\").elements.origins.value = (p.pushCallbacks || []).join(\"\\n\");\n $(\"#push-policy-form\").elements.lettered.checked = !!p.letteredNips;\n fi.name.value = p.name; fi.contact.value = p.contact; fi.description.value = p.description; fi.icon.value = p.icon;\n const fj = $(\"#joinform\"); fj.joinTerms.value = p.joinTerms; fj.directoryPublic.checked = !!p.directoryPublic;\n loadCard();\n fi.banner.value = p.banner || \"\"; fi.postingPolicy.value = p.postingPolicy || \"\"; fi.privacyPolicy.value = p.privacyPolicy || \"\";\n fi.tags.value = (p.tags || []).join(\", \"); fi.languageTags.value = (p.languageTags || []).join(\", \"); fi.relayCountries.value = (p.relayCountries || []).join(\", \");\n const fn = $(\"#notify\"), nt = p.notify || {};\n fn.reports.checked = !!nt.reports; fn.fuel.checked = !!nt.fuel; fn.jobs.checked = !!nt.jobs; fn.succession.checked = !!nt.succession; fn.digest.checked = !!nt.digest;\n showTab(location.hash.slice(1));\n if (isOwner) { await renderPresets(); renderWire(); renderSuccession(); loadDomains(); }\n await Promise.all([loadLists(), loadEvents(true), loadPins(), ...(isOwner ? [loadStorage()] : [])]);\n }\n\n async function loadAudit(before) {\n const rows = await rpc(\"listaudit\", before || 0);\n const html = rows.map((r) => '' + esc(fmtTime(r.at)) + '' + av(r.actor) + key(r.actor) + '' + esc(r.action) + '' + (/^[0-9a-f]{64}$/.test(r.target) ? key(r.target) : esc(r.target)) + '' + esc(r.detail) + '').join(\"\");\n const tb = $(\"#audit tbody\");\n if (before) tb.insertAdjacentHTML(\"beforeend\", html); else tb.innerHTML = html || 'Nothing yet';\n $(\"#audit-more\").style.display = rows.length < 100 ? \"none\" : \"\";\n }\n $(\"#audit-more\").onclick = guard(async () => { const last = $(\"#audit tbody tr:last-child\"); await loadAudit(last ? +last.dataset.seq : 0); });\n async function loadLists() {\n loadAudit(0);\n const [mem, bans, bannedEvents, allow, block, invites, reports, blobs, blocks, sites] = await Promise.all([rpc(\"listmembers\"), rpc(\"listbannedpubkeys\"), rpc(\"listbannedevents\"), rpc(\"listallowedkinds\"), rpc(\"listblockedkinds\"), rpc(\"listinvites\"), rpc(\"listreports\"), rpc(\"listblobs\", 100), rpc(\"listblockedips\"), rpc(\"listsites\")]);\n const members = mem.members;\n window.__members = members;\n const roleCell = (m) => m.role === \"owner\" ? ' owner' : myRole === \"owner\" ? ' ' : m.role === \"moderator\" ? ' moderator' : \"\";\n const untouchable = (m) => m.role === \"owner\" || (myRole !== \"owner\" && m.role === \"moderator\");\n // The tree: everyone under whoever invited them, the owner and the owner's own additions at the root.\n const known = new Set(members.map((m) => m.pubkey));\n const byInviter = new Map();\n for (const m of members) { const k = m.role !== \"owner\" && known.has(m.invited_by) ? m.invited_by : \"\"; if (!byInviter.has(k)) byInviter.set(k, []); byInviter.get(k).push(m); }\n const ordered = [], placed = new Set();\n const walk = (k, depth) => { for (const m of byInviter.get(k) || []) { if (placed.has(m.pubkey)) continue; placed.add(m.pubkey); m.depth = depth; ordered.push(m); walk(m.pubkey, depth + 1); } };\n walk(\"\", 0);\n for (const m of members) if (!placed.has(m.pubkey)) { m.depth = 0; ordered.push(m); }\n const nameOf = (pk) => { const x = members.find((m) => m.pubkey === pk); return x && x.name ? x.name : short(pk); };\n const limits = (m) => m.role === \"owner\" ? \"\" : myRole === \"owner\"\n ? ''\n : '' + (m.keep_days ? m.keep_days + \" d\" : \"\") + (m.max_bytes ? \" \" + fmtBytes(m.max_bytes) : \"\") + \"\";\n $(\"#members tbody\").innerHTML = ordered.map((m) => '' + av(m.pubkey) + key(m.pubkey) + roleCell(m) + (m.depth ? ' via ' + esc(nameOf(m.invited_by)) + \"\" : \"\") + (m.invites ? ' ' + m.invites + \" inv\" : \"\") + '' + limits(m) + '' + esc(fmtDay(m.joined_at)) + \", \" + esc(m.via) + '' + ib(\"check\", \"Save\", \"savemember\", m.pubkey) + (untouchable(m) ? \"\" : ib(\"x\", \"Remove\", \"removemember\", m.pubkey) + ib(\"banuser\", \"Ban\", \"banpubkey\", m.pubkey, \"danger\")) + \"\").join(\"\");\n const tf = $(\"#treeform\");\n tf.classList.toggle(\"hidden\", myRole !== \"owner\");\n if (policy && policy.memberInvites) { tf.depth.value = policy.memberInvites.depth; tf.quota.value = policy.memberInvites.quota; }\n $(\"#transfer [name=pubkey]\").innerHTML = members.filter((m) => m.role !== \"owner\").map((m) => '\").join(\"\");\n $(\"#succession [name=heir]\").innerHTML = $(\"#transfer [name=pubkey]\").innerHTML;\n $(\"#tc-people\").textContent = members.length;\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#invites\").innerHTML = invites.length ? invites.map((i) => '
                                                        • inv…' + i.code.slice(-8) + \" \" + esc(i.note || \"\") + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
                                                        • \").join(\"\") : '
                                                        • no invites
                                                        • ';\n const person = (r, icon, label, act) => \"
                                                        • \" + av(r.pubkey) + '' + short(r.pubkey) + \" \" + esc(r.reason || \"\") + \"\" + ib(icon, label, act, r.pubkey) + \"
                                                        • \";\n $(\"#bans\").innerHTML = bans.length ? bans.map((r) => person(r, \"undo\", \"Unban\", \"unrulepubkey\")).join(\"\") : '
                                                        • nobody banned
                                                        • ';\n $(\"#blocks\").innerHTML = blocks.length ? blocks.map((r) => '
                                                        • ip' + esc(r.ip) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"undo\", \"Unblock\", \"unblockip\", r.ip) + \"
                                                        • \").join(\"\") : '
                                                        • no addresses blocked
                                                        • ';\n $(\"#banned-events\").innerHTML = bannedEvents.map((r) => '
                                                        • ev' + key(r.id) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"check\", \"Allow again\", \"allowevent\", r.id) + \"
                                                        • \").join(\"\");\n $(\"#reports tbody\").innerHTML = reports.length ? reports.map((r) => '' + esc(fmtTime(r.at)) + '' + esc(r.type || \"report\") + '' + av(r.target_pubkey) + key(r.target_pubkey) + (r.target_event ? (r.blob ? ' file ' + key(r.target_event) + \"\" : ' ev ' + key(r.target_event) + (r.hidden ? ' hidden' : \"\") + \"\") : \"\") + '' + esc(r.content) + '' + ib(\"check\", \"Dismiss\", \"resolve:dismiss\", r.id) + ib(\"trash\", r.blob ? \"Delete the file\" : \"Delete the event\", \"resolve:delete\", r.id) + ib(\"banuser\", \"Ban the author\", \"resolve:ban\", r.id, \"danger\") + \"\").join(\"\") : 'nothing reported';\n $(\"#reports-count\").textContent = reports.length || \"\"; $(\"#tc-reports\").textContent = reports.length || \"\";\n $(\"#blobs tbody\").innerHTML = blobs.length ? blobs.map((b) => '' + esc(fmtTime(b.uploaded)) + '' + key(b.sha256) + ' ' + esc(b.type) + '' + fmtBytes(b.size) + '' + av(b.uploader) + key(b.uploader) + '' + ib(\"trash\", \"Delete file\", \"deleteblob\", b.sha256, \"danger\") + \"\").join(\"\") : 'no uploads';\n $(\"#blobs-count\").textContent = blobs.length || \"\";\n $(\"#sites tbody\").innerHTML = sites.length ? sites.map((s) => '' + av(s.author) + key(s.author) + '' + esc(s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + '' + esc(s.url) + '' + esc(s.paths) + (s.missing ? ' ' + esc(s.missing) + ' missing' : '') + '' + fmtBytes(s.size) + '' + esc(s.expires_at ? fmtTime(s.expires_at) : \"never\") + '' + ib(\"trash\", \"Delete site\", \"deleteevent\", s.id, \"danger\") + '').join(\"\") : 'no sites yet';\n $(\"#sites-count\").textContent = sites.length || \"\";\n const tag = (k, cls) => '' + k + ib(\"x\", \"Remove rule\", \"unrulekind\", String(k)) + \"\";\n $(\"#kinds-allow\").innerHTML = allow.length ? allow.map((k) => tag(k, \"ok\")).join(\"\") : 'all';\n $(\"#kinds-block\").innerHTML = block.length ? block.map((k) => tag(k, \"blk\")).join(\"\") : 'none';\n await loadPeople();\n }\n\n const KIND_NAMES = { 0: \"profiles\", 1: \"notes\", 3: \"contacts\", 4: \"DMs\", 5: \"deletions\", 6: \"reposts\", 7: \"reactions\", 16: \"reposts\", 1059: \"gift wraps\", 1063: \"file headers\", 1111: \"comments\", 1984: \"reports\", 9734: \"zap requests\", 9735: \"zap receipts\", 9802: \"highlights\", 10002: \"relay lists\", 13534: \"roster\", 9000: \"group adds\", 9001: \"group removals\", 9021: \"join requests\", 9022: \"leave requests\", 39000: \"group info\", 39001: \"group admins\", 39002: \"group members\", 39003: \"group roles\", 30023: \"articles\", 30024: \"drafts\", 30078: \"app data\", 30311: \"live events\", 30818: \"wiki\" };\n const kindName = (k) => KIND_NAMES[k] || (k >= 20000 && k < 30000 ? \"ephemeral\" : k >= 30000 && k < 40000 ? \"addressable\" : k >= 10000 && k < 20000 ? \"replaceable\" : \"kind \" + k);\n const SYS_KINDS = new Set([0, 3, 10002, 9735, 13534, 8000, 8001, 9000, 9001, 39000, 39001, 39002, 39003]);\n let storage = null;\n\n // Features (settings.ts): a select each; search has three modes, the rest on or off.\n const FEATURES = [\n [\"search\", \"Search\", \"NIP-50. Prose indexes notes, threads, comments, highlights, articles and wiki pages; full indexes every public kind with content. A change applies to events from then on.\", [\"prose:prose\", \"full:full\", \"off:off\"]],\n [\"sync\", \"Sync\", \"NIP-77 reconciliation, which reads the whole matching set per sync.\"],\n [\"count\", \"Counts\", \"NIP-45 COUNT, with HLL sketches.\"],\n [\"discovery\", \"Discovery record\", \"NIP-66: the record the relay signs about itself, for crawlers.\"],\n [\"names\", \"Names\", \"NIP-05 addresses under this relay's domain.\"],\n [\"files\", \"Files\", \"Blossom and NIP-96: uploads, downloads and listings.\"],\n [\"pages\", \"Pages and feed\", \"Notes and articles as pages, and the Atom feed.\"],\n [\"sites\", \"Static websites\", \"NIP-5A sites on their own hostnames. Mirroring copies missing files into this relay and costs fuel.\", [\"mirror:on, mirror files\", \"proxy:on, fetch as needed\", \"off:off\"]],\n [\"marmot\", \"Marmot transport\", \"Signed KeyPackages and encrypted group messages, with account admission for ephemeral authors.\"],\n [\"grasp\", \"Git repositories\", \"GRASP Git hosting with admitted repository state. The prototype backend has bounded storage and compute limits.\"],\n [\"push\", \"Relay push\", \"NIP-9a callback delivery for members and the owner. Requires approved callback origins; advertises lettered NIP identifiers.\"],\n [\"signer\", \"Signer traffic\", \"NIP-46 remote signing carried for anyone, never stored.\"],\n ];\n function renderFeatures(f) {\n $(\"#features\").innerHTML = FEATURES.map(([k, title, about, modes]) => {\n const cur = k === \"sites\" ? (f.sites?.enabled === false ? \"off\" : f.sites?.mirror === false ? \"proxy\" : \"mirror\") : modes ? String(f[k] || \"prose\") : String(f[k] !== false);\n const opts = (modes || [\"true:on\", \"false:off\"]).map((m) => { const [v, l] = m.split(\":\"); return '\"; }).join(\"\");\n return \"\";\n }).join(\"\");\n }\n $(\"#features\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-feature]\"); if (!sel) return;\n const k = sel.dataset.feature, v = k === \"search\" || k === \"sites\" ? sel.value : sel.value === \"true\";\n policy = await rpc(\"setpolicy\", { features: { [k]: k === \"sites\" ? { enabled: v !== \"off\", mirror: v === \"mirror\" } : v } });\n toast(k === \"search\" ? \"Search: \" + v : (v ? \"Switched on \" : \"Switched off \") + k); await loadInfo();\n }));\n\n $(\"#push-policy-form\").addEventListener(\"submit\", guard(async (ev) => {\n ev.preventDefault();\n const form = ev.target;\n const origins = form.elements.origins.value.split(/\\s+/).filter(Boolean);\n const updated = await rpc(\"setpolicy\", { pushCallbacks: origins, letteredNips: form.elements.lettered.checked });\n if (JSON.stringify(updated.pushCallbacks) !== JSON.stringify([...new Set(origins.map((s) => s.replace(/\\/$/, \"\")))])) throw new Error(\"Use up to sixteen exact HTTPS origins, with no path or credentials.\");\n policy = updated;\n toast(\"Saved callback policy\"); await loadInfo();\n }));\n\n async function loadViews() {\n let views;\n try { views = await rpc(\"listviews\"); } catch { return; }\n $(\"#views\").innerHTML = views.map((v) => {\n const runs = v.trigger === \"off\" ? \"off\" : v.trigger === \"live\" ? \"live, from memory\" : v.trigger === \"write\" ? \"on write and daily\" : v.trigger;\n const label = (c) => (c === \"off\" ? \"off\" : c === \"write\" ? \"on write\" : c === \"live\" ? \"on\" : c);\n const choices = [...v.choices, v.default].filter((c, i, a) => a.indexOf(c) === i);\n const pick = '\";\n const who = v.audience === \"members\" ? \"members\" + (v.stored ? \"\" : \", on request\") : \"anyone\";\n const last = v.trigger === \"live\" ? \"\" : v.last ? \"last run \" + fmtTime(v.last.at) : \"not run yet\";\n const rows = v.trigger === \"live\" ? \"no rows\" : v.last ? v.last.rows.toLocaleString() + \" rows\" : \"\";\n const meta = [runs, who, last, rows].filter(Boolean).map((x) => \"\" + esc(x) + \"\").join('|');\n return '
                                                          ' + esc(v.name) + '
                                                          ' + esc(v.about) + '
                                                          ' + pick + (v.on ? 'Open' : \"\") + '
                                                          ' + meta + \"
                                                          \";\n }).join(\"\");\n }\n $(\"#views\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-view]\"); if (!sel) return;\n const value = sel.value === \"live\" ? true : sel.value;\n await rpc(\"setpolicy\", { views: { [sel.dataset.view]: value } });\n toast(sel.value === \"off\" ? \"Took down \" + sel.dataset.view : sel.dataset.view + \": \" + (sel.value === \"live\" ? \"on\" : sel.value === \"write\" ? \"on write\" : sel.value)); await loadViews(); await loadInfo();\n }));\n async function loadDumps() {\n const list = await rpc(\"listdumps\");\n const f = $(\"#dumpform\");\n if (policy) { f.dumps.value = policy.dumps || \"off\"; f.keep.value = policy.dumpsKeep || 7; }\n $(\"#dumps-count\").textContent = list.length || \"\";\n $(\"#dumps\").innerHTML = list.length ? list.map((d) => '
                                                        • jsonl' + esc(d.name) + ' ' + d.events.toLocaleString() + \" events, \" + fmtBytes(d.bytes) + \"\" + ib(\"copy\", \"Download\", \"downloaddump\", d.name) + ib(\"trash\", \"Delete\", \"deletedump\", d.name, \"danger\") + \"
                                                        • \").join(\"\") : '
                                                        • no dumps yet
                                                        • ';\n }\n // A dump is fetched with a signed request and handed to the browser as a file.\n async function downloadDump(name) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const url = location.origin + \"/dumps/\" + name;\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"GET\"]] });\n const resp = await fetch(url, { headers: { authorization: \"Nostr \" + btoa(JSON.stringify(ev)) } });\n if (!resp.ok) throw new Error((await resp.json()).error || \"download failed\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(\".\")[0] + \"-\" + name; a.click(); URL.revokeObjectURL(a.href);\n }\n // A plain member's own invites, when the owner lets members invite.\n async function loadMine() {\n const sec = $(\"#myinvites\");\n if (!me || !owner || myRole || !signer.ready()) { sec.classList.add(\"hidden\"); return; }\n let mine;\n try { mine = await rpc(\"listinvites\"); } catch { sec.classList.add(\"hidden\"); return; }\n sec.classList.remove(\"hidden\");\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#mine\").innerHTML = mine.length ? mine.map((i) => '
                                                        • inv…' + i.code.slice(-8) + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
                                                        • \").join(\"\") : '
                                                        • no invites yet
                                                        • ';\n }\n async function loadStorage() {\n storage = await rpc(\"storagestats\");\n pollJobs();\n loadViews();\n loadDumps().catch(() => {});\n const st = storage;\n const top = st.kinds.slice(0, 4), rest = st.kinds.slice(4).reduce((a, k) => a + k.bytes, 0);\n const parts = [...top.map((k, i) => [k.kind + \" \" + kindName(k.kind), \"k\" + (i + 1), k.bytes]), ...(rest ? [[\"other\", \"k5\", rest]] : [])];\n const total = st.eventBytes || 1;\n $(\"#s-bar\").innerHTML = parts.length ? parts.map(([n, c, b]) => '').join(\"\") : '';\n $(\"#s-legend\").innerHTML = parts.length ? parts.map(([n, c, b]) => '' + esc(n) + \" \" + Math.round(b * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n const overhead = Math.max(0, st.databaseBytes - st.eventBytes);\n $(\"#s-totals\").innerHTML = [[\"events\", st.events.toLocaleString() + \" \" + fmtBytes(st.eventBytes) + \"\"], [\"index and overhead\", fmtBytes(overhead)], [\"files\", st.blobs.toLocaleString() + \" \" + fmtBytes(st.mediaBytes) + \"\"]]\n .map(([k, v]) => \"
                                                          \" + k + \"\" + v + \"
                                                          \").join(\"\");\n const any = st.retention.find((r) => r.kind === null);\n const own = (kind) => { const r = st.retention.find((x) => x.kind === kind); return r ? r.days : \"\"; };\n const row = (k) => {\n const key = k.kind === null ? \"\" : String(k.kind);\n const placeholder = k.kind === null ? \"forever\" : k.replaceable || !any ? \"forever\" : any.days + \" (everything else)\";\n const pill = k.kind === null ? \"everything else\" : '' + k.kind + \" \" + esc(kindName(k.kind));\n const cells = k.protected\n ? 'required'\n : ' days' + ib(\"check\", \"Save keep-for rule\", \"saveretention\", key) + ib(\"trash\", \"Purge\", \"purgekind\", key, \"danger\") + \"\";\n return '' + pill + '' + (k.n === undefined ? \"\" : k.n.toLocaleString()) + '' + (k.bytes === undefined ? \"\" : fmtBytes(k.bytes)) + '' + (k.oldest ? esc(fmtDay(k.oldest)) : \"\") + \"\" + cells + \"\";\n };\n $(\"#kinds tbody\").innerHTML = [...st.kinds, { kind: null, days: any ? any.days : 0 }].map(row).join(\"\");\n loadListHistory().catch(() => {});\n }\n\n async function loadListHistory() {\n const rows = await rpc(\"listlisthistory\");\n const labels = { 3: \"follows\", 10002: \"relay list\", 10003: \"bookmarks\", 30003: \"bookmark list\" };\n $(\"#listhistory tbody\").innerHTML = rows.length ? rows.map((r) => '' + esc(labels[r.kind] || (\"kind \" + r.kind) + (r.d ? \" / \" + r.d : \"\")) + '' + esc(fmtTime(r.created_at)) + '' + esc(fmtTime(r.saved_at)) + '' + ib(\"undo\", \"Restore this version\", \"restorelist\", r.event_id) + '').join(\"\") : 'no older list versions yet';\n }\n\n let searchQuery = \"\";\n async function loadEvents(reset) {\n const list = searchQuery ? await rpc(\"searchevents\", searchQuery, 200) : await rpc(\"listrecentevents\", reset ? 50 : 200);\n $(\"#events tbody\").innerHTML = list.length ? list.map((e) => '' + esc(fmtTime(e.created_at)) + '' + e.kind + '' + av(e.pubkey) + key(e.pubkey) + '' + esc(e.content) + '' + ib(\"pin\", \"Pin\", \"pinevent\", e.id) + ib(\"trash\", \"Delete event\", \"deleteevent\", e.id) + ib(\"ban\", \"Ban event\", \"banevent\", e.id, \"danger\") + ib(\"banuser\", \"Ban author\", \"banpubkey\", e.pubkey, \"danger\") + \"\").join(\"\") : 'no events yet';\n $(\"#more\").classList.toggle(\"hidden\", searchQuery || list.length < 50);\n }\n $(\"#searchform\").onsubmit = guard(async (ev) => {\n searchQuery = ev.target.q.value.trim();\n $(\"#searchclear\").classList.toggle(\"hidden\", !searchQuery);\n await loadEvents(true);\n });\n $(\"#searchclear\").onclick = guard(async () => {\n searchQuery = \"\"; $(\"#searchform\").reset(); $(\"#searchclear\").classList.add(\"hidden\");\n await loadEvents(true);\n });\n async function loadPins() {\n const pins = await rpc(\"listpins\");\n $(\"#pins-count\").textContent = pins.length || \"\";\n $(\"#pins\").innerHTML = pins.length ? pins.map((t) => '
                                                        • ' + (t[0] === \"e\" ? \"event\" : \"address\") + '' + esc(t[1].length > 40 ? t[1].slice(0, 16) + \"\\u2026\" + t[1].slice(-8) : t[1]) + \"\" + ib(\"copy\", \"Copy\", \"copy\", t[1]) + ib(\"x\", \"Unpin\", \"unpinevent\", t[1]) + \"
                                                        • \").join(\"\") : '
                                                        • nothing pinned
                                                        • ';\n }\n $(\"#pinform\").onsubmit = guard(async (ev) => {\n await rpc(\"pinevent\", ev.target.id.value.trim()); ev.target.reset(); toast(\"Pinned\"); await loadPins();\n });\n\n // ---- actions ----\n const refresh = () => Promise.all([loadLists(), loadEvents(true), loadStorage(), loadPins()]);\n\n $(\"#copy\").onclick = async () => { await navigator.clipboard.writeText(wsURL); toast(\"copied \" + wsURL); };\n $(\"#signin\").onclick = guard(async () => {\n if (!window.nostr) throw new Error(\"No nostr extension found. Install one (Alby, nos2x, …) and reload, or use a remote signer.\");\n me = await window.nostr.getPublicKey();\n localStorage.setItem(\"me\", me);\n renderHeader(); await loadAdmin(); await loadPeople();\n });\n $(\"#signin46\").onclick = () => showRemote(null);\n $$(\".remote\").forEach((b) => { b.onclick = () => showRemote($(\"#\" + b.dataset.note)); });\n $(\"#nccopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#nclink\").href); toast(\"Link copied; paste it into your signer app\"); };\n $(\"#bunkerform\").onsubmit = guard(async (ev) => {\n $(\"#remotenote\").textContent = \"Connecting…\";\n try { await connectBunker(ev.target.url.value); } catch (e) { $(\"#remotenote\").textContent = e.message; throw e; }\n $(\"#remotenote\").textContent = \"\"; ev.target.reset();\n await remoteDone();\n });\n $(\"#signout\").onclick = async () => {\n if (remote) { const r = remote; remote = null; try { await withTimeout(r.logout(), 5000, \"The signer\"); } catch { /* the session is gone either way */ } }\n localStorage.removeItem(\"nip46\");\n me = null; localStorage.removeItem(\"me\"); renderHeader(); await loadAdmin(); await loadPeople();\n };\n const claimNow = async (note) => {\n if (!signer.ready()) { note.textContent = \"Sign the claim with a nostr extension or a remote signer.\"; showRemote(note); return; }\n me = await signer.getPublicKey();\n const r = await rpc(\"claim\");\n if (!r.claimed) throw new Error(\"Somebody else claimed it first.\");\n localStorage.setItem(\"me\", me);\n toast(\"It's yours.\");\n await loadInfo(); await loadFuel(); await loadAdmin(); await loadPeople();\n if (r.converted && confirm(\"This relay began as a temporary one: anyone can write, and everything is deleted after 14 days. Switch to the default rules and keep everything from now on? Each rule can be changed later on the Rules and Storage tabs.\")) {\n await rpc(\"resetrules\"); toast(\"Rules reset\"); await loadInfo(); await loadAdmin();\n }\n };\n $(\"#claim\").onclick = guard(() => claimNow($(\"#claimnote\")));\n $(\"#claimlease\").onclick = guard(() => claimNow($(\"#leasenote\")));\n // Jobs run in the background; the table follows them while the tab is open.\n let jobsTimer = 0;\n const fmtJob = (j) => {\n const what = j.kind === \"mirror\" ? \"mirror site\" : j.kind === \"import\" ? \"import\" : j.label === \"backfill\" ? \"fetch my history\" : j.kind === \"pull\" ? \"pull\" : \"rebroadcast\";\n const f = [];\n if (j.filter.authors) f.push(j.filter.authors.length === 1 && j.filter.authors[0] === me ? \"my events\" : j.filter.authors.length + \" authors\");\n if (j.filter.kinds) f.push(\"kinds \" + j.filter.kinds.join(\", \"));\n if (j.filter.since) f.push(\"since \" + fmtDay(j.filter.since));\n const when = j.every ? \"every \" + (j.every === 24 ? \"day\" : j.every + \" h\") + (j.nextRun ? \", next \" + fmtTime(j.nextRun) : \"\") : \"once\";\n const l = j.last;\n const count = (stored, blobs, sent, refused) => (j.kind === \"mirror\" ? blobs.toLocaleString() + \" files mirrored\" : j.kind === \"import\" ? stored.toLocaleString() + \" events\" + ((j.last ? j.last.duplicates : j.duplicates) ? \", \" + (j.last ? j.last.duplicates : j.duplicates) + \" already here\" : \"\") : j.kind === \"pull\" ? stored.toLocaleString() + \" events\" + (blobs ? \", \" + blobs + \" files\" : \"\") : sent.toLocaleString() + \" sent\" + (refused ? \", \" + refused + \" refused\" : \"\"));\n const res = j.running ? \"running: \" + count(j.stored, j.blobs, j.sent, j.refused) + \"...\" : !l ? \"waiting\" : l.error ? \"failed: \" + l.error : count(l.stored, l.blobs, l.sent, l.refused) + (l.skipped ? \", \" + l.skipped + \" skipped\" : \"\") + \", \" + fmtTime(l.finishedAt);\n const sources = j.running ? j.pullSources : l?.sources;\n const details = sources?.length ? '
                                                          Source results' + sources.map((s) => '

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

                                                          ').join('') + '
                                                          ' : '';\n return \"\" + what + \"\" + j.relays.map(esc).join(\"
                                                          \") + \"\" + (f.join(\", \") || \"everything\") + \"\" + when + \"\" + esc(res) + details + \"\" + (j.running ? \"\" : ib(\"undo\", \"Run now\", \"runjob\", j.id)) + ib(\"x\", \"Remove\", \"removejob\", j.id) + \"\";\n };\n async function pollJobs() {\n clearTimeout(jobsTimer);\n let jobs;\n try { jobs = await rpc(\"listjobs\"); } catch { return; }\n $(\"#jobs tbody\").innerHTML = jobs.length ? jobs.map(fmtJob).join(\"\") : 'no jobs yet';\n if (jobs.some((j) => j.running || (j.nextRun && j.nextRun <= Math.floor(Date.now() / 1000) + 1))) jobsTimer = setTimeout(pollJobs, 3000);\n }\n const urls = (s) => s.split(/[\\s,]+/).map((u) => u.trim()).filter(Boolean);\n const kindsOf = (s) => s.split(/[\\s,]+/).map((k) => parseInt(k, 10)).filter((k) => Number.isInteger(k) && k >= 0);\n $(\"#pullform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"addjob\", { kind: \"pull\", relays: [f.url.value.trim()], every: +f.every.value });\n toast(+f.every.value ? \"Mirror scheduled\" : \"Pull started\"); f.reset(); await pollJobs();\n });\n $(\"#backfillform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"backfill\", urls(f.relays.value));\n toast(\"Fetching your history\"); f.reset(); await pollJobs();\n });\n $(\"#pushform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const filter = {};\n const kinds = kindsOf(f.kinds.value); if (kinds.length) filter.kinds = kinds;\n const days = +f.days.value; if (days > 0) filter.since = Math.floor(Date.now() / 1000) - days * 86400;\n await rpc(\"addjob\", { kind: \"push\", relays: urls(f.targets.value), filter, every: +f.every.value });\n toast(\"Rebroadcast started\"); f.reset(); await pollJobs();\n });\n // ---- presets: writes, reads, kinds and keep-for rules in one click ----\n let presets = null;\n async function renderPresets() {\n if (!presets) { try { presets = await rpc(\"listpresets\"); } catch { presets = []; } }\n $(\"#presets\").innerHTML = presets.map((p) => '\").join(\"\");\n $(\"#presetsourcerow\").classList.toggle(\"hidden\", !presets.some((p) => p.source));\n }\n $(\"#presets\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-preset]\"); if (!b) return;\n const p = (presets || []).find((x) => x.name === b.dataset.preset); if (!p) return;\n if (!confirm(p.title + \": \" + p.about + \"\\n\\nThis replaces the writes and reads rules, the directory setting, the kind rules and the keep-for rules. Limits, identity, people and bans stay.\")) return;\n const source = $(\"#presetsource\").value.trim();\n if (p.source === \"required\" && !source) { $(\"#presetnote\").textContent = p.title + \" needs a source relay to mirror; enter its wss:// URL first.\"; return; }\n b.disabled = true;\n try { policy = await rpc(\"applypreset\", p.name, p.source && source ? { source } : undefined); $(\"#presetnote\").textContent = \"Now: \" + p.about + (policy.job ? \" Mirroring \" + source + \" every \" + policy.job.every + \" h.\" : \"\"); toast(p.title + \" applied\"); await loadInfo(); await loadAdmin(); }\n catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // ---- wire me in: this relay in the owner's own lists ----\n // The lists are replaceable, so a fresh list with only this relay would\n // clobber the real one once it spread. Every publish starts from the\n // newest copy found here or on the indexers, verified when the signer\n // library is around, and adds or removes this relay in it.\n const INDEXERS = [\"wss://purplepag.es\", \"wss://relay.nostr.band\", \"wss://relay.damus.io\", \"wss://nos.lol\"];\n const LISTS = [\n { kind: 10002, tag: \"r\", title: \"Relay list\", nip: \"NIP-65\", about: \"where clients read your notes and send you mentions\" },\n { kind: 10050, tag: \"relay\", title: \"DM inbox\", nip: \"NIP-17\", about: \"where people send you private messages\" },\n { kind: 10007, tag: \"relay\", title: \"Search relays\", nip: \"NIP-51\", about: \"where clients run your searches\" },\n { kind: 10063, tag: \"server\", title: \"Blossom servers\", nip: \"BUD-03\", about: \"where clients upload and look for your files\" },\n ];\n const mineFor = (l) => (l.tag === \"server\" ? location.origin : wsURL);\n const normURL = (u) => { try { const x = new URL(String(u).trim()); return (x.host + x.pathname).replace(/\\/+$/, \"\").toLowerCase(); } catch { return String(u).trim().toLowerCase(); } };\n const isMine = (l, t) => t[0] === l.tag && normURL(t[1] || \"\") === normURL(mineFor(l));\n const listHas = (list, l) => !!list && list.tags.some((t) => isMine(l, t));\n // mergeList is pure: the newest list with this relay put first or taken out, every other tag kept.\n function mergeList(list, l, include) {\n const tags = (list ? list.tags : []).filter((t) => !isMine(l, t));\n if (include) tags.unshift([l.tag, mineFor(l)]);\n return { kind: l.kind, created_at: Math.floor(Date.now() / 1000), content: list ? list.content : \"\", tags };\n }\n const relaysIn = (list) => list.tags.filter((t) => (t[0] === \"r\" || t[0] === \"relay\") && /^wss?:\\/\\//i.test(t[1] || \"\")).map((t) => t[1].trim());\n // overWS opens one socket, sends one message, feeds answers to onMessage until it says done or time runs out.\n function overWS(url, ms, first, onMessage) {\n return new Promise((res) => {\n let ws = null, done = false, out = null;\n const finish = () => { if (done) return; done = true; clearTimeout(timer); try { if (ws) ws.close(); } catch { /* closed */ } res(out); };\n const timer = setTimeout(finish, ms);\n try { ws = new WebSocket(url); } catch { return finish(); }\n ws.onopen = () => ws.send(JSON.stringify(first));\n ws.onmessage = (m) => { let d; try { d = JSON.parse(m.data); } catch { return; } if (!Array.isArray(d)) return; if (onMessage(d, (v) => { out = v; })) finish(); };\n ws.onerror = finish; ws.onclose = finish;\n });\n }\n function fetchNewest(url, filter, ms) {\n let best = null;\n return overWS(url, ms, [\"REQ\", \"w\", filter], (d, set) => {\n if (d[0] === \"EVENT\" && d[1] === \"w\" && d[2] && (!best || d[2].created_at > best.created_at)) { best = d[2]; set(best); }\n return (d[0] === \"EOSE\" || d[0] === \"CLOSED\") && d[1] === \"w\";\n });\n }\n function publishTo(url, event, ms) {\n return overWS(url, ms, [\"EVENT\", event], (d, set) => { if (d[0] === \"OK\" && d[1] === event.id) { set({ url, ok: !!d[2], msg: d[3] || \"\" }); return true; } return false; })\n .then((r) => r || { url, ok: false, msg: \"no answer\" });\n }\n // bridge signs a NIP-98 request to this relay's HTTP door, so the owner's own reads and writes pass any read rule.\n async function bridge(path, body) {\n const url = location.origin + path, raw = JSON.stringify(body);\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"POST\"], [\"payload\", await sha256hex(raw)]] });\n const resp = await fetch(url, { method: \"POST\", headers: { \"content-type\": \"application/json\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body: raw });\n const json = await resp.json();\n if (!resp.ok) throw new Error(json.error || \"HTTP \" + resp.status);\n return json;\n }\n const wire = {};\n function renderWire() {\n const pill = (state) => {\n const cls = state === \"listed\" ? \"on\" : state === \"failed\" ? \"bad\" : state === \"not listed\" ? \"off\" : \"\";\n const label = state === \"checking\" || state === \"publishing\" ? state + \"\\u2026\" : state;\n return '' + esc(label) + \"\";\n };\n $(\"#wire\").innerHTML = LISTS.map((l) => {\n const w = wire[l.kind] || { state: \"\", results: [], list: null };\n const busy = w.state === \"checking\" || w.state === \"publishing\";\n const btn = (act, cls, label) => '\";\n const buttons = w.state === \"listed\" ? btn(\"remove\", \"\", \"Remove this relay\") : btn(\"add\", \"pri\", \"Add this relay\") + btn(\"check\", \"\", w.state ? \"Check again\" : \"Check\");\n const n = w.list ? w.list.tags.filter((t) => t[0] === l.tag).length : 0;\n const from = w.list ? \"\" + (n === 1 ? \"1 entry\" : n + \" entries\") + \", \" + fmtTime(w.list.created_at) + \"\" : w.state && !busy ? \"no list found\" : \"\";\n const sent = (w.results || []).map((r) => '' + esc(r.url.replace(/^wss?:\\/\\//, \"\")) + (r.ok ? \"\" : \" failed\") + \"\").join(\"\");\n const meta = (w.state ? pill(w.state) : \"\") + from + sent;\n return '
                                                          ' + l.title + ' ' + l.nip + \"
                                                          \" + l.about + '
                                                          ' + buttons + '
                                                          ' + meta + \"
                                                          \";\n }).join(\"\");\n }\n async function wireCheck(l) {\n const w = (wire[l.kind] = { state: \"checking\", list: null, results: [] }); renderWire();\n const filter = { kinds: [l.kind], authors: [me], limit: 1 };\n const found = [];\n try { found.push(...(await bridge(\"/query\", [filter]))); } catch { /* the relay may hold none */ }\n const remote = await Promise.all(INDEXERS.map((u) => fetchNewest(u, filter, 4000)));\n let lib = null; try { lib = await signerLib(); } catch { /* unverified lists are still the owner's own, by pubkey */ }\n for (const e of remote) if (e && e.pubkey === me && e.kind === l.kind && Array.isArray(e.tags) && (!lib || lib.verifyEvent(e))) found.push(e);\n w.list = found.sort((a, b) => b.created_at - a.created_at)[0] || null;\n w.state = listHas(w.list, l) ? \"listed\" : \"not listed\"; renderWire();\n }\n async function wirePublish(l, include) {\n if (!wire[l.kind] || !wire[l.kind].state || wire[l.kind].state === \"failed\") await wireCheck(l);\n const w = wire[l.kind]; w.state = \"publishing\"; w.results = []; renderWire();\n try {\n const signed = await signer.signEvent(mergeList(w.list, l, include));\n const here = await bridge(\"/events\", signed).then((r) => ({ url: wsURL, ok: !!r.accepted, msg: r.message || \"\" })).catch((e) => ({ url: wsURL, ok: false, msg: e.message }));\n const targets = [...new Set([...relaysIn(signed), ...INDEXERS])].filter((u) => normURL(u) !== normURL(wsURL));\n const rest = await Promise.all(targets.map((u) => publishTo(u, signed, 6000)));\n w.results = [here, ...rest]; w.list = signed; w.state = include ? \"listed\" : \"not listed\";\n toast((include ? \"Added to your \" : \"Removed from your \") + l.title.toLowerCase());\n } catch (e) { w.state = \"failed\"; w.results = [{ url: wsURL, ok: false, msg: e.message }]; toast(e.message); }\n renderWire();\n }\n $(\"#wire\").addEventListener(\"click\", (ev) => {\n const b = ev.target.closest(\"button[data-wire]\"); if (!b) return;\n const l = LISTS.find((x) => String(x.kind) === b.dataset.wire); if (!l) return;\n if (b.dataset.do === \"check\") wireCheck(l); else wirePublish(l, b.dataset.do === \"add\");\n });\n\n // ---- custom domains: this relay under a hostname the owner controls ----\n let domainSites = [];\n const domainOptions = (current = \"\") => [{ label: \"\", title: \"This relay\" }, ...domainSites.map((s) => ({ label: s.label, title: (s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + \" / \" + key(s.author) })), ...(current && !domainSites.some((s) => s.label === current) ? [{ label: current, title: \"Site \" + current }] : [])].map((s) => '\").join(\"\");\n let domains = null; // null: not enabled on this host\n function renderDomains() {\n $(\"#domains\").innerHTML = (domains || []).map((d) => {\n const state = d.ready ? \"active\" : \"hostname \" + d.status.replace(/_/g, \" \") + \", certificate \" + d.sslStatus.replace(/_/g, \" \");\n const btn = (act, cls, label) => '\";\n const rows = d.ready ? \"\" : '
                                                          ' + d.records.map((r) => \"\").join(\"\") + \"
                                                          TypeNameValue
                                                          \" + esc(r.type) + '' + esc(r.name) + '' + esc(r.value) + \"\" + esc(r.note) + \"
                                                          \";\n return '
                                                          ' + esc(d.host) + '' + esc(state) + \"\" + btn(\"check\", \"\", \"Check\") + btn(\"remove\", \"danger\", \"Remove\") + '
                                                          \" + rows;\n }).join(\"\");\n }\n async function loadDomains() {\n try { [domains, domainSites] = await Promise.all([rpc(\"listdomains\"), rpc(\"listsites\")]); $(\"#adddomain select[name=site]\").innerHTML = domainOptions(); $(\"#domainnote\").textContent = domains.length ? \"\" : \"No custom domain yet.\"; $(\"#adddomain\").classList.remove(\"hidden\"); }\n catch (e) { domains = null; $(\"#domainnote\").textContent = /^unsupported/.test(e.message) ? \"Custom domains are not enabled on this host.\" : e.message; $(\"#adddomain\").classList.add(\"hidden\"); }\n renderDomains();\n }\n $(\"#adddomain\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"adddomain\", f.host.value.trim(), f.site.value); f.reset(); toast(\"Domain added; now create the CNAME\"); await loadDomains();\n });\n $(\"#domains\").addEventListener(\"change\", guard(async (ev) => {\n const select = ev.target.closest(\"select[data-domain-site]\"); if (!select) return;\n await rpc(\"setdomainsite\", select.dataset.domainSite, select.value);\n toast(\"Domain destination saved\"); await loadDomains();\n }));\n $(\"#domains\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-domain]\"); if (!b) return;\n const host = b.dataset.domain;\n if (b.dataset.do === \"remove\" && !confirm(\"Remove \" + host + \"? Its certificate goes with it and the name stops answering.\")) return;\n b.disabled = true;\n try {\n if (b.dataset.do === \"remove\") { await rpc(\"removedomain\", host); toast(\"Removed\"); }\n else { const d = await rpc(\"checkdomain\", host); toast(d.ready ? host + \" is live\" : \"Not yet: \" + (d.status === \"active\" ? \"certificate pending\" : \"waiting for the CNAME\")); }\n await loadDomains();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n $(\"#access\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const openKinds = f.openKinds.value.split(/[\\s,]+/).filter(Boolean).map(Number);\n if (openKinds.some((k) => !Number.isInteger(k) || k < 0 || k > 65535)) throw new Error(\"Open kinds must be whole numbers.\");\n policy = await rpc(\"setpolicy\", { writes: f.writes.value, reads: f.reads.value, openKinds, guestReplies: f.guestReplies.checked, minPow: +f.minPow.value, maxFuture: +f.maxFuture.value, maxLimit: +f.maxLimit.value, maxSubs: +f.maxSubs.value, maxMessageKB: +f.maxMessageKB.value, eventsPerMinute: +f.eventsPerMinute.value, reqsPerMinute: +f.reqsPerMinute.value, maxBlobMB: +f.maxBlobMB.value });\n toast(\"Rules saved\"); await loadInfo();\n });\n $(\"#notify\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { notify: { reports: f.reports.checked, fuel: f.fuel.checked, jobs: f.jobs.checked, succession: f.succession.checked, digest: f.digest.checked } });\n toast(\"Notifications saved\");\n });\n $(\"#notifytest\").onclick = guard(async () => {\n const r = await rpc(\"notifytest\");\n toast(r.sent ? \"Sent. Look for a message from the relay in your DMs.\" : \"Could not send.\");\n });\n $(\"#identity\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const list = (v) => v.split(\",\").map((s) => s.trim()).filter(Boolean);\n policy = await rpc(\"setpolicy\", { name: f.name.value, contact: f.contact.value, description: f.description.value, icon: f.icon.value,\n banner: f.banner.value, postingPolicy: f.postingPolicy.value, privacyPolicy: f.privacyPolicy.value, tags: list(f.tags.value), languageTags: list(f.languageTags.value), relayCountries: list(f.relayCountries.value) });\n toast(\"Identity saved\"); await loadInfo(); await loadPeople(); loadCard();\n });\n $(\"#addmember\").onsubmit = guard(async (ev) => {\n const f = ev.target; const pk = npubToHex(f.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"setmember\", pk, { name: f.name.value.trim() || null, note: f.note.value }); f.reset(); toast(\"Member added\"); await loadLists();\n });\n $(\"#addblock\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"blockip\", f.ip.value.trim(), f.reason.value); f.reset(); toast(\"Blocked\"); await loadLists();\n });\n $(\"#wordsform\").onsubmit = guard(async (ev) => {\n const words = ev.target.words.value.split(\"\\n\").map((s) => s.trim()).filter(Boolean);\n const kept = await rpc(\"setblockedwords\", words);\n policy = await rpc(\"setpolicy\", { blockedWordsInTags: ev.target.inTags.checked });\n ev.target.words.value = kept.join(\"\\n\"); toast(kept.length ? kept.length + \" words blocked\" : \"No words blocked\");\n });\n $(\"#thresholdform\").onsubmit = guard(async (ev) => {\n policy = await rpc(\"setpolicy\", { reportThreshold: +ev.target.reportThreshold.value });\n toast(policy.reportThreshold ? \"Hidden after \" + policy.reportThreshold + \" reports\" : \"Never hidden by reports\"); await loadLists();\n });\n $(\"#addban\").onsubmit = guard(async (ev) => {\n const pk = npubToHex(ev.target.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"banpubkey\", pk, ev.target.reason.value, ev.target.erase.checked); ev.target.reset(); toast(ev.target.erase.checked ? \"Banned and erased\" : \"Banned\"); await loadLists();\n });\n $(\"#mintinvite\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const inv = await rpc(\"createinvite\", +f.ttl.value, +f.max.value, f.note.value);\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n f.note.value = \"\"; toast(\"Invite created and copied\"); await loadLists();\n });\n $(\"#kindform\").onsubmit = guard(async (ev) => {\n const rule = ev.submitter.value; const k = +ev.target.kind.value;\n await rpc(rule === \"allow\" ? \"allowkind\" : \"disallowkind\", k); ev.target.reset(); toast((rule === \"allow\" ? \"Allowed kind \" : \"Blocked kind \") + k); await loadLists();\n });\n $(\"#more\").onclick = guard(() => loadEvents(false));\n $(\"#dumpform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { dumps: f.dumps.value, dumpsKeep: Math.max(1, Math.min(60, Math.floor(+f.keep.value || 7))) });\n toast(policy.dumps === \"off\" ? \"Dumps off\" : \"Dumping \" + policy.dumps); await loadDumps();\n });\n $(\"#importform\").onsubmit = guard(async (ev) => {\n const file = ev.target.file.files[0]; if (!file) return;\n if (!signer.ready()) throw new Error(NO_SIGNER);\n if (file.size > 64 * 1024 * 1024) throw new Error(\"At most 64 MB per import.\");\n const body = await file.text();\n const url = location.origin + \"/import?name=\" + encodeURIComponent(file.name);\n const token = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"PUT\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(url, { method: \"PUT\", headers: { authorization: \"Nostr \" + btoa(JSON.stringify(token)), \"content-type\": \"application/x-ndjson\" }, body });\n const r = await resp.json();\n if (!resp.ok) throw new Error(r.error || \"import failed\");\n ev.target.reset(); toast(\"Importing \" + fmtBytes(r.bytes)); await pollJobs();\n });\n $(\"#dumpnow\").onclick = guard(async () => { const d = await rpc(\"dumpnow\"); toast(\"Dumped \" + d.events.toLocaleString() + \" events\"); await loadStorage(); });\n $(\"#treeform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { memberInvites: { depth: Math.max(0, Math.floor(+f.depth.value || 0)), quota: Math.max(0, Math.floor(+f.quota.value || 0)) } });\n toast(policy.memberInvites.depth ? \"Members may invite\" : \"Only you and moderators invite\");\n });\n $(\"#mintmine\").onclick = guard(async () => {\n const inv = await rpc(\"createinvite\", 259200, 1, \"\");\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n toast(\"Invite created and copied\"); await loadMine();\n });\n $(\"#exportcfg\").onclick = guard(async () => {\n const cfg = await rpc(\"exportconfig\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(new Blob([JSON.stringify(cfg, null, 2)], { type: \"application/json\" }));\n a.download = host.split(\".\")[0] + \".bind.ws.config.json\";\n a.click(); URL.revokeObjectURL(a.href);\n });\n $(\"#importcfg\").onclick = () => $(\"#cfgfile\").click();\n $(\"#cfgfile\").onchange = guard(async (ev) => {\n const file = ev.target.files[0]; if (!file) return;\n const cfg = JSON.parse(await file.text());\n const check = await rpc(\"importconfig\", cfg, { dryRun: true });\n const lines = check.changes.summary.length ? check.changes.summary.join(\"\\n\") : \"Nothing would change.\";\n const dropped = check.warnings.length ? \"\\n\\nNot taken:\\n\" + check.warnings.join(\"\\n\") : \"\";\n if (!confirm(\"Apply \" + file.name + \" to this relay?\\n\\n\" + lines + dropped)) { ev.target.value = \"\"; return; }\n await rpc(\"importconfig\", cfg); ev.target.value = \"\"; toast(\"Configuration imported\"); await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // Succession: the status line under the heir form, from successionstatus.\n async function renderSuccession() {\n const el = $(\"#successionnote\"), f = $(\"#succession\");\n let st;\n try { st = await rpc(\"successionstatus\"); } catch { el.textContent = \"\"; return; }\n const sc = st.succession;\n $(\"#clearsuccession\").classList.toggle(\"hidden\", !sc);\n f.querySelector(\"button.btn:not(#clearsuccession)\").textContent = sc ? \"Change heir\" : \"Set heir\";\n if (!sc) { el.textContent = \"No heir named. Last signed in \" + fmtTime(st.ownerSeenAt) + \".\"; return; }\n if (f.heir.querySelector('[value=\"' + sc.heir + '\"]')) f.heir.value = sc.heir;\n f.afterDays.value = String(sc.afterDays);\n const who = (window.__members || []).find((m) => m.pubkey === sc.heir);\n const heir = who && who.name ? who.name + \"@\" + host : short(sc.heir);\n el.textContent = \"Heir: \" + heir + \". Last signed in \" + fmtTime(st.ownerSeenAt) + (st.silentDays ? \" (\" + st.silentDays + \" days ago)\" : \"\") + \". \" +\n (st.warning ? \"The warning month is running: the relay goes to \" + heir + \" on \" + fmtDay(st.handoverAt) + \" unless you sign in.\" : \"If you stay away, it goes to \" + heir + \" around \" + fmtDay(st.handoverAt) + \".\") +\n (st.log && st.log.length ? \" Handed over before: \" + st.log.map((l) => fmtDay(l.at) + \" to \" + short(l.to)).join(\", \") + \".\" : \"\");\n }\n $(\"#succession\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"setsuccession\", { heir: f.heir.value, afterDays: +f.afterDays.value });\n toast(\"Heir set\"); await loadAdmin();\n });\n $(\"#clearsuccession\").onclick = guard(async () => {\n await rpc(\"clearsuccession\");\n toast(\"Heir cleared\"); await loadAdmin();\n });\n $(\"#transfer\").onsubmit = guard(async (ev) => {\n const pk = ev.target.pubkey.value; if (!pk) return;\n const name = host.split(\".\")[0];\n const typed = prompt(\"This hands \" + host + \" to \" + short(pk) + \" for good. You stay on as a moderator. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing changed.\"); return; }\n await rpc(\"transferowner\", pk);\n toast(\"Transferred\");\n await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // ---- fork: a new name with this relay's events, claim reserved for a key ----\n $(\"#forkform\").scope.onchange = (ev) => $(\"#forkkinds\").classList.toggle(\"hidden\", ev.target.value !== \"kinds\");\n $(\"#joinform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { joinTerms: f.joinTerms.value, directoryPublic: f.directoryPublic.checked });\n toast(\"Saved\"); await loadInfo(); await loadPeople();\n });\n $(\"#forkform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const opts = { people: f.people.checked };\n if (f.name.value.trim()) opts.name = f.name.value.trim().toLowerCase();\n if (f.holder.value.trim()) { const pk = npubToHex(f.holder.value); if (!pk) throw new Error(\"That is not a pubkey.\"); opts.holder = pk; }\n if (f.scope.value === \"mine\") opts.filter = { authors: [me] };\n if (f.scope.value === \"kinds\") { const kinds = f.kinds.value.split(/[\\s,]+/).filter(Boolean).map(Number); if (!kinds.length || kinds.some((k) => !Number.isInteger(k) || k < 0)) throw new Error(\"Give kinds as numbers.\"); opts.filter = { kinds }; }\n if (!confirm(\"Fork this relay into a new name\" + (opts.name ? \" (\" + opts.name + \")\" : \"\") + \"? It pulls \" + (f.scope.value === \"all\" ? \"everything\" : f.scope.value === \"mine\" ? \"your events\" : \"the chosen kinds\") + (opts.people ? \" and the people\" : \"\") + \", and only \" + (opts.holder ? \"that key\" : \"you\") + \" can claim it.\")) return;\n const r = await rpc(\"forkrelay\", opts);\n $(\"#forknote\").textContent = r.handover + \" Expires \" + fmtTime(r.expires_at) + \".\";\n $(\"#forkurl\").textContent = r.console;\n $(\"#forkresult\").classList.remove(\"hidden\");\n toast(\"Forked to \" + r.name);\n });\n $(\"#forkcopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#forkurl\").textContent); toast(\"copied\"); };\n $(\"#deleterelay\").onclick = guard(async () => {\n const name = host.split(\".\")[0];\n const typed = prompt(\"This deletes everything on \" + host + \" and gives the name up. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing was deleted.\"); return; }\n await rpc(\"deleterelay\", name);\n localStorage.removeItem(\"me\");\n location.href = \"/\";\n });\n $(\"#topup\").onsubmit = guard((ev) => topUp(+ev.target.sats.value));\n $(\"#inv-copy\").onclick = async () => { await navigator.clipboard.writeText($(\"#inv-text\").value); toast(\"invoice copied\"); };\n $$(\"button[data-copy]\").forEach((b) => { b.onclick = async () => { await navigator.clipboard.writeText(b.dataset.copy === \"ws\" ? wsURL : location.origin); toast(\"copied\"); }; });\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-act]\"); if (!b) return;\n const act = b.dataset.act, id = b.dataset.id;\n if (act === \"copy\") { await navigator.clipboard.writeText(id); toast(\"copied\"); return; }\n if ((act === \"banpubkey\" || act === \"banevent\" || act === \"resolve:ban\") && !confirm(act === \"banevent\" ? \"Delete this event and refuse it forever?\" : \"Ban this author and refuse everything they post?\")) return;\n const erase = (act === \"banpubkey\" || act === \"resolve:ban\") && confirm(\"Also erase everything they wrote and uploaded here?\");\n if (act === \"deleteblob\" && !confirm(\"Delete this file for good?\")) return;\n if (act === \"deletedump\" && !confirm(\"Delete this dump?\")) return;\n if (act === \"downloaddump\") { b.disabled = true; try { await downloadDump(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }\n if (act === \"restorelist\") {\n if (!signer.ready()) { toast(NO_SIGNER); return; }\n if (!confirm(\"Restore this list version? It will be signed and published as the newest version.\")) return;\n try {\n const draft = await rpc(\"restorelist\", id);\n const signed = await signer.signEvent(draft);\n const result = await bridge(\"/events\", signed);\n if (!result.accepted) throw new Error(result.message || \"The relay refused the restored list.\");\n toast(\"List restored\"); await loadListHistory(); await loadStorage();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if ((act === \"removemember\" || act === \"banpubkey\") && (window.__members || []).some((m) => m.invited_by === id) && confirm(\"Also remove everyone this member invited, and everyone they invited in turn?\")) {\n b.disabled = true;\n try { const r = await rpc(\"removesubtree\", id); if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase); toast(\"Removed \" + r.removed.length); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"removejob\" && !confirm(\"Remove this job?\")) return;\n if (act === \"purgekind\") {\n const kind = id === \"\" ? null : +id;\n const label = kind === null ? \"everything without its own rule\" : kindName(kind) + \" (kind \" + kind + \")\";\n const typed = prompt(\"Purge \" + label + \" older than how many days? 0 purges all of them. This cannot be undone.\", \"30\");\n if (typed === null) return;\n const days = Math.max(0, Math.floor(+typed || 0));\n b.disabled = true;\n try { const r = await rpc(\"purgekind\", kind, days); toast(\"Purged \" + r.deleted.toLocaleString() + \" events\"); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"saveretention\") {\n const kind = id === \"\" ? null : +id;\n const days = Math.max(0, Math.floor(+b.closest(\"tr\").querySelector(\".days\").value || 0));\n b.disabled = true;\n try { await rpc(\"setretention\", kind, days); toast(days ? \"Kept for \" + days + \" days\" : \"Kept forever\"); await loadStorage(); await loadInfo(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n b.disabled = true;\n try {\n if (act === \"savemember\") { const tr = b.closest(\"tr\"), role = tr.querySelector(\"select.role\"), keep = tr.querySelector(\".keep\"), cap = tr.querySelector(\".cap\"); await rpc(\"setmember\", id, { name: tr.querySelector(\".name\").value.trim() || null, note: tr.querySelector(\".note\").value, ...(role ? { role: role.value } : {}), ...(keep ? { keepDays: Math.max(0, Math.floor(+keep.value || 0)), maxBytes: Math.max(0, Math.floor(+cap.value || 0)) * 1024 } : {}) }); }\n else if (act.startsWith(\"resolve:\")) await rpc(\"resolvereport\", id, act.slice(8), erase);\n else if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase);\n else await rpc(act, act === \"unrulekind\" ? +id : id, \"\");\n toast(\"Done\"); await (myRole ? refresh() : loadMine());\n if (act === \"pinevent\" || act === \"unpinevent\") await loadPins();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // The share block: the card picture, the group naddr and its QR.\n let card = null;\n async function loadCard() {\n try {\n card = await (await fetch(\"/card.json\", { cache: \"no-store\" })).json();\n $(\"#cardimg\").src = \"/card.svg?t=\" + Date.now();\n $(\"#naddr\").textContent = card.naddr || \"\";\n const q = $(\"#naddrqr\");\n if (card.naddr) { q.src = \"/qr.svg?text=\" + encodeURIComponent(card.naddr); q.classList.remove(\"hidden\"); } else q.classList.add(\"hidden\");\n } catch { /* the card is decoration */ }\n renderApps();\n }\n // The app rows on the Connect section: one per client people actually use,\n // with the link that lands on this relay in it. Relay apps take the relay\n // or the group address; feed apps take the owner's profile with this relay\n // as the hint, since they have no notion of opening a relay.\n function renderApps() {\n const el = $(\"#apps\"); if (!el) return;\n const enc = encodeURIComponent;\n const nprofile = card && card.nprofile ? card.nprofile : \"\";\n const naddr = card && card.naddr ? card.naddr : \"\";\n const whose = me && owner && me === owner ? \"your\" : \"the owner's\";\n const link = (label, href) => '' + label + \"\";\n const app = (label, uri) => '' + label + \"\";\n const copy = (label, text) => '\";\n const row = (name, where, note, acts) => '
                                                          ' + name + \"\" + where + \"

                                                          \" + note + '

                                                          ' + acts.filter(Boolean).join(\"\") + \"
                                                          \";\n const profileNote = \"Opens \" + whose + \" profile with this relay attached.\";\n const groups = [\n [\"As a place\", \"These open the relay itself: its feed, its people, its group.\", [\n row(\"Jumble\", \"web\", \"A feed of everything on this relay.\", [link(\"Open\", \"https://jumble.social/?r=\" + enc(wsURL))]),\n row(\"Coracle\", \"web\", \"The relay's page: its feed and its people.\", [link(\"Open\", \"https://coracle.social/relays/\" + enc(host))]),\n row(\"Flotilla\", \"web, phone\", \"The relay as a space, with the group as a room.\", [link(\"Open\", \"https://app.flotilla.social/spaces/\" + enc(host)), naddr && app(\"Open group\", \"nostr:\" + naddr)]),\n row(\"0xchat\", \"phone\", \"The group, in a chat app.\", [naddr && app(\"Open group\", \"nostr:\" + naddr), naddr && copy(\"Copy naddr\", naddr)]),\n row(\"noStrudel\", \"web\", \"Relays, add this one, then open its page.\", [copy(\"Copy relay URL\", wsURL)]),\n ]],\n [\"Find me here\", \"Feed apps have no relay pages. They meet this relay through a profile link that names it, then keep it once it is in the relay settings.\", [\n row(\"Primal\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://primal.net/p/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"YakiHonne\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://yakihonne.com/profile/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"Damus\", \"iPhone\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Amethyst\", \"Android\", profileNote + \" Then Relays in the drawer.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Nostur\", \"iPhone, Mac\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n ]],\n ];\n if (info?.supported_grasps?.includes(\"GRASP-01\")) groups.push([\n \"Git repositories\", \"Use this relay with a Git client.\", [\n row(\"GitWorkshop\", \"web\", \"Browse this relay's Git repositories.\", [link(\"Open in app\", \"https://gitworkshop.dev/relay/\" + enc((wsURL.startsWith(\"ws://\") ? \"ws:\" : \"\") + host)), copy(\"Copy relay URL\", wsURL)]),\n ],\n ]);\n el.innerHTML = groups.map(([h, note, rows]) => '

                                                          ' + h + '

                                                          ' + note + '

                                                          ' + rows.join(\"\") + \"
                                                          \").join(\"\");\n $(\"#apps-ws\").textContent = wsURL;\n const tile = (label, text) => '
                                                          ' + label + '\"QR
                                                          ';\n $(\"#phones\").innerHTML = [nprofile && tile(\"Find me here, for a phone\", \"nostr:\" + nprofile), naddr && tile(\"The group, for a phone\", \"nostr:\" + naddr)].filter(Boolean).join(\"\");\n }\n // The folds are a group: opening one closes the rest. Browsers with the\n // details name attribute do this themselves; this covers the others.\n $$(\".folds details\").forEach((d) => d.addEventListener(\"toggle\", () => { if (d.open) $$(\".folds details\").forEach((o) => { if (o !== d && o.open) o.open = false; }); }));\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-copytext]\"); if (!b) return;\n try { await navigator.clipboard.writeText(b.dataset.copytext); toast(\"copied\"); } catch { /* no clipboard */ }\n });\n $(\"#copynaddr\").onclick = async () => { if (!card || !card.naddr) return; await navigator.clipboard.writeText(card.naddr); toast(\"copied naddr\"); };\n $(\"#copyembed\").onclick = async () => { await navigator.clipboard.writeText('\"''); toast(\"copied embed\"); };\n\n // A click on a key copies it; a double-click selects the whole key so the usual copy shortcut takes it too.\n document.addEventListener(\"dblclick\", (ev) => {\n const k = ev.target.closest(\".key\"); if (!k) return;\n const s = window.getSelection(), r = document.createRange(); r.selectNodeContents(k.querySelector(\".full\")); s.removeAllRanges(); s.addRange(r);\n });\n document.addEventListener(\"click\", async (ev) => {\n const k = ev.target.closest(\".key\"); if (!k || ev.detail > 1 || String(window.getSelection())) return;\n try { await navigator.clipboard.writeText(k.title); toast(\"copied \" + k.dataset.short); } catch { /* no clipboard */ }\n });\n // ---- boot ----\n try { await loadInfo(); } catch { renderHeader(); }\n await loadFuel();\n await loadCard();\n me = localStorage.getItem(\"me\");\n if (me && await resumeRemote()) { /* a remote session answers for itself when first used */ }\n else if (me && window.nostr) { try { const pk = await window.nostr.getPublicKey(); if (pk !== me) me = null; } catch { me = null; } }\n else if (me) me = null;\n renderHeader();\n renderApps();\n await loadAdmin();\n await loadPeople();\n})();\n"; +export const CONSOLE_JS = "(async () => {\n const $ = (s) => document.querySelector(s);\n const $$ = (s) => document.querySelectorAll(s);\n const host = location.host;\n const wsURL = (location.protocol === \"https:\" ? \"wss://\" : \"ws://\") + host;\n const rpcURL = location.origin + \"/\";\n let info = null, me = null, owner = \"\", policy = null, fuel = null, people = null, myRole = \"\";\n\n const toast = (msg) => { const t = $(\"#toast\"); t.textContent = msg; t.classList.add(\"show\"); clearTimeout(t._t); t._t = setTimeout(() => t.classList.remove(\"show\"), 2600); };\n // guard wraps a handler: disables the button, shows errors as toasts. Defined first, since handlers below use it.\n const guard = (fn) => async (ev) => { ev.preventDefault(); const b = ev.submitter || ev.target; if (b) b.disabled = true; try { await fn(ev); } catch (e) { toast(e.message); } finally { if (b) b.disabled = false; } };\n const short = (hex) => hex ? hex.slice(0, 8) + \"…\" + hex.slice(-4) : \"\";\n // key shows a key short but keeps the whole of it in the DOM: selecting, double-clicking or clicking it copies the full hex.\n const key = (hex) => hex ? '' + hex + '' : '';\n const hue = (hex) => (parseInt(hex.slice(0, 2), 16) * 360 / 256).toFixed(0) + \"deg\";\n const av = (hex) => '';\n const fmtBytes = (n) => n < 1e6 ? (n / 1e3).toFixed(0) + \" KB\" : n < 1e9 ? (n / 1e6).toFixed(1) + \" MB\" : (n / 1e9).toFixed(2) + \" GB\";\n const fmtHours = (ms) => ms < 3600e3 ? Math.round(ms / 60e3) + \" min\" : (ms / 3600e3).toFixed(ms < 36e6 ? 1 : 0) + \" h\";\n const fuelOver = () => !!fuel && (fuel.eventBytes > fuel.freeEventBytes || fuel.mediaBytes > fuel.freeMediaBytes || fuel.activeMs > fuel.freeActiveMs || fuel.rowsWritten > fuel.freeRowsWritten);\n const fmtTime = (t) => t ? new Date(t * 1000).toLocaleString(undefined, { month: \"short\", day: \"numeric\", hour: \"2-digit\", minute: \"2-digit\" }) : \"–\";\n const fmtDay = (t) => t ? new Date(t * 1000).toLocaleDateString(undefined, { month: \"short\", day: \"numeric\" }) : \"–\";\n const ago = (t) => { const s = Math.max(0, Math.floor(Date.now() / 1000) - t); return s < 60 ? \"just now\" : s < 3600 ? Math.floor(s / 60) + \" min\" : s < 86400 ? Math.floor(s / 3600) + \" h\" : Math.floor(s / 86400) + \" d\"; };\n const esc = (s) => String(s ?? \"\").replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c]);\n\n const IC = {\n x: '',\n undo: '',\n check: '',\n trash: '',\n ban: '',\n banuser: '',\n eye: '',\n lock: '',\n pen: '',\n people: '',\n person: '',\n gauge: '',\n bolt: '',\n copy: '',\n pin: '',\n };\n const ib = (icon, label, act, id, extra) => '\";\n\n const CH = \"qpzry9x8gf2tvdw0s3jn54khce6mua7l\";\n function npubToHex(s) {\n s = s.trim().toLowerCase();\n if (/^[0-9a-f]{64}$/.test(s)) return s;\n if (!s.startsWith(\"npub1\")) return null;\n const data = s.slice(5, -6).split(\"\").map((c) => CH.indexOf(c));\n if (data.some((d) => d < 0)) return null;\n let bits = 0, acc = 0, out = [];\n for (const d of data) { acc = (acc << 5) | d; bits += 5; if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 255); } }\n return out.length === 32 ? out.map((b) => b.toString(16).padStart(2, \"0\")).join(\"\") : null;\n }\n\n async function sha256hex(s) {\n const b = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(s));\n return [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, \"0\")).join(\"\");\n }\n // ---- signing: a NIP-07 extension, or a NIP-46 remote signer ----\n // The remote path loads the bundled library on first use; its session\n // (client key, signer pubkey, relays) lives in localStorage until sign-out.\n let remote = null, lib = null, pendingNote = null;\n const NO_SIGNER = \"Install a nostr extension (Alby, nos2x, …) or connect a remote signer.\";\n const withTimeout = (p, ms, what) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(what + \" did not answer; is the signer app open?\")), ms))]);\n const signer = {\n ready: () => !!remote || !!window.nostr,\n async getPublicKey() { if (remote) return withTimeout(remote.getPublicKey(), 20000, \"The remote signer\"); if (window.nostr) return window.nostr.getPublicKey(); throw new Error(NO_SIGNER); },\n async signEvent(ev) { if (remote) return withTimeout(remote.signEvent(ev), 60000, \"The remote signer\"); if (window.nostr) return window.nostr.signEvent(ev); throw new Error(NO_SIGNER); },\n };\n async function signerLib() {\n if (lib) return lib;\n if (!window.NostrSigner) await new Promise((res, rej) => { const s = document.createElement(\"script\"); s.src = window.SIGNER_URL || \"/signer.js\"; s.onload = res; s.onerror = () => rej(new Error(\"Could not load the signer library.\")); document.head.appendChild(s); });\n return (lib = window.NostrSigner);\n }\n const onauth = (u) => window.open(u, \"_blank\");\n // The signer conversation rides this relay, and a relay may ask the socket to\n // authenticate before it delivers kind 24133 to its parties; answer with the\n // session's own key.\n const authPool = (L, sk) => new L.SimplePool({ automaticallyAuth: () => (evt) => Promise.resolve(L.finalizeEvent(evt, sk)) });\n const saveSession = (sk, s, secret) => localStorage.setItem(\"nip46\", JSON.stringify({ sk: lib.bytesToHex(sk), pubkey: s.bp.pubkey, relays: s.bp.relays, secret: secret || null }));\n async function resumeRemote() {\n const raw = localStorage.getItem(\"nip46\");\n if (!raw) return false;\n try {\n const s = JSON.parse(raw); const L = await signerLib();\n remote = L.BunkerSigner.fromBunker(L.hexToBytes(s.sk), { pubkey: s.pubkey, relays: s.relays, secret: s.secret }, { onauth, pool: authPool(L, L.hexToBytes(s.sk)) });\n return true;\n } catch { localStorage.removeItem(\"nip46\"); return false; }\n }\n async function connectBunker(input) {\n const L = await signerLib();\n const bp = await L.parseBunkerInput(input.trim());\n if (!bp) throw new Error(\"That is not a bunker:// URL.\");\n if (!bp.relays.length) throw new Error(\"The bunker URL names no relay.\");\n const sk = L.generateSecretKey();\n const s = L.BunkerSigner.fromBunker(sk, bp, { onauth, pool: authPool(L, sk) });\n await withTimeout(s.connect({ name: host, url: location.origin }), 60000, \"The signer\");\n await Promise.race([s.switchRelays(), new Promise((r) => setTimeout(r, 3000))]);\n remote = s; saveSession(sk, s, bp.secret);\n }\n // The nostrconnect:// flow: this relay carries the traffic, so no third relay is involved.\n let ncPending = null;\n async function offerNostrConnect() {\n if (ncPending) return ncPending;\n const L = await signerLib();\n const sk = L.generateSecretKey();\n const secret = L.bytesToHex(crypto.getRandomValues(new Uint8Array(8)));\n const uri = L.createNostrConnectURI({ clientPubkey: L.getPublicKey(sk), relays: [wsURL], secret, name: host, url: location.origin, perms: [\"sign_event:27235\", \"sign_event:9734\"] });\n $(\"#nclink\").href = uri; $(\"#ncnote\").textContent = \"\";\n $(\"#ncqr\").src = \"/qr.svg?text=\" + encodeURIComponent(uri); $(\"#ncqr\").classList.remove(\"hidden\");\n ncPending = L.BunkerSigner.fromURI(sk, uri, { onauth, pool: authPool(L, sk) }, 600000).then((s) => { remote = s; saveSession(sk, s, secret); ncPending = null; return s; }, (e) => { ncPending = null; throw e; });\n return ncPending;\n }\n async function remoteDone() {\n me = await signer.getPublicKey();\n localStorage.setItem(\"me\", me);\n $(\"#remotesec\").classList.add(\"hidden\");\n toast(\"Remote signer connected\");\n if (pendingNote && !owner) { const n = pendingNote; pendingNote = null; await claimNow(n); return; }\n pendingNote = null;\n renderHeader(); await loadAdmin(); await loadPeople();\n }\n function showRemote(note) {\n pendingNote = note || null;\n $(\"#remotesec\").classList.remove(\"hidden\");\n $(\"#remotesec\").scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n offerNostrConnect().then(remoteDone).catch((e) => { $(\"#ncnote\").textContent = e.message; });\n }\n\n async function rpc(method, ...params) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const body = JSON.stringify({ method, params });\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", rpcURL], [\"method\", \"POST\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(rpcURL, { method: \"POST\", headers: { \"content-type\": \"application/nostr+json+rpc\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body });\n const json = await resp.json();\n if (json.error) throw new Error(json.error);\n return json.result;\n }\n\n // ---- front of house ----\n // urlish shows a URL as a short link: the host and the start of the path, the whole thing on hover and as the target.\n class Html { constructor(s) { this.s = s; } }\n const urlish = (u) => {\n if (!u) return \"\";\n let label = u; try { const x = new URL(u); label = x.host + (x.pathname.length > 1 ? x.pathname.slice(0, 18) + (x.pathname.length > 18 ? \"\\u2026\" : \"\") : \"\"); } catch { /* not a URL */ }\n if (!/^https?:\\/\\//.test(u)) return u;\n return new Html('' + esc(label) + \"\");\n };\n function renderHeader() {\n const name = (info && info.name) || host.split(\".\")[0];\n $(\"#title\").textContent = name;\n document.title = name + \" - relay\";\n $(\"#url\").textContent = wsURL;\n $(\"#d-ws\").textContent = wsURL; $(\"#d-blossom\").textContent = location.origin; $(\"#d-nip05\").textContent = \"you@\" + host;\n $(\"#nip05-example\").textContent = \"alice@\" + host;\n const desc = (info && info.description) || \"\";\n $(\"#desc\").textContent = desc; $(\"#desc\").classList.toggle(\"hidden\", !desc);\n // What the owner declared about the relay, on the page, not only in the document clients read.\n const img = (id, url) => { const el = $(id); if (url) el.src = url; el.classList.toggle(\"hidden\", !url); };\n img(\"#bannerimg\", info?.banner); $(\"#banner\").classList.toggle(\"hidden\", !info?.banner);\n img(\"#iconimg\", info?.icon);\n const meta = [];\n for (const t of info?.tags || []) meta.push('' + esc(t) + \"\");\n const where = [...(info?.language_tags || []), ...(info?.relay_countries || [])];\n if (where.length) meta.push(\"\" + esc(where.join(\", \")) + \"\");\n const link = (label, href) => href ? '' + label + \"\" : \"\";\n const contact = info?.contact ? (/^(mailto:|https?:\\/\\/)/.test(info.contact) ? link(\"Contact\", info.contact) : \"\" + esc(info.contact) + \"\") : \"\";\n for (const x of [contact, link(\"Terms\", info?.terms_of_service), link(\"Posting policy\", info?.posting_policy), link(\"Privacy\", info?.privacy_policy)]) if (x) meta.push(x);\n $(\"#metarow\").innerHTML = meta.join('|'); $(\"#metarow\").classList.toggle(\"hidden\", meta.length === 0);\n $(\"#frontsec\").classList.toggle(\"hidden\", !desc && meta.length === 0);\n owner = (info && info.pubkey) || \"\";\n const isOwner = !!me && me === owner;\n const lease = !owner && info && info.lease ? info.lease : null;\n $(\"#ownerline\").innerHTML = owner ? 'run by ' + key(owner) + \"\" : lease ? \"temporary until \" + fmtDay(lease.expires_at) : \"unclaimed\";\n $(\"#owner-av\").classList.toggle(\"hidden\", !owner);\n if (owner) $(\"#owner-av\").style.setProperty(\"--h\", hue(owner));\n $(\"#who\").textContent = me ? (isOwner ? \"(that's you)\" : \"\") : \"\";\n $(\"#unclaimed\").classList.toggle(\"hidden\", !!owner || !!lease);\n $(\"#leased\").classList.toggle(\"hidden\", !lease);\n if (lease) {\n $(\"#lease-until\").textContent = fmtTime(lease.expires_at);\n $(\"#leasenote\").textContent = lease.holder ? \"Reserved for the key that asked for it; sign with that key.\" : \"\";\n }\n $(\"#signin\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signin46\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signout\").classList.toggle(\"hidden\", !me);\n $(\"#about\").innerHTML = [\n [\"Name\", info?.name], [\"Description\", info?.description], [\"Contact\", info?.contact], [\"Owner\", owner ? short(owner) : \"\"], [\"Relay key\", info?.self ? short(info.self) : \"\"], [\"Software\", urlish(info?.software)], [\"Version\", info?.version],\n [\"Max query\", info?.limitation?.max_limit], [\"Auth required\", String(!!info?.limitation?.auth_required)], [\"Restricted writes\", String(!!info?.limitation?.restricted_writes)], [\"Min PoW\", info?.limitation?.min_pow_difficulty || 0],\n [\"Tags\", info?.tags?.join(\", \")], [\"Languages\", info?.language_tags?.join(\", \")], [\"Countries\", info?.relay_countries?.join(\", \")],\n [\"Terms\", urlish(info?.terms_of_service)], [\"Posting policy\", urlish(info?.posting_policy)], [\"Privacy policy\", urlish(info?.privacy_policy)], [\"Icon\", urlish(info?.icon)], [\"Banner\", urlish(info?.banner)],\n [\"NIPs\", info?.supported_nips?.join(\" \")],\n ].map(([k, v]) => \"
                                                          \" + k + \"\" + (v === undefined || v === \"\" ? '' : v instanceof Html ? v.s : esc(v)) + \"
                                                          \").join(\"\");\n renderCare();\n }\n\n function renderCare() {\n const g = (icon, label, off) => '' + IC[icon] + \"\" + label + \"\";\n const writes = policy ? policy.writes : (info?.limitation?.restricted_writes ? \"allowlist\" : \"open\");\n const reads = policy ? policy.reads : (info?.limitation?.auth_required ? \"auth\" : \"open\");\n let out = writes === \"owner\" ? g(\"person\", \"only owner writes\") : writes === \"allowlist\" ? g(\"people\", \"members write\") : writes === \"wot\" ? g(\"people\", \"members and follows write\") : g(\"pen\", \"anyone writes\");\n out += reads === \"members\" ? g(\"people\", \"members read\") : reads === \"auth\" ? g(\"lock\", \"sign in to read\") : g(\"eye\", \"anyone reads\");\n if (fuel) {\n const over = fuelOver();\n out += fuel.outOfFuel ? g(\"gauge\", \"out of fuel\", true) : over ? g(\"bolt\", \"burning sats\") : g(\"gauge\", \"on free allowance\");\n }\n $(\"#care\").innerHTML = out;\n }\n\n async function loadPeople() {\n try { people = await (await fetch(\"/people\")).json(); } catch { return; }\n const isOwner = !!me && me === owner;\n let list = people.people || [];\n if (!people.public && isOwner && policy) list = (window.__members || []);\n $(\"#peoplesec\").classList.toggle(\"hidden\", !owner || list.length === 0);\n $(\"#people-note\").classList.toggle(\"hidden\", people.public);\n $(\"#people-count\").textContent = list.length || \"\";\n $(\"#dir\").innerHTML = list.map((m) => '' + av(m.pubkey) + '' + (m.name ? esc(m.name) + \"@\" + host : key(m.pubkey)) + \"\" + (m.role === \"owner\" ? 'owner' : \"\") + \"\").join(\"\");\n }\n\n async function loadInfo() {\n info = await (await fetch(\"/\", { headers: { accept: \"application/nostr+json\" } })).json();\n renderHeader();\n const git = info.supported_grasps?.includes(\"GRASP-01\");\n $(\"#git-connect\").classList.toggle(\"hidden\", !git);\n const clone = \"git clone '\" + location.origin + \"//.git'\";\n $(\"#git-clone\").textContent = clone;\n $(\"#git-copy\").dataset.copytext = clone;\n renderApps();\n }\n\n async function loadFuel() {\n try { fuel = await (await fetch(\"/fuel\")).json(); } catch { return; }\n $(\"#fuelsec\").classList.toggle(\"hidden\", !owner);\n const pct = (used, free) => Math.min(100, Math.round((used / Math.max(free, 1)) * 100));\n const gauge = (id, used, free, text) => { const g = $(\"#g-\" + id), share = free ? used / free : 0; g.style.width = pct(used, free) + \"%\"; g.classList.toggle(\"over\", share > 1); g.classList.toggle(\"warm\", share > 0.75 && share <= 1); $(\"#p-\" + id).textContent = share > 1 ? \"over\" : Math.round(share * 100) + \"%\"; $(\"#t-\" + id).textContent = text; };\n gauge(\"events\", fuel.eventBytes, fuel.freeEventBytes, fmtBytes(fuel.eventBytes) + \" of \" + fmtBytes(fuel.freeEventBytes) + \" free\");\n gauge(\"media\", fuel.mediaBytes, fuel.freeMediaBytes, fmtBytes(fuel.mediaBytes) + \" of \" + fmtBytes(fuel.freeMediaBytes) + \" free\");\n gauge(\"active\", fuel.activeMs, fuel.freeActiveMs, fmtHours(fuel.activeMs) + \" of \" + fmtHours(fuel.freeActiveMs) + \" free\");\n gauge(\"rows\", fuel.rowsWritten, fuel.freeRowsWritten, fuel.rowsWritten.toLocaleString() + \" of \" + fuel.freeRowsWritten.toLocaleString() + \" free\");\n const sats = Math.floor(fuel.balanceMsats / 1000);\n const r = fuel.rates;\n $(\"#fuel-balance\").innerHTML = fuel.outOfFuel\n ? 'out of fuel Writes are paused until someone tops up.'\n : \"Balance \" + sats.toLocaleString() + \" sats. Past the allowances, prices track what the hosting costs: \" + r.satsPerGBMonthEvents.toLocaleString() + \" sats per GB-month of events, \" + r.satsPerGBMonthMedia.toLocaleString() + \" per GB-month of files, \" + r.satsPerActiveHour.toLocaleString() + \" per hour awake, \" + r.satsPerMillionRows.toLocaleString() + \" per million rows written. Traffic is free.\";\n $(\"#topup\").classList.toggle(\"hidden\", !fuel.enabled);\n // Who paid is the owner's to see: it comes with the signed stats call.\n let credits = [];\n if (owner && signer.ready()) { try { credits = (await rpc(\"stats\")).credits || []; } catch { credits = []; } }\n $(\"#credits tbody\").innerHTML = credits.length ? credits.map((c) => '' + esc(fmtTime(c.at)) + '' + av(c.payer) + key(c.payer) + '' + Math.floor(c.msats / 1000).toLocaleString() + \"\").join(\"\") : 'no zaps yet';\n if (!fuel.enabled) $(\"#topup-note\").textContent = \"Top-ups are not enabled on this service yet.\";\n renderCare();\n renderFuelTile();\n }\n\n async function topUp(sats) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const msats = Math.round(sats * 1000);\n const zapRequest = await signer.signEvent({ kind: 9734, created_at: Math.floor(Date.now() / 1000), content: \"fuel for \" + host, tags: [[\"p\", fuel.servicePubkey], [\"amount\", String(msats)], [\"relays\", wsURL]] });\n const r = await (await fetch(\"/fuel/invoice\", { method: \"POST\", body: JSON.stringify({ zapRequest }) })).json();\n if (r.error) throw new Error(r.error);\n const inv = r.invoice;\n $(\"#invoice\").classList.remove(\"hidden\");\n $(\"#inv-text\").value = inv; $(\"#inv-link\").href = \"lightning:\" + inv; $(\"#inv-state\").textContent = \"waiting for payment…\";\n if (window.webln) { try { await window.webln.enable(); await window.webln.sendPayment(inv); } catch (e) { toast(e.message || \"wallet declined\"); } }\n const before = fuel.creditedMsats;\n for (let i = 0; i < 120; i++) {\n await new Promise((r) => setTimeout(r, 2500));\n await loadFuel();\n if (fuel.creditedMsats > before) { $(\"#inv-state\").textContent = \"paid, thank you\"; toast(\"Fuel credited\"); return; }\n }\n $(\"#inv-state\").textContent = \"no receipt yet; it can take a minute after paying\";\n }\n\n // ---- console ----\n function showTab(name) {\n if (name === \"content\" || name === \"storage\") name = \"data\";\n const known = myRole === \"moderator\" ? [\"people\", \"moderation\"] : [\"people\", \"moderation\", \"rules\", \"identity\", \"data\", \"sync\", \"views\", \"health\", \"owner\"];\n if (!known.includes(name)) name = \"people\";\n $$(\".tabs a\").forEach((a) => a.classList.toggle(\"on\", a.dataset.tab === name));\n $$(\".panel\").forEach((p) => p.classList.toggle(\"on\", p.dataset.panel === name));\n }\n // Tabs switch in place. A fragment still opens a tab when someone arrives with one,\n // but clicking never writes one, and any fragment already there is dropped.\n $$(\".tabs a\").forEach((a) => a.addEventListener(\"click\", (ev) => { ev.preventDefault(); showTab(a.dataset.tab); if (location.hash) history.replaceState(null, \"\", location.pathname + location.search); }));\n window.addEventListener(\"hashchange\", () => showTab(location.hash.slice(1)));\n // The strip fades on whichever side has more tabs off screen.\n const tabsFade = () => { const t = $(\"#tabs\"); t.classList.toggle(\"fade-l\", t.scrollLeft > 4); t.classList.toggle(\"fade-r\", t.scrollLeft + t.clientWidth < t.scrollWidth - 4); };\n $(\"#tabs\").addEventListener(\"scroll\", tabsFade); window.addEventListener(\"resize\", tabsFade); new ResizeObserver(tabsFade).observe($(\"#tabs\"));\n\n function renderFuelTile() {\n if (!fuel) return;\n const over = fuelOver();\n const sats = Math.max(0, Math.floor(fuel.balanceMsats / 1000));\n const d = new Date(), end = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1), days = Math.max(1, Math.ceil((end - Date.now()) / 86400000));\n if (fuel.outOfFuel) { $(\"#h-fuel\").textContent = \"0 sats\"; $(\"#h-fuel-label\").textContent = \"out of fuel\"; $(\"#h-fuel-sub\").textContent = \"writes are paused\"; }\n else if (over) { $(\"#h-fuel\").textContent = sats.toLocaleString() + \" sats\"; $(\"#h-fuel-label\").textContent = \"fuel left\"; $(\"#h-fuel-sub\").textContent = \"past the free allowance\"; }\n else { $(\"#h-fuel\").textContent = days + (days === 1 ? \" day\" : \" days\"); $(\"#h-fuel-label\").textContent = \"free allowance left\"; $(\"#h-fuel-sub\").textContent = sats ? \"then \" + sats.toLocaleString() + \" sats\" : \"then a top-up is needed\"; }\n $(\"#usage\").innerHTML = [[\"events stored\", fmtBytes(fuel.eventBytes)], [\"files stored\", fmtBytes(fuel.mediaBytes)], [\"awake this month\", fmtHours(fuel.activeMs)], [\"rows written\", fuel.rowsWritten.toLocaleString()], [\"rows read\", fuel.rowsRead.toLocaleString()], [\"received this month\", fmtBytes(fuel.bytesIn)], [\"served this month\", fmtBytes(fuel.bytesOut)], [\"charged\", Math.floor(fuel.chargedMsats / 1000).toLocaleString() + \" sats\"]]\n .map(([k, v]) => \"
                                                          \" + k + \"\" + v + \"
                                                          \").join(\"\");\n }\n\n function renderHealth(stats) {\n $(\"#h-last\").textContent = stats.newest ? ago(stats.newest) : \"none\";\n $(\"#h-last-sub\").textContent = stats.newest ? fmtTime(stats.newest) : \"no events yet\";\n $(\"#h-conns\").textContent = stats.connections;\n const buckets = [[\"notes\", \"k1\", [1]], [\"reactions\", \"k2\", [7]], [\"DMs\", \"k3\", [4, 14, 1059]], [\"long-form\", \"k4\", [30023]], [\"other\", \"k5\", null]];\n const sums = buckets.map(() => 0); let total = 0;\n for (const { kind, n } of stats.kinds || []) { total += n; const i = buckets.findIndex((b) => b[2] && b[2].includes(kind)); sums[i < 0 ? 4 : i] += n; }\n const parts = buckets.map((b, i) => [b[0], b[1], sums[i]]).filter((p) => p[2] > 0);\n $(\"#h-kinds\").innerHTML = total ? parts.map(([name, cls, n]) => '').join(\"\") : '';\n $(\"#h-kinds-legend\").innerHTML = total ? parts.map(([name, cls, n]) => '' + name + \" \" + Math.round(n * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n renderFuelTile();\n }\n\n async function loadAdmin() {\n const isOwner = !!me && me === owner;\n myRole = isOwner ? \"owner\" : \"\";\n let stats = null, p = null;\n // A signed-in member may be a moderator; the relay says by answering stats.\n if (me && owner && !isOwner) { try { [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]); myRole = \"moderator\"; } catch { myRole = \"\"; } }\n $(\"#console\").classList.toggle(\"hidden\", !myRole);\n loadMine();\n $(\"#console\").classList.toggle(\"mod\", myRole === \"moderator\");\n if (!myRole) { policy = null; renderCare(); return; }\n if (isOwner) [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]);\n policy = p;\n renderHealth(stats);\n renderCare();\n const fa = $(\"#access\"), fi = $(\"#identity\");\n fa.writes.value = p.writes; fa.reads.value = p.reads; fa.openKinds.value = (p.openKinds || []).join(\", \"); fa.guestReplies.checked = !!p.guestReplies;\n $(\"#wordsform\").words.value = (p.blockedWords || []).join(\"\\n\"); $(\"#wordsform\").inTags.checked = !!p.blockedWordsInTags; $(\"#thresholdform\").reportThreshold.value = p.reportThreshold || 0; fa.minPow.value = p.minPow; fa.maxFuture.value = p.maxFuture; fa.maxLimit.value = p.maxLimit; fa.maxSubs.value = p.maxSubs; fa.maxMessageKB.value = p.maxMessageKB;\n fa.eventsPerMinute.value = p.eventsPerMinute; fa.reqsPerMinute.value = p.reqsPerMinute; fa.maxBlobMB.value = p.maxBlobMB;\n renderFeatures(p.features || {});\n $(\"#push-policy-form\").elements.origins.value = (p.pushCallbacks || []).join(\"\\n\");\n $(\"#push-policy-form\").elements.lettered.checked = !!p.letteredNips;\n fi.name.value = p.name; fi.contact.value = p.contact; fi.description.value = p.description; fi.icon.value = p.icon;\n const fj = $(\"#joinform\"); fj.joinTerms.value = p.joinTerms; fj.directoryPublic.checked = !!p.directoryPublic;\n loadCard();\n fi.banner.value = p.banner || \"\"; fi.postingPolicy.value = p.postingPolicy || \"\"; fi.privacyPolicy.value = p.privacyPolicy || \"\";\n fi.tags.value = (p.tags || []).join(\", \"); fi.languageTags.value = (p.languageTags || []).join(\", \"); fi.relayCountries.value = (p.relayCountries || []).join(\", \");\n const fn = $(\"#notify\"), nt = p.notify || {};\n fn.reports.checked = !!nt.reports; fn.fuel.checked = !!nt.fuel; fn.jobs.checked = !!nt.jobs; fn.succession.checked = !!nt.succession; fn.digest.checked = !!nt.digest;\n showTab(location.hash.slice(1));\n if (isOwner) { await renderPresets(); renderWire(); renderSuccession(); loadDomains(); }\n await Promise.all([loadLists(), loadEvents(true), loadPins(), ...(isOwner ? [loadStorage()] : [])]);\n }\n\n async function loadAudit(before) {\n const rows = await rpc(\"listaudit\", before || 0);\n const html = rows.map((r) => '' + esc(fmtTime(r.at)) + '' + av(r.actor) + key(r.actor) + '' + esc(r.action) + '' + (/^[0-9a-f]{64}$/.test(r.target) ? key(r.target) : esc(r.target)) + '' + esc(r.detail) + '').join(\"\");\n const tb = $(\"#audit tbody\");\n if (before) tb.insertAdjacentHTML(\"beforeend\", html); else tb.innerHTML = html || 'Nothing yet';\n $(\"#audit-more\").style.display = rows.length < 100 ? \"none\" : \"\";\n }\n $(\"#audit-more\").onclick = guard(async () => { const last = $(\"#audit tbody tr:last-child\"); await loadAudit(last ? +last.dataset.seq : 0); });\n async function loadLists() {\n loadAudit(0);\n const [mem, bans, bannedEvents, allow, block, invites, reports, blobs, blocks, sites] = await Promise.all([rpc(\"listmembers\"), rpc(\"listbannedpubkeys\"), rpc(\"listbannedevents\"), rpc(\"listallowedkinds\"), rpc(\"listblockedkinds\"), rpc(\"listinvites\"), rpc(\"listreports\"), rpc(\"listblobs\", 100), rpc(\"listblockedips\"), rpc(\"listsites\")]);\n const members = mem.members;\n window.__members = members;\n const roleCell = (m) => m.role === \"owner\" ? ' owner' : myRole === \"owner\" ? ' ' : m.role === \"moderator\" ? ' moderator' : \"\";\n const untouchable = (m) => m.role === \"owner\" || (myRole !== \"owner\" && m.role === \"moderator\");\n // The tree: everyone under whoever invited them, the owner and the owner's own additions at the root.\n const known = new Set(members.map((m) => m.pubkey));\n const byInviter = new Map();\n for (const m of members) { const k = m.role !== \"owner\" && known.has(m.invited_by) ? m.invited_by : \"\"; if (!byInviter.has(k)) byInviter.set(k, []); byInviter.get(k).push(m); }\n const ordered = [], placed = new Set();\n const walk = (k, depth) => { for (const m of byInviter.get(k) || []) { if (placed.has(m.pubkey)) continue; placed.add(m.pubkey); m.depth = depth; ordered.push(m); walk(m.pubkey, depth + 1); } };\n walk(\"\", 0);\n for (const m of members) if (!placed.has(m.pubkey)) { m.depth = 0; ordered.push(m); }\n const nameOf = (pk) => { const x = members.find((m) => m.pubkey === pk); return x && x.name ? x.name : short(pk); };\n const limits = (m) => m.role === \"owner\" ? \"\" : myRole === \"owner\"\n ? ''\n : '' + (m.keep_days ? m.keep_days + \" d\" : \"\") + (m.max_bytes ? \" \" + fmtBytes(m.max_bytes) : \"\") + \"\";\n $(\"#members tbody\").innerHTML = ordered.map((m) => '' + av(m.pubkey) + key(m.pubkey) + roleCell(m) + (m.depth ? ' via ' + esc(nameOf(m.invited_by)) + \"\" : \"\") + (m.invites ? ' ' + m.invites + \" inv\" : \"\") + '' + limits(m) + '' + esc(fmtDay(m.joined_at)) + \", \" + esc(m.via) + '' + ib(\"check\", \"Save\", \"savemember\", m.pubkey) + (untouchable(m) ? \"\" : ib(\"x\", \"Remove\", \"removemember\", m.pubkey) + ib(\"banuser\", \"Ban\", \"banpubkey\", m.pubkey, \"danger\")) + \"\").join(\"\");\n const tf = $(\"#treeform\");\n tf.classList.toggle(\"hidden\", myRole !== \"owner\");\n if (policy && policy.memberInvites) { tf.depth.value = policy.memberInvites.depth; tf.quota.value = policy.memberInvites.quota; }\n $(\"#transfer [name=pubkey]\").innerHTML = members.filter((m) => m.role !== \"owner\").map((m) => '\").join(\"\");\n $(\"#succession [name=heir]\").innerHTML = $(\"#transfer [name=pubkey]\").innerHTML;\n $(\"#tc-people\").textContent = members.length;\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#invites\").innerHTML = invites.length ? invites.map((i) => '
                                                        • inv…' + i.code.slice(-8) + \" \" + esc(i.note || \"\") + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
                                                        • \").join(\"\") : '
                                                        • no invites
                                                        • ';\n const person = (r, icon, label, act) => \"
                                                        • \" + av(r.pubkey) + '' + short(r.pubkey) + \" \" + esc(r.reason || \"\") + \"\" + ib(icon, label, act, r.pubkey) + \"
                                                        • \";\n $(\"#bans\").innerHTML = bans.length ? bans.map((r) => person(r, \"undo\", \"Unban\", \"unrulepubkey\")).join(\"\") : '
                                                        • nobody banned
                                                        • ';\n $(\"#blocks\").innerHTML = blocks.length ? blocks.map((r) => '
                                                        • ip' + esc(r.ip) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"undo\", \"Unblock\", \"unblockip\", r.ip) + \"
                                                        • \").join(\"\") : '
                                                        • no addresses blocked
                                                        • ';\n $(\"#banned-events\").innerHTML = bannedEvents.map((r) => '
                                                        • ev' + key(r.id) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"check\", \"Allow again\", \"allowevent\", r.id) + \"
                                                        • \").join(\"\");\n $(\"#reports tbody\").innerHTML = reports.length ? reports.map((r) => '' + esc(fmtTime(r.at)) + '' + esc(r.type || \"report\") + '' + av(r.target_pubkey) + key(r.target_pubkey) + (r.target_event ? (r.blob ? ' file ' + key(r.target_event) + \"\" : ' ev ' + key(r.target_event) + (r.hidden ? ' hidden' : \"\") + \"\") : \"\") + '' + esc(r.content) + '' + ib(\"check\", \"Dismiss\", \"resolve:dismiss\", r.id) + ib(\"trash\", r.blob ? \"Delete the file\" : \"Delete the event\", \"resolve:delete\", r.id) + ib(\"banuser\", \"Ban the author\", \"resolve:ban\", r.id, \"danger\") + \"\").join(\"\") : 'nothing reported';\n $(\"#reports-count\").textContent = reports.length || \"\"; $(\"#tc-reports\").textContent = reports.length || \"\";\n $(\"#blobs tbody\").innerHTML = blobs.length ? blobs.map((b) => '' + esc(fmtTime(b.uploaded)) + '' + key(b.sha256) + ' ' + esc(b.type) + '' + fmtBytes(b.size) + '' + av(b.uploader) + key(b.uploader) + '' + ib(\"trash\", \"Delete file\", \"deleteblob\", b.sha256, \"danger\") + \"\").join(\"\") : 'no uploads';\n $(\"#blobs-count\").textContent = blobs.length || \"\";\n $(\"#sites tbody\").innerHTML = sites.length ? sites.map((s) => '' + av(s.author) + key(s.author) + '' + esc(s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + '' + esc(s.url) + '' + esc(s.paths) + (s.missing ? ' ' + esc(s.missing) + ' missing' : '') + '' + fmtBytes(s.size) + '' + esc(s.expires_at ? fmtTime(s.expires_at) : \"never\") + '' + ib(\"trash\", \"Delete site\", \"deleteevent\", s.id, \"danger\") + '').join(\"\") : 'no sites yet';\n $(\"#sites-count\").textContent = sites.length || \"\";\n const tag = (k, cls) => '' + k + ib(\"x\", \"Remove rule\", \"unrulekind\", String(k)) + \"\";\n $(\"#kinds-allow\").innerHTML = allow.length ? allow.map((k) => tag(k, \"ok\")).join(\"\") : 'all';\n $(\"#kinds-block\").innerHTML = block.length ? block.map((k) => tag(k, \"blk\")).join(\"\") : 'none';\n await loadPeople();\n }\n\n const KIND_NAMES = { 0: \"profiles\", 1: \"notes\", 3: \"contacts\", 4: \"DMs\", 5: \"deletions\", 6: \"reposts\", 7: \"reactions\", 16: \"reposts\", 1059: \"gift wraps\", 1063: \"file headers\", 1111: \"comments\", 1984: \"reports\", 9734: \"zap requests\", 9735: \"zap receipts\", 9802: \"highlights\", 10002: \"relay lists\", 13534: \"roster\", 9000: \"group adds\", 9001: \"group removals\", 9021: \"join requests\", 9022: \"leave requests\", 39000: \"group info\", 39001: \"group admins\", 39002: \"group members\", 39003: \"group roles\", 30023: \"articles\", 30024: \"drafts\", 30078: \"app data\", 30311: \"live events\", 30818: \"wiki\" };\n const kindName = (k) => KIND_NAMES[k] || (k >= 20000 && k < 30000 ? \"ephemeral\" : k >= 30000 && k < 40000 ? \"addressable\" : k >= 10000 && k < 20000 ? \"replaceable\" : \"kind \" + k);\n const SYS_KINDS = new Set([0, 3, 10002, 9735, 13534, 8000, 8001, 9000, 9001, 39000, 39001, 39002, 39003]);\n let storage = null;\n\n // Features (settings.ts): a select each; search has three modes, the rest on or off.\n const FEATURES = [\n [\"search\", \"Search\", \"NIP-50. Prose indexes notes, threads, comments, highlights, articles and wiki pages; full indexes every public kind with content. A change applies to events from then on.\", [\"prose:prose\", \"full:full\", \"off:off\"]],\n [\"sync\", \"Sync\", \"NIP-77 reconciliation, which reads the whole matching set per sync.\"],\n [\"count\", \"Counts\", \"NIP-45 COUNT, with HLL sketches.\"],\n [\"discovery\", \"Discovery record\", \"NIP-66: the record the relay signs about itself, for crawlers.\"],\n [\"names\", \"Names\", \"NIP-05 addresses under this relay's domain.\"],\n [\"files\", \"Files\", \"Blossom and NIP-96: uploads, downloads and listings.\"],\n [\"pages\", \"Pages and feed\", \"Notes and articles as pages, and the Atom feed.\"],\n [\"sites\", \"Static websites\", \"NIP-5A sites on their own hostnames. Mirroring copies missing files into this relay and costs fuel.\", [\"mirror:on, mirror files\", \"proxy:on, fetch as needed\", \"off:off\"]],\n [\"marmot\", \"Marmot transport\", \"Signed KeyPackages and encrypted group messages, with account admission for ephemeral authors.\"],\n [\"grasp\", \"Git repositories\", \"GRASP Git hosting with admitted repository state. The prototype backend has bounded storage and compute limits.\"],\n [\"push\", \"Relay push\", \"NIP-9a callback delivery for members and the owner. Requires approved callback origins; advertises lettered NIP identifiers.\"],\n [\"signer\", \"Signer traffic\", \"NIP-46 remote signing carried for anyone, never stored.\"],\n ];\n function renderFeatures(f) {\n $(\"#features\").innerHTML = FEATURES.map(([k, title, about, modes]) => {\n const cur = k === \"sites\" ? (f.sites?.enabled === false ? \"off\" : f.sites?.mirror === false ? \"proxy\" : \"mirror\") : modes ? String(f[k] || \"prose\") : String(f[k] !== false);\n const opts = (modes || [\"true:on\", \"false:off\"]).map((m) => { const [v, l] = m.split(\":\"); return '\"; }).join(\"\");\n return \"\";\n }).join(\"\");\n }\n $(\"#features\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-feature]\"); if (!sel) return;\n const k = sel.dataset.feature, v = k === \"search\" || k === \"sites\" ? sel.value : sel.value === \"true\";\n policy = await rpc(\"setpolicy\", { features: { [k]: k === \"sites\" ? { enabled: v !== \"off\", mirror: v === \"mirror\" } : v } });\n toast(k === \"search\" ? \"Search: \" + v : (v ? \"Switched on \" : \"Switched off \") + k); await loadInfo();\n }));\n\n $(\"#push-policy-form\").addEventListener(\"submit\", guard(async (ev) => {\n ev.preventDefault();\n const form = ev.target;\n const origins = form.elements.origins.value.split(/\\s+/).filter(Boolean);\n const updated = await rpc(\"setpolicy\", { pushCallbacks: origins, letteredNips: form.elements.lettered.checked });\n if (JSON.stringify(updated.pushCallbacks) !== JSON.stringify([...new Set(origins.map((s) => s.replace(/\\/$/, \"\")))])) throw new Error(\"Use up to sixteen exact HTTPS origins, with no path or credentials.\");\n policy = updated;\n toast(\"Saved callback policy\"); await loadInfo();\n }));\n\n async function loadViews() {\n let views;\n try { views = await rpc(\"listviews\"); } catch { return; }\n $(\"#views\").innerHTML = views.map((v) => {\n const runs = v.trigger === \"off\" ? \"off\" : v.trigger === \"live\" ? \"live, from memory\" : v.trigger === \"write\" ? \"on write and daily\" : v.trigger;\n const label = (c) => (c === \"off\" ? \"off\" : c === \"write\" ? \"on write\" : c === \"live\" ? \"on\" : c);\n const choices = [...v.choices, v.default].filter((c, i, a) => a.indexOf(c) === i);\n const pick = '\";\n const who = v.audience === \"members\" ? \"members\" + (v.stored ? \"\" : \", on request\") : \"anyone\";\n const last = v.trigger === \"live\" ? \"\" : v.last ? \"last run \" + fmtTime(v.last.at) : \"not run yet\";\n const rows = v.trigger === \"live\" ? \"no rows\" : v.last ? v.last.rows.toLocaleString() + \" rows\" : \"\";\n const meta = [runs, who, last, rows].filter(Boolean).map((x) => \"\" + esc(x) + \"\").join('|');\n return '
                                                          ' + esc(v.name) + '
                                                          ' + esc(v.about) + '
                                                          ' + pick + (v.on ? 'Open' : \"\") + '
                                                          ' + meta + \"
                                                          \";\n }).join(\"\");\n }\n $(\"#views\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-view]\"); if (!sel) return;\n const value = sel.value === \"live\" ? true : sel.value;\n await rpc(\"setpolicy\", { views: { [sel.dataset.view]: value } });\n toast(sel.value === \"off\" ? \"Took down \" + sel.dataset.view : sel.dataset.view + \": \" + (sel.value === \"live\" ? \"on\" : sel.value === \"write\" ? \"on write\" : sel.value)); await loadViews(); await loadInfo();\n }));\n async function loadDumps() {\n const list = await rpc(\"listdumps\");\n const f = $(\"#dumpform\");\n if (policy) { f.dumps.value = policy.dumps || \"off\"; f.keep.value = policy.dumpsKeep || 7; }\n $(\"#dumps-count\").textContent = list.length || \"\";\n $(\"#dumps\").innerHTML = list.length ? list.map((d) => '
                                                        • jsonl' + esc(d.name) + ' ' + d.events.toLocaleString() + \" events, \" + fmtBytes(d.bytes) + \"\" + ib(\"copy\", \"Download\", \"downloaddump\", d.name) + ib(\"trash\", \"Delete\", \"deletedump\", d.name, \"danger\") + \"
                                                        • \").join(\"\") : '
                                                        • no dumps yet
                                                        • ';\n }\n // A dump is fetched with a signed request and handed to the browser as a file.\n async function downloadDump(name) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const url = location.origin + \"/dumps/\" + name;\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"GET\"]] });\n const resp = await fetch(url, { headers: { authorization: \"Nostr \" + btoa(JSON.stringify(ev)) } });\n if (!resp.ok) throw new Error((await resp.json()).error || \"download failed\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(\".\")[0] + \"-\" + name; a.click(); URL.revokeObjectURL(a.href);\n }\n // A plain member's own invites, when the owner lets members invite.\n async function loadMine() {\n const sec = $(\"#myinvites\");\n if (!me || !owner || myRole || !signer.ready()) { sec.classList.add(\"hidden\"); return; }\n let mine;\n try { mine = await rpc(\"listinvites\"); } catch { sec.classList.add(\"hidden\"); return; }\n sec.classList.remove(\"hidden\");\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#mine\").innerHTML = mine.length ? mine.map((i) => '
                                                        • inv…' + i.code.slice(-8) + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
                                                        • \").join(\"\") : '
                                                        • no invites yet
                                                        • ';\n }\n async function loadStorage() {\n storage = await rpc(\"storagestats\");\n pollJobs();\n loadViews();\n loadDumps().catch(() => {});\n const st = storage;\n const top = st.kinds.slice(0, 4), rest = st.kinds.slice(4).reduce((a, k) => a + k.bytes, 0);\n const parts = [...top.map((k, i) => [k.kind + \" \" + kindName(k.kind), \"k\" + (i + 1), k.bytes]), ...(rest ? [[\"other\", \"k5\", rest]] : [])];\n const total = st.eventBytes || 1;\n $(\"#s-bar\").innerHTML = parts.length ? parts.map(([n, c, b]) => '').join(\"\") : '';\n $(\"#s-legend\").innerHTML = parts.length ? parts.map(([n, c, b]) => '' + esc(n) + \" \" + Math.round(b * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n const overhead = Math.max(0, st.databaseBytes - st.eventBytes);\n $(\"#s-totals\").innerHTML = [[\"events\", st.events.toLocaleString() + \" \" + fmtBytes(st.eventBytes) + \"\"], [\"index and overhead\", fmtBytes(overhead)], [\"files\", st.blobs.toLocaleString() + \" \" + fmtBytes(st.mediaBytes) + \"\"]]\n .map(([k, v]) => \"
                                                          \" + k + \"\" + v + \"
                                                          \").join(\"\");\n const any = st.retention.find((r) => r.kind === null);\n const own = (kind) => { const r = st.retention.find((x) => x.kind === kind); return r ? r.days : \"\"; };\n const row = (k) => {\n const key = k.kind === null ? \"\" : String(k.kind);\n const placeholder = k.kind === null ? \"forever\" : k.replaceable || !any ? \"forever\" : any.days + \" (everything else)\";\n const pill = k.kind === null ? \"everything else\" : '' + k.kind + \" \" + esc(kindName(k.kind));\n const cells = k.protected\n ? 'required'\n : ' days' + ib(\"check\", \"Save keep-for rule\", \"saveretention\", key) + ib(\"trash\", \"Purge\", \"purgekind\", key, \"danger\") + \"\";\n return '' + pill + '' + (k.n === undefined ? \"\" : k.n.toLocaleString()) + '' + (k.bytes === undefined ? \"\" : fmtBytes(k.bytes)) + '' + (k.oldest ? esc(fmtDay(k.oldest)) : \"\") + \"\" + cells + \"\";\n };\n $(\"#kinds tbody\").innerHTML = [...st.kinds, { kind: null, days: any ? any.days : 0 }].map(row).join(\"\");\n loadListHistory().catch(() => {});\n }\n\n async function loadListHistory() {\n const rows = await rpc(\"listlisthistory\");\n const labels = { 3: \"follows\", 10002: \"relay list\", 10003: \"bookmarks\", 30003: \"bookmark list\" };\n $(\"#listhistory tbody\").innerHTML = rows.length ? rows.map((r) => '' + esc(labels[r.kind] || (\"kind \" + r.kind) + (r.d ? \" / \" + r.d : \"\")) + '' + esc(fmtTime(r.created_at)) + '' + esc(fmtTime(r.saved_at)) + '' + ib(\"undo\", \"Restore this version\", \"restorelist\", r.event_id) + '').join(\"\") : 'no older list versions yet';\n }\n\n let searchQuery = \"\";\n async function loadEvents(reset) {\n const list = searchQuery ? await rpc(\"searchevents\", searchQuery, 200) : await rpc(\"listrecentevents\", reset ? 50 : 200);\n $(\"#events tbody\").innerHTML = list.length ? list.map((e) => '' + esc(fmtTime(e.created_at)) + '' + e.kind + '' + av(e.pubkey) + key(e.pubkey) + '' + esc(e.content) + '' + ib(\"pin\", \"Pin\", \"pinevent\", e.id) + ib(\"trash\", \"Delete event\", \"deleteevent\", e.id) + ib(\"ban\", \"Ban event\", \"banevent\", e.id, \"danger\") + ib(\"banuser\", \"Ban author\", \"banpubkey\", e.pubkey, \"danger\") + \"\").join(\"\") : 'no events yet';\n $(\"#more\").classList.toggle(\"hidden\", searchQuery || list.length < 50);\n }\n $(\"#searchform\").onsubmit = guard(async (ev) => {\n searchQuery = ev.target.q.value.trim();\n $(\"#searchclear\").classList.toggle(\"hidden\", !searchQuery);\n await loadEvents(true);\n });\n $(\"#searchclear\").onclick = guard(async () => {\n searchQuery = \"\"; $(\"#searchform\").reset(); $(\"#searchclear\").classList.add(\"hidden\");\n await loadEvents(true);\n });\n async function loadPins() {\n const pins = await rpc(\"listpins\");\n $(\"#pins-count\").textContent = pins.length || \"\";\n $(\"#pins\").innerHTML = pins.length ? pins.map((t) => '
                                                        • ' + (t[0] === \"e\" ? \"event\" : \"address\") + '' + esc(t[1].length > 40 ? t[1].slice(0, 16) + \"\\u2026\" + t[1].slice(-8) : t[1]) + \"\" + ib(\"copy\", \"Copy\", \"copy\", t[1]) + ib(\"x\", \"Unpin\", \"unpinevent\", t[1]) + \"
                                                        • \").join(\"\") : '
                                                        • nothing pinned
                                                        • ';\n }\n $(\"#pinform\").onsubmit = guard(async (ev) => {\n await rpc(\"pinevent\", ev.target.id.value.trim()); ev.target.reset(); toast(\"Pinned\"); await loadPins();\n });\n\n // ---- actions ----\n const refresh = () => Promise.all([loadLists(), loadEvents(true), loadStorage(), loadPins()]);\n\n $(\"#copy\").onclick = async () => { await navigator.clipboard.writeText(wsURL); toast(\"copied \" + wsURL); };\n $(\"#signin\").onclick = guard(async () => {\n if (!window.nostr) throw new Error(\"No nostr extension found. Install one (Alby, nos2x, …) and reload, or use a remote signer.\");\n me = await window.nostr.getPublicKey();\n localStorage.setItem(\"me\", me);\n renderHeader(); await loadAdmin(); await loadPeople();\n });\n $(\"#signin46\").onclick = () => showRemote(null);\n $$(\".remote\").forEach((b) => { b.onclick = () => showRemote($(\"#\" + b.dataset.note)); });\n $(\"#nccopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#nclink\").href); toast(\"Link copied; paste it into your signer app\"); };\n $(\"#bunkerform\").onsubmit = guard(async (ev) => {\n $(\"#remotenote\").textContent = \"Connecting…\";\n try { await connectBunker(ev.target.url.value); } catch (e) { $(\"#remotenote\").textContent = e.message; throw e; }\n $(\"#remotenote\").textContent = \"\"; ev.target.reset();\n await remoteDone();\n });\n $(\"#signout\").onclick = async () => {\n if (remote) { const r = remote; remote = null; try { await withTimeout(r.logout(), 5000, \"The signer\"); } catch { /* the session is gone either way */ } }\n localStorage.removeItem(\"nip46\");\n me = null; localStorage.removeItem(\"me\"); renderHeader(); await loadAdmin(); await loadPeople();\n };\n const claimNow = async (note) => {\n if (!signer.ready()) { note.textContent = \"Sign the claim with a nostr extension or a remote signer.\"; showRemote(note); return; }\n me = await signer.getPublicKey();\n const r = await rpc(\"claim\");\n if (!r.claimed) throw new Error(\"Somebody else claimed it first.\");\n localStorage.setItem(\"me\", me);\n toast(\"It's yours.\");\n await loadInfo(); await loadFuel(); await loadAdmin(); await loadPeople();\n if (r.converted && confirm(\"This relay began as a temporary one: anyone can write, and everything is deleted after 14 days. Switch to the default rules and keep everything from now on? Each rule can be changed later on the Rules and Storage tabs.\")) {\n await rpc(\"resetrules\"); toast(\"Rules reset\"); await loadInfo(); await loadAdmin();\n }\n };\n $(\"#claim\").onclick = guard(() => claimNow($(\"#claimnote\")));\n $(\"#claimlease\").onclick = guard(() => claimNow($(\"#leasenote\")));\n // Jobs run in the background; the table follows them while the tab is open.\n let jobsTimer = 0;\n const fmtJob = (j) => {\n const what = j.kind === \"mirror\" ? \"mirror site\" : j.kind === \"import\" ? \"import\" : j.label === \"backfill\" ? \"fetch my history\" : j.kind === \"pull\" ? \"pull\" : \"rebroadcast\";\n const f = [];\n if (j.filter.authors) f.push(j.filter.authors.length === 1 && j.filter.authors[0] === me ? \"my events\" : j.filter.authors.length + \" authors\");\n if (j.filter.kinds) f.push(\"kinds \" + j.filter.kinds.join(\", \"));\n if (j.filter.since) f.push(\"since \" + fmtDay(j.filter.since));\n const when = j.every ? \"every \" + (j.every === 24 ? \"day\" : j.every + \" h\") + (j.nextRun ? \", next \" + fmtTime(j.nextRun) : \"\") : \"once\";\n const l = j.last;\n const count = (stored, blobs, sent, refused) => (j.kind === \"mirror\" ? blobs.toLocaleString() + \" files mirrored\" : j.kind === \"import\" ? stored.toLocaleString() + \" events\" + ((j.last ? j.last.duplicates : j.duplicates) ? \", \" + (j.last ? j.last.duplicates : j.duplicates) + \" already here\" : \"\") : j.kind === \"pull\" ? stored.toLocaleString() + \" events\" + (blobs ? \", \" + blobs + \" files\" : \"\") : sent.toLocaleString() + \" sent\" + (refused ? \", \" + refused + \" refused\" : \"\"));\n const res = j.running ? \"running: \" + count(j.stored, j.blobs, j.sent, j.refused) + \"...\" : !l ? \"waiting\" : l.error ? \"failed: \" + l.error : count(l.stored, l.blobs, l.sent, l.refused) + (l.skipped ? \", \" + l.skipped + \" skipped\" : \"\") + \", \" + fmtTime(l.finishedAt);\n const sources = j.running ? j.pullSources : l?.sources;\n const details = sources?.length ? '
                                                          Source results' + sources.map((s) => '

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

                                                          ').join('') + '
                                                          ' : '';\n return \"\" + what + \"\" + j.relays.map(esc).join(\"
                                                          \") + \"\" + (f.join(\", \") || \"everything\") + \"\" + when + \"\" + esc(res) + details + \"\" + (j.running ? \"\" : ib(\"undo\", \"Run now\", \"runjob\", j.id)) + ib(\"x\", \"Remove\", \"removejob\", j.id) + \"\";\n };\n async function pollJobs() {\n clearTimeout(jobsTimer);\n let jobs;\n try { jobs = await rpc(\"listjobs\"); } catch { return; }\n $(\"#jobs tbody\").innerHTML = jobs.length ? jobs.map(fmtJob).join(\"\") : 'no jobs yet';\n if (jobs.some((j) => j.running || (j.nextRun && j.nextRun <= Math.floor(Date.now() / 1000) + 1))) jobsTimer = setTimeout(pollJobs, 3000);\n }\n const urls = (s) => s.split(/[\\s,]+/).map((u) => u.trim()).filter(Boolean);\n const kindsOf = (s) => s.split(/[\\s,]+/).map((k) => parseInt(k, 10)).filter((k) => Number.isInteger(k) && k >= 0);\n $(\"#pullform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"addjob\", { kind: \"pull\", relays: [f.url.value.trim()], every: +f.every.value });\n toast(+f.every.value ? \"Mirror scheduled\" : \"Pull started\"); f.reset(); await pollJobs();\n });\n $(\"#backfillform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"backfill\", urls(f.relays.value));\n toast(\"Fetching your history\"); f.reset(); await pollJobs();\n });\n $(\"#pushform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const filter = {};\n const kinds = kindsOf(f.kinds.value); if (kinds.length) filter.kinds = kinds;\n const days = +f.days.value; if (days > 0) filter.since = Math.floor(Date.now() / 1000) - days * 86400;\n await rpc(\"addjob\", { kind: \"push\", relays: urls(f.targets.value), filter, every: +f.every.value });\n toast(\"Rebroadcast started\"); f.reset(); await pollJobs();\n });\n // ---- presets: writes, reads, kinds and keep-for rules in one click ----\n let presets = null;\n async function renderPresets() {\n if (!presets) { try { presets = await rpc(\"listpresets\"); } catch { presets = []; } }\n $(\"#presets\").innerHTML = presets.map((p) => '\").join(\"\");\n $(\"#presetsourcerow\").classList.toggle(\"hidden\", !presets.some((p) => p.source));\n }\n $(\"#presets\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-preset]\"); if (!b) return;\n const p = (presets || []).find((x) => x.name === b.dataset.preset); if (!p) return;\n if (!confirm(p.title + \": \" + p.about + \"\\n\\nThis replaces the writes and reads rules, the directory setting, the kind rules and the keep-for rules. Limits, identity, people and bans stay.\")) return;\n const source = $(\"#presetsource\").value.trim();\n if (p.source === \"required\" && !source) { $(\"#presetnote\").textContent = p.title + \" needs a source relay to mirror; enter its wss:// URL first.\"; return; }\n b.disabled = true;\n try { policy = await rpc(\"applypreset\", p.name, p.source && source ? { source } : undefined); $(\"#presetnote\").textContent = \"Now: \" + p.about + (policy.job ? \" Mirroring \" + source + \" every \" + policy.job.every + \" h.\" : \"\"); toast(p.title + \" applied\"); await loadInfo(); await loadAdmin(); }\n catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // ---- wire me in: this relay in the owner's own lists ----\n // The lists are replaceable, so a fresh list with only this relay would\n // clobber the real one once it spread. Every publish starts from the\n // newest copy found here or on the indexers, verified when the signer\n // library is around, and adds or removes this relay in it.\n const INDEXERS = [\"wss://purplepag.es\", \"wss://relay.nostr.band\", \"wss://relay.damus.io\", \"wss://nos.lol\"];\n const LISTS = [\n { kind: 10002, tag: \"r\", title: \"Relay list\", nip: \"NIP-65\", about: \"where clients read your notes and send you mentions\" },\n { kind: 10050, tag: \"relay\", title: \"DM inbox\", nip: \"NIP-17\", about: \"where people send you private messages\" },\n { kind: 10007, tag: \"relay\", title: \"Search relays\", nip: \"NIP-51\", about: \"where clients run your searches\" },\n { kind: 10063, tag: \"server\", title: \"Blossom servers\", nip: \"BUD-03\", about: \"where clients upload and look for your files\" },\n ];\n const mineFor = (l) => (l.tag === \"server\" ? location.origin : wsURL);\n const normURL = (u) => { try { const x = new URL(String(u).trim()); return (x.host + x.pathname).replace(/\\/+$/, \"\").toLowerCase(); } catch { return String(u).trim().toLowerCase(); } };\n const isMine = (l, t) => t[0] === l.tag && normURL(t[1] || \"\") === normURL(mineFor(l));\n const listHas = (list, l) => !!list && list.tags.some((t) => isMine(l, t));\n // mergeList is pure: the newest list with this relay put first or taken out, every other tag kept.\n function mergeList(list, l, include) {\n const tags = (list ? list.tags : []).filter((t) => !isMine(l, t));\n if (include) tags.unshift([l.tag, mineFor(l)]);\n return { kind: l.kind, created_at: Math.floor(Date.now() / 1000), content: list ? list.content : \"\", tags };\n }\n const relaysIn = (list) => list.tags.filter((t) => (t[0] === \"r\" || t[0] === \"relay\") && /^wss?:\\/\\//i.test(t[1] || \"\")).map((t) => t[1].trim());\n // overWS opens one socket, sends one message, feeds answers to onMessage until it says done or time runs out.\n function overWS(url, ms, first, onMessage) {\n return new Promise((res) => {\n let ws = null, done = false, out = null;\n const finish = () => { if (done) return; done = true; clearTimeout(timer); try { if (ws) ws.close(); } catch { /* closed */ } res(out); };\n const timer = setTimeout(finish, ms);\n try { ws = new WebSocket(url); } catch { return finish(); }\n ws.onopen = () => ws.send(JSON.stringify(first));\n ws.onmessage = (m) => { let d; try { d = JSON.parse(m.data); } catch { return; } if (!Array.isArray(d)) return; if (onMessage(d, (v) => { out = v; })) finish(); };\n ws.onerror = finish; ws.onclose = finish;\n });\n }\n function fetchNewest(url, filter, ms) {\n let best = null;\n return overWS(url, ms, [\"REQ\", \"w\", filter], (d, set) => {\n if (d[0] === \"EVENT\" && d[1] === \"w\" && d[2] && (!best || d[2].created_at > best.created_at)) { best = d[2]; set(best); }\n return (d[0] === \"EOSE\" || d[0] === \"CLOSED\") && d[1] === \"w\";\n });\n }\n function publishTo(url, event, ms) {\n return overWS(url, ms, [\"EVENT\", event], (d, set) => { if (d[0] === \"OK\" && d[1] === event.id) { set({ url, ok: !!d[2], msg: d[3] || \"\" }); return true; } return false; })\n .then((r) => r || { url, ok: false, msg: \"no answer\" });\n }\n // bridge signs a NIP-98 request to this relay's HTTP door, so the owner's own reads and writes pass any read rule.\n async function bridge(path, body) {\n const url = location.origin + path, raw = JSON.stringify(body);\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"POST\"], [\"payload\", await sha256hex(raw)]] });\n const resp = await fetch(url, { method: \"POST\", headers: { \"content-type\": \"application/json\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body: raw });\n const json = await resp.json();\n if (!resp.ok) throw new Error(json.error || \"HTTP \" + resp.status);\n return json;\n }\n const wire = {};\n function renderWire() {\n const pill = (state) => {\n const cls = state === \"listed\" ? \"on\" : state === \"failed\" ? \"bad\" : state === \"not listed\" ? \"off\" : \"\";\n const label = state === \"checking\" || state === \"publishing\" ? state + \"\\u2026\" : state;\n return '' + esc(label) + \"\";\n };\n $(\"#wire\").innerHTML = LISTS.map((l) => {\n const w = wire[l.kind] || { state: \"\", results: [], list: null };\n const busy = w.state === \"checking\" || w.state === \"publishing\";\n const btn = (act, cls, label) => '\";\n const buttons = w.state === \"listed\" ? btn(\"remove\", \"\", \"Remove this relay\") : btn(\"add\", \"pri\", \"Add this relay\") + btn(\"check\", \"\", w.state ? \"Check again\" : \"Check\");\n const n = w.list ? w.list.tags.filter((t) => t[0] === l.tag).length : 0;\n const from = w.list ? \"\" + (n === 1 ? \"1 entry\" : n + \" entries\") + \", \" + fmtTime(w.list.created_at) + \"\" : w.state && !busy ? \"no list found\" : \"\";\n const sent = (w.results || []).map((r) => '' + esc(r.url.replace(/^wss?:\\/\\//, \"\")) + (r.ok ? \"\" : \" failed\") + \"\").join(\"\");\n const meta = (w.state ? pill(w.state) : \"\") + from + sent;\n return '
                                                          ' + l.title + ' ' + l.nip + \"
                                                          \" + l.about + '
                                                          ' + buttons + '
                                                          ' + meta + \"
                                                          \";\n }).join(\"\");\n }\n async function wireCheck(l) {\n const w = (wire[l.kind] = { state: \"checking\", list: null, results: [] }); renderWire();\n const filter = { kinds: [l.kind], authors: [me], limit: 1 };\n const found = [];\n try { found.push(...(await bridge(\"/query\", [filter]))); } catch { /* the relay may hold none */ }\n const remote = await Promise.all(INDEXERS.map((u) => fetchNewest(u, filter, 4000)));\n let lib = null; try { lib = await signerLib(); } catch { /* unverified lists are still the owner's own, by pubkey */ }\n for (const e of remote) if (e && e.pubkey === me && e.kind === l.kind && Array.isArray(e.tags) && (!lib || lib.verifyEvent(e))) found.push(e);\n w.list = found.sort((a, b) => b.created_at - a.created_at)[0] || null;\n w.state = listHas(w.list, l) ? \"listed\" : \"not listed\"; renderWire();\n }\n async function wirePublish(l, include) {\n if (!wire[l.kind] || !wire[l.kind].state || wire[l.kind].state === \"failed\") await wireCheck(l);\n const w = wire[l.kind]; w.state = \"publishing\"; w.results = []; renderWire();\n try {\n const signed = await signer.signEvent(mergeList(w.list, l, include));\n const here = await bridge(\"/events\", signed).then((r) => ({ url: wsURL, ok: !!r.accepted, msg: r.message || \"\" })).catch((e) => ({ url: wsURL, ok: false, msg: e.message }));\n const targets = [...new Set([...relaysIn(signed), ...INDEXERS])].filter((u) => normURL(u) !== normURL(wsURL));\n const rest = await Promise.all(targets.map((u) => publishTo(u, signed, 6000)));\n w.results = [here, ...rest]; w.list = signed; w.state = include ? \"listed\" : \"not listed\";\n toast((include ? \"Added to your \" : \"Removed from your \") + l.title.toLowerCase());\n } catch (e) { w.state = \"failed\"; w.results = [{ url: wsURL, ok: false, msg: e.message }]; toast(e.message); }\n renderWire();\n }\n $(\"#wire\").addEventListener(\"click\", (ev) => {\n const b = ev.target.closest(\"button[data-wire]\"); if (!b) return;\n const l = LISTS.find((x) => String(x.kind) === b.dataset.wire); if (!l) return;\n if (b.dataset.do === \"check\") wireCheck(l); else wirePublish(l, b.dataset.do === \"add\");\n });\n\n // ---- custom domains: this relay under a hostname the owner controls ----\n let domainSites = [];\n const domainOptions = (current = \"\") => [{ label: \"\", title: \"This relay\" }, ...domainSites.map((s) => ({ label: s.label, title: (s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + \" / \" + key(s.author) })), ...(current && !domainSites.some((s) => s.label === current) ? [{ label: current, title: \"Site \" + current }] : [])].map((s) => '\").join(\"\");\n let domains = null; // null: not enabled on this host\n function renderDomains() {\n $(\"#domains\").innerHTML = (domains || []).map((d) => {\n const state = d.ready ? \"active\" : \"hostname \" + d.status.replace(/_/g, \" \") + \", certificate \" + d.sslStatus.replace(/_/g, \" \");\n const btn = (act, cls, label) => '\";\n const rows = d.ready ? \"\" : '
                                                          ' + d.records.map((r) => \"\").join(\"\") + \"
                                                          TypeNameValue
                                                          \" + esc(r.type) + '' + esc(r.name) + '' + esc(r.value) + \"\" + esc(r.note) + \"
                                                          \";\n return '
                                                          ' + esc(d.host) + '' + esc(state) + \"\" + btn(\"check\", \"\", \"Check\") + btn(\"remove\", \"danger\", \"Remove\") + '
                                                          \" + rows;\n }).join(\"\");\n }\n async function loadDomains() {\n try { [domains, domainSites] = await Promise.all([rpc(\"listdomains\"), rpc(\"listsites\")]); $(\"#adddomain select[name=site]\").innerHTML = domainOptions(); $(\"#domainnote\").textContent = domains.length ? \"\" : \"No custom domain yet.\"; $(\"#adddomain\").classList.remove(\"hidden\"); }\n catch (e) { domains = null; $(\"#domainnote\").textContent = /^unsupported/.test(e.message) ? \"Custom domains are not enabled on this host.\" : e.message; $(\"#adddomain\").classList.add(\"hidden\"); }\n renderDomains();\n }\n $(\"#adddomain\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"adddomain\", f.host.value.trim(), f.site.value); f.reset(); toast(\"Domain added; now create the CNAME\"); await loadDomains();\n });\n $(\"#domains\").addEventListener(\"change\", guard(async (ev) => {\n const select = ev.target.closest(\"select[data-domain-site]\"); if (!select) return;\n await rpc(\"setdomainsite\", select.dataset.domainSite, select.value);\n toast(\"Domain destination saved\"); await loadDomains();\n }));\n $(\"#domains\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-domain]\"); if (!b) return;\n const host = b.dataset.domain;\n if (b.dataset.do === \"remove\" && !confirm(\"Remove \" + host + \"? Its certificate goes with it and the name stops answering.\")) return;\n b.disabled = true;\n try {\n if (b.dataset.do === \"remove\") { await rpc(\"removedomain\", host); toast(\"Removed\"); }\n else { const d = await rpc(\"checkdomain\", host); toast(d.ready ? host + \" is live\" : \"Not yet: \" + (d.status === \"active\" ? \"certificate pending\" : \"waiting for the CNAME\")); }\n await loadDomains();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n $(\"#access\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const openKinds = f.openKinds.value.split(/[\\s,]+/).filter(Boolean).map(Number);\n if (openKinds.some((k) => !Number.isInteger(k) || k < 0 || k > 65535)) throw new Error(\"Open kinds must be whole numbers.\");\n policy = await rpc(\"setpolicy\", { writes: f.writes.value, reads: f.reads.value, openKinds, guestReplies: f.guestReplies.checked, minPow: +f.minPow.value, maxFuture: +f.maxFuture.value, maxLimit: +f.maxLimit.value, maxSubs: +f.maxSubs.value, maxMessageKB: +f.maxMessageKB.value, eventsPerMinute: +f.eventsPerMinute.value, reqsPerMinute: +f.reqsPerMinute.value, maxBlobMB: +f.maxBlobMB.value });\n toast(\"Rules saved\"); await loadInfo();\n });\n $(\"#notify\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { notify: { reports: f.reports.checked, fuel: f.fuel.checked, jobs: f.jobs.checked, succession: f.succession.checked, digest: f.digest.checked } });\n toast(\"Notifications saved\");\n });\n $(\"#notifytest\").onclick = guard(async () => {\n const r = await rpc(\"notifytest\");\n toast(r.sent ? \"Sent. Look for a message from the relay in your DMs.\" : \"Could not send.\");\n });\n $(\"#identity\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const list = (v) => v.split(\",\").map((s) => s.trim()).filter(Boolean);\n policy = await rpc(\"setpolicy\", { name: f.name.value, contact: f.contact.value, description: f.description.value, icon: f.icon.value,\n banner: f.banner.value, postingPolicy: f.postingPolicy.value, privacyPolicy: f.privacyPolicy.value, tags: list(f.tags.value), languageTags: list(f.languageTags.value), relayCountries: list(f.relayCountries.value) });\n toast(\"Identity saved\"); await loadInfo(); await loadPeople(); loadCard();\n });\n $(\"#addmember\").onsubmit = guard(async (ev) => {\n const f = ev.target; const pk = npubToHex(f.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"setmember\", pk, { name: f.name.value.trim() || null, note: f.note.value }); f.reset(); toast(\"Member added\"); await loadLists();\n });\n $(\"#addblock\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"blockip\", f.ip.value.trim(), f.reason.value); f.reset(); toast(\"Blocked\"); await loadLists();\n });\n $(\"#wordsform\").onsubmit = guard(async (ev) => {\n const words = ev.target.words.value.split(\"\\n\").map((s) => s.trim()).filter(Boolean);\n const kept = await rpc(\"setblockedwords\", words);\n policy = await rpc(\"setpolicy\", { blockedWordsInTags: ev.target.inTags.checked });\n ev.target.words.value = kept.join(\"\\n\"); toast(kept.length ? kept.length + \" words blocked\" : \"No words blocked\");\n });\n $(\"#thresholdform\").onsubmit = guard(async (ev) => {\n policy = await rpc(\"setpolicy\", { reportThreshold: +ev.target.reportThreshold.value });\n toast(policy.reportThreshold ? \"Hidden after \" + policy.reportThreshold + \" reports\" : \"Never hidden by reports\"); await loadLists();\n });\n $(\"#addban\").onsubmit = guard(async (ev) => {\n const pk = npubToHex(ev.target.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"banpubkey\", pk, ev.target.reason.value, ev.target.erase.checked); ev.target.reset(); toast(ev.target.erase.checked ? \"Banned and erased\" : \"Banned\"); await loadLists();\n });\n $(\"#mintinvite\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const inv = await rpc(\"createinvite\", +f.ttl.value, +f.max.value, f.note.value);\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n f.note.value = \"\"; toast(\"Invite created and copied\"); await loadLists();\n });\n $(\"#kindform\").onsubmit = guard(async (ev) => {\n const rule = ev.submitter.value; const k = +ev.target.kind.value;\n await rpc(rule === \"allow\" ? \"allowkind\" : \"disallowkind\", k); ev.target.reset(); toast((rule === \"allow\" ? \"Allowed kind \" : \"Blocked kind \") + k); await loadLists();\n });\n $(\"#more\").onclick = guard(() => loadEvents(false));\n $(\"#dumpform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { dumps: f.dumps.value, dumpsKeep: Math.max(1, Math.min(60, Math.floor(+f.keep.value || 7))) });\n toast(policy.dumps === \"off\" ? \"Dumps off\" : \"Dumping \" + policy.dumps); await loadDumps();\n });\n $(\"#importform\").onsubmit = guard(async (ev) => {\n const file = ev.target.file.files[0]; if (!file) return;\n if (!signer.ready()) throw new Error(NO_SIGNER);\n if (file.size > 64 * 1024 * 1024) throw new Error(\"At most 64 MB per import.\");\n const body = await file.text();\n const url = location.origin + \"/import?name=\" + encodeURIComponent(file.name);\n const token = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"PUT\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(url, { method: \"PUT\", headers: { authorization: \"Nostr \" + btoa(JSON.stringify(token)), \"content-type\": \"application/x-ndjson\" }, body });\n const r = await resp.json();\n if (!resp.ok) throw new Error(r.error || \"import failed\");\n ev.target.reset(); toast(\"Importing \" + fmtBytes(r.bytes)); await pollJobs();\n });\n $(\"#dumpnow\").onclick = guard(async () => { const d = await rpc(\"dumpnow\"); toast(\"Dumped \" + d.events.toLocaleString() + \" events\"); await loadStorage(); });\n $(\"#treeform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { memberInvites: { depth: Math.max(0, Math.floor(+f.depth.value || 0)), quota: Math.max(0, Math.floor(+f.quota.value || 0)) } });\n toast(policy.memberInvites.depth ? \"Members may invite\" : \"Only you and moderators invite\");\n });\n $(\"#mintmine\").onclick = guard(async () => {\n const inv = await rpc(\"createinvite\", 259200, 1, \"\");\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n toast(\"Invite created and copied\"); await loadMine();\n });\n $(\"#exportcfg\").onclick = guard(async () => {\n const cfg = await rpc(\"exportconfig\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(new Blob([JSON.stringify(cfg, null, 2)], { type: \"application/json\" }));\n a.download = host.split(\".\")[0] + \".bind.ws.config.json\";\n a.click(); URL.revokeObjectURL(a.href);\n });\n $(\"#importcfg\").onclick = () => $(\"#cfgfile\").click();\n $(\"#cfgfile\").onchange = guard(async (ev) => {\n const file = ev.target.files[0]; if (!file) return;\n const cfg = JSON.parse(await file.text());\n const check = await rpc(\"importconfig\", cfg, { dryRun: true });\n const lines = check.changes.summary.length ? check.changes.summary.join(\"\\n\") : \"Nothing would change.\";\n const dropped = check.warnings.length ? \"\\n\\nNot taken:\\n\" + check.warnings.join(\"\\n\") : \"\";\n if (!confirm(\"Apply \" + file.name + \" to this relay?\\n\\n\" + lines + dropped)) { ev.target.value = \"\"; return; }\n await rpc(\"importconfig\", cfg); ev.target.value = \"\"; toast(\"Configuration imported\"); await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // Succession: the status line under the heir form, from successionstatus.\n async function renderSuccession() {\n const el = $(\"#successionnote\"), f = $(\"#succession\");\n let st;\n try { st = await rpc(\"successionstatus\"); } catch { el.textContent = \"\"; return; }\n const sc = st.succession;\n $(\"#clearsuccession\").classList.toggle(\"hidden\", !sc);\n f.querySelector(\"button.btn:not(#clearsuccession)\").textContent = sc ? \"Change heir\" : \"Set heir\";\n if (!sc) { el.textContent = \"No heir named. Last signed in \" + fmtTime(st.ownerSeenAt) + \".\"; return; }\n if (f.heir.querySelector('[value=\"' + sc.heir + '\"]')) f.heir.value = sc.heir;\n f.afterDays.value = String(sc.afterDays);\n const who = (window.__members || []).find((m) => m.pubkey === sc.heir);\n const heir = who && who.name ? who.name + \"@\" + host : short(sc.heir);\n el.textContent = \"Heir: \" + heir + \". Last signed in \" + fmtTime(st.ownerSeenAt) + (st.silentDays ? \" (\" + st.silentDays + \" days ago)\" : \"\") + \". \" +\n (st.warning ? \"The warning month is running: the relay goes to \" + heir + \" on \" + fmtDay(st.handoverAt) + \" unless you sign in.\" : \"If you stay away, it goes to \" + heir + \" around \" + fmtDay(st.handoverAt) + \".\") +\n (st.log && st.log.length ? \" Handed over before: \" + st.log.map((l) => fmtDay(l.at) + \" to \" + short(l.to)).join(\", \") + \".\" : \"\");\n }\n $(\"#succession\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"setsuccession\", { heir: f.heir.value, afterDays: +f.afterDays.value });\n toast(\"Heir set\"); await loadAdmin();\n });\n $(\"#clearsuccession\").onclick = guard(async () => {\n await rpc(\"clearsuccession\");\n toast(\"Heir cleared\"); await loadAdmin();\n });\n $(\"#transfer\").onsubmit = guard(async (ev) => {\n const pk = ev.target.pubkey.value; if (!pk) return;\n const name = host.split(\".\")[0];\n const typed = prompt(\"This hands \" + host + \" to \" + short(pk) + \" for good. You stay on as a moderator. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing changed.\"); return; }\n await rpc(\"transferowner\", pk);\n toast(\"Transferred\");\n await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // ---- fork: a new name with this relay's events, claim reserved for a key ----\n $(\"#forkform\").scope.onchange = (ev) => $(\"#forkkinds\").classList.toggle(\"hidden\", ev.target.value !== \"kinds\");\n $(\"#joinform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { joinTerms: f.joinTerms.value, directoryPublic: f.directoryPublic.checked });\n toast(\"Saved\"); await loadInfo(); await loadPeople();\n });\n $(\"#forkform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const opts = { people: f.people.checked };\n if (f.name.value.trim()) opts.name = f.name.value.trim().toLowerCase();\n if (f.holder.value.trim()) { const pk = npubToHex(f.holder.value); if (!pk) throw new Error(\"That is not a pubkey.\"); opts.holder = pk; }\n if (f.scope.value === \"mine\") opts.filter = { authors: [me] };\n if (f.scope.value === \"kinds\") { const kinds = f.kinds.value.split(/[\\s,]+/).filter(Boolean).map(Number); if (!kinds.length || kinds.some((k) => !Number.isInteger(k) || k < 0)) throw new Error(\"Give kinds as numbers.\"); opts.filter = { kinds }; }\n if (!confirm(\"Fork this relay into a new name\" + (opts.name ? \" (\" + opts.name + \")\" : \"\") + \"? It pulls \" + (f.scope.value === \"all\" ? \"everything\" : f.scope.value === \"mine\" ? \"your events\" : \"the chosen kinds\") + (opts.people ? \" and the people\" : \"\") + \", and only \" + (opts.holder ? \"that key\" : \"you\") + \" can claim it.\")) return;\n const r = await rpc(\"forkrelay\", opts);\n $(\"#forknote\").textContent = r.handover + \" Expires \" + fmtTime(r.expires_at) + \".\";\n $(\"#forkurl\").textContent = r.console;\n $(\"#forkresult\").classList.remove(\"hidden\");\n toast(\"Forked to \" + r.name);\n });\n $(\"#forkcopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#forkurl\").textContent); toast(\"copied\"); };\n $(\"#deleterelay\").onclick = guard(async () => {\n const name = host.split(\".\")[0];\n const typed = prompt(\"This deletes everything on \" + host + \" and gives the name up. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing was deleted.\"); return; }\n await rpc(\"deleterelay\", name);\n localStorage.removeItem(\"me\");\n location.href = \"/\";\n });\n $(\"#topup\").onsubmit = guard((ev) => topUp(+ev.target.sats.value));\n $(\"#inv-copy\").onclick = async () => { await navigator.clipboard.writeText($(\"#inv-text\").value); toast(\"invoice copied\"); };\n $$(\"button[data-copy]\").forEach((b) => { b.onclick = async () => { await navigator.clipboard.writeText(b.dataset.copy === \"ws\" ? wsURL : location.origin); toast(\"copied\"); }; });\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-act]\"); if (!b) return;\n const act = b.dataset.act, id = b.dataset.id;\n if (act === \"copy\") { await navigator.clipboard.writeText(id); toast(\"copied\"); return; }\n if ((act === \"banpubkey\" || act === \"banevent\" || act === \"resolve:ban\") && !confirm(act === \"banevent\" ? \"Delete this event and refuse it forever?\" : \"Ban this author and refuse everything they post?\")) return;\n const erase = (act === \"banpubkey\" || act === \"resolve:ban\") && confirm(\"Also erase everything they wrote and uploaded here?\");\n if (act === \"deleteblob\" && !confirm(\"Delete this file for good?\")) return;\n if (act === \"deletedump\" && !confirm(\"Delete this dump?\")) return;\n if (act === \"downloaddump\") { b.disabled = true; try { await downloadDump(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }\n if (act === \"restorelist\") {\n if (!signer.ready()) { toast(NO_SIGNER); return; }\n try {\n const preview = await rpc(\"restorelist\", id);\n const d = preview.diff || {};\n const added = (d.addedTags || []).map((t) => \"+ \" + JSON.stringify(t)).join(\"\\n\");\n const removed = (d.removedTags || []).map((t) => \"- \" + JSON.stringify(t)).join(\"\\n\");\n const changes = [added, removed, d.contentChanged ? \"content changed\" : \"content unchanged\"].filter(Boolean).join(\"\\n\");\n if (!confirm(\"Restore this list version?\\n\\n\" + (changes || \"No tag or content changes\") + \"\\n\\nIt will be signed and published as the newest version.\")) return;\n const signed = await signer.signEvent(preview.draft);\n const result = await bridge(\"/events\", signed);\n if (!result.accepted) throw new Error(result.message || \"The relay refused the restored list.\");\n toast(\"List restored\"); await loadListHistory(); await loadStorage();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if ((act === \"removemember\" || act === \"banpubkey\") && (window.__members || []).some((m) => m.invited_by === id) && confirm(\"Also remove everyone this member invited, and everyone they invited in turn?\")) {\n b.disabled = true;\n try { const r = await rpc(\"removesubtree\", id); if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase); toast(\"Removed \" + r.removed.length); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"removejob\" && !confirm(\"Remove this job?\")) return;\n if (act === \"purgekind\") {\n const kind = id === \"\" ? null : +id;\n const label = kind === null ? \"everything without its own rule\" : kindName(kind) + \" (kind \" + kind + \")\";\n const typed = prompt(\"Purge \" + label + \" older than how many days? 0 purges all of them. This cannot be undone.\", \"30\");\n if (typed === null) return;\n const days = Math.max(0, Math.floor(+typed || 0));\n b.disabled = true;\n try { const r = await rpc(\"purgekind\", kind, days); toast(\"Purged \" + r.deleted.toLocaleString() + \" events\"); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"saveretention\") {\n const kind = id === \"\" ? null : +id;\n const days = Math.max(0, Math.floor(+b.closest(\"tr\").querySelector(\".days\").value || 0));\n b.disabled = true;\n try { await rpc(\"setretention\", kind, days); toast(days ? \"Kept for \" + days + \" days\" : \"Kept forever\"); await loadStorage(); await loadInfo(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n b.disabled = true;\n try {\n if (act === \"savemember\") { const tr = b.closest(\"tr\"), role = tr.querySelector(\"select.role\"), keep = tr.querySelector(\".keep\"), cap = tr.querySelector(\".cap\"); await rpc(\"setmember\", id, { name: tr.querySelector(\".name\").value.trim() || null, note: tr.querySelector(\".note\").value, ...(role ? { role: role.value } : {}), ...(keep ? { keepDays: Math.max(0, Math.floor(+keep.value || 0)), maxBytes: Math.max(0, Math.floor(+cap.value || 0)) * 1024 } : {}) }); }\n else if (act.startsWith(\"resolve:\")) await rpc(\"resolvereport\", id, act.slice(8), erase);\n else if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase);\n else await rpc(act, act === \"unrulekind\" ? +id : id, \"\");\n toast(\"Done\"); await (myRole ? refresh() : loadMine());\n if (act === \"pinevent\" || act === \"unpinevent\") await loadPins();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // The share block: the card picture, the group naddr and its QR.\n let card = null;\n async function loadCard() {\n try {\n card = await (await fetch(\"/card.json\", { cache: \"no-store\" })).json();\n $(\"#cardimg\").src = \"/card.svg?t=\" + Date.now();\n $(\"#naddr\").textContent = card.naddr || \"\";\n const q = $(\"#naddrqr\");\n if (card.naddr) { q.src = \"/qr.svg?text=\" + encodeURIComponent(card.naddr); q.classList.remove(\"hidden\"); } else q.classList.add(\"hidden\");\n } catch { /* the card is decoration */ }\n renderApps();\n }\n // The app rows on the Connect section: one per client people actually use,\n // with the link that lands on this relay in it. Relay apps take the relay\n // or the group address; feed apps take the owner's profile with this relay\n // as the hint, since they have no notion of opening a relay.\n function renderApps() {\n const el = $(\"#apps\"); if (!el) return;\n const enc = encodeURIComponent;\n const nprofile = card && card.nprofile ? card.nprofile : \"\";\n const naddr = card && card.naddr ? card.naddr : \"\";\n const whose = me && owner && me === owner ? \"your\" : \"the owner's\";\n const link = (label, href) => '' + label + \"\";\n const app = (label, uri) => '' + label + \"\";\n const copy = (label, text) => '\";\n const row = (name, where, note, acts) => '
                                                          ' + name + \"\" + where + \"

                                                          \" + note + '

                                                          ' + acts.filter(Boolean).join(\"\") + \"
                                                          \";\n const profileNote = \"Opens \" + whose + \" profile with this relay attached.\";\n const groups = [\n [\"As a place\", \"These open the relay itself: its feed, its people, its group.\", [\n row(\"Jumble\", \"web\", \"A feed of everything on this relay.\", [link(\"Open\", \"https://jumble.social/?r=\" + enc(wsURL))]),\n row(\"Coracle\", \"web\", \"The relay's page: its feed and its people.\", [link(\"Open\", \"https://coracle.social/relays/\" + enc(host))]),\n row(\"Flotilla\", \"web, phone\", \"The relay as a space, with the group as a room.\", [link(\"Open\", \"https://app.flotilla.social/spaces/\" + enc(host)), naddr && app(\"Open group\", \"nostr:\" + naddr)]),\n row(\"0xchat\", \"phone\", \"The group, in a chat app.\", [naddr && app(\"Open group\", \"nostr:\" + naddr), naddr && copy(\"Copy naddr\", naddr)]),\n row(\"noStrudel\", \"web\", \"Relays, add this one, then open its page.\", [copy(\"Copy relay URL\", wsURL)]),\n ]],\n [\"Find me here\", \"Feed apps have no relay pages. They meet this relay through a profile link that names it, then keep it once it is in the relay settings.\", [\n row(\"Primal\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://primal.net/p/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"YakiHonne\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://yakihonne.com/profile/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"Damus\", \"iPhone\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Amethyst\", \"Android\", profileNote + \" Then Relays in the drawer.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Nostur\", \"iPhone, Mac\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n ]],\n ];\n if (info?.supported_grasps?.includes(\"GRASP-01\")) groups.push([\n \"Git repositories\", \"Use this relay with a Git client.\", [\n row(\"GitWorkshop\", \"web\", \"Browse this relay's Git repositories.\", [link(\"Open in app\", \"https://gitworkshop.dev/relay/\" + enc((wsURL.startsWith(\"ws://\") ? \"ws:\" : \"\") + host)), copy(\"Copy relay URL\", wsURL)]),\n ],\n ]);\n el.innerHTML = groups.map(([h, note, rows]) => '

                                                          ' + h + '

                                                          ' + note + '

                                                          ' + rows.join(\"\") + \"
                                                          \").join(\"\");\n $(\"#apps-ws\").textContent = wsURL;\n const tile = (label, text) => '
                                                          ' + label + '\"QR
                                                          ';\n $(\"#phones\").innerHTML = [nprofile && tile(\"Find me here, for a phone\", \"nostr:\" + nprofile), naddr && tile(\"The group, for a phone\", \"nostr:\" + naddr)].filter(Boolean).join(\"\");\n }\n // The folds are a group: opening one closes the rest. Browsers with the\n // details name attribute do this themselves; this covers the others.\n $$(\".folds details\").forEach((d) => d.addEventListener(\"toggle\", () => { if (d.open) $$(\".folds details\").forEach((o) => { if (o !== d && o.open) o.open = false; }); }));\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-copytext]\"); if (!b) return;\n try { await navigator.clipboard.writeText(b.dataset.copytext); toast(\"copied\"); } catch { /* no clipboard */ }\n });\n $(\"#copynaddr\").onclick = async () => { if (!card || !card.naddr) return; await navigator.clipboard.writeText(card.naddr); toast(\"copied naddr\"); };\n $(\"#copyembed\").onclick = async () => { await navigator.clipboard.writeText('\"''); toast(\"copied embed\"); };\n\n // A click on a key copies it; a double-click selects the whole key so the usual copy shortcut takes it too.\n document.addEventListener(\"dblclick\", (ev) => {\n const k = ev.target.closest(\".key\"); if (!k) return;\n const s = window.getSelection(), r = document.createRange(); r.selectNodeContents(k.querySelector(\".full\")); s.removeAllRanges(); s.addRange(r);\n });\n document.addEventListener(\"click\", async (ev) => {\n const k = ev.target.closest(\".key\"); if (!k || ev.detail > 1 || String(window.getSelection())) return;\n try { await navigator.clipboard.writeText(k.title); toast(\"copied \" + k.dataset.short); } catch { /* no clipboard */ }\n });\n // ---- boot ----\n try { await loadInfo(); } catch { renderHeader(); }\n await loadFuel();\n await loadCard();\n me = localStorage.getItem(\"me\");\n if (me && await resumeRemote()) { /* a remote session answers for itself when first used */ }\n else if (me && window.nostr) { try { const pk = await window.nostr.getPublicKey(); if (pk !== me) me = null; } catch { me = null; } }\n else if (me) me = null;\n renderHeader();\n renderApps();\n await loadAdmin();\n await loadPeople();\n})();\n"; diff --git a/src/list-history.ts b/src/list-history.ts index 1ef29f0..a9d38b7 100644 --- a/src/list-history.ts +++ b/src/list-history.ts @@ -1,10 +1,15 @@ import type { Event } from "./event.ts"; +export type UnsignedEvent = { kind: number; created_at: number; tags: string[][]; content: string }; +type HistorySQL = >(q: string, ...args: unknown[]) => SqlStorageCursor; + // List history keeps a small private undo trail for the replaceable lists that // are difficult to recreate. It is keyed by the signing pubkey, never indexed // as events, and only the same signer can ask for a version back. export const LIST_KINDS = [3, 10002, 10003, 30003] as const; export const LIST_HISTORY_LIMIT = 12; +export const LIST_HISTORY_OWNER_LIMIT = 96; +export const LIST_HISTORY_GLOBAL_LIMIT = 4096; export const LIST_HISTORY_SCHEMA = ` CREATE TABLE IF NOT EXISTS list_history ( owner TEXT NOT NULL, @@ -26,34 +31,46 @@ const listD = (e: Pick) => e.kind >= 30000 ? e.tags.find // archiveCurrent records the version about to be replaced, then trims the // owner's trail. The current event remains available through ordinary NIP-01 // queries; history is an internal recovery aid. -export const archiveCurrent = (sql: SqlStorage, e: Event, savedAt: number) => { +export const archiveCurrent = (x: HistorySQL, e: Event, savedAt: number) => { if (!listKind(e.kind)) return; - sql.exec(`INSERT OR IGNORE INTO list_history(owner,kind,d,event_id,created_at,saved_at,raw) VALUES(?,?,?,?,?,?,?)`, e.pubkey, e.kind, listD(e), e.id, e.created_at, savedAt, JSON.stringify(e)); - sql.exec(`DELETE FROM list_history WHERE owner=? AND kind=? AND d=? AND event_id NOT IN (SELECT event_id FROM list_history WHERE owner=? AND kind=? AND d=? ORDER BY created_at DESC, event_id ASC LIMIT ?)`, e.pubkey, e.kind, listD(e), e.pubkey, e.kind, listD(e), LIST_HISTORY_LIMIT); + const d = listD(e); + x(`INSERT OR IGNORE INTO list_history(owner,kind,d,event_id,created_at,saved_at,raw) VALUES(?,?,?,?,?,?,?)`, e.pubkey, e.kind, d, e.id, e.created_at, savedAt, JSON.stringify(e)); + x(`DELETE FROM list_history WHERE owner=? AND kind=? AND d=? AND event_id NOT IN (SELECT event_id FROM list_history WHERE owner=? AND kind=? AND d=? ORDER BY created_at DESC, event_id ASC LIMIT ?)`, e.pubkey, e.kind, d, e.pubkey, e.kind, d, LIST_HISTORY_LIMIT); + x(`DELETE FROM list_history WHERE owner=? AND event_id NOT IN (SELECT event_id FROM list_history WHERE owner=? ORDER BY saved_at DESC, event_id ASC LIMIT ?)`, e.pubkey, e.pubkey, LIST_HISTORY_OWNER_LIMIT); + x(`DELETE FROM list_history WHERE event_id NOT IN (SELECT event_id FROM list_history ORDER BY saved_at DESC, event_id ASC LIMIT ?)`, LIST_HISTORY_GLOBAL_LIMIT); }; // clearForDelete removes versions an author's NIP-09 deletion names. A list // deletion must not leave private old tags recoverable through this feature. -export const clearForDelete = (sql: SqlStorage, author: string, eventID: string) => { - sql.exec(`DELETE FROM list_history WHERE owner=? AND event_id=?`, author, eventID); +export const clearForDelete = (x: HistorySQL, author: string, eventID: string) => { + x(`DELETE FROM list_history WHERE owner=? AND event_id=?`, author, eventID); }; -export const clearList = (sql: SqlStorage, owner: string, kind: number, d: string, before?: number) => { - if (before === undefined) sql.exec(`DELETE FROM list_history WHERE owner=? AND kind=? AND d=?`, owner, kind, d); - else sql.exec(`DELETE FROM list_history WHERE owner=? AND kind=? AND d=? AND created_at<=?`, owner, kind, d, before); +export const clearList = (x: HistorySQL, owner: string, kind: number, d: string, before?: number) => { + if (before === undefined) x(`DELETE FROM list_history WHERE owner=? AND kind=? AND d=?`, owner, kind, d); + else x(`DELETE FROM list_history WHERE owner=? AND kind=? AND d=? AND created_at<=?`, owner, kind, d, before); }; export interface ListHistoryRow { owner: string; kind: number; d: string; event_id: string; created_at: number; saved_at: number; raw: string; } -export const listHistory = (sql: SqlStorage, owner: string): Omit[] => - sql.exec>(`SELECT kind,d,event_id,created_at,saved_at FROM list_history WHERE owner=? ORDER BY saved_at DESC LIMIT 100`, owner).toArray(); +export const listHistory = (x: HistorySQL, owner: string): Omit[] => + x>(`SELECT kind,d,event_id,created_at,saved_at FROM list_history WHERE owner=? ORDER BY saved_at DESC LIMIT ?`, owner, LIST_HISTORY_OWNER_LIMIT).toArray(); -export const restoreHistory = (sql: SqlStorage, owner: string, eventID: string): (Event & { sig: string }) | string => { - const row = sql.exec<{ raw: string }>(`SELECT raw FROM list_history WHERE owner=? AND event_id=?`, owner, eventID).toArray()[0]; +export const restoreHistory = (x: HistorySQL, owner: string, eventID: string): { draft: UnsignedEvent; diff: { addedTags: string[][]; removedTags: string[][]; contentChanged: boolean } } | string => { + const row = x<{ raw: string }>(`SELECT raw FROM list_history WHERE owner=? AND event_id=?`, owner, eventID).toArray()[0]; if (!row) return "not found"; try { const event = JSON.parse(row.raw) as Event; - return { kind: event.kind, created_at: Math.floor(Date.now() / 1000), tags: event.tags, content: event.content } as Event & { sig: string }; + const current = x<{ created_at: number; raw: string }>(`SELECT created_at,raw FROM events WHERE pubkey=? AND kind=? AND d=?`, owner, event.kind, listD(event)).toArray()[0]; + let currentEvent: Event | undefined; + try { currentEvent = current ? JSON.parse(current.raw) as Event : undefined; } catch { /* saved current rows are validated events */ } + const key = (t: string[]) => JSON.stringify(t); + const currentTags = new Set((currentEvent?.tags ?? []).map(key)); + const oldTags = new Set(event.tags.map(key)); + return { + draft: { kind: event.kind, created_at: Math.max(Math.floor(Date.now() / 1000), (current?.created_at ?? 0) + 1), tags: event.tags, content: event.content }, + diff: { addedTags: event.tags.filter((t) => !currentTags.has(key(t))), removedTags: (currentEvent?.tags ?? []).filter((t) => !oldTags.has(key(t))), contentChanged: (currentEvent?.content ?? "") !== event.content }, + }; } catch { return "error: saved list is unreadable"; } diff --git a/src/manage.ts b/src/manage.ts index 42caa1a..07f0e70 100644 --- a/src/manage.ts +++ b/src/manage.ts @@ -26,7 +26,6 @@ import { addDomain, checkDomain, listDomains, removeDomain, setDomainSite } from import { verifyNIP98 } from "./auth.ts"; import { SITE_KINDS, checkSite, siteLabel, sitePaths } from "./sites.ts"; import { gitStorage } from "./git-storage.ts"; -import { listHistory, restoreHistory } from "./list-history.ts"; // A call: the relay and the request, who is calling and as what, the // parameters with their readers, and how to answer. @@ -260,13 +259,13 @@ export const METHODS: Record = { }, listlisthistory: { action: "read", reads: true, ownListHistory: true, - run: ({ relay, caller, reply }) => reply({ result: listHistory(relay.sql, caller) }), + run: ({ relay, caller, reply }) => reply({ result: relay.store.listHistory(caller) }), }, restorelist: { action: "read", reads: true, ownListHistory: true, run: ({ relay, caller, params, str, hex64, reply }) => { if (params.length !== 1 || !hex64(str(0))) return reply({ error: "invalid: give one saved list event id" }, 400); - const restored = restoreHistory(relay.sql, caller, str(0)); + const restored = relay.store.restoreHistory(caller, str(0)); return typeof restored === "string" ? reply({ error: restored }, 404) : reply({ result: restored }); }, }, diff --git a/src/store.ts b/src/store.ts index 9a9fb1e..66e0492 100644 --- a/src/store.ts +++ b/src/store.ts @@ -7,7 +7,7 @@ import { ftsQuery, searchTerms, type Filter } from "./filter.ts"; import { SITE_SCHEMA, SITE_KINDS } from "./sites.ts"; import { HLL } from "./hll.ts"; import { hexToBytes, type SyncItem } from "./negentropy.ts"; -import { archiveCurrent, clearForDelete, clearList, LIST_HISTORY_SCHEMA } from "./list-history.ts"; +import { archiveCurrent, clearForDelete, clearList, listHistory, restoreHistory, LIST_HISTORY_SCHEMA, type ListHistoryRow } from "./list-history.ts"; export const SCHEMA = ` CREATE TABLE IF NOT EXISTS events ( @@ -83,6 +83,8 @@ export class Store { onSitesChanged: () => void = () => {}; + private historySQL = >(q: string, ...args: unknown[]) => this.x(q, ...args); + constructor(private sql: SqlStorage) {} init() { @@ -139,14 +141,14 @@ export class Store { if (has(`SELECT 1 FROM events WHERE pubkey=? AND kind=? AND (created_at>? OR (created_at=? AND id(`SELECT raw FROM events WHERE pubkey=? AND kind=?`, e.pubkey, e.kind).toArray()[0]; - if (current) archiveCurrent(this.sql, JSON.parse(current.raw) as Event, now); + if (current) archiveCurrent(this.historySQL, JSON.parse(current.raw) as Event, now); this.x(`DELETE FROM events WHERE pubkey=? AND kind=?`, e.pubkey, e.kind); } else if (isAddressable(e.kind)) { d = tag(e, "d"); if (has(`SELECT 1 FROM events WHERE pubkey=? AND kind=? AND d=? AND (created_at>? OR (created_at=? AND id(`SELECT raw FROM events WHERE pubkey=? AND kind=? AND d=?`, e.pubkey, e.kind, d).toArray()[0]; - if (current) archiveCurrent(this.sql, JSON.parse(current.raw) as Event, now); + if (current) archiveCurrent(this.historySQL, JSON.parse(current.raw) as Event, now); this.x(`DELETE FROM events WHERE pubkey=? AND kind=? AND d=?`, e.pubkey, e.kind, d); } else if (e.kind === 5) { for (const t of e.tags) { @@ -156,12 +158,12 @@ export class Store { `DELETE FROM events WHERE id=? AND (pubkey=? OR (kind=1059 AND EXISTS (SELECT 1 FROM tags WHERE tags.event_id=events.id AND name='p' AND value=?)))`, t[1], e.pubkey, e.pubkey, ); - clearForDelete(this.sql, e.pubkey, t[1]); + clearForDelete(this.historySQL, e.pubkey, t[1]); } else if (t[0] === "a") { const parts = t[1].split(":"); if (parts.length === 3 && parts[1] === e.pubkey) { this.x(`DELETE FROM events WHERE kind=? AND pubkey=? AND d=? AND created_at<=?`, parseInt(parts[0], 10) || 0, e.pubkey, parts[2], e.created_at); - clearList(this.sql, e.pubkey, parseInt(parts[0], 10) || 0, parts[2], e.created_at); + clearList(this.historySQL, e.pubkey, parseInt(parts[0], 10) || 0, parts[2], e.created_at); } } } @@ -390,6 +392,17 @@ export class Store { return n; } + // listHistory returns only metadata for versions signed by owner; raw event + // content stays behind restoreHistory and the same owner check. + listHistory(owner: string): Omit[] { + return listHistory(this.historySQL, owner); + } + + // restoreHistory returns a fresh unsigned draft and its current-list diff. + restoreHistory(owner: string, eventID: string): ReturnType { + return restoreHistory(this.historySQL, owner, eventID); + } + // dumpPage reads a page of raw events by sequence for the JSONL dump. dumpPage(afterSeq: number, limit: number): { seq: number; raw: string }[] { return this.x<{ seq: number; raw: string }>(`SELECT seq, raw FROM events WHERE seq>? AND kind <> ${KIND_PUSH_REGISTRATION} ORDER BY seq LIMIT ?`, afterSeq, limit).toArray(); diff --git a/test/object/list-history.test.ts b/test/object/list-history.test.ts index 286998a..c6efa2d 100644 --- a/test/object/list-history.test.ts +++ b/test/object/list-history.test.ts @@ -10,8 +10,8 @@ describe("private list recovery", () => { const owner = generateSecretKey(); const other = generateSecretKey(); await rpc(host, owner, "claim"); + const base = now(); await runInDurableObject(env.RELAY.getByName("list-history"), (relay: Relay) => { - const base = now(); for (let i = 0; i < 14; i++) expect(relay.store.save(ev(owner, 10002, "v" + i, [["r", "wss://relay" + i]], base + i), base + i)).toBe(""); expect(relay.store.save(ev(other, 10002, "other", [], base + 20), base + 20)).toBe(""); expect(relay.store.save(ev(owner, 10003, "bookmarks", [], base + 21), base + 21)).toBe(""); @@ -22,10 +22,15 @@ describe("private list recovery", () => { expect((await rpc(host, other, "listlisthistory")).status).toBe(403); const chosen = history.find((x) => x.kind === 10002); const restored = (await rpc(host, owner, "restorelist", chosen.event_id)).result; - expect(restored).toMatchObject({ kind: 10002, content: expect.stringMatching(/^v/) }); - expect(restored).not.toHaveProperty("sig"); - expect(restored).not.toHaveProperty("pubkey"); + expect(restored.draft).toMatchObject({ kind: 10002, content: expect.stringMatching(/^v/) }); + expect(restored.draft).not.toHaveProperty("sig"); + expect(restored.draft).not.toHaveProperty("pubkey"); + expect(restored.diff).toMatchObject({ contentChanged: true }); + expect(restored.draft.created_at).toBeGreaterThanOrEqual(base + 14); expect((await rpc(host, other, "restorelist", chosen.event_id)).status).toBe(403); + await runInDurableObject(env.RELAY.getByName("list-history"), (relay: Relay) => { + expect(relay.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM list_history WHERE owner=?`, pk(owner)).one().n).toBeLessThanOrEqual(96); + }); }); it("clears prior versions when the author vanishes", async () => { From 976bc092b0caf81f3e00cf77b02601dfe8514f19 Mon Sep 17 00:00:00 2001 From: Dami Date: Fri, 4 Sep 2026 18:12:17 -0600 Subject: [PATCH 04/14] console: list recovery identifies named bookmarks and shares the data guide Keeps recovery guidance in the existing user and management references, including signer-only access and retained version limits. --- README.md | 1 - docs/04-data-and-names.md | 6 ++++++ docs/13-scripts-and-agents.md | 2 ++ docs/27-list-recovery.md | 10 ---------- src/console/console.js | 2 +- src/gen/console.ts | 2 +- 6 files changed, 10 insertions(+), 13 deletions(-) delete mode 100644 docs/27-list-recovery.md diff --git a/README.md b/README.md index 6f7f091..90d4dff 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,6 @@ Site hosting and mirroring are on by default; Marmot and GRASP are off until ena - [NIP-86 membership claims](docs/24-nip86-claims.md): create, list and revoke invitation codes through the standard management methods. - [NIP-9a relay push](docs/25-nip-9a-relay-push.md): opt-in callback delivery, privacy, bounds and operator setup. - [NIP-11 identifier compatibility](docs/26-nip11-compatibility.md): lettered capabilities and concrete client parser behavior. -- [List recovery](docs/27-list-recovery.md): privately review and restore older follows, relay lists and bookmarks. ### Protocol guides diff --git a/docs/04-data-and-names.md b/docs/04-data-and-names.md index 7e469fb..e012735 100644 --- a/docs/04-data-and-names.md +++ b/docs/04-data-and-names.md @@ -11,6 +11,12 @@ What your relay holds, how it moves and how a name can do one job. The Data tab shows bytes by kind, the files people uploaded and a keep-for rule per kind. Kinds the relay depends on, such as profiles, contact lists, relay lists, zap receipts and the roster, are never expired or purged. The controls are in [Relay configuration](01-relay-configuration.md#data). +## Recover a list + +The Data tab keeps older versions of your follows, relay lists and bookmarks (kinds 3, 10002, 10003 and 30003). Versions stay private to the publishing key and stay outside event queries, search and ordinary dumps. History starts when a newer version replaces a stored list. + +Choose **Restore** to preview added and removed tags and whether the content changed. Your extension or remote signer signs the old contents with a newer timestamp, then the console publishes it through the normal event door. The relay never receives your private key. History keeps at most twelve versions per list, 96 per publishing key and 4,096 per relay. Author deletions and vanish remove matching saved history. + ## Jobs A job is work the relay does on its own, one small round at a time, so it keeps going while the relay sleeps between rounds. The **Jobs** table on the Sync tab lists each job with its relays, filter, schedule and last result, with run-now and remove. A job that fails three rounds in a row stops and says why. A pull retries a failing source three times, then continues with the next source. An explicit refusal skips that source immediately. diff --git a/docs/13-scripts-and-agents.md b/docs/13-scripts-and-agents.md index 3d25ad2..18e45a2 100644 --- a/docs/13-scripts-and-agents.md +++ b/docs/13-scripts-and-agents.md @@ -144,6 +144,8 @@ The bridge takes the same header. `POST /events` answers `{ event_id, accepted, - `gitstorage owner identifier`: an owner-only inventory of one accepted GRASP repository. The result compares bounded physical R2 listing with live Git dependencies and reports physical, live, unreferenced and unknown objects by class, SQL reservations and the byte difference. It never deletes data. - `deleteblob sha256`. - `listdumps`, `dumpnow`, `deletedump name`. +- `listlisthistory`: private older list versions belonging to the authenticated owner, moderator or member. +- `restorelist eventId`: an unsigned `draft` and `diff` (added tags, removed tags, content changed) for one saved version belonging to that signer. Sign the draft in the client and publish it normally. **Config** diff --git a/docs/27-list-recovery.md b/docs/27-list-recovery.md deleted file mode 100644 index 3f44a48..0000000 --- a/docs/27-list-recovery.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: List recovery -audience: user ---- - -Your relay keeps up to twelve older versions of your follows, relay lists and bookmark lists. The versions stay private to the key that published them and do not appear in ordinary queries, dumps or search. - -Open the Data tab in your relay console to see saved versions. Restore prepares the old tags and content as a new event with a current timestamp. Your browser extension or remote signer signs it, and the console publishes the signed event through the normal event door. The relay never receives or stores your private key. - -History starts when a list is replaced, so a newly claimed relay has no older version until you publish a newer list. Author deletion and NIP-62 vanish remove the saved versions as well. diff --git a/src/console/console.js b/src/console/console.js index 365d129..5a3b203 100644 --- a/src/console/console.js +++ b/src/console/console.js @@ -523,7 +523,7 @@ async function loadListHistory() { const rows = await rpc("listlisthistory"); const labels = { 3: "follows", 10002: "relay list", 10003: "bookmarks", 30003: "bookmark list" }; - $("#listhistory tbody").innerHTML = rows.length ? rows.map((r) => '' + esc(labels[r.kind] || ("kind " + r.kind) + (r.d ? " / " + r.d : "")) + '' + esc(fmtTime(r.created_at)) + '' + esc(fmtTime(r.saved_at)) + '' + ib("undo", "Restore this version", "restorelist", r.event_id) + '').join("") : 'no older list versions yet'; + $("#listhistory tbody").innerHTML = rows.length ? rows.map((r) => '' + esc((labels[r.kind] || ("kind " + r.kind)) + (r.d ? " / " + r.d : "")) + '' + esc(fmtTime(r.created_at)) + '' + esc(fmtTime(r.saved_at)) + '' + ib("undo", "Restore this version", "restorelist", r.event_id) + '').join("") : 'no older list versions yet'; } let searchQuery = ""; diff --git a/src/gen/console.ts b/src/gen/console.ts index fb2d1d3..e3f4ffd 100644 --- a/src/gen/console.ts +++ b/src/gen/console.ts @@ -1,4 +1,4 @@ // Generated by scripts/build/build-console.mjs from src/console. Do not edit; run npm run build:console. export const CONSOLE_HTML = "
                                                          \n
                                                          \n
                                                          \"\"
                                                          \n
                                                          \"\"

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

                                                          Nobody owns this relay yet.

                                                          \n

                                                          Claim it and it's yours: you decide who can post, who can read, and what stays. One signature with a nostr browser extension; no account, no email, no card.

                                                          \n
                                                          \n
                                                          \n\n
                                                          \n

                                                          A temporary relay, for now.

                                                          \n

                                                          Anyone can read and write here until . Then everything on it is deleted and the name is freed. Claim it and it stays, events and files included: one signature with a nostr browser extension. Or claim a new name and pull this one into it from its Storage tab.

                                                          \n
                                                          \n
                                                          \n\n
                                                          \n

                                                          Connect a remote signer.

                                                          \n

                                                          Your key stays in a signer app such as Amber or nsec.app; this page asks it to sign. On a phone, open the link and approve there. On a computer, paste the bunker:// URL the app gives you.

                                                          \n \n
                                                          \n \"QR\n

                                                          \n
                                                          \n\n
                                                          \n
                                                          \n

                                                          \n

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

                                                          Git repositories, ntig

                                                          \n
                                                          \n

                                                          Use ordinary Git to clone a repository hosted here.

                                                          \n
                                                          Clone a repository
                                                          \n

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

                                                          \n

                                                          To host your own, use a Nostr Git client to publish your repository and signed branch state to this relay before pushing to the same remote. Git hosting guide.

                                                          \n
                                                          \n
                                                          \n
                                                          \n

                                                          Open it in an app

                                                          \n

                                                          Relay apps open this relay as a place. Feed apps do not: they find the owner here through a profile link that carries this relay as the hint, and learn the relay from that. Either way, add under the app's relay settings to post here.

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

                                                          People

                                                          \n

                                                          Hidden from visitors. Only you see this list.

                                                          \n
                                                          \n
                                                          \n\n
                                                          \n

                                                          Fuel

                                                          \n

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

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

                                                          \n
                                                          sats
                                                          \n
                                                          \n

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

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

                                                          Your invites

                                                          \n

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

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

                                                            People

                                                            \n

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

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

                                                            Invites

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

                                                              Joining

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

                                                              Moderation

                                                              \n

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

                                                              \n
                                                              \n

                                                              Reports

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

                                                              Log

                                                              \n

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

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

                                                              Bans

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

                                                                  Blocked addresses

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

                                                                    Recent events

                                                                    \n

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

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

                                                                    Pinned

                                                                    \n

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

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

                                                                      Rules

                                                                      \n

                                                                      Bans apply regardless of these.

                                                                      \n
                                                                      \n

                                                                      Presets

                                                                      \n

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

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

                                                                      \n
                                                                      \n
                                                                      \n
                                                                      \n

                                                                      Writes

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

                                                                      Reads

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

                                                                      Kinds

                                                                      \n

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

                                                                      \n
                                                                      \n

                                                                      Allowed:

                                                                      \n

                                                                      Blocked:

                                                                      \n
                                                                      \n
                                                                      \n

                                                                      Features

                                                                      \n

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

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

                                                                      Blocked words

                                                                      \n

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

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

                                                                      Identity

                                                                      \n
                                                                      \n

                                                                      Profile

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

                                                                      For directories

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

                                                                      Your own domain

                                                                      \n

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

                                                                      \n
                                                                      \n
                                                                      \n

                                                                      \n
                                                                      \n
                                                                      \n

                                                                      Your relay lists

                                                                      \n

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

                                                                      \n
                                                                      \n
                                                                      \n
                                                                      \n

                                                                      Share

                                                                      \n

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

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

                                                                      Data

                                                                      \n

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

                                                                      \n
                                                                      \n

                                                                      Recover your lists

                                                                      \n

                                                                      Older signed versions of your follows, relay lists and bookmarks stay private here. Restore a version to review it, then sign and publish it from this relay.

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

                                                                      By kind

                                                                      \n
                                                                      KindCountSizeOldestKeep for
                                                                      \n
                                                                      \n
                                                                      \n

                                                                      Files

                                                                      \n
                                                                      TimeFileSizeUploader
                                                                      \n
                                                                      \n
                                                                      \n

                                                                      Sites

                                                                      \n

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

                                                                      \n
                                                                      AuthorNameURLFilesSizeExpiry
                                                                      \n
                                                                      \n
                                                                      \n

                                                                      Dumps

                                                                      \n

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

                                                                      \n
                                                                      \n
                                                                        \n
                                                                        \n
                                                                        \n

                                                                        Import a file

                                                                        \n

                                                                        A JSONL of events, one per line, such as a dump or a strfry export, or a JSON array. Up to 64 MB. Signatures are checked; bans and kind rules apply; the write rule does not. Progress shows under Jobs.

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

                                                                        Sync

                                                                        \n

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

                                                                        \n
                                                                        \n

                                                                        Jobs

                                                                        \n

                                                                        Work the relay does on its own, one round at a time. A pull copies what another relay has and this one lacks. Fetch my history pulls your own events from the relays in your relay list. Rebroadcast sends what is here to other relays. Bans and kind rules apply to what arrives.

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

                                                                        Fork this relay

                                                                        \n

                                                                        A new name, temporary until claimed, filled from this relay. Yours to split a name by job, or somebody else's to hand them a community with its history. One fork an hour.

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

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

                                                                        Views

                                                                        \n

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

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

                                                                        Health

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

                                                                        Zaps received

                                                                        \n
                                                                        WhenFromSats
                                                                        \n
                                                                        \n
                                                                        \n

                                                                        Notifications

                                                                        \n

                                                                        The relay writes you a private message (NIP-17) with its own key. It lands here, in your inbox on your own relay, and on your DM relays when this relay holds your kind 10050.

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

                                                                        Owner

                                                                        \n

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

                                                                        \n
                                                                        \n

                                                                        Configuration

                                                                        \n

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

                                                                        \n
                                                                        \n
                                                                        \n
                                                                        \n

                                                                        Transfer ownership

                                                                        \n

                                                                        Hands the relay to a member. You stay on as a moderator. The relay's key, events, files and fuel do not change. There is no undo.

                                                                        \n
                                                                        \n
                                                                        \n
                                                                        \n

                                                                        If I lose my key

                                                                        \n

                                                                        Name a member as your heir. If you do not sign in here for the time you pick, the relay writes to you once a week for a month, then hands itself to the heir and keeps you on as a moderator. Any signed action on the relay resets the clock.

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

                                                                        \n
                                                                        \n
                                                                        \n

                                                                        Delete this relay

                                                                        \n

                                                                        Deletes every event, file, member, invite and setting, closes every connection, and returns the name to unclaimed for anyone to take. There is no undo.

                                                                        \n \n
                                                                        \n
                                                                        \n
                                                                        \n\n \n
                                                                        \n"; export const CONSOLE_CSS = "main { max-width: 64rem; }\n.mast { display: grid; grid-template-columns: 1fr auto; gap: 1.5rem 2rem; align-items: end; margin-bottom: 2rem; }\n.mast .wordmark { grid-column: 1 / -1; justify-self: center; margin-bottom: .5rem; }\n.mast .right { display: grid; gap: .7rem; justify-items: end; text-align: right; }\n.urlrow, .owner { display: inline-flex; align-items: center; gap: .4rem; color: var(--ink-2); }\n.owner b { color: var(--ink); } .owner em { font-style: normal; color: var(--forest); }\n.urlrow .ib, .owner .ib { width: 26px; height: 26px; margin-left: .2rem; box-shadow: 1px 1px 0 var(--ink); } .urlrow .ib svg, .owner .ib svg { width: 13px; height: 13px; }\n.care { display: flex; gap: .9rem; flex-wrap: wrap; }\n.care .g { display: grid; justify-items: center; gap: .3rem; width: 5.4rem; }\n.care .g i { display: grid; place-items: center; width: 44px; height: 44px; border: 2px solid var(--ink); border-radius: 10px; background: var(--paper); box-shadow: 2px 2px 0 var(--ink); position: relative; }\n.care .g i svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }\n.care .g.off i::after { content: \"\"; position: absolute; width: 3px; height: 40px; background: var(--ink); transform: rotate(45deg); border-radius: 2px; box-shadow: 0 0 0 2px var(--paper); }\n.care .g small { font: 500 10px/1.2 var(--mono); text-transform: uppercase; letter-spacing: .04em; color: var(--ink-2); text-align: center; }\n#unclaimed { background: var(--butter); }\n.doors { display: grid; grid-template-columns: repeat(4, 1fr); gap: .8rem; } @media (max-width: 52rem) { .doors { grid-template-columns: 1fr 1fr; } } @media (max-width: 30rem) { .doors { grid-template-columns: 1fr; } }\n.door { display: grid; grid-template-rows: auto 1fr; align-content: start; gap: .35rem; padding: .8rem .9rem; border: 2px solid var(--ink); border-radius: 12px; background: var(--paper); box-shadow: 3px 3px 0 var(--ink); min-width: 0; }\n.apps { display: grid; gap: 1.4rem; margin-top: 1rem; } .appgroup h4 { margin: 0 0 .2rem; font-size: 1rem; } .appgroup > .note { margin-top: 0; }\n.appgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: .8rem; } @media (max-width: 52rem) { .appgrid { grid-template-columns: 1fr 1fr; } } @media (max-width: 30rem) { .appgrid { grid-template-columns: 1fr; } }\n.app { display: grid; align-content: start; gap: .4rem; padding: .8rem .9rem; border: 2px solid var(--ink); border-radius: 12px; background: var(--paper); box-shadow: 3px 3px 0 var(--ink); min-width: 0; }\n.app-head { display: flex; align-items: baseline; gap: .5rem; flex-wrap: wrap; } .app-head b { font-size: 15px; } .app-head small { color: var(--ink-3); } .app p { margin: 0; font-size: 13px; color: var(--ink-2); }\n.app-acts { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .2rem; } .app-acts .btn { padding: .3rem .7rem; font-size: 13px; }\n.phones { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, max-content)); gap: .8rem; margin-top: 1rem; } .phones img { display: block; width: 150px; height: 150px; } .phones .door { justify-items: start; }\n.door small { font: 700 11px var(--sans); text-transform: uppercase; letter-spacing: .06em; color: var(--ink-2); }\n.door .v { display: grid; grid-template-columns: 1fr auto; align-items: start; gap: .5rem; font: 500 13px/1.45 var(--mono); } .door .v span { min-width: 0; overflow-wrap: anywhere; }\n.door .ib { width: 24px; height: 24px; box-shadow: 1px 1px 0 var(--ink); flex: 0 0 auto; } .door .ib svg { width: 12px; height: 12px; }\n#peoplesec { background: var(--peach); }\n.dir { display: flex; flex-wrap: wrap; gap: .5rem; }\n.who { display: inline-flex; align-items: center; gap: .1rem; padding: .3rem .7rem .3rem .4rem; border: 2px solid var(--ink); border-radius: 999px; background: var(--paper); box-shadow: 2px 2px 0 var(--ink); font-size: 14px; }\n.who .role { font: 700 10px var(--mono); text-transform: uppercase; letter-spacing: .05em; color: var(--forest); margin-left: .4rem; }\n.who.me { background: var(--sun); }\n#fuelsec { background: var(--mint); }\n.gauges { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }\n.gauge small { display: block; font-weight: 600; font-size: 13px; }\n.gauge .bar { position: relative; height: 22px; border: 2px solid var(--ink); border-radius: 999px; margin: .5rem 0 .35rem; overflow: hidden; padding: 3px; background: var(--paper) linear-gradient(90deg, transparent calc(25% - 1px), var(--line) calc(25% - 1px) 25%, transparent 25% calc(50% - 1px), var(--line) calc(50% - 1px) 50%, transparent 50% calc(75% - 1px), var(--line) calc(75% - 1px) 75%, transparent 75%); }\n.gauge .bar b { position: absolute; right: .6rem; top: 0; bottom: 0; display: flex; align-items: center; font: 600 11px var(--mono); color: var(--ink-3); pointer-events: none; }\n.gauge .bar i { display: block; height: 100%; width: 0; min-width: 14px; border-radius: 999px; background: repeating-linear-gradient(-45deg, var(--forest) 0 8px, var(--forest-2) 8px 16px); transition: width .4s; }\n.gauge .bar i.warm { background: repeating-linear-gradient(-45deg, #d9a52a 0 8px, var(--sun) 8px 16px); }\n.gauge .bar i.over { background: repeating-linear-gradient(-45deg, var(--red) 0 8px, #d4614c 8px 16px); }\n.gauge span { font-size: 13px; color: var(--ink-3); }\n.balance { margin: 1.2rem 0 0; color: var(--ink-2); } .balance b { color: var(--ink); font-weight: 700; }\n.sats { display: inline-flex; align-items: center; width: auto; } .sats input { font: 15px var(--sans); width: 6rem; text-align: right; border: 0; outline: none; padding: 0; background: transparent; color: var(--ink); } .sats span { color: var(--ink-3); padding-left: .4rem; }\n.topup { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; margin-top: .8rem; }\n.invoice { margin-top: 1rem; border: 2px dashed var(--ink); border-radius: 12px; padding: .9rem 1rem; background: var(--paper); }\n.invoice p { margin: 0 0 .6rem; color: var(--ink-2); font-size: 14px; } .invoice .state { font-size: 13px; color: var(--forest); }\n.invoice textarea { margin-top: .7rem; min-height: 3.4rem; font-family: var(--mono); font-size: 11.5px; color: var(--ink-3); }\ndetails.disclosure summary { cursor: pointer; font: 400 1.6rem/1 var(--display); list-style: none; display: flex; align-items: center; gap: .5rem; }\ndetails.disclosure summary::-webkit-details-marker { display: none; }\n.folds details + details, .folds #frontsec:not(.hidden) + details { border-top: 2px solid var(--line); margin-top: 1.2rem; padding-top: 1.2rem; } .folds #frontsec .metarow { margin-bottom: 0; } .folds .doors { margin-top: 1rem; } .folds > details > .block:first-of-type { margin-top: 1.4rem; }\ndetails.disclosure summary::before { content: \"+\"; font: 700 1.4rem var(--mono); width: 1.4rem; } details.disclosure[open] summary::before { content: \"–\"; }\n.about { display: grid; grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); gap: .6rem 1.2rem; margin-top: 1rem; font-size: 14px; }\n.about small { display: block; color: var(--ink-3); font-size: 12px; } .about div > div { overflow-wrap: anywhere; }\n.console { margin-top: 3rem; }\n.tabs { display: flex; gap: .45rem; flex-wrap: nowrap; align-items: flex-end; margin: 0 0 -2px 1rem; padding: 2px 3rem 2px 0; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; }\n.tabs.fade-r { -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 2.5rem), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 2.5rem), transparent); }\n.tabs.fade-l { -webkit-mask-image: linear-gradient(90deg, transparent, #000 2.5rem); mask-image: linear-gradient(90deg, transparent, #000 2.5rem); }\n.tabs.fade-l.fade-r { -webkit-mask-image: linear-gradient(90deg, transparent, #000 2.5rem, #000 calc(100% - 2.5rem), transparent); mask-image: linear-gradient(90deg, transparent, #000 2.5rem, #000 calc(100% - 2.5rem), transparent); } .tabs::-webkit-scrollbar { display: none; } .tabs a { flex: 0 0 auto; white-space: nowrap; }\n.tabs a { font: 600 14px var(--sans); padding: .55rem 1rem; border: 2px solid var(--ink); border-bottom: 0; border-radius: 12px 12px 0 0; background: var(--head); color: var(--ink-2); text-decoration: none; margin-bottom: -2px; position: relative; z-index: 1; }\n.tabs a.on { color: var(--ink); z-index: 3; padding-top: .7rem; } .tabs a.on:nth-child(4n+1) { background: var(--sun); } .tabs a.on:nth-child(4n+2) { background: var(--peach); } .tabs a.on:nth-child(4n+3) { background: var(--sky); } .tabs a.on:nth-child(4n+4) { background: var(--mint); }\n.count { display: inline-grid; place-items: center; min-width: 1.5em; height: 1.5em; padding: 0 .4em; margin-left: .45rem; border: 1.5px solid var(--ink); border-radius: 999px; background: var(--sun); color: var(--ink); font: 700 11px/1 var(--mono); vertical-align: middle; box-shadow: 1px 1px 0 var(--ink); } .count:empty { display: none; } h2 .count { font-size: 12px; vertical-align: .45em; }\n.panel { display: none; margin: 0; border-radius: 0 18px 18px 18px; position: relative; z-index: 2; }\n.panel.on { display: block; }\n.panel h2 { font-size: 2rem; }\n.block { margin-top: 1.8rem; }\n.two { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; } .two > * { min-width: 0; }\n.actions { display: flex; justify-content: flex-end; margin-top: 1.1rem; } .actions.left { justify-content: flex-start; }\n.addrow { display: flex; gap: .5rem; align-items: center; margin-bottom: .9rem; } .addrow input { flex: 1; min-width: 0; } .addrow label { display: inline-flex; align-items: center; gap: .35rem; flex: 0 0 auto; white-space: nowrap; font-size: 14px; color: var(--ink-2); cursor: pointer; } .addrow label input { flex: none; width: auto; margin: 0; accent-color: var(--forest); } .addrow .ib { width: 38px; height: 38px; flex: 0 0 auto; border-radius: 10px; }\n.choices { display: grid; gap: .5rem; }\n.choices label { display: grid; grid-template-columns: auto 1fr; gap: 0 .7rem; cursor: pointer; padding: .7rem .8rem; border: 2px solid var(--line-2); border-radius: 12px; background: var(--paper); }\n.choices label:has(:checked) { border-color: var(--ink); box-shadow: 3px 3px 0 var(--ink); }\n.choices input { grid-row: span 2; margin: .2rem 0 0; accent-color: var(--forest); }\n.choices b { font-weight: 600; } .choices small { color: var(--ink-2); font-size: 13px; }\n.limits { display: grid; grid-template-columns: 1fr 1fr; gap: .6rem 1.5rem; margin-top: 1.3rem; }\n.limits label { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: .6rem; } .limits label small { color: var(--ink-3); }\n.form { display: grid; grid-template-columns: 1fr 1fr; gap: .9rem 1.5rem; } .form label { display: grid; gap: .3rem; font-weight: 600; font-size: 14px; } .form .wide { grid-column: 1 / -1; }\n.danger-zone { border: 2px solid var(--red); border-radius: 12px; padding: 1rem 1.1rem; background: var(--red-soft); } .danger-zone h3 { color: var(--red); } .form label.switch { display: flex; flex-direction: row; align-items: center; gap: .6rem; font-weight: 600; font-size: 14px; } .switch input { accent-color: var(--forest); width: 18px; height: 18px; }\ntable { width: 100%; border-collapse: collapse; }\nth { text-align: left; font-size: 12px; font-weight: 600; color: var(--ink-3); padding: .4rem .5rem; border-bottom: 2px solid var(--ink); white-space: nowrap; }\ntd { padding: .5rem .5rem; vertical-align: middle; border-bottom: 1px solid var(--line); }\ntbody tr:nth-child(even) td { background: rgba(255,255,255,.55); }\nth.r, td.r { text-align: right; white-space: nowrap; } td.mono, td.dim { white-space: nowrap; } .events td.c:empty::before { content: \"(no content)\"; color: var(--ink-3); }\ntd .ib { width: 26px; height: 26px; box-shadow: 1px 1px 0 var(--ink); } td .ib svg { width: 13px; height: 13px; }\ntd input.txt { padding: .25rem .5rem; font-size: 14px; } td select.role { width: auto; min-width: 7rem; padding: .25rem .4rem; font-size: 13px; } .people-table input.name { width: 7rem; } .people-table input.note { width: 9.5rem; }\n.kind { font-family: var(--mono); font-size: 12px; background: var(--sun); border: 1.5px solid var(--ink); padding: 0 .5rem; border-radius: 999px; font-weight: 500; white-space: nowrap; }\n.events td.c { max-width: 0; width: 100%; overflow-x: auto; white-space: nowrap; scrollbar-width: thin; -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 2rem), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 2rem), transparent); }\n.people-table td.name { width: 12rem; }\n.ev { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 4px; vertical-align: -5px; margin-right: .45rem; font: 500 9px var(--mono); background: var(--line); color: var(--ink-2); font-style: normal; }\n.plain { list-style: none; margin: 0; padding: 0; } .plain li { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: .5rem; padding: .5rem 0; border-bottom: 2px dotted var(--line-2); } .plain li > span { min-width: 0; overflow-wrap: anywhere; } .plain li:last-child { border-bottom: 0; } .plain li.empty { grid-template-columns: 1fr; color: var(--ink-3); }\n.kindline { margin: .4rem 0; color: var(--ink-2); display: flex; flex-wrap: wrap; align-items: center; gap: .35rem; }\n.tag { display: inline-flex; align-items: center; gap: .2rem; font-family: var(--mono); font-size: 13px; padding: .05rem .2rem .05rem .55rem; border: 2px solid var(--ink); border-radius: 999px; background: var(--paper); box-shadow: 1px 1px 0 var(--ink); }\n.tag.plain { padding-right: .55rem; box-shadow: none; border-color: var(--line-2); color: var(--ink-3); } .tag.blk { color: var(--red); }\n.tag .ib { width: 18px; height: 18px; border-width: 1.5px; box-shadow: none; margin-left: .2rem; } .tag .ib svg { width: 9px; height: 9px; }\n.counters { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1.2rem; margin-bottom: 1.6rem; }\n.counter { display: grid; justify-items: center; align-content: center; gap: .15rem; min-height: 7.2rem; text-align: center; padding: 1rem .8rem; border: 2px solid var(--ink); border-radius: 16px; box-shadow: 4px 4px 0 var(--ink); background: var(--paper); }\n.counter:nth-child(1) { background: var(--sun); } .counter:nth-child(2) { background: var(--peach); } .counter:nth-child(3) { background: var(--sky); } .counter:nth-child(4) { background: var(--mint); }\n.counter .big { font: 400 2.6rem/1 var(--display); white-space: nowrap; } .counter small { display: block; margin-top: .35rem; font: 700 12px var(--sans); text-transform: uppercase; letter-spacing: .08em; } .counter .sub { font-size: 12px; color: var(--ink-2); }\n.kbar { display: flex; width: 100%; height: 22px; border: 2px solid var(--ink); border-radius: 999px; overflow: hidden; background: var(--paper); margin-bottom: .35rem; } .kbar i { display: block; height: 100%; border-right: 2px solid var(--ink); } .kbar i:last-child { border-right: 0; }\n.k1 { background: var(--ink); } .k2 { background: var(--forest); } .k3 { background: var(--id-3); } .k4 { background: var(--id-2); } .k5 { background: var(--line-2); }\n.legend { display: flex; flex-wrap: wrap; justify-content: center; gap: .1rem .6rem; font-family: var(--mono); font-size: 10.5px; text-transform: uppercase; } .legend i { display: inline-block; width: 9px; height: 9px; border: 1.5px solid var(--ink); border-radius: 2px; margin-right: .3rem; vertical-align: -1px; }\n.usage { display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: .8rem; } .usage div { padding: .7rem .9rem; border: 2px solid var(--ink); border-radius: 12px; background: var(--paper); } .usage small { display: block; color: var(--ink-3); font-size: 12px; } .usage b { font: 400 1.5rem/1.2 var(--display); }\n@media (max-width: 52rem) { .two, .gauges, .limits, .form, .counters { grid-template-columns: 1fr !important; } .addrow { flex-wrap: wrap; } .mast { grid-template-columns: 1fr; } .mast .right { justify-items: start; text-align: left; } }\n@media (max-width: 40rem) { .events:not(#kinds):not(#jobs) th:first-child, .events:not(#kinds):not(#jobs) td:first-child { display: none; } }\n\n.slegend { justify-content: flex-start; margin: 0 0 1.2rem; }\n#s-totals { margin-bottom: .4rem; }\ntd.keep { white-space: nowrap; } td.keep input { width: 5.5rem; display: inline-block; margin-right: .3rem; } td.keep input::placeholder { color: var(--ink-3); }\n#kinds tr.any td:first-child { font-weight: 600; }\n#kinds td:first-child .kind { display: inline-block; min-width: 5.2em; text-align: center; margin-right: .35rem; }\n/* Tables keep their columns and scroll inside the card on narrow screens instead of pushing the page sideways. */\n.scroll { overflow-x: auto; max-width: 100%; scrollbar-width: thin; } .scroll table { min-width: 100%; }\nsection, .card { overflow-x: clip; }\n.kind.sys { background: var(--mint); border-color: var(--forest); color: var(--forest); }\ntd.keep.sys { color: var(--ink-3); font-size: 13px; white-space: normal; }\n\n#console.mod .tabs a:not([data-tab=people]):not([data-tab=moderation]) { display: none; } #console.mod #thresholdform { display: none; }\n#members select.role { width: auto; padding: .1rem .3rem; font-size: 12px; margin-left: .3rem; }\n.wire-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .5rem 1.2rem; padding: .8rem 0; border-bottom: 1px solid var(--line); align-items: start; } .wire-row:first-child { border-top: 2px solid var(--ink); }\n.wire-main { min-width: 0; } .wire-side { display: grid; justify-items: end; gap: .45rem; max-width: 26rem; }\n.wire-acts { display: flex; gap: .4rem; white-space: nowrap; } .wire-acts .btn { padding: .35rem .75rem; font-size: 13px; }\n.wire-meta { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .35rem .5rem; align-items: center; font-size: 13px; color: var(--ink-2); text-align: right; } .wire-meta:empty { display: none; }\n.pill { display: inline-block; padding: .1rem .6rem; border: 1.5px solid var(--ink); border-radius: 999px; font: 600 12px var(--sans); color: var(--ink); background: var(--paper); } .pill.on { background: var(--mint); } .pill.off { color: var(--ink-3); } .pill.bad { color: var(--red); }\n.chip { display: inline-block; padding: .05rem .45rem; border: 1px solid var(--forest); border-radius: 6px; font: 12px var(--mono); color: var(--forest); } .chip.bad { border-color: var(--red); color: var(--red); }\n.share { display: grid; grid-template-columns: minmax(0, 3fr) minmax(14rem, 2fr); gap: 1.4rem; align-items: start; } .share #cardimg { width: 100%; height: auto; display: block; }\n.share-side { display: grid; gap: .8rem; justify-items: start; } .naddr { display: block; font-size: 12px; line-height: 1.5; word-break: break-all; color: var(--ink-2); }\n@media (max-width: 52rem) { .share { grid-template-columns: 1fr; } .wire-side { display: contents; } .wire-acts { justify-self: end; } .wire-meta { grid-column: 1 / -1; justify-content: flex-start; text-align: left; } }\n#presets .btn { margin: 0 .4rem .4rem 0; }\n.panel form h3.gap { margin-top: 1.4rem; }\n.key { position: relative; display: inline-block; font-family: var(--mono); } .key::before { content: attr(data-short); } .key .full { position: absolute; left: 0; top: 0; width: 1px; height: 1px; overflow: hidden; opacity: 0; white-space: nowrap; }\n.mast .banner { grid-column: 1 / -1; width: 100%; aspect-ratio: 4 / 1; overflow: hidden; border: 2px solid var(--ink); border-radius: 18px; box-shadow: 4px 4px 0 var(--ink); background: var(--paper); margin-bottom: .5rem; } .mast .banner img { width: 100%; height: 100%; object-fit: cover; display: block; }\n.mast .wordmark { display: flex; align-items: center; gap: 1rem; } .mast .icon { width: 64px; height: 64px; border-radius: 50%; border: 2px solid var(--ink); box-shadow: 3px 3px 0 var(--ink); background: var(--paper); object-fit: cover; }\n.metarow { display: flex; flex-wrap: wrap; gap: .4rem .6rem; align-items: center; margin: -.4rem 0 1rem; font-size: 13px; color: var(--ink-2); }\n.metarow .tag { display: inline-block; padding: .1rem .6rem; border: 1.5px solid var(--ink); border-radius: 999px; font: 600 12px var(--sans); color: var(--ink); background: var(--sun); }\n.metarow .sep { color: var(--ink-3); } .metarow a { color: var(--forest); text-decoration: none; border-bottom: 1.5px solid var(--forest); } .metarow a:hover { border-bottom-width: 2px; }\n.about a { color: var(--forest); text-decoration: none; border-bottom: 1.5px solid var(--forest); }\n.wire-acts label { display: inline-flex; align-items: center; gap: .35rem; font-size: 14px; color: var(--ink-2); cursor: pointer; } .wire-acts label input { margin: 0; accent-color: var(--forest); } .wire-row.dim .wire-main { color: var(--ink-3); }\n"; -export const CONSOLE_JS = "(async () => {\n const $ = (s) => document.querySelector(s);\n const $$ = (s) => document.querySelectorAll(s);\n const host = location.host;\n const wsURL = (location.protocol === \"https:\" ? \"wss://\" : \"ws://\") + host;\n const rpcURL = location.origin + \"/\";\n let info = null, me = null, owner = \"\", policy = null, fuel = null, people = null, myRole = \"\";\n\n const toast = (msg) => { const t = $(\"#toast\"); t.textContent = msg; t.classList.add(\"show\"); clearTimeout(t._t); t._t = setTimeout(() => t.classList.remove(\"show\"), 2600); };\n // guard wraps a handler: disables the button, shows errors as toasts. Defined first, since handlers below use it.\n const guard = (fn) => async (ev) => { ev.preventDefault(); const b = ev.submitter || ev.target; if (b) b.disabled = true; try { await fn(ev); } catch (e) { toast(e.message); } finally { if (b) b.disabled = false; } };\n const short = (hex) => hex ? hex.slice(0, 8) + \"…\" + hex.slice(-4) : \"\";\n // key shows a key short but keeps the whole of it in the DOM: selecting, double-clicking or clicking it copies the full hex.\n const key = (hex) => hex ? '' + hex + '' : '';\n const hue = (hex) => (parseInt(hex.slice(0, 2), 16) * 360 / 256).toFixed(0) + \"deg\";\n const av = (hex) => '';\n const fmtBytes = (n) => n < 1e6 ? (n / 1e3).toFixed(0) + \" KB\" : n < 1e9 ? (n / 1e6).toFixed(1) + \" MB\" : (n / 1e9).toFixed(2) + \" GB\";\n const fmtHours = (ms) => ms < 3600e3 ? Math.round(ms / 60e3) + \" min\" : (ms / 3600e3).toFixed(ms < 36e6 ? 1 : 0) + \" h\";\n const fuelOver = () => !!fuel && (fuel.eventBytes > fuel.freeEventBytes || fuel.mediaBytes > fuel.freeMediaBytes || fuel.activeMs > fuel.freeActiveMs || fuel.rowsWritten > fuel.freeRowsWritten);\n const fmtTime = (t) => t ? new Date(t * 1000).toLocaleString(undefined, { month: \"short\", day: \"numeric\", hour: \"2-digit\", minute: \"2-digit\" }) : \"–\";\n const fmtDay = (t) => t ? new Date(t * 1000).toLocaleDateString(undefined, { month: \"short\", day: \"numeric\" }) : \"–\";\n const ago = (t) => { const s = Math.max(0, Math.floor(Date.now() / 1000) - t); return s < 60 ? \"just now\" : s < 3600 ? Math.floor(s / 60) + \" min\" : s < 86400 ? Math.floor(s / 3600) + \" h\" : Math.floor(s / 86400) + \" d\"; };\n const esc = (s) => String(s ?? \"\").replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c]);\n\n const IC = {\n x: '',\n undo: '',\n check: '',\n trash: '',\n ban: '',\n banuser: '',\n eye: '',\n lock: '',\n pen: '',\n people: '',\n person: '',\n gauge: '',\n bolt: '',\n copy: '',\n pin: '',\n };\n const ib = (icon, label, act, id, extra) => '\";\n\n const CH = \"qpzry9x8gf2tvdw0s3jn54khce6mua7l\";\n function npubToHex(s) {\n s = s.trim().toLowerCase();\n if (/^[0-9a-f]{64}$/.test(s)) return s;\n if (!s.startsWith(\"npub1\")) return null;\n const data = s.slice(5, -6).split(\"\").map((c) => CH.indexOf(c));\n if (data.some((d) => d < 0)) return null;\n let bits = 0, acc = 0, out = [];\n for (const d of data) { acc = (acc << 5) | d; bits += 5; if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 255); } }\n return out.length === 32 ? out.map((b) => b.toString(16).padStart(2, \"0\")).join(\"\") : null;\n }\n\n async function sha256hex(s) {\n const b = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(s));\n return [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, \"0\")).join(\"\");\n }\n // ---- signing: a NIP-07 extension, or a NIP-46 remote signer ----\n // The remote path loads the bundled library on first use; its session\n // (client key, signer pubkey, relays) lives in localStorage until sign-out.\n let remote = null, lib = null, pendingNote = null;\n const NO_SIGNER = \"Install a nostr extension (Alby, nos2x, …) or connect a remote signer.\";\n const withTimeout = (p, ms, what) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(what + \" did not answer; is the signer app open?\")), ms))]);\n const signer = {\n ready: () => !!remote || !!window.nostr,\n async getPublicKey() { if (remote) return withTimeout(remote.getPublicKey(), 20000, \"The remote signer\"); if (window.nostr) return window.nostr.getPublicKey(); throw new Error(NO_SIGNER); },\n async signEvent(ev) { if (remote) return withTimeout(remote.signEvent(ev), 60000, \"The remote signer\"); if (window.nostr) return window.nostr.signEvent(ev); throw new Error(NO_SIGNER); },\n };\n async function signerLib() {\n if (lib) return lib;\n if (!window.NostrSigner) await new Promise((res, rej) => { const s = document.createElement(\"script\"); s.src = window.SIGNER_URL || \"/signer.js\"; s.onload = res; s.onerror = () => rej(new Error(\"Could not load the signer library.\")); document.head.appendChild(s); });\n return (lib = window.NostrSigner);\n }\n const onauth = (u) => window.open(u, \"_blank\");\n // The signer conversation rides this relay, and a relay may ask the socket to\n // authenticate before it delivers kind 24133 to its parties; answer with the\n // session's own key.\n const authPool = (L, sk) => new L.SimplePool({ automaticallyAuth: () => (evt) => Promise.resolve(L.finalizeEvent(evt, sk)) });\n const saveSession = (sk, s, secret) => localStorage.setItem(\"nip46\", JSON.stringify({ sk: lib.bytesToHex(sk), pubkey: s.bp.pubkey, relays: s.bp.relays, secret: secret || null }));\n async function resumeRemote() {\n const raw = localStorage.getItem(\"nip46\");\n if (!raw) return false;\n try {\n const s = JSON.parse(raw); const L = await signerLib();\n remote = L.BunkerSigner.fromBunker(L.hexToBytes(s.sk), { pubkey: s.pubkey, relays: s.relays, secret: s.secret }, { onauth, pool: authPool(L, L.hexToBytes(s.sk)) });\n return true;\n } catch { localStorage.removeItem(\"nip46\"); return false; }\n }\n async function connectBunker(input) {\n const L = await signerLib();\n const bp = await L.parseBunkerInput(input.trim());\n if (!bp) throw new Error(\"That is not a bunker:// URL.\");\n if (!bp.relays.length) throw new Error(\"The bunker URL names no relay.\");\n const sk = L.generateSecretKey();\n const s = L.BunkerSigner.fromBunker(sk, bp, { onauth, pool: authPool(L, sk) });\n await withTimeout(s.connect({ name: host, url: location.origin }), 60000, \"The signer\");\n await Promise.race([s.switchRelays(), new Promise((r) => setTimeout(r, 3000))]);\n remote = s; saveSession(sk, s, bp.secret);\n }\n // The nostrconnect:// flow: this relay carries the traffic, so no third relay is involved.\n let ncPending = null;\n async function offerNostrConnect() {\n if (ncPending) return ncPending;\n const L = await signerLib();\n const sk = L.generateSecretKey();\n const secret = L.bytesToHex(crypto.getRandomValues(new Uint8Array(8)));\n const uri = L.createNostrConnectURI({ clientPubkey: L.getPublicKey(sk), relays: [wsURL], secret, name: host, url: location.origin, perms: [\"sign_event:27235\", \"sign_event:9734\"] });\n $(\"#nclink\").href = uri; $(\"#ncnote\").textContent = \"\";\n $(\"#ncqr\").src = \"/qr.svg?text=\" + encodeURIComponent(uri); $(\"#ncqr\").classList.remove(\"hidden\");\n ncPending = L.BunkerSigner.fromURI(sk, uri, { onauth, pool: authPool(L, sk) }, 600000).then((s) => { remote = s; saveSession(sk, s, secret); ncPending = null; return s; }, (e) => { ncPending = null; throw e; });\n return ncPending;\n }\n async function remoteDone() {\n me = await signer.getPublicKey();\n localStorage.setItem(\"me\", me);\n $(\"#remotesec\").classList.add(\"hidden\");\n toast(\"Remote signer connected\");\n if (pendingNote && !owner) { const n = pendingNote; pendingNote = null; await claimNow(n); return; }\n pendingNote = null;\n renderHeader(); await loadAdmin(); await loadPeople();\n }\n function showRemote(note) {\n pendingNote = note || null;\n $(\"#remotesec\").classList.remove(\"hidden\");\n $(\"#remotesec\").scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n offerNostrConnect().then(remoteDone).catch((e) => { $(\"#ncnote\").textContent = e.message; });\n }\n\n async function rpc(method, ...params) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const body = JSON.stringify({ method, params });\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", rpcURL], [\"method\", \"POST\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(rpcURL, { method: \"POST\", headers: { \"content-type\": \"application/nostr+json+rpc\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body });\n const json = await resp.json();\n if (json.error) throw new Error(json.error);\n return json.result;\n }\n\n // ---- front of house ----\n // urlish shows a URL as a short link: the host and the start of the path, the whole thing on hover and as the target.\n class Html { constructor(s) { this.s = s; } }\n const urlish = (u) => {\n if (!u) return \"\";\n let label = u; try { const x = new URL(u); label = x.host + (x.pathname.length > 1 ? x.pathname.slice(0, 18) + (x.pathname.length > 18 ? \"\\u2026\" : \"\") : \"\"); } catch { /* not a URL */ }\n if (!/^https?:\\/\\//.test(u)) return u;\n return new Html('' + esc(label) + \"\");\n };\n function renderHeader() {\n const name = (info && info.name) || host.split(\".\")[0];\n $(\"#title\").textContent = name;\n document.title = name + \" - relay\";\n $(\"#url\").textContent = wsURL;\n $(\"#d-ws\").textContent = wsURL; $(\"#d-blossom\").textContent = location.origin; $(\"#d-nip05\").textContent = \"you@\" + host;\n $(\"#nip05-example\").textContent = \"alice@\" + host;\n const desc = (info && info.description) || \"\";\n $(\"#desc\").textContent = desc; $(\"#desc\").classList.toggle(\"hidden\", !desc);\n // What the owner declared about the relay, on the page, not only in the document clients read.\n const img = (id, url) => { const el = $(id); if (url) el.src = url; el.classList.toggle(\"hidden\", !url); };\n img(\"#bannerimg\", info?.banner); $(\"#banner\").classList.toggle(\"hidden\", !info?.banner);\n img(\"#iconimg\", info?.icon);\n const meta = [];\n for (const t of info?.tags || []) meta.push('' + esc(t) + \"\");\n const where = [...(info?.language_tags || []), ...(info?.relay_countries || [])];\n if (where.length) meta.push(\"\" + esc(where.join(\", \")) + \"\");\n const link = (label, href) => href ? '' + label + \"\" : \"\";\n const contact = info?.contact ? (/^(mailto:|https?:\\/\\/)/.test(info.contact) ? link(\"Contact\", info.contact) : \"\" + esc(info.contact) + \"\") : \"\";\n for (const x of [contact, link(\"Terms\", info?.terms_of_service), link(\"Posting policy\", info?.posting_policy), link(\"Privacy\", info?.privacy_policy)]) if (x) meta.push(x);\n $(\"#metarow\").innerHTML = meta.join('|'); $(\"#metarow\").classList.toggle(\"hidden\", meta.length === 0);\n $(\"#frontsec\").classList.toggle(\"hidden\", !desc && meta.length === 0);\n owner = (info && info.pubkey) || \"\";\n const isOwner = !!me && me === owner;\n const lease = !owner && info && info.lease ? info.lease : null;\n $(\"#ownerline\").innerHTML = owner ? 'run by ' + key(owner) + \"\" : lease ? \"temporary until \" + fmtDay(lease.expires_at) : \"unclaimed\";\n $(\"#owner-av\").classList.toggle(\"hidden\", !owner);\n if (owner) $(\"#owner-av\").style.setProperty(\"--h\", hue(owner));\n $(\"#who\").textContent = me ? (isOwner ? \"(that's you)\" : \"\") : \"\";\n $(\"#unclaimed\").classList.toggle(\"hidden\", !!owner || !!lease);\n $(\"#leased\").classList.toggle(\"hidden\", !lease);\n if (lease) {\n $(\"#lease-until\").textContent = fmtTime(lease.expires_at);\n $(\"#leasenote\").textContent = lease.holder ? \"Reserved for the key that asked for it; sign with that key.\" : \"\";\n }\n $(\"#signin\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signin46\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signout\").classList.toggle(\"hidden\", !me);\n $(\"#about\").innerHTML = [\n [\"Name\", info?.name], [\"Description\", info?.description], [\"Contact\", info?.contact], [\"Owner\", owner ? short(owner) : \"\"], [\"Relay key\", info?.self ? short(info.self) : \"\"], [\"Software\", urlish(info?.software)], [\"Version\", info?.version],\n [\"Max query\", info?.limitation?.max_limit], [\"Auth required\", String(!!info?.limitation?.auth_required)], [\"Restricted writes\", String(!!info?.limitation?.restricted_writes)], [\"Min PoW\", info?.limitation?.min_pow_difficulty || 0],\n [\"Tags\", info?.tags?.join(\", \")], [\"Languages\", info?.language_tags?.join(\", \")], [\"Countries\", info?.relay_countries?.join(\", \")],\n [\"Terms\", urlish(info?.terms_of_service)], [\"Posting policy\", urlish(info?.posting_policy)], [\"Privacy policy\", urlish(info?.privacy_policy)], [\"Icon\", urlish(info?.icon)], [\"Banner\", urlish(info?.banner)],\n [\"NIPs\", info?.supported_nips?.join(\" \")],\n ].map(([k, v]) => \"
                                                                        \" + k + \"\" + (v === undefined || v === \"\" ? '' : v instanceof Html ? v.s : esc(v)) + \"
                                                                        \").join(\"\");\n renderCare();\n }\n\n function renderCare() {\n const g = (icon, label, off) => '' + IC[icon] + \"\" + label + \"\";\n const writes = policy ? policy.writes : (info?.limitation?.restricted_writes ? \"allowlist\" : \"open\");\n const reads = policy ? policy.reads : (info?.limitation?.auth_required ? \"auth\" : \"open\");\n let out = writes === \"owner\" ? g(\"person\", \"only owner writes\") : writes === \"allowlist\" ? g(\"people\", \"members write\") : writes === \"wot\" ? g(\"people\", \"members and follows write\") : g(\"pen\", \"anyone writes\");\n out += reads === \"members\" ? g(\"people\", \"members read\") : reads === \"auth\" ? g(\"lock\", \"sign in to read\") : g(\"eye\", \"anyone reads\");\n if (fuel) {\n const over = fuelOver();\n out += fuel.outOfFuel ? g(\"gauge\", \"out of fuel\", true) : over ? g(\"bolt\", \"burning sats\") : g(\"gauge\", \"on free allowance\");\n }\n $(\"#care\").innerHTML = out;\n }\n\n async function loadPeople() {\n try { people = await (await fetch(\"/people\")).json(); } catch { return; }\n const isOwner = !!me && me === owner;\n let list = people.people || [];\n if (!people.public && isOwner && policy) list = (window.__members || []);\n $(\"#peoplesec\").classList.toggle(\"hidden\", !owner || list.length === 0);\n $(\"#people-note\").classList.toggle(\"hidden\", people.public);\n $(\"#people-count\").textContent = list.length || \"\";\n $(\"#dir\").innerHTML = list.map((m) => '' + av(m.pubkey) + '' + (m.name ? esc(m.name) + \"@\" + host : key(m.pubkey)) + \"\" + (m.role === \"owner\" ? 'owner' : \"\") + \"\").join(\"\");\n }\n\n async function loadInfo() {\n info = await (await fetch(\"/\", { headers: { accept: \"application/nostr+json\" } })).json();\n renderHeader();\n const git = info.supported_grasps?.includes(\"GRASP-01\");\n $(\"#git-connect\").classList.toggle(\"hidden\", !git);\n const clone = \"git clone '\" + location.origin + \"//.git'\";\n $(\"#git-clone\").textContent = clone;\n $(\"#git-copy\").dataset.copytext = clone;\n renderApps();\n }\n\n async function loadFuel() {\n try { fuel = await (await fetch(\"/fuel\")).json(); } catch { return; }\n $(\"#fuelsec\").classList.toggle(\"hidden\", !owner);\n const pct = (used, free) => Math.min(100, Math.round((used / Math.max(free, 1)) * 100));\n const gauge = (id, used, free, text) => { const g = $(\"#g-\" + id), share = free ? used / free : 0; g.style.width = pct(used, free) + \"%\"; g.classList.toggle(\"over\", share > 1); g.classList.toggle(\"warm\", share > 0.75 && share <= 1); $(\"#p-\" + id).textContent = share > 1 ? \"over\" : Math.round(share * 100) + \"%\"; $(\"#t-\" + id).textContent = text; };\n gauge(\"events\", fuel.eventBytes, fuel.freeEventBytes, fmtBytes(fuel.eventBytes) + \" of \" + fmtBytes(fuel.freeEventBytes) + \" free\");\n gauge(\"media\", fuel.mediaBytes, fuel.freeMediaBytes, fmtBytes(fuel.mediaBytes) + \" of \" + fmtBytes(fuel.freeMediaBytes) + \" free\");\n gauge(\"active\", fuel.activeMs, fuel.freeActiveMs, fmtHours(fuel.activeMs) + \" of \" + fmtHours(fuel.freeActiveMs) + \" free\");\n gauge(\"rows\", fuel.rowsWritten, fuel.freeRowsWritten, fuel.rowsWritten.toLocaleString() + \" of \" + fuel.freeRowsWritten.toLocaleString() + \" free\");\n const sats = Math.floor(fuel.balanceMsats / 1000);\n const r = fuel.rates;\n $(\"#fuel-balance\").innerHTML = fuel.outOfFuel\n ? 'out of fuel Writes are paused until someone tops up.'\n : \"Balance \" + sats.toLocaleString() + \" sats. Past the allowances, prices track what the hosting costs: \" + r.satsPerGBMonthEvents.toLocaleString() + \" sats per GB-month of events, \" + r.satsPerGBMonthMedia.toLocaleString() + \" per GB-month of files, \" + r.satsPerActiveHour.toLocaleString() + \" per hour awake, \" + r.satsPerMillionRows.toLocaleString() + \" per million rows written. Traffic is free.\";\n $(\"#topup\").classList.toggle(\"hidden\", !fuel.enabled);\n // Who paid is the owner's to see: it comes with the signed stats call.\n let credits = [];\n if (owner && signer.ready()) { try { credits = (await rpc(\"stats\")).credits || []; } catch { credits = []; } }\n $(\"#credits tbody\").innerHTML = credits.length ? credits.map((c) => '' + esc(fmtTime(c.at)) + '' + av(c.payer) + key(c.payer) + '' + Math.floor(c.msats / 1000).toLocaleString() + \"\").join(\"\") : 'no zaps yet';\n if (!fuel.enabled) $(\"#topup-note\").textContent = \"Top-ups are not enabled on this service yet.\";\n renderCare();\n renderFuelTile();\n }\n\n async function topUp(sats) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const msats = Math.round(sats * 1000);\n const zapRequest = await signer.signEvent({ kind: 9734, created_at: Math.floor(Date.now() / 1000), content: \"fuel for \" + host, tags: [[\"p\", fuel.servicePubkey], [\"amount\", String(msats)], [\"relays\", wsURL]] });\n const r = await (await fetch(\"/fuel/invoice\", { method: \"POST\", body: JSON.stringify({ zapRequest }) })).json();\n if (r.error) throw new Error(r.error);\n const inv = r.invoice;\n $(\"#invoice\").classList.remove(\"hidden\");\n $(\"#inv-text\").value = inv; $(\"#inv-link\").href = \"lightning:\" + inv; $(\"#inv-state\").textContent = \"waiting for payment…\";\n if (window.webln) { try { await window.webln.enable(); await window.webln.sendPayment(inv); } catch (e) { toast(e.message || \"wallet declined\"); } }\n const before = fuel.creditedMsats;\n for (let i = 0; i < 120; i++) {\n await new Promise((r) => setTimeout(r, 2500));\n await loadFuel();\n if (fuel.creditedMsats > before) { $(\"#inv-state\").textContent = \"paid, thank you\"; toast(\"Fuel credited\"); return; }\n }\n $(\"#inv-state\").textContent = \"no receipt yet; it can take a minute after paying\";\n }\n\n // ---- console ----\n function showTab(name) {\n if (name === \"content\" || name === \"storage\") name = \"data\";\n const known = myRole === \"moderator\" ? [\"people\", \"moderation\"] : [\"people\", \"moderation\", \"rules\", \"identity\", \"data\", \"sync\", \"views\", \"health\", \"owner\"];\n if (!known.includes(name)) name = \"people\";\n $$(\".tabs a\").forEach((a) => a.classList.toggle(\"on\", a.dataset.tab === name));\n $$(\".panel\").forEach((p) => p.classList.toggle(\"on\", p.dataset.panel === name));\n }\n // Tabs switch in place. A fragment still opens a tab when someone arrives with one,\n // but clicking never writes one, and any fragment already there is dropped.\n $$(\".tabs a\").forEach((a) => a.addEventListener(\"click\", (ev) => { ev.preventDefault(); showTab(a.dataset.tab); if (location.hash) history.replaceState(null, \"\", location.pathname + location.search); }));\n window.addEventListener(\"hashchange\", () => showTab(location.hash.slice(1)));\n // The strip fades on whichever side has more tabs off screen.\n const tabsFade = () => { const t = $(\"#tabs\"); t.classList.toggle(\"fade-l\", t.scrollLeft > 4); t.classList.toggle(\"fade-r\", t.scrollLeft + t.clientWidth < t.scrollWidth - 4); };\n $(\"#tabs\").addEventListener(\"scroll\", tabsFade); window.addEventListener(\"resize\", tabsFade); new ResizeObserver(tabsFade).observe($(\"#tabs\"));\n\n function renderFuelTile() {\n if (!fuel) return;\n const over = fuelOver();\n const sats = Math.max(0, Math.floor(fuel.balanceMsats / 1000));\n const d = new Date(), end = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1), days = Math.max(1, Math.ceil((end - Date.now()) / 86400000));\n if (fuel.outOfFuel) { $(\"#h-fuel\").textContent = \"0 sats\"; $(\"#h-fuel-label\").textContent = \"out of fuel\"; $(\"#h-fuel-sub\").textContent = \"writes are paused\"; }\n else if (over) { $(\"#h-fuel\").textContent = sats.toLocaleString() + \" sats\"; $(\"#h-fuel-label\").textContent = \"fuel left\"; $(\"#h-fuel-sub\").textContent = \"past the free allowance\"; }\n else { $(\"#h-fuel\").textContent = days + (days === 1 ? \" day\" : \" days\"); $(\"#h-fuel-label\").textContent = \"free allowance left\"; $(\"#h-fuel-sub\").textContent = sats ? \"then \" + sats.toLocaleString() + \" sats\" : \"then a top-up is needed\"; }\n $(\"#usage\").innerHTML = [[\"events stored\", fmtBytes(fuel.eventBytes)], [\"files stored\", fmtBytes(fuel.mediaBytes)], [\"awake this month\", fmtHours(fuel.activeMs)], [\"rows written\", fuel.rowsWritten.toLocaleString()], [\"rows read\", fuel.rowsRead.toLocaleString()], [\"received this month\", fmtBytes(fuel.bytesIn)], [\"served this month\", fmtBytes(fuel.bytesOut)], [\"charged\", Math.floor(fuel.chargedMsats / 1000).toLocaleString() + \" sats\"]]\n .map(([k, v]) => \"
                                                                        \" + k + \"\" + v + \"
                                                                        \").join(\"\");\n }\n\n function renderHealth(stats) {\n $(\"#h-last\").textContent = stats.newest ? ago(stats.newest) : \"none\";\n $(\"#h-last-sub\").textContent = stats.newest ? fmtTime(stats.newest) : \"no events yet\";\n $(\"#h-conns\").textContent = stats.connections;\n const buckets = [[\"notes\", \"k1\", [1]], [\"reactions\", \"k2\", [7]], [\"DMs\", \"k3\", [4, 14, 1059]], [\"long-form\", \"k4\", [30023]], [\"other\", \"k5\", null]];\n const sums = buckets.map(() => 0); let total = 0;\n for (const { kind, n } of stats.kinds || []) { total += n; const i = buckets.findIndex((b) => b[2] && b[2].includes(kind)); sums[i < 0 ? 4 : i] += n; }\n const parts = buckets.map((b, i) => [b[0], b[1], sums[i]]).filter((p) => p[2] > 0);\n $(\"#h-kinds\").innerHTML = total ? parts.map(([name, cls, n]) => '').join(\"\") : '';\n $(\"#h-kinds-legend\").innerHTML = total ? parts.map(([name, cls, n]) => '' + name + \" \" + Math.round(n * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n renderFuelTile();\n }\n\n async function loadAdmin() {\n const isOwner = !!me && me === owner;\n myRole = isOwner ? \"owner\" : \"\";\n let stats = null, p = null;\n // A signed-in member may be a moderator; the relay says by answering stats.\n if (me && owner && !isOwner) { try { [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]); myRole = \"moderator\"; } catch { myRole = \"\"; } }\n $(\"#console\").classList.toggle(\"hidden\", !myRole);\n loadMine();\n $(\"#console\").classList.toggle(\"mod\", myRole === \"moderator\");\n if (!myRole) { policy = null; renderCare(); return; }\n if (isOwner) [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]);\n policy = p;\n renderHealth(stats);\n renderCare();\n const fa = $(\"#access\"), fi = $(\"#identity\");\n fa.writes.value = p.writes; fa.reads.value = p.reads; fa.openKinds.value = (p.openKinds || []).join(\", \"); fa.guestReplies.checked = !!p.guestReplies;\n $(\"#wordsform\").words.value = (p.blockedWords || []).join(\"\\n\"); $(\"#wordsform\").inTags.checked = !!p.blockedWordsInTags; $(\"#thresholdform\").reportThreshold.value = p.reportThreshold || 0; fa.minPow.value = p.minPow; fa.maxFuture.value = p.maxFuture; fa.maxLimit.value = p.maxLimit; fa.maxSubs.value = p.maxSubs; fa.maxMessageKB.value = p.maxMessageKB;\n fa.eventsPerMinute.value = p.eventsPerMinute; fa.reqsPerMinute.value = p.reqsPerMinute; fa.maxBlobMB.value = p.maxBlobMB;\n renderFeatures(p.features || {});\n $(\"#push-policy-form\").elements.origins.value = (p.pushCallbacks || []).join(\"\\n\");\n $(\"#push-policy-form\").elements.lettered.checked = !!p.letteredNips;\n fi.name.value = p.name; fi.contact.value = p.contact; fi.description.value = p.description; fi.icon.value = p.icon;\n const fj = $(\"#joinform\"); fj.joinTerms.value = p.joinTerms; fj.directoryPublic.checked = !!p.directoryPublic;\n loadCard();\n fi.banner.value = p.banner || \"\"; fi.postingPolicy.value = p.postingPolicy || \"\"; fi.privacyPolicy.value = p.privacyPolicy || \"\";\n fi.tags.value = (p.tags || []).join(\", \"); fi.languageTags.value = (p.languageTags || []).join(\", \"); fi.relayCountries.value = (p.relayCountries || []).join(\", \");\n const fn = $(\"#notify\"), nt = p.notify || {};\n fn.reports.checked = !!nt.reports; fn.fuel.checked = !!nt.fuel; fn.jobs.checked = !!nt.jobs; fn.succession.checked = !!nt.succession; fn.digest.checked = !!nt.digest;\n showTab(location.hash.slice(1));\n if (isOwner) { await renderPresets(); renderWire(); renderSuccession(); loadDomains(); }\n await Promise.all([loadLists(), loadEvents(true), loadPins(), ...(isOwner ? [loadStorage()] : [])]);\n }\n\n async function loadAudit(before) {\n const rows = await rpc(\"listaudit\", before || 0);\n const html = rows.map((r) => '' + esc(fmtTime(r.at)) + '' + av(r.actor) + key(r.actor) + '' + esc(r.action) + '' + (/^[0-9a-f]{64}$/.test(r.target) ? key(r.target) : esc(r.target)) + '' + esc(r.detail) + '').join(\"\");\n const tb = $(\"#audit tbody\");\n if (before) tb.insertAdjacentHTML(\"beforeend\", html); else tb.innerHTML = html || 'Nothing yet';\n $(\"#audit-more\").style.display = rows.length < 100 ? \"none\" : \"\";\n }\n $(\"#audit-more\").onclick = guard(async () => { const last = $(\"#audit tbody tr:last-child\"); await loadAudit(last ? +last.dataset.seq : 0); });\n async function loadLists() {\n loadAudit(0);\n const [mem, bans, bannedEvents, allow, block, invites, reports, blobs, blocks, sites] = await Promise.all([rpc(\"listmembers\"), rpc(\"listbannedpubkeys\"), rpc(\"listbannedevents\"), rpc(\"listallowedkinds\"), rpc(\"listblockedkinds\"), rpc(\"listinvites\"), rpc(\"listreports\"), rpc(\"listblobs\", 100), rpc(\"listblockedips\"), rpc(\"listsites\")]);\n const members = mem.members;\n window.__members = members;\n const roleCell = (m) => m.role === \"owner\" ? ' owner' : myRole === \"owner\" ? ' ' : m.role === \"moderator\" ? ' moderator' : \"\";\n const untouchable = (m) => m.role === \"owner\" || (myRole !== \"owner\" && m.role === \"moderator\");\n // The tree: everyone under whoever invited them, the owner and the owner's own additions at the root.\n const known = new Set(members.map((m) => m.pubkey));\n const byInviter = new Map();\n for (const m of members) { const k = m.role !== \"owner\" && known.has(m.invited_by) ? m.invited_by : \"\"; if (!byInviter.has(k)) byInviter.set(k, []); byInviter.get(k).push(m); }\n const ordered = [], placed = new Set();\n const walk = (k, depth) => { for (const m of byInviter.get(k) || []) { if (placed.has(m.pubkey)) continue; placed.add(m.pubkey); m.depth = depth; ordered.push(m); walk(m.pubkey, depth + 1); } };\n walk(\"\", 0);\n for (const m of members) if (!placed.has(m.pubkey)) { m.depth = 0; ordered.push(m); }\n const nameOf = (pk) => { const x = members.find((m) => m.pubkey === pk); return x && x.name ? x.name : short(pk); };\n const limits = (m) => m.role === \"owner\" ? \"\" : myRole === \"owner\"\n ? ''\n : '' + (m.keep_days ? m.keep_days + \" d\" : \"\") + (m.max_bytes ? \" \" + fmtBytes(m.max_bytes) : \"\") + \"\";\n $(\"#members tbody\").innerHTML = ordered.map((m) => '' + av(m.pubkey) + key(m.pubkey) + roleCell(m) + (m.depth ? ' via ' + esc(nameOf(m.invited_by)) + \"\" : \"\") + (m.invites ? ' ' + m.invites + \" inv\" : \"\") + '' + limits(m) + '' + esc(fmtDay(m.joined_at)) + \", \" + esc(m.via) + '' + ib(\"check\", \"Save\", \"savemember\", m.pubkey) + (untouchable(m) ? \"\" : ib(\"x\", \"Remove\", \"removemember\", m.pubkey) + ib(\"banuser\", \"Ban\", \"banpubkey\", m.pubkey, \"danger\")) + \"\").join(\"\");\n const tf = $(\"#treeform\");\n tf.classList.toggle(\"hidden\", myRole !== \"owner\");\n if (policy && policy.memberInvites) { tf.depth.value = policy.memberInvites.depth; tf.quota.value = policy.memberInvites.quota; }\n $(\"#transfer [name=pubkey]\").innerHTML = members.filter((m) => m.role !== \"owner\").map((m) => '\").join(\"\");\n $(\"#succession [name=heir]\").innerHTML = $(\"#transfer [name=pubkey]\").innerHTML;\n $(\"#tc-people\").textContent = members.length;\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#invites\").innerHTML = invites.length ? invites.map((i) => '
                                                                      • inv…' + i.code.slice(-8) + \" \" + esc(i.note || \"\") + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
                                                                      • \").join(\"\") : '
                                                                      • no invites
                                                                      • ';\n const person = (r, icon, label, act) => \"
                                                                      • \" + av(r.pubkey) + '' + short(r.pubkey) + \" \" + esc(r.reason || \"\") + \"\" + ib(icon, label, act, r.pubkey) + \"
                                                                      • \";\n $(\"#bans\").innerHTML = bans.length ? bans.map((r) => person(r, \"undo\", \"Unban\", \"unrulepubkey\")).join(\"\") : '
                                                                      • nobody banned
                                                                      • ';\n $(\"#blocks\").innerHTML = blocks.length ? blocks.map((r) => '
                                                                      • ip' + esc(r.ip) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"undo\", \"Unblock\", \"unblockip\", r.ip) + \"
                                                                      • \").join(\"\") : '
                                                                      • no addresses blocked
                                                                      • ';\n $(\"#banned-events\").innerHTML = bannedEvents.map((r) => '
                                                                      • ev' + key(r.id) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"check\", \"Allow again\", \"allowevent\", r.id) + \"
                                                                      • \").join(\"\");\n $(\"#reports tbody\").innerHTML = reports.length ? reports.map((r) => '' + esc(fmtTime(r.at)) + '' + esc(r.type || \"report\") + '' + av(r.target_pubkey) + key(r.target_pubkey) + (r.target_event ? (r.blob ? ' file ' + key(r.target_event) + \"\" : ' ev ' + key(r.target_event) + (r.hidden ? ' hidden' : \"\") + \"\") : \"\") + '' + esc(r.content) + '' + ib(\"check\", \"Dismiss\", \"resolve:dismiss\", r.id) + ib(\"trash\", r.blob ? \"Delete the file\" : \"Delete the event\", \"resolve:delete\", r.id) + ib(\"banuser\", \"Ban the author\", \"resolve:ban\", r.id, \"danger\") + \"\").join(\"\") : 'nothing reported';\n $(\"#reports-count\").textContent = reports.length || \"\"; $(\"#tc-reports\").textContent = reports.length || \"\";\n $(\"#blobs tbody\").innerHTML = blobs.length ? blobs.map((b) => '' + esc(fmtTime(b.uploaded)) + '' + key(b.sha256) + ' ' + esc(b.type) + '' + fmtBytes(b.size) + '' + av(b.uploader) + key(b.uploader) + '' + ib(\"trash\", \"Delete file\", \"deleteblob\", b.sha256, \"danger\") + \"\").join(\"\") : 'no uploads';\n $(\"#blobs-count\").textContent = blobs.length || \"\";\n $(\"#sites tbody\").innerHTML = sites.length ? sites.map((s) => '' + av(s.author) + key(s.author) + '' + esc(s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + '' + esc(s.url) + '' + esc(s.paths) + (s.missing ? ' ' + esc(s.missing) + ' missing' : '') + '' + fmtBytes(s.size) + '' + esc(s.expires_at ? fmtTime(s.expires_at) : \"never\") + '' + ib(\"trash\", \"Delete site\", \"deleteevent\", s.id, \"danger\") + '').join(\"\") : 'no sites yet';\n $(\"#sites-count\").textContent = sites.length || \"\";\n const tag = (k, cls) => '' + k + ib(\"x\", \"Remove rule\", \"unrulekind\", String(k)) + \"\";\n $(\"#kinds-allow\").innerHTML = allow.length ? allow.map((k) => tag(k, \"ok\")).join(\"\") : 'all';\n $(\"#kinds-block\").innerHTML = block.length ? block.map((k) => tag(k, \"blk\")).join(\"\") : 'none';\n await loadPeople();\n }\n\n const KIND_NAMES = { 0: \"profiles\", 1: \"notes\", 3: \"contacts\", 4: \"DMs\", 5: \"deletions\", 6: \"reposts\", 7: \"reactions\", 16: \"reposts\", 1059: \"gift wraps\", 1063: \"file headers\", 1111: \"comments\", 1984: \"reports\", 9734: \"zap requests\", 9735: \"zap receipts\", 9802: \"highlights\", 10002: \"relay lists\", 13534: \"roster\", 9000: \"group adds\", 9001: \"group removals\", 9021: \"join requests\", 9022: \"leave requests\", 39000: \"group info\", 39001: \"group admins\", 39002: \"group members\", 39003: \"group roles\", 30023: \"articles\", 30024: \"drafts\", 30078: \"app data\", 30311: \"live events\", 30818: \"wiki\" };\n const kindName = (k) => KIND_NAMES[k] || (k >= 20000 && k < 30000 ? \"ephemeral\" : k >= 30000 && k < 40000 ? \"addressable\" : k >= 10000 && k < 20000 ? \"replaceable\" : \"kind \" + k);\n const SYS_KINDS = new Set([0, 3, 10002, 9735, 13534, 8000, 8001, 9000, 9001, 39000, 39001, 39002, 39003]);\n let storage = null;\n\n // Features (settings.ts): a select each; search has three modes, the rest on or off.\n const FEATURES = [\n [\"search\", \"Search\", \"NIP-50. Prose indexes notes, threads, comments, highlights, articles and wiki pages; full indexes every public kind with content. A change applies to events from then on.\", [\"prose:prose\", \"full:full\", \"off:off\"]],\n [\"sync\", \"Sync\", \"NIP-77 reconciliation, which reads the whole matching set per sync.\"],\n [\"count\", \"Counts\", \"NIP-45 COUNT, with HLL sketches.\"],\n [\"discovery\", \"Discovery record\", \"NIP-66: the record the relay signs about itself, for crawlers.\"],\n [\"names\", \"Names\", \"NIP-05 addresses under this relay's domain.\"],\n [\"files\", \"Files\", \"Blossom and NIP-96: uploads, downloads and listings.\"],\n [\"pages\", \"Pages and feed\", \"Notes and articles as pages, and the Atom feed.\"],\n [\"sites\", \"Static websites\", \"NIP-5A sites on their own hostnames. Mirroring copies missing files into this relay and costs fuel.\", [\"mirror:on, mirror files\", \"proxy:on, fetch as needed\", \"off:off\"]],\n [\"marmot\", \"Marmot transport\", \"Signed KeyPackages and encrypted group messages, with account admission for ephemeral authors.\"],\n [\"grasp\", \"Git repositories\", \"GRASP Git hosting with admitted repository state. The prototype backend has bounded storage and compute limits.\"],\n [\"push\", \"Relay push\", \"NIP-9a callback delivery for members and the owner. Requires approved callback origins; advertises lettered NIP identifiers.\"],\n [\"signer\", \"Signer traffic\", \"NIP-46 remote signing carried for anyone, never stored.\"],\n ];\n function renderFeatures(f) {\n $(\"#features\").innerHTML = FEATURES.map(([k, title, about, modes]) => {\n const cur = k === \"sites\" ? (f.sites?.enabled === false ? \"off\" : f.sites?.mirror === false ? \"proxy\" : \"mirror\") : modes ? String(f[k] || \"prose\") : String(f[k] !== false);\n const opts = (modes || [\"true:on\", \"false:off\"]).map((m) => { const [v, l] = m.split(\":\"); return '\"; }).join(\"\");\n return \"\";\n }).join(\"\");\n }\n $(\"#features\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-feature]\"); if (!sel) return;\n const k = sel.dataset.feature, v = k === \"search\" || k === \"sites\" ? sel.value : sel.value === \"true\";\n policy = await rpc(\"setpolicy\", { features: { [k]: k === \"sites\" ? { enabled: v !== \"off\", mirror: v === \"mirror\" } : v } });\n toast(k === \"search\" ? \"Search: \" + v : (v ? \"Switched on \" : \"Switched off \") + k); await loadInfo();\n }));\n\n $(\"#push-policy-form\").addEventListener(\"submit\", guard(async (ev) => {\n ev.preventDefault();\n const form = ev.target;\n const origins = form.elements.origins.value.split(/\\s+/).filter(Boolean);\n const updated = await rpc(\"setpolicy\", { pushCallbacks: origins, letteredNips: form.elements.lettered.checked });\n if (JSON.stringify(updated.pushCallbacks) !== JSON.stringify([...new Set(origins.map((s) => s.replace(/\\/$/, \"\")))])) throw new Error(\"Use up to sixteen exact HTTPS origins, with no path or credentials.\");\n policy = updated;\n toast(\"Saved callback policy\"); await loadInfo();\n }));\n\n async function loadViews() {\n let views;\n try { views = await rpc(\"listviews\"); } catch { return; }\n $(\"#views\").innerHTML = views.map((v) => {\n const runs = v.trigger === \"off\" ? \"off\" : v.trigger === \"live\" ? \"live, from memory\" : v.trigger === \"write\" ? \"on write and daily\" : v.trigger;\n const label = (c) => (c === \"off\" ? \"off\" : c === \"write\" ? \"on write\" : c === \"live\" ? \"on\" : c);\n const choices = [...v.choices, v.default].filter((c, i, a) => a.indexOf(c) === i);\n const pick = '\";\n const who = v.audience === \"members\" ? \"members\" + (v.stored ? \"\" : \", on request\") : \"anyone\";\n const last = v.trigger === \"live\" ? \"\" : v.last ? \"last run \" + fmtTime(v.last.at) : \"not run yet\";\n const rows = v.trigger === \"live\" ? \"no rows\" : v.last ? v.last.rows.toLocaleString() + \" rows\" : \"\";\n const meta = [runs, who, last, rows].filter(Boolean).map((x) => \"\" + esc(x) + \"\").join('|');\n return '
                                                                        ' + esc(v.name) + '
                                                                        ' + esc(v.about) + '
                                                                        ' + pick + (v.on ? 'Open' : \"\") + '
                                                                        ' + meta + \"
                                                                        \";\n }).join(\"\");\n }\n $(\"#views\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-view]\"); if (!sel) return;\n const value = sel.value === \"live\" ? true : sel.value;\n await rpc(\"setpolicy\", { views: { [sel.dataset.view]: value } });\n toast(sel.value === \"off\" ? \"Took down \" + sel.dataset.view : sel.dataset.view + \": \" + (sel.value === \"live\" ? \"on\" : sel.value === \"write\" ? \"on write\" : sel.value)); await loadViews(); await loadInfo();\n }));\n async function loadDumps() {\n const list = await rpc(\"listdumps\");\n const f = $(\"#dumpform\");\n if (policy) { f.dumps.value = policy.dumps || \"off\"; f.keep.value = policy.dumpsKeep || 7; }\n $(\"#dumps-count\").textContent = list.length || \"\";\n $(\"#dumps\").innerHTML = list.length ? list.map((d) => '
                                                                      • jsonl' + esc(d.name) + ' ' + d.events.toLocaleString() + \" events, \" + fmtBytes(d.bytes) + \"\" + ib(\"copy\", \"Download\", \"downloaddump\", d.name) + ib(\"trash\", \"Delete\", \"deletedump\", d.name, \"danger\") + \"
                                                                      • \").join(\"\") : '
                                                                      • no dumps yet
                                                                      • ';\n }\n // A dump is fetched with a signed request and handed to the browser as a file.\n async function downloadDump(name) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const url = location.origin + \"/dumps/\" + name;\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"GET\"]] });\n const resp = await fetch(url, { headers: { authorization: \"Nostr \" + btoa(JSON.stringify(ev)) } });\n if (!resp.ok) throw new Error((await resp.json()).error || \"download failed\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(\".\")[0] + \"-\" + name; a.click(); URL.revokeObjectURL(a.href);\n }\n // A plain member's own invites, when the owner lets members invite.\n async function loadMine() {\n const sec = $(\"#myinvites\");\n if (!me || !owner || myRole || !signer.ready()) { sec.classList.add(\"hidden\"); return; }\n let mine;\n try { mine = await rpc(\"listinvites\"); } catch { sec.classList.add(\"hidden\"); return; }\n sec.classList.remove(\"hidden\");\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#mine\").innerHTML = mine.length ? mine.map((i) => '
                                                                      • inv…' + i.code.slice(-8) + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
                                                                      • \").join(\"\") : '
                                                                      • no invites yet
                                                                      • ';\n }\n async function loadStorage() {\n storage = await rpc(\"storagestats\");\n pollJobs();\n loadViews();\n loadDumps().catch(() => {});\n const st = storage;\n const top = st.kinds.slice(0, 4), rest = st.kinds.slice(4).reduce((a, k) => a + k.bytes, 0);\n const parts = [...top.map((k, i) => [k.kind + \" \" + kindName(k.kind), \"k\" + (i + 1), k.bytes]), ...(rest ? [[\"other\", \"k5\", rest]] : [])];\n const total = st.eventBytes || 1;\n $(\"#s-bar\").innerHTML = parts.length ? parts.map(([n, c, b]) => '').join(\"\") : '';\n $(\"#s-legend\").innerHTML = parts.length ? parts.map(([n, c, b]) => '' + esc(n) + \" \" + Math.round(b * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n const overhead = Math.max(0, st.databaseBytes - st.eventBytes);\n $(\"#s-totals\").innerHTML = [[\"events\", st.events.toLocaleString() + \" \" + fmtBytes(st.eventBytes) + \"\"], [\"index and overhead\", fmtBytes(overhead)], [\"files\", st.blobs.toLocaleString() + \" \" + fmtBytes(st.mediaBytes) + \"\"]]\n .map(([k, v]) => \"
                                                                        \" + k + \"\" + v + \"
                                                                        \").join(\"\");\n const any = st.retention.find((r) => r.kind === null);\n const own = (kind) => { const r = st.retention.find((x) => x.kind === kind); return r ? r.days : \"\"; };\n const row = (k) => {\n const key = k.kind === null ? \"\" : String(k.kind);\n const placeholder = k.kind === null ? \"forever\" : k.replaceable || !any ? \"forever\" : any.days + \" (everything else)\";\n const pill = k.kind === null ? \"everything else\" : '' + k.kind + \" \" + esc(kindName(k.kind));\n const cells = k.protected\n ? 'required'\n : ' days' + ib(\"check\", \"Save keep-for rule\", \"saveretention\", key) + ib(\"trash\", \"Purge\", \"purgekind\", key, \"danger\") + \"\";\n return '' + pill + '' + (k.n === undefined ? \"\" : k.n.toLocaleString()) + '' + (k.bytes === undefined ? \"\" : fmtBytes(k.bytes)) + '' + (k.oldest ? esc(fmtDay(k.oldest)) : \"\") + \"\" + cells + \"\";\n };\n $(\"#kinds tbody\").innerHTML = [...st.kinds, { kind: null, days: any ? any.days : 0 }].map(row).join(\"\");\n loadListHistory().catch(() => {});\n }\n\n async function loadListHistory() {\n const rows = await rpc(\"listlisthistory\");\n const labels = { 3: \"follows\", 10002: \"relay list\", 10003: \"bookmarks\", 30003: \"bookmark list\" };\n $(\"#listhistory tbody\").innerHTML = rows.length ? rows.map((r) => '' + esc(labels[r.kind] || (\"kind \" + r.kind) + (r.d ? \" / \" + r.d : \"\")) + '' + esc(fmtTime(r.created_at)) + '' + esc(fmtTime(r.saved_at)) + '' + ib(\"undo\", \"Restore this version\", \"restorelist\", r.event_id) + '').join(\"\") : 'no older list versions yet';\n }\n\n let searchQuery = \"\";\n async function loadEvents(reset) {\n const list = searchQuery ? await rpc(\"searchevents\", searchQuery, 200) : await rpc(\"listrecentevents\", reset ? 50 : 200);\n $(\"#events tbody\").innerHTML = list.length ? list.map((e) => '' + esc(fmtTime(e.created_at)) + '' + e.kind + '' + av(e.pubkey) + key(e.pubkey) + '' + esc(e.content) + '' + ib(\"pin\", \"Pin\", \"pinevent\", e.id) + ib(\"trash\", \"Delete event\", \"deleteevent\", e.id) + ib(\"ban\", \"Ban event\", \"banevent\", e.id, \"danger\") + ib(\"banuser\", \"Ban author\", \"banpubkey\", e.pubkey, \"danger\") + \"\").join(\"\") : 'no events yet';\n $(\"#more\").classList.toggle(\"hidden\", searchQuery || list.length < 50);\n }\n $(\"#searchform\").onsubmit = guard(async (ev) => {\n searchQuery = ev.target.q.value.trim();\n $(\"#searchclear\").classList.toggle(\"hidden\", !searchQuery);\n await loadEvents(true);\n });\n $(\"#searchclear\").onclick = guard(async () => {\n searchQuery = \"\"; $(\"#searchform\").reset(); $(\"#searchclear\").classList.add(\"hidden\");\n await loadEvents(true);\n });\n async function loadPins() {\n const pins = await rpc(\"listpins\");\n $(\"#pins-count\").textContent = pins.length || \"\";\n $(\"#pins\").innerHTML = pins.length ? pins.map((t) => '
                                                                      • ' + (t[0] === \"e\" ? \"event\" : \"address\") + '' + esc(t[1].length > 40 ? t[1].slice(0, 16) + \"\\u2026\" + t[1].slice(-8) : t[1]) + \"\" + ib(\"copy\", \"Copy\", \"copy\", t[1]) + ib(\"x\", \"Unpin\", \"unpinevent\", t[1]) + \"
                                                                      • \").join(\"\") : '
                                                                      • nothing pinned
                                                                      • ';\n }\n $(\"#pinform\").onsubmit = guard(async (ev) => {\n await rpc(\"pinevent\", ev.target.id.value.trim()); ev.target.reset(); toast(\"Pinned\"); await loadPins();\n });\n\n // ---- actions ----\n const refresh = () => Promise.all([loadLists(), loadEvents(true), loadStorage(), loadPins()]);\n\n $(\"#copy\").onclick = async () => { await navigator.clipboard.writeText(wsURL); toast(\"copied \" + wsURL); };\n $(\"#signin\").onclick = guard(async () => {\n if (!window.nostr) throw new Error(\"No nostr extension found. Install one (Alby, nos2x, …) and reload, or use a remote signer.\");\n me = await window.nostr.getPublicKey();\n localStorage.setItem(\"me\", me);\n renderHeader(); await loadAdmin(); await loadPeople();\n });\n $(\"#signin46\").onclick = () => showRemote(null);\n $$(\".remote\").forEach((b) => { b.onclick = () => showRemote($(\"#\" + b.dataset.note)); });\n $(\"#nccopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#nclink\").href); toast(\"Link copied; paste it into your signer app\"); };\n $(\"#bunkerform\").onsubmit = guard(async (ev) => {\n $(\"#remotenote\").textContent = \"Connecting…\";\n try { await connectBunker(ev.target.url.value); } catch (e) { $(\"#remotenote\").textContent = e.message; throw e; }\n $(\"#remotenote\").textContent = \"\"; ev.target.reset();\n await remoteDone();\n });\n $(\"#signout\").onclick = async () => {\n if (remote) { const r = remote; remote = null; try { await withTimeout(r.logout(), 5000, \"The signer\"); } catch { /* the session is gone either way */ } }\n localStorage.removeItem(\"nip46\");\n me = null; localStorage.removeItem(\"me\"); renderHeader(); await loadAdmin(); await loadPeople();\n };\n const claimNow = async (note) => {\n if (!signer.ready()) { note.textContent = \"Sign the claim with a nostr extension or a remote signer.\"; showRemote(note); return; }\n me = await signer.getPublicKey();\n const r = await rpc(\"claim\");\n if (!r.claimed) throw new Error(\"Somebody else claimed it first.\");\n localStorage.setItem(\"me\", me);\n toast(\"It's yours.\");\n await loadInfo(); await loadFuel(); await loadAdmin(); await loadPeople();\n if (r.converted && confirm(\"This relay began as a temporary one: anyone can write, and everything is deleted after 14 days. Switch to the default rules and keep everything from now on? Each rule can be changed later on the Rules and Storage tabs.\")) {\n await rpc(\"resetrules\"); toast(\"Rules reset\"); await loadInfo(); await loadAdmin();\n }\n };\n $(\"#claim\").onclick = guard(() => claimNow($(\"#claimnote\")));\n $(\"#claimlease\").onclick = guard(() => claimNow($(\"#leasenote\")));\n // Jobs run in the background; the table follows them while the tab is open.\n let jobsTimer = 0;\n const fmtJob = (j) => {\n const what = j.kind === \"mirror\" ? \"mirror site\" : j.kind === \"import\" ? \"import\" : j.label === \"backfill\" ? \"fetch my history\" : j.kind === \"pull\" ? \"pull\" : \"rebroadcast\";\n const f = [];\n if (j.filter.authors) f.push(j.filter.authors.length === 1 && j.filter.authors[0] === me ? \"my events\" : j.filter.authors.length + \" authors\");\n if (j.filter.kinds) f.push(\"kinds \" + j.filter.kinds.join(\", \"));\n if (j.filter.since) f.push(\"since \" + fmtDay(j.filter.since));\n const when = j.every ? \"every \" + (j.every === 24 ? \"day\" : j.every + \" h\") + (j.nextRun ? \", next \" + fmtTime(j.nextRun) : \"\") : \"once\";\n const l = j.last;\n const count = (stored, blobs, sent, refused) => (j.kind === \"mirror\" ? blobs.toLocaleString() + \" files mirrored\" : j.kind === \"import\" ? stored.toLocaleString() + \" events\" + ((j.last ? j.last.duplicates : j.duplicates) ? \", \" + (j.last ? j.last.duplicates : j.duplicates) + \" already here\" : \"\") : j.kind === \"pull\" ? stored.toLocaleString() + \" events\" + (blobs ? \", \" + blobs + \" files\" : \"\") : sent.toLocaleString() + \" sent\" + (refused ? \", \" + refused + \" refused\" : \"\"));\n const res = j.running ? \"running: \" + count(j.stored, j.blobs, j.sent, j.refused) + \"...\" : !l ? \"waiting\" : l.error ? \"failed: \" + l.error : count(l.stored, l.blobs, l.sent, l.refused) + (l.skipped ? \", \" + l.skipped + \" skipped\" : \"\") + \", \" + fmtTime(l.finishedAt);\n const sources = j.running ? j.pullSources : l?.sources;\n const details = sources?.length ? '
                                                                        Source results' + sources.map((s) => '

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

                                                                        ').join('') + '
                                                                        ' : '';\n return \"\" + what + \"\" + j.relays.map(esc).join(\"
                                                                        \") + \"\" + (f.join(\", \") || \"everything\") + \"\" + when + \"\" + esc(res) + details + \"\" + (j.running ? \"\" : ib(\"undo\", \"Run now\", \"runjob\", j.id)) + ib(\"x\", \"Remove\", \"removejob\", j.id) + \"\";\n };\n async function pollJobs() {\n clearTimeout(jobsTimer);\n let jobs;\n try { jobs = await rpc(\"listjobs\"); } catch { return; }\n $(\"#jobs tbody\").innerHTML = jobs.length ? jobs.map(fmtJob).join(\"\") : 'no jobs yet';\n if (jobs.some((j) => j.running || (j.nextRun && j.nextRun <= Math.floor(Date.now() / 1000) + 1))) jobsTimer = setTimeout(pollJobs, 3000);\n }\n const urls = (s) => s.split(/[\\s,]+/).map((u) => u.trim()).filter(Boolean);\n const kindsOf = (s) => s.split(/[\\s,]+/).map((k) => parseInt(k, 10)).filter((k) => Number.isInteger(k) && k >= 0);\n $(\"#pullform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"addjob\", { kind: \"pull\", relays: [f.url.value.trim()], every: +f.every.value });\n toast(+f.every.value ? \"Mirror scheduled\" : \"Pull started\"); f.reset(); await pollJobs();\n });\n $(\"#backfillform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"backfill\", urls(f.relays.value));\n toast(\"Fetching your history\"); f.reset(); await pollJobs();\n });\n $(\"#pushform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const filter = {};\n const kinds = kindsOf(f.kinds.value); if (kinds.length) filter.kinds = kinds;\n const days = +f.days.value; if (days > 0) filter.since = Math.floor(Date.now() / 1000) - days * 86400;\n await rpc(\"addjob\", { kind: \"push\", relays: urls(f.targets.value), filter, every: +f.every.value });\n toast(\"Rebroadcast started\"); f.reset(); await pollJobs();\n });\n // ---- presets: writes, reads, kinds and keep-for rules in one click ----\n let presets = null;\n async function renderPresets() {\n if (!presets) { try { presets = await rpc(\"listpresets\"); } catch { presets = []; } }\n $(\"#presets\").innerHTML = presets.map((p) => '\").join(\"\");\n $(\"#presetsourcerow\").classList.toggle(\"hidden\", !presets.some((p) => p.source));\n }\n $(\"#presets\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-preset]\"); if (!b) return;\n const p = (presets || []).find((x) => x.name === b.dataset.preset); if (!p) return;\n if (!confirm(p.title + \": \" + p.about + \"\\n\\nThis replaces the writes and reads rules, the directory setting, the kind rules and the keep-for rules. Limits, identity, people and bans stay.\")) return;\n const source = $(\"#presetsource\").value.trim();\n if (p.source === \"required\" && !source) { $(\"#presetnote\").textContent = p.title + \" needs a source relay to mirror; enter its wss:// URL first.\"; return; }\n b.disabled = true;\n try { policy = await rpc(\"applypreset\", p.name, p.source && source ? { source } : undefined); $(\"#presetnote\").textContent = \"Now: \" + p.about + (policy.job ? \" Mirroring \" + source + \" every \" + policy.job.every + \" h.\" : \"\"); toast(p.title + \" applied\"); await loadInfo(); await loadAdmin(); }\n catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // ---- wire me in: this relay in the owner's own lists ----\n // The lists are replaceable, so a fresh list with only this relay would\n // clobber the real one once it spread. Every publish starts from the\n // newest copy found here or on the indexers, verified when the signer\n // library is around, and adds or removes this relay in it.\n const INDEXERS = [\"wss://purplepag.es\", \"wss://relay.nostr.band\", \"wss://relay.damus.io\", \"wss://nos.lol\"];\n const LISTS = [\n { kind: 10002, tag: \"r\", title: \"Relay list\", nip: \"NIP-65\", about: \"where clients read your notes and send you mentions\" },\n { kind: 10050, tag: \"relay\", title: \"DM inbox\", nip: \"NIP-17\", about: \"where people send you private messages\" },\n { kind: 10007, tag: \"relay\", title: \"Search relays\", nip: \"NIP-51\", about: \"where clients run your searches\" },\n { kind: 10063, tag: \"server\", title: \"Blossom servers\", nip: \"BUD-03\", about: \"where clients upload and look for your files\" },\n ];\n const mineFor = (l) => (l.tag === \"server\" ? location.origin : wsURL);\n const normURL = (u) => { try { const x = new URL(String(u).trim()); return (x.host + x.pathname).replace(/\\/+$/, \"\").toLowerCase(); } catch { return String(u).trim().toLowerCase(); } };\n const isMine = (l, t) => t[0] === l.tag && normURL(t[1] || \"\") === normURL(mineFor(l));\n const listHas = (list, l) => !!list && list.tags.some((t) => isMine(l, t));\n // mergeList is pure: the newest list with this relay put first or taken out, every other tag kept.\n function mergeList(list, l, include) {\n const tags = (list ? list.tags : []).filter((t) => !isMine(l, t));\n if (include) tags.unshift([l.tag, mineFor(l)]);\n return { kind: l.kind, created_at: Math.floor(Date.now() / 1000), content: list ? list.content : \"\", tags };\n }\n const relaysIn = (list) => list.tags.filter((t) => (t[0] === \"r\" || t[0] === \"relay\") && /^wss?:\\/\\//i.test(t[1] || \"\")).map((t) => t[1].trim());\n // overWS opens one socket, sends one message, feeds answers to onMessage until it says done or time runs out.\n function overWS(url, ms, first, onMessage) {\n return new Promise((res) => {\n let ws = null, done = false, out = null;\n const finish = () => { if (done) return; done = true; clearTimeout(timer); try { if (ws) ws.close(); } catch { /* closed */ } res(out); };\n const timer = setTimeout(finish, ms);\n try { ws = new WebSocket(url); } catch { return finish(); }\n ws.onopen = () => ws.send(JSON.stringify(first));\n ws.onmessage = (m) => { let d; try { d = JSON.parse(m.data); } catch { return; } if (!Array.isArray(d)) return; if (onMessage(d, (v) => { out = v; })) finish(); };\n ws.onerror = finish; ws.onclose = finish;\n });\n }\n function fetchNewest(url, filter, ms) {\n let best = null;\n return overWS(url, ms, [\"REQ\", \"w\", filter], (d, set) => {\n if (d[0] === \"EVENT\" && d[1] === \"w\" && d[2] && (!best || d[2].created_at > best.created_at)) { best = d[2]; set(best); }\n return (d[0] === \"EOSE\" || d[0] === \"CLOSED\") && d[1] === \"w\";\n });\n }\n function publishTo(url, event, ms) {\n return overWS(url, ms, [\"EVENT\", event], (d, set) => { if (d[0] === \"OK\" && d[1] === event.id) { set({ url, ok: !!d[2], msg: d[3] || \"\" }); return true; } return false; })\n .then((r) => r || { url, ok: false, msg: \"no answer\" });\n }\n // bridge signs a NIP-98 request to this relay's HTTP door, so the owner's own reads and writes pass any read rule.\n async function bridge(path, body) {\n const url = location.origin + path, raw = JSON.stringify(body);\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"POST\"], [\"payload\", await sha256hex(raw)]] });\n const resp = await fetch(url, { method: \"POST\", headers: { \"content-type\": \"application/json\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body: raw });\n const json = await resp.json();\n if (!resp.ok) throw new Error(json.error || \"HTTP \" + resp.status);\n return json;\n }\n const wire = {};\n function renderWire() {\n const pill = (state) => {\n const cls = state === \"listed\" ? \"on\" : state === \"failed\" ? \"bad\" : state === \"not listed\" ? \"off\" : \"\";\n const label = state === \"checking\" || state === \"publishing\" ? state + \"\\u2026\" : state;\n return '' + esc(label) + \"\";\n };\n $(\"#wire\").innerHTML = LISTS.map((l) => {\n const w = wire[l.kind] || { state: \"\", results: [], list: null };\n const busy = w.state === \"checking\" || w.state === \"publishing\";\n const btn = (act, cls, label) => '\";\n const buttons = w.state === \"listed\" ? btn(\"remove\", \"\", \"Remove this relay\") : btn(\"add\", \"pri\", \"Add this relay\") + btn(\"check\", \"\", w.state ? \"Check again\" : \"Check\");\n const n = w.list ? w.list.tags.filter((t) => t[0] === l.tag).length : 0;\n const from = w.list ? \"\" + (n === 1 ? \"1 entry\" : n + \" entries\") + \", \" + fmtTime(w.list.created_at) + \"\" : w.state && !busy ? \"no list found\" : \"\";\n const sent = (w.results || []).map((r) => '' + esc(r.url.replace(/^wss?:\\/\\//, \"\")) + (r.ok ? \"\" : \" failed\") + \"\").join(\"\");\n const meta = (w.state ? pill(w.state) : \"\") + from + sent;\n return '
                                                                        ' + l.title + ' ' + l.nip + \"
                                                                        \" + l.about + '
                                                                        ' + buttons + '
                                                                        ' + meta + \"
                                                                        \";\n }).join(\"\");\n }\n async function wireCheck(l) {\n const w = (wire[l.kind] = { state: \"checking\", list: null, results: [] }); renderWire();\n const filter = { kinds: [l.kind], authors: [me], limit: 1 };\n const found = [];\n try { found.push(...(await bridge(\"/query\", [filter]))); } catch { /* the relay may hold none */ }\n const remote = await Promise.all(INDEXERS.map((u) => fetchNewest(u, filter, 4000)));\n let lib = null; try { lib = await signerLib(); } catch { /* unverified lists are still the owner's own, by pubkey */ }\n for (const e of remote) if (e && e.pubkey === me && e.kind === l.kind && Array.isArray(e.tags) && (!lib || lib.verifyEvent(e))) found.push(e);\n w.list = found.sort((a, b) => b.created_at - a.created_at)[0] || null;\n w.state = listHas(w.list, l) ? \"listed\" : \"not listed\"; renderWire();\n }\n async function wirePublish(l, include) {\n if (!wire[l.kind] || !wire[l.kind].state || wire[l.kind].state === \"failed\") await wireCheck(l);\n const w = wire[l.kind]; w.state = \"publishing\"; w.results = []; renderWire();\n try {\n const signed = await signer.signEvent(mergeList(w.list, l, include));\n const here = await bridge(\"/events\", signed).then((r) => ({ url: wsURL, ok: !!r.accepted, msg: r.message || \"\" })).catch((e) => ({ url: wsURL, ok: false, msg: e.message }));\n const targets = [...new Set([...relaysIn(signed), ...INDEXERS])].filter((u) => normURL(u) !== normURL(wsURL));\n const rest = await Promise.all(targets.map((u) => publishTo(u, signed, 6000)));\n w.results = [here, ...rest]; w.list = signed; w.state = include ? \"listed\" : \"not listed\";\n toast((include ? \"Added to your \" : \"Removed from your \") + l.title.toLowerCase());\n } catch (e) { w.state = \"failed\"; w.results = [{ url: wsURL, ok: false, msg: e.message }]; toast(e.message); }\n renderWire();\n }\n $(\"#wire\").addEventListener(\"click\", (ev) => {\n const b = ev.target.closest(\"button[data-wire]\"); if (!b) return;\n const l = LISTS.find((x) => String(x.kind) === b.dataset.wire); if (!l) return;\n if (b.dataset.do === \"check\") wireCheck(l); else wirePublish(l, b.dataset.do === \"add\");\n });\n\n // ---- custom domains: this relay under a hostname the owner controls ----\n let domainSites = [];\n const domainOptions = (current = \"\") => [{ label: \"\", title: \"This relay\" }, ...domainSites.map((s) => ({ label: s.label, title: (s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + \" / \" + key(s.author) })), ...(current && !domainSites.some((s) => s.label === current) ? [{ label: current, title: \"Site \" + current }] : [])].map((s) => '\").join(\"\");\n let domains = null; // null: not enabled on this host\n function renderDomains() {\n $(\"#domains\").innerHTML = (domains || []).map((d) => {\n const state = d.ready ? \"active\" : \"hostname \" + d.status.replace(/_/g, \" \") + \", certificate \" + d.sslStatus.replace(/_/g, \" \");\n const btn = (act, cls, label) => '\";\n const rows = d.ready ? \"\" : '
                                                                        ' + d.records.map((r) => \"\").join(\"\") + \"
                                                                        TypeNameValue
                                                                        \" + esc(r.type) + '' + esc(r.name) + '' + esc(r.value) + \"\" + esc(r.note) + \"
                                                                        \";\n return '
                                                                        ' + esc(d.host) + '' + esc(state) + \"\" + btn(\"check\", \"\", \"Check\") + btn(\"remove\", \"danger\", \"Remove\") + '
                                                                        \" + rows;\n }).join(\"\");\n }\n async function loadDomains() {\n try { [domains, domainSites] = await Promise.all([rpc(\"listdomains\"), rpc(\"listsites\")]); $(\"#adddomain select[name=site]\").innerHTML = domainOptions(); $(\"#domainnote\").textContent = domains.length ? \"\" : \"No custom domain yet.\"; $(\"#adddomain\").classList.remove(\"hidden\"); }\n catch (e) { domains = null; $(\"#domainnote\").textContent = /^unsupported/.test(e.message) ? \"Custom domains are not enabled on this host.\" : e.message; $(\"#adddomain\").classList.add(\"hidden\"); }\n renderDomains();\n }\n $(\"#adddomain\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"adddomain\", f.host.value.trim(), f.site.value); f.reset(); toast(\"Domain added; now create the CNAME\"); await loadDomains();\n });\n $(\"#domains\").addEventListener(\"change\", guard(async (ev) => {\n const select = ev.target.closest(\"select[data-domain-site]\"); if (!select) return;\n await rpc(\"setdomainsite\", select.dataset.domainSite, select.value);\n toast(\"Domain destination saved\"); await loadDomains();\n }));\n $(\"#domains\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-domain]\"); if (!b) return;\n const host = b.dataset.domain;\n if (b.dataset.do === \"remove\" && !confirm(\"Remove \" + host + \"? Its certificate goes with it and the name stops answering.\")) return;\n b.disabled = true;\n try {\n if (b.dataset.do === \"remove\") { await rpc(\"removedomain\", host); toast(\"Removed\"); }\n else { const d = await rpc(\"checkdomain\", host); toast(d.ready ? host + \" is live\" : \"Not yet: \" + (d.status === \"active\" ? \"certificate pending\" : \"waiting for the CNAME\")); }\n await loadDomains();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n $(\"#access\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const openKinds = f.openKinds.value.split(/[\\s,]+/).filter(Boolean).map(Number);\n if (openKinds.some((k) => !Number.isInteger(k) || k < 0 || k > 65535)) throw new Error(\"Open kinds must be whole numbers.\");\n policy = await rpc(\"setpolicy\", { writes: f.writes.value, reads: f.reads.value, openKinds, guestReplies: f.guestReplies.checked, minPow: +f.minPow.value, maxFuture: +f.maxFuture.value, maxLimit: +f.maxLimit.value, maxSubs: +f.maxSubs.value, maxMessageKB: +f.maxMessageKB.value, eventsPerMinute: +f.eventsPerMinute.value, reqsPerMinute: +f.reqsPerMinute.value, maxBlobMB: +f.maxBlobMB.value });\n toast(\"Rules saved\"); await loadInfo();\n });\n $(\"#notify\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { notify: { reports: f.reports.checked, fuel: f.fuel.checked, jobs: f.jobs.checked, succession: f.succession.checked, digest: f.digest.checked } });\n toast(\"Notifications saved\");\n });\n $(\"#notifytest\").onclick = guard(async () => {\n const r = await rpc(\"notifytest\");\n toast(r.sent ? \"Sent. Look for a message from the relay in your DMs.\" : \"Could not send.\");\n });\n $(\"#identity\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const list = (v) => v.split(\",\").map((s) => s.trim()).filter(Boolean);\n policy = await rpc(\"setpolicy\", { name: f.name.value, contact: f.contact.value, description: f.description.value, icon: f.icon.value,\n banner: f.banner.value, postingPolicy: f.postingPolicy.value, privacyPolicy: f.privacyPolicy.value, tags: list(f.tags.value), languageTags: list(f.languageTags.value), relayCountries: list(f.relayCountries.value) });\n toast(\"Identity saved\"); await loadInfo(); await loadPeople(); loadCard();\n });\n $(\"#addmember\").onsubmit = guard(async (ev) => {\n const f = ev.target; const pk = npubToHex(f.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"setmember\", pk, { name: f.name.value.trim() || null, note: f.note.value }); f.reset(); toast(\"Member added\"); await loadLists();\n });\n $(\"#addblock\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"blockip\", f.ip.value.trim(), f.reason.value); f.reset(); toast(\"Blocked\"); await loadLists();\n });\n $(\"#wordsform\").onsubmit = guard(async (ev) => {\n const words = ev.target.words.value.split(\"\\n\").map((s) => s.trim()).filter(Boolean);\n const kept = await rpc(\"setblockedwords\", words);\n policy = await rpc(\"setpolicy\", { blockedWordsInTags: ev.target.inTags.checked });\n ev.target.words.value = kept.join(\"\\n\"); toast(kept.length ? kept.length + \" words blocked\" : \"No words blocked\");\n });\n $(\"#thresholdform\").onsubmit = guard(async (ev) => {\n policy = await rpc(\"setpolicy\", { reportThreshold: +ev.target.reportThreshold.value });\n toast(policy.reportThreshold ? \"Hidden after \" + policy.reportThreshold + \" reports\" : \"Never hidden by reports\"); await loadLists();\n });\n $(\"#addban\").onsubmit = guard(async (ev) => {\n const pk = npubToHex(ev.target.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"banpubkey\", pk, ev.target.reason.value, ev.target.erase.checked); ev.target.reset(); toast(ev.target.erase.checked ? \"Banned and erased\" : \"Banned\"); await loadLists();\n });\n $(\"#mintinvite\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const inv = await rpc(\"createinvite\", +f.ttl.value, +f.max.value, f.note.value);\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n f.note.value = \"\"; toast(\"Invite created and copied\"); await loadLists();\n });\n $(\"#kindform\").onsubmit = guard(async (ev) => {\n const rule = ev.submitter.value; const k = +ev.target.kind.value;\n await rpc(rule === \"allow\" ? \"allowkind\" : \"disallowkind\", k); ev.target.reset(); toast((rule === \"allow\" ? \"Allowed kind \" : \"Blocked kind \") + k); await loadLists();\n });\n $(\"#more\").onclick = guard(() => loadEvents(false));\n $(\"#dumpform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { dumps: f.dumps.value, dumpsKeep: Math.max(1, Math.min(60, Math.floor(+f.keep.value || 7))) });\n toast(policy.dumps === \"off\" ? \"Dumps off\" : \"Dumping \" + policy.dumps); await loadDumps();\n });\n $(\"#importform\").onsubmit = guard(async (ev) => {\n const file = ev.target.file.files[0]; if (!file) return;\n if (!signer.ready()) throw new Error(NO_SIGNER);\n if (file.size > 64 * 1024 * 1024) throw new Error(\"At most 64 MB per import.\");\n const body = await file.text();\n const url = location.origin + \"/import?name=\" + encodeURIComponent(file.name);\n const token = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"PUT\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(url, { method: \"PUT\", headers: { authorization: \"Nostr \" + btoa(JSON.stringify(token)), \"content-type\": \"application/x-ndjson\" }, body });\n const r = await resp.json();\n if (!resp.ok) throw new Error(r.error || \"import failed\");\n ev.target.reset(); toast(\"Importing \" + fmtBytes(r.bytes)); await pollJobs();\n });\n $(\"#dumpnow\").onclick = guard(async () => { const d = await rpc(\"dumpnow\"); toast(\"Dumped \" + d.events.toLocaleString() + \" events\"); await loadStorage(); });\n $(\"#treeform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { memberInvites: { depth: Math.max(0, Math.floor(+f.depth.value || 0)), quota: Math.max(0, Math.floor(+f.quota.value || 0)) } });\n toast(policy.memberInvites.depth ? \"Members may invite\" : \"Only you and moderators invite\");\n });\n $(\"#mintmine\").onclick = guard(async () => {\n const inv = await rpc(\"createinvite\", 259200, 1, \"\");\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n toast(\"Invite created and copied\"); await loadMine();\n });\n $(\"#exportcfg\").onclick = guard(async () => {\n const cfg = await rpc(\"exportconfig\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(new Blob([JSON.stringify(cfg, null, 2)], { type: \"application/json\" }));\n a.download = host.split(\".\")[0] + \".bind.ws.config.json\";\n a.click(); URL.revokeObjectURL(a.href);\n });\n $(\"#importcfg\").onclick = () => $(\"#cfgfile\").click();\n $(\"#cfgfile\").onchange = guard(async (ev) => {\n const file = ev.target.files[0]; if (!file) return;\n const cfg = JSON.parse(await file.text());\n const check = await rpc(\"importconfig\", cfg, { dryRun: true });\n const lines = check.changes.summary.length ? check.changes.summary.join(\"\\n\") : \"Nothing would change.\";\n const dropped = check.warnings.length ? \"\\n\\nNot taken:\\n\" + check.warnings.join(\"\\n\") : \"\";\n if (!confirm(\"Apply \" + file.name + \" to this relay?\\n\\n\" + lines + dropped)) { ev.target.value = \"\"; return; }\n await rpc(\"importconfig\", cfg); ev.target.value = \"\"; toast(\"Configuration imported\"); await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // Succession: the status line under the heir form, from successionstatus.\n async function renderSuccession() {\n const el = $(\"#successionnote\"), f = $(\"#succession\");\n let st;\n try { st = await rpc(\"successionstatus\"); } catch { el.textContent = \"\"; return; }\n const sc = st.succession;\n $(\"#clearsuccession\").classList.toggle(\"hidden\", !sc);\n f.querySelector(\"button.btn:not(#clearsuccession)\").textContent = sc ? \"Change heir\" : \"Set heir\";\n if (!sc) { el.textContent = \"No heir named. Last signed in \" + fmtTime(st.ownerSeenAt) + \".\"; return; }\n if (f.heir.querySelector('[value=\"' + sc.heir + '\"]')) f.heir.value = sc.heir;\n f.afterDays.value = String(sc.afterDays);\n const who = (window.__members || []).find((m) => m.pubkey === sc.heir);\n const heir = who && who.name ? who.name + \"@\" + host : short(sc.heir);\n el.textContent = \"Heir: \" + heir + \". Last signed in \" + fmtTime(st.ownerSeenAt) + (st.silentDays ? \" (\" + st.silentDays + \" days ago)\" : \"\") + \". \" +\n (st.warning ? \"The warning month is running: the relay goes to \" + heir + \" on \" + fmtDay(st.handoverAt) + \" unless you sign in.\" : \"If you stay away, it goes to \" + heir + \" around \" + fmtDay(st.handoverAt) + \".\") +\n (st.log && st.log.length ? \" Handed over before: \" + st.log.map((l) => fmtDay(l.at) + \" to \" + short(l.to)).join(\", \") + \".\" : \"\");\n }\n $(\"#succession\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"setsuccession\", { heir: f.heir.value, afterDays: +f.afterDays.value });\n toast(\"Heir set\"); await loadAdmin();\n });\n $(\"#clearsuccession\").onclick = guard(async () => {\n await rpc(\"clearsuccession\");\n toast(\"Heir cleared\"); await loadAdmin();\n });\n $(\"#transfer\").onsubmit = guard(async (ev) => {\n const pk = ev.target.pubkey.value; if (!pk) return;\n const name = host.split(\".\")[0];\n const typed = prompt(\"This hands \" + host + \" to \" + short(pk) + \" for good. You stay on as a moderator. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing changed.\"); return; }\n await rpc(\"transferowner\", pk);\n toast(\"Transferred\");\n await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // ---- fork: a new name with this relay's events, claim reserved for a key ----\n $(\"#forkform\").scope.onchange = (ev) => $(\"#forkkinds\").classList.toggle(\"hidden\", ev.target.value !== \"kinds\");\n $(\"#joinform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { joinTerms: f.joinTerms.value, directoryPublic: f.directoryPublic.checked });\n toast(\"Saved\"); await loadInfo(); await loadPeople();\n });\n $(\"#forkform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const opts = { people: f.people.checked };\n if (f.name.value.trim()) opts.name = f.name.value.trim().toLowerCase();\n if (f.holder.value.trim()) { const pk = npubToHex(f.holder.value); if (!pk) throw new Error(\"That is not a pubkey.\"); opts.holder = pk; }\n if (f.scope.value === \"mine\") opts.filter = { authors: [me] };\n if (f.scope.value === \"kinds\") { const kinds = f.kinds.value.split(/[\\s,]+/).filter(Boolean).map(Number); if (!kinds.length || kinds.some((k) => !Number.isInteger(k) || k < 0)) throw new Error(\"Give kinds as numbers.\"); opts.filter = { kinds }; }\n if (!confirm(\"Fork this relay into a new name\" + (opts.name ? \" (\" + opts.name + \")\" : \"\") + \"? It pulls \" + (f.scope.value === \"all\" ? \"everything\" : f.scope.value === \"mine\" ? \"your events\" : \"the chosen kinds\") + (opts.people ? \" and the people\" : \"\") + \", and only \" + (opts.holder ? \"that key\" : \"you\") + \" can claim it.\")) return;\n const r = await rpc(\"forkrelay\", opts);\n $(\"#forknote\").textContent = r.handover + \" Expires \" + fmtTime(r.expires_at) + \".\";\n $(\"#forkurl\").textContent = r.console;\n $(\"#forkresult\").classList.remove(\"hidden\");\n toast(\"Forked to \" + r.name);\n });\n $(\"#forkcopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#forkurl\").textContent); toast(\"copied\"); };\n $(\"#deleterelay\").onclick = guard(async () => {\n const name = host.split(\".\")[0];\n const typed = prompt(\"This deletes everything on \" + host + \" and gives the name up. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing was deleted.\"); return; }\n await rpc(\"deleterelay\", name);\n localStorage.removeItem(\"me\");\n location.href = \"/\";\n });\n $(\"#topup\").onsubmit = guard((ev) => topUp(+ev.target.sats.value));\n $(\"#inv-copy\").onclick = async () => { await navigator.clipboard.writeText($(\"#inv-text\").value); toast(\"invoice copied\"); };\n $$(\"button[data-copy]\").forEach((b) => { b.onclick = async () => { await navigator.clipboard.writeText(b.dataset.copy === \"ws\" ? wsURL : location.origin); toast(\"copied\"); }; });\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-act]\"); if (!b) return;\n const act = b.dataset.act, id = b.dataset.id;\n if (act === \"copy\") { await navigator.clipboard.writeText(id); toast(\"copied\"); return; }\n if ((act === \"banpubkey\" || act === \"banevent\" || act === \"resolve:ban\") && !confirm(act === \"banevent\" ? \"Delete this event and refuse it forever?\" : \"Ban this author and refuse everything they post?\")) return;\n const erase = (act === \"banpubkey\" || act === \"resolve:ban\") && confirm(\"Also erase everything they wrote and uploaded here?\");\n if (act === \"deleteblob\" && !confirm(\"Delete this file for good?\")) return;\n if (act === \"deletedump\" && !confirm(\"Delete this dump?\")) return;\n if (act === \"downloaddump\") { b.disabled = true; try { await downloadDump(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }\n if (act === \"restorelist\") {\n if (!signer.ready()) { toast(NO_SIGNER); return; }\n try {\n const preview = await rpc(\"restorelist\", id);\n const d = preview.diff || {};\n const added = (d.addedTags || []).map((t) => \"+ \" + JSON.stringify(t)).join(\"\\n\");\n const removed = (d.removedTags || []).map((t) => \"- \" + JSON.stringify(t)).join(\"\\n\");\n const changes = [added, removed, d.contentChanged ? \"content changed\" : \"content unchanged\"].filter(Boolean).join(\"\\n\");\n if (!confirm(\"Restore this list version?\\n\\n\" + (changes || \"No tag or content changes\") + \"\\n\\nIt will be signed and published as the newest version.\")) return;\n const signed = await signer.signEvent(preview.draft);\n const result = await bridge(\"/events\", signed);\n if (!result.accepted) throw new Error(result.message || \"The relay refused the restored list.\");\n toast(\"List restored\"); await loadListHistory(); await loadStorage();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if ((act === \"removemember\" || act === \"banpubkey\") && (window.__members || []).some((m) => m.invited_by === id) && confirm(\"Also remove everyone this member invited, and everyone they invited in turn?\")) {\n b.disabled = true;\n try { const r = await rpc(\"removesubtree\", id); if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase); toast(\"Removed \" + r.removed.length); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"removejob\" && !confirm(\"Remove this job?\")) return;\n if (act === \"purgekind\") {\n const kind = id === \"\" ? null : +id;\n const label = kind === null ? \"everything without its own rule\" : kindName(kind) + \" (kind \" + kind + \")\";\n const typed = prompt(\"Purge \" + label + \" older than how many days? 0 purges all of them. This cannot be undone.\", \"30\");\n if (typed === null) return;\n const days = Math.max(0, Math.floor(+typed || 0));\n b.disabled = true;\n try { const r = await rpc(\"purgekind\", kind, days); toast(\"Purged \" + r.deleted.toLocaleString() + \" events\"); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"saveretention\") {\n const kind = id === \"\" ? null : +id;\n const days = Math.max(0, Math.floor(+b.closest(\"tr\").querySelector(\".days\").value || 0));\n b.disabled = true;\n try { await rpc(\"setretention\", kind, days); toast(days ? \"Kept for \" + days + \" days\" : \"Kept forever\"); await loadStorage(); await loadInfo(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n b.disabled = true;\n try {\n if (act === \"savemember\") { const tr = b.closest(\"tr\"), role = tr.querySelector(\"select.role\"), keep = tr.querySelector(\".keep\"), cap = tr.querySelector(\".cap\"); await rpc(\"setmember\", id, { name: tr.querySelector(\".name\").value.trim() || null, note: tr.querySelector(\".note\").value, ...(role ? { role: role.value } : {}), ...(keep ? { keepDays: Math.max(0, Math.floor(+keep.value || 0)), maxBytes: Math.max(0, Math.floor(+cap.value || 0)) * 1024 } : {}) }); }\n else if (act.startsWith(\"resolve:\")) await rpc(\"resolvereport\", id, act.slice(8), erase);\n else if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase);\n else await rpc(act, act === \"unrulekind\" ? +id : id, \"\");\n toast(\"Done\"); await (myRole ? refresh() : loadMine());\n if (act === \"pinevent\" || act === \"unpinevent\") await loadPins();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // The share block: the card picture, the group naddr and its QR.\n let card = null;\n async function loadCard() {\n try {\n card = await (await fetch(\"/card.json\", { cache: \"no-store\" })).json();\n $(\"#cardimg\").src = \"/card.svg?t=\" + Date.now();\n $(\"#naddr\").textContent = card.naddr || \"\";\n const q = $(\"#naddrqr\");\n if (card.naddr) { q.src = \"/qr.svg?text=\" + encodeURIComponent(card.naddr); q.classList.remove(\"hidden\"); } else q.classList.add(\"hidden\");\n } catch { /* the card is decoration */ }\n renderApps();\n }\n // The app rows on the Connect section: one per client people actually use,\n // with the link that lands on this relay in it. Relay apps take the relay\n // or the group address; feed apps take the owner's profile with this relay\n // as the hint, since they have no notion of opening a relay.\n function renderApps() {\n const el = $(\"#apps\"); if (!el) return;\n const enc = encodeURIComponent;\n const nprofile = card && card.nprofile ? card.nprofile : \"\";\n const naddr = card && card.naddr ? card.naddr : \"\";\n const whose = me && owner && me === owner ? \"your\" : \"the owner's\";\n const link = (label, href) => '' + label + \"\";\n const app = (label, uri) => '' + label + \"\";\n const copy = (label, text) => '\";\n const row = (name, where, note, acts) => '
                                                                        ' + name + \"\" + where + \"

                                                                        \" + note + '

                                                                        ' + acts.filter(Boolean).join(\"\") + \"
                                                                        \";\n const profileNote = \"Opens \" + whose + \" profile with this relay attached.\";\n const groups = [\n [\"As a place\", \"These open the relay itself: its feed, its people, its group.\", [\n row(\"Jumble\", \"web\", \"A feed of everything on this relay.\", [link(\"Open\", \"https://jumble.social/?r=\" + enc(wsURL))]),\n row(\"Coracle\", \"web\", \"The relay's page: its feed and its people.\", [link(\"Open\", \"https://coracle.social/relays/\" + enc(host))]),\n row(\"Flotilla\", \"web, phone\", \"The relay as a space, with the group as a room.\", [link(\"Open\", \"https://app.flotilla.social/spaces/\" + enc(host)), naddr && app(\"Open group\", \"nostr:\" + naddr)]),\n row(\"0xchat\", \"phone\", \"The group, in a chat app.\", [naddr && app(\"Open group\", \"nostr:\" + naddr), naddr && copy(\"Copy naddr\", naddr)]),\n row(\"noStrudel\", \"web\", \"Relays, add this one, then open its page.\", [copy(\"Copy relay URL\", wsURL)]),\n ]],\n [\"Find me here\", \"Feed apps have no relay pages. They meet this relay through a profile link that names it, then keep it once it is in the relay settings.\", [\n row(\"Primal\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://primal.net/p/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"YakiHonne\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://yakihonne.com/profile/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"Damus\", \"iPhone\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Amethyst\", \"Android\", profileNote + \" Then Relays in the drawer.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Nostur\", \"iPhone, Mac\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n ]],\n ];\n if (info?.supported_grasps?.includes(\"GRASP-01\")) groups.push([\n \"Git repositories\", \"Use this relay with a Git client.\", [\n row(\"GitWorkshop\", \"web\", \"Browse this relay's Git repositories.\", [link(\"Open in app\", \"https://gitworkshop.dev/relay/\" + enc((wsURL.startsWith(\"ws://\") ? \"ws:\" : \"\") + host)), copy(\"Copy relay URL\", wsURL)]),\n ],\n ]);\n el.innerHTML = groups.map(([h, note, rows]) => '

                                                                        ' + h + '

                                                                        ' + note + '

                                                                        ' + rows.join(\"\") + \"
                                                                        \").join(\"\");\n $(\"#apps-ws\").textContent = wsURL;\n const tile = (label, text) => '
                                                                        ' + label + '\"QR
                                                                        ';\n $(\"#phones\").innerHTML = [nprofile && tile(\"Find me here, for a phone\", \"nostr:\" + nprofile), naddr && tile(\"The group, for a phone\", \"nostr:\" + naddr)].filter(Boolean).join(\"\");\n }\n // The folds are a group: opening one closes the rest. Browsers with the\n // details name attribute do this themselves; this covers the others.\n $$(\".folds details\").forEach((d) => d.addEventListener(\"toggle\", () => { if (d.open) $$(\".folds details\").forEach((o) => { if (o !== d && o.open) o.open = false; }); }));\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-copytext]\"); if (!b) return;\n try { await navigator.clipboard.writeText(b.dataset.copytext); toast(\"copied\"); } catch { /* no clipboard */ }\n });\n $(\"#copynaddr\").onclick = async () => { if (!card || !card.naddr) return; await navigator.clipboard.writeText(card.naddr); toast(\"copied naddr\"); };\n $(\"#copyembed\").onclick = async () => { await navigator.clipboard.writeText('\"''); toast(\"copied embed\"); };\n\n // A click on a key copies it; a double-click selects the whole key so the usual copy shortcut takes it too.\n document.addEventListener(\"dblclick\", (ev) => {\n const k = ev.target.closest(\".key\"); if (!k) return;\n const s = window.getSelection(), r = document.createRange(); r.selectNodeContents(k.querySelector(\".full\")); s.removeAllRanges(); s.addRange(r);\n });\n document.addEventListener(\"click\", async (ev) => {\n const k = ev.target.closest(\".key\"); if (!k || ev.detail > 1 || String(window.getSelection())) return;\n try { await navigator.clipboard.writeText(k.title); toast(\"copied \" + k.dataset.short); } catch { /* no clipboard */ }\n });\n // ---- boot ----\n try { await loadInfo(); } catch { renderHeader(); }\n await loadFuel();\n await loadCard();\n me = localStorage.getItem(\"me\");\n if (me && await resumeRemote()) { /* a remote session answers for itself when first used */ }\n else if (me && window.nostr) { try { const pk = await window.nostr.getPublicKey(); if (pk !== me) me = null; } catch { me = null; } }\n else if (me) me = null;\n renderHeader();\n renderApps();\n await loadAdmin();\n await loadPeople();\n})();\n"; +export const CONSOLE_JS = "(async () => {\n const $ = (s) => document.querySelector(s);\n const $$ = (s) => document.querySelectorAll(s);\n const host = location.host;\n const wsURL = (location.protocol === \"https:\" ? \"wss://\" : \"ws://\") + host;\n const rpcURL = location.origin + \"/\";\n let info = null, me = null, owner = \"\", policy = null, fuel = null, people = null, myRole = \"\";\n\n const toast = (msg) => { const t = $(\"#toast\"); t.textContent = msg; t.classList.add(\"show\"); clearTimeout(t._t); t._t = setTimeout(() => t.classList.remove(\"show\"), 2600); };\n // guard wraps a handler: disables the button, shows errors as toasts. Defined first, since handlers below use it.\n const guard = (fn) => async (ev) => { ev.preventDefault(); const b = ev.submitter || ev.target; if (b) b.disabled = true; try { await fn(ev); } catch (e) { toast(e.message); } finally { if (b) b.disabled = false; } };\n const short = (hex) => hex ? hex.slice(0, 8) + \"…\" + hex.slice(-4) : \"\";\n // key shows a key short but keeps the whole of it in the DOM: selecting, double-clicking or clicking it copies the full hex.\n const key = (hex) => hex ? '' + hex + '' : '';\n const hue = (hex) => (parseInt(hex.slice(0, 2), 16) * 360 / 256).toFixed(0) + \"deg\";\n const av = (hex) => '';\n const fmtBytes = (n) => n < 1e6 ? (n / 1e3).toFixed(0) + \" KB\" : n < 1e9 ? (n / 1e6).toFixed(1) + \" MB\" : (n / 1e9).toFixed(2) + \" GB\";\n const fmtHours = (ms) => ms < 3600e3 ? Math.round(ms / 60e3) + \" min\" : (ms / 3600e3).toFixed(ms < 36e6 ? 1 : 0) + \" h\";\n const fuelOver = () => !!fuel && (fuel.eventBytes > fuel.freeEventBytes || fuel.mediaBytes > fuel.freeMediaBytes || fuel.activeMs > fuel.freeActiveMs || fuel.rowsWritten > fuel.freeRowsWritten);\n const fmtTime = (t) => t ? new Date(t * 1000).toLocaleString(undefined, { month: \"short\", day: \"numeric\", hour: \"2-digit\", minute: \"2-digit\" }) : \"–\";\n const fmtDay = (t) => t ? new Date(t * 1000).toLocaleDateString(undefined, { month: \"short\", day: \"numeric\" }) : \"–\";\n const ago = (t) => { const s = Math.max(0, Math.floor(Date.now() / 1000) - t); return s < 60 ? \"just now\" : s < 3600 ? Math.floor(s / 60) + \" min\" : s < 86400 ? Math.floor(s / 3600) + \" h\" : Math.floor(s / 86400) + \" d\"; };\n const esc = (s) => String(s ?? \"\").replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c]);\n\n const IC = {\n x: '',\n undo: '',\n check: '',\n trash: '',\n ban: '',\n banuser: '',\n eye: '',\n lock: '',\n pen: '',\n people: '',\n person: '',\n gauge: '',\n bolt: '',\n copy: '',\n pin: '',\n };\n const ib = (icon, label, act, id, extra) => '\";\n\n const CH = \"qpzry9x8gf2tvdw0s3jn54khce6mua7l\";\n function npubToHex(s) {\n s = s.trim().toLowerCase();\n if (/^[0-9a-f]{64}$/.test(s)) return s;\n if (!s.startsWith(\"npub1\")) return null;\n const data = s.slice(5, -6).split(\"\").map((c) => CH.indexOf(c));\n if (data.some((d) => d < 0)) return null;\n let bits = 0, acc = 0, out = [];\n for (const d of data) { acc = (acc << 5) | d; bits += 5; if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 255); } }\n return out.length === 32 ? out.map((b) => b.toString(16).padStart(2, \"0\")).join(\"\") : null;\n }\n\n async function sha256hex(s) {\n const b = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(s));\n return [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, \"0\")).join(\"\");\n }\n // ---- signing: a NIP-07 extension, or a NIP-46 remote signer ----\n // The remote path loads the bundled library on first use; its session\n // (client key, signer pubkey, relays) lives in localStorage until sign-out.\n let remote = null, lib = null, pendingNote = null;\n const NO_SIGNER = \"Install a nostr extension (Alby, nos2x, …) or connect a remote signer.\";\n const withTimeout = (p, ms, what) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(what + \" did not answer; is the signer app open?\")), ms))]);\n const signer = {\n ready: () => !!remote || !!window.nostr,\n async getPublicKey() { if (remote) return withTimeout(remote.getPublicKey(), 20000, \"The remote signer\"); if (window.nostr) return window.nostr.getPublicKey(); throw new Error(NO_SIGNER); },\n async signEvent(ev) { if (remote) return withTimeout(remote.signEvent(ev), 60000, \"The remote signer\"); if (window.nostr) return window.nostr.signEvent(ev); throw new Error(NO_SIGNER); },\n };\n async function signerLib() {\n if (lib) return lib;\n if (!window.NostrSigner) await new Promise((res, rej) => { const s = document.createElement(\"script\"); s.src = window.SIGNER_URL || \"/signer.js\"; s.onload = res; s.onerror = () => rej(new Error(\"Could not load the signer library.\")); document.head.appendChild(s); });\n return (lib = window.NostrSigner);\n }\n const onauth = (u) => window.open(u, \"_blank\");\n // The signer conversation rides this relay, and a relay may ask the socket to\n // authenticate before it delivers kind 24133 to its parties; answer with the\n // session's own key.\n const authPool = (L, sk) => new L.SimplePool({ automaticallyAuth: () => (evt) => Promise.resolve(L.finalizeEvent(evt, sk)) });\n const saveSession = (sk, s, secret) => localStorage.setItem(\"nip46\", JSON.stringify({ sk: lib.bytesToHex(sk), pubkey: s.bp.pubkey, relays: s.bp.relays, secret: secret || null }));\n async function resumeRemote() {\n const raw = localStorage.getItem(\"nip46\");\n if (!raw) return false;\n try {\n const s = JSON.parse(raw); const L = await signerLib();\n remote = L.BunkerSigner.fromBunker(L.hexToBytes(s.sk), { pubkey: s.pubkey, relays: s.relays, secret: s.secret }, { onauth, pool: authPool(L, L.hexToBytes(s.sk)) });\n return true;\n } catch { localStorage.removeItem(\"nip46\"); return false; }\n }\n async function connectBunker(input) {\n const L = await signerLib();\n const bp = await L.parseBunkerInput(input.trim());\n if (!bp) throw new Error(\"That is not a bunker:// URL.\");\n if (!bp.relays.length) throw new Error(\"The bunker URL names no relay.\");\n const sk = L.generateSecretKey();\n const s = L.BunkerSigner.fromBunker(sk, bp, { onauth, pool: authPool(L, sk) });\n await withTimeout(s.connect({ name: host, url: location.origin }), 60000, \"The signer\");\n await Promise.race([s.switchRelays(), new Promise((r) => setTimeout(r, 3000))]);\n remote = s; saveSession(sk, s, bp.secret);\n }\n // The nostrconnect:// flow: this relay carries the traffic, so no third relay is involved.\n let ncPending = null;\n async function offerNostrConnect() {\n if (ncPending) return ncPending;\n const L = await signerLib();\n const sk = L.generateSecretKey();\n const secret = L.bytesToHex(crypto.getRandomValues(new Uint8Array(8)));\n const uri = L.createNostrConnectURI({ clientPubkey: L.getPublicKey(sk), relays: [wsURL], secret, name: host, url: location.origin, perms: [\"sign_event:27235\", \"sign_event:9734\"] });\n $(\"#nclink\").href = uri; $(\"#ncnote\").textContent = \"\";\n $(\"#ncqr\").src = \"/qr.svg?text=\" + encodeURIComponent(uri); $(\"#ncqr\").classList.remove(\"hidden\");\n ncPending = L.BunkerSigner.fromURI(sk, uri, { onauth, pool: authPool(L, sk) }, 600000).then((s) => { remote = s; saveSession(sk, s, secret); ncPending = null; return s; }, (e) => { ncPending = null; throw e; });\n return ncPending;\n }\n async function remoteDone() {\n me = await signer.getPublicKey();\n localStorage.setItem(\"me\", me);\n $(\"#remotesec\").classList.add(\"hidden\");\n toast(\"Remote signer connected\");\n if (pendingNote && !owner) { const n = pendingNote; pendingNote = null; await claimNow(n); return; }\n pendingNote = null;\n renderHeader(); await loadAdmin(); await loadPeople();\n }\n function showRemote(note) {\n pendingNote = note || null;\n $(\"#remotesec\").classList.remove(\"hidden\");\n $(\"#remotesec\").scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n offerNostrConnect().then(remoteDone).catch((e) => { $(\"#ncnote\").textContent = e.message; });\n }\n\n async function rpc(method, ...params) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const body = JSON.stringify({ method, params });\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", rpcURL], [\"method\", \"POST\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(rpcURL, { method: \"POST\", headers: { \"content-type\": \"application/nostr+json+rpc\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body });\n const json = await resp.json();\n if (json.error) throw new Error(json.error);\n return json.result;\n }\n\n // ---- front of house ----\n // urlish shows a URL as a short link: the host and the start of the path, the whole thing on hover and as the target.\n class Html { constructor(s) { this.s = s; } }\n const urlish = (u) => {\n if (!u) return \"\";\n let label = u; try { const x = new URL(u); label = x.host + (x.pathname.length > 1 ? x.pathname.slice(0, 18) + (x.pathname.length > 18 ? \"\\u2026\" : \"\") : \"\"); } catch { /* not a URL */ }\n if (!/^https?:\\/\\//.test(u)) return u;\n return new Html('' + esc(label) + \"\");\n };\n function renderHeader() {\n const name = (info && info.name) || host.split(\".\")[0];\n $(\"#title\").textContent = name;\n document.title = name + \" - relay\";\n $(\"#url\").textContent = wsURL;\n $(\"#d-ws\").textContent = wsURL; $(\"#d-blossom\").textContent = location.origin; $(\"#d-nip05\").textContent = \"you@\" + host;\n $(\"#nip05-example\").textContent = \"alice@\" + host;\n const desc = (info && info.description) || \"\";\n $(\"#desc\").textContent = desc; $(\"#desc\").classList.toggle(\"hidden\", !desc);\n // What the owner declared about the relay, on the page, not only in the document clients read.\n const img = (id, url) => { const el = $(id); if (url) el.src = url; el.classList.toggle(\"hidden\", !url); };\n img(\"#bannerimg\", info?.banner); $(\"#banner\").classList.toggle(\"hidden\", !info?.banner);\n img(\"#iconimg\", info?.icon);\n const meta = [];\n for (const t of info?.tags || []) meta.push('' + esc(t) + \"\");\n const where = [...(info?.language_tags || []), ...(info?.relay_countries || [])];\n if (where.length) meta.push(\"\" + esc(where.join(\", \")) + \"\");\n const link = (label, href) => href ? '' + label + \"\" : \"\";\n const contact = info?.contact ? (/^(mailto:|https?:\\/\\/)/.test(info.contact) ? link(\"Contact\", info.contact) : \"\" + esc(info.contact) + \"\") : \"\";\n for (const x of [contact, link(\"Terms\", info?.terms_of_service), link(\"Posting policy\", info?.posting_policy), link(\"Privacy\", info?.privacy_policy)]) if (x) meta.push(x);\n $(\"#metarow\").innerHTML = meta.join('|'); $(\"#metarow\").classList.toggle(\"hidden\", meta.length === 0);\n $(\"#frontsec\").classList.toggle(\"hidden\", !desc && meta.length === 0);\n owner = (info && info.pubkey) || \"\";\n const isOwner = !!me && me === owner;\n const lease = !owner && info && info.lease ? info.lease : null;\n $(\"#ownerline\").innerHTML = owner ? 'run by ' + key(owner) + \"\" : lease ? \"temporary until \" + fmtDay(lease.expires_at) : \"unclaimed\";\n $(\"#owner-av\").classList.toggle(\"hidden\", !owner);\n if (owner) $(\"#owner-av\").style.setProperty(\"--h\", hue(owner));\n $(\"#who\").textContent = me ? (isOwner ? \"(that's you)\" : \"\") : \"\";\n $(\"#unclaimed\").classList.toggle(\"hidden\", !!owner || !!lease);\n $(\"#leased\").classList.toggle(\"hidden\", !lease);\n if (lease) {\n $(\"#lease-until\").textContent = fmtTime(lease.expires_at);\n $(\"#leasenote\").textContent = lease.holder ? \"Reserved for the key that asked for it; sign with that key.\" : \"\";\n }\n $(\"#signin\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signin46\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signout\").classList.toggle(\"hidden\", !me);\n $(\"#about\").innerHTML = [\n [\"Name\", info?.name], [\"Description\", info?.description], [\"Contact\", info?.contact], [\"Owner\", owner ? short(owner) : \"\"], [\"Relay key\", info?.self ? short(info.self) : \"\"], [\"Software\", urlish(info?.software)], [\"Version\", info?.version],\n [\"Max query\", info?.limitation?.max_limit], [\"Auth required\", String(!!info?.limitation?.auth_required)], [\"Restricted writes\", String(!!info?.limitation?.restricted_writes)], [\"Min PoW\", info?.limitation?.min_pow_difficulty || 0],\n [\"Tags\", info?.tags?.join(\", \")], [\"Languages\", info?.language_tags?.join(\", \")], [\"Countries\", info?.relay_countries?.join(\", \")],\n [\"Terms\", urlish(info?.terms_of_service)], [\"Posting policy\", urlish(info?.posting_policy)], [\"Privacy policy\", urlish(info?.privacy_policy)], [\"Icon\", urlish(info?.icon)], [\"Banner\", urlish(info?.banner)],\n [\"NIPs\", info?.supported_nips?.join(\" \")],\n ].map(([k, v]) => \"
                                                                        \" + k + \"\" + (v === undefined || v === \"\" ? '' : v instanceof Html ? v.s : esc(v)) + \"
                                                                        \").join(\"\");\n renderCare();\n }\n\n function renderCare() {\n const g = (icon, label, off) => '' + IC[icon] + \"\" + label + \"\";\n const writes = policy ? policy.writes : (info?.limitation?.restricted_writes ? \"allowlist\" : \"open\");\n const reads = policy ? policy.reads : (info?.limitation?.auth_required ? \"auth\" : \"open\");\n let out = writes === \"owner\" ? g(\"person\", \"only owner writes\") : writes === \"allowlist\" ? g(\"people\", \"members write\") : writes === \"wot\" ? g(\"people\", \"members and follows write\") : g(\"pen\", \"anyone writes\");\n out += reads === \"members\" ? g(\"people\", \"members read\") : reads === \"auth\" ? g(\"lock\", \"sign in to read\") : g(\"eye\", \"anyone reads\");\n if (fuel) {\n const over = fuelOver();\n out += fuel.outOfFuel ? g(\"gauge\", \"out of fuel\", true) : over ? g(\"bolt\", \"burning sats\") : g(\"gauge\", \"on free allowance\");\n }\n $(\"#care\").innerHTML = out;\n }\n\n async function loadPeople() {\n try { people = await (await fetch(\"/people\")).json(); } catch { return; }\n const isOwner = !!me && me === owner;\n let list = people.people || [];\n if (!people.public && isOwner && policy) list = (window.__members || []);\n $(\"#peoplesec\").classList.toggle(\"hidden\", !owner || list.length === 0);\n $(\"#people-note\").classList.toggle(\"hidden\", people.public);\n $(\"#people-count\").textContent = list.length || \"\";\n $(\"#dir\").innerHTML = list.map((m) => '' + av(m.pubkey) + '' + (m.name ? esc(m.name) + \"@\" + host : key(m.pubkey)) + \"\" + (m.role === \"owner\" ? 'owner' : \"\") + \"\").join(\"\");\n }\n\n async function loadInfo() {\n info = await (await fetch(\"/\", { headers: { accept: \"application/nostr+json\" } })).json();\n renderHeader();\n const git = info.supported_grasps?.includes(\"GRASP-01\");\n $(\"#git-connect\").classList.toggle(\"hidden\", !git);\n const clone = \"git clone '\" + location.origin + \"//.git'\";\n $(\"#git-clone\").textContent = clone;\n $(\"#git-copy\").dataset.copytext = clone;\n renderApps();\n }\n\n async function loadFuel() {\n try { fuel = await (await fetch(\"/fuel\")).json(); } catch { return; }\n $(\"#fuelsec\").classList.toggle(\"hidden\", !owner);\n const pct = (used, free) => Math.min(100, Math.round((used / Math.max(free, 1)) * 100));\n const gauge = (id, used, free, text) => { const g = $(\"#g-\" + id), share = free ? used / free : 0; g.style.width = pct(used, free) + \"%\"; g.classList.toggle(\"over\", share > 1); g.classList.toggle(\"warm\", share > 0.75 && share <= 1); $(\"#p-\" + id).textContent = share > 1 ? \"over\" : Math.round(share * 100) + \"%\"; $(\"#t-\" + id).textContent = text; };\n gauge(\"events\", fuel.eventBytes, fuel.freeEventBytes, fmtBytes(fuel.eventBytes) + \" of \" + fmtBytes(fuel.freeEventBytes) + \" free\");\n gauge(\"media\", fuel.mediaBytes, fuel.freeMediaBytes, fmtBytes(fuel.mediaBytes) + \" of \" + fmtBytes(fuel.freeMediaBytes) + \" free\");\n gauge(\"active\", fuel.activeMs, fuel.freeActiveMs, fmtHours(fuel.activeMs) + \" of \" + fmtHours(fuel.freeActiveMs) + \" free\");\n gauge(\"rows\", fuel.rowsWritten, fuel.freeRowsWritten, fuel.rowsWritten.toLocaleString() + \" of \" + fuel.freeRowsWritten.toLocaleString() + \" free\");\n const sats = Math.floor(fuel.balanceMsats / 1000);\n const r = fuel.rates;\n $(\"#fuel-balance\").innerHTML = fuel.outOfFuel\n ? 'out of fuel Writes are paused until someone tops up.'\n : \"Balance \" + sats.toLocaleString() + \" sats. Past the allowances, prices track what the hosting costs: \" + r.satsPerGBMonthEvents.toLocaleString() + \" sats per GB-month of events, \" + r.satsPerGBMonthMedia.toLocaleString() + \" per GB-month of files, \" + r.satsPerActiveHour.toLocaleString() + \" per hour awake, \" + r.satsPerMillionRows.toLocaleString() + \" per million rows written. Traffic is free.\";\n $(\"#topup\").classList.toggle(\"hidden\", !fuel.enabled);\n // Who paid is the owner's to see: it comes with the signed stats call.\n let credits = [];\n if (owner && signer.ready()) { try { credits = (await rpc(\"stats\")).credits || []; } catch { credits = []; } }\n $(\"#credits tbody\").innerHTML = credits.length ? credits.map((c) => '' + esc(fmtTime(c.at)) + '' + av(c.payer) + key(c.payer) + '' + Math.floor(c.msats / 1000).toLocaleString() + \"\").join(\"\") : 'no zaps yet';\n if (!fuel.enabled) $(\"#topup-note\").textContent = \"Top-ups are not enabled on this service yet.\";\n renderCare();\n renderFuelTile();\n }\n\n async function topUp(sats) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const msats = Math.round(sats * 1000);\n const zapRequest = await signer.signEvent({ kind: 9734, created_at: Math.floor(Date.now() / 1000), content: \"fuel for \" + host, tags: [[\"p\", fuel.servicePubkey], [\"amount\", String(msats)], [\"relays\", wsURL]] });\n const r = await (await fetch(\"/fuel/invoice\", { method: \"POST\", body: JSON.stringify({ zapRequest }) })).json();\n if (r.error) throw new Error(r.error);\n const inv = r.invoice;\n $(\"#invoice\").classList.remove(\"hidden\");\n $(\"#inv-text\").value = inv; $(\"#inv-link\").href = \"lightning:\" + inv; $(\"#inv-state\").textContent = \"waiting for payment…\";\n if (window.webln) { try { await window.webln.enable(); await window.webln.sendPayment(inv); } catch (e) { toast(e.message || \"wallet declined\"); } }\n const before = fuel.creditedMsats;\n for (let i = 0; i < 120; i++) {\n await new Promise((r) => setTimeout(r, 2500));\n await loadFuel();\n if (fuel.creditedMsats > before) { $(\"#inv-state\").textContent = \"paid, thank you\"; toast(\"Fuel credited\"); return; }\n }\n $(\"#inv-state\").textContent = \"no receipt yet; it can take a minute after paying\";\n }\n\n // ---- console ----\n function showTab(name) {\n if (name === \"content\" || name === \"storage\") name = \"data\";\n const known = myRole === \"moderator\" ? [\"people\", \"moderation\"] : [\"people\", \"moderation\", \"rules\", \"identity\", \"data\", \"sync\", \"views\", \"health\", \"owner\"];\n if (!known.includes(name)) name = \"people\";\n $$(\".tabs a\").forEach((a) => a.classList.toggle(\"on\", a.dataset.tab === name));\n $$(\".panel\").forEach((p) => p.classList.toggle(\"on\", p.dataset.panel === name));\n }\n // Tabs switch in place. A fragment still opens a tab when someone arrives with one,\n // but clicking never writes one, and any fragment already there is dropped.\n $$(\".tabs a\").forEach((a) => a.addEventListener(\"click\", (ev) => { ev.preventDefault(); showTab(a.dataset.tab); if (location.hash) history.replaceState(null, \"\", location.pathname + location.search); }));\n window.addEventListener(\"hashchange\", () => showTab(location.hash.slice(1)));\n // The strip fades on whichever side has more tabs off screen.\n const tabsFade = () => { const t = $(\"#tabs\"); t.classList.toggle(\"fade-l\", t.scrollLeft > 4); t.classList.toggle(\"fade-r\", t.scrollLeft + t.clientWidth < t.scrollWidth - 4); };\n $(\"#tabs\").addEventListener(\"scroll\", tabsFade); window.addEventListener(\"resize\", tabsFade); new ResizeObserver(tabsFade).observe($(\"#tabs\"));\n\n function renderFuelTile() {\n if (!fuel) return;\n const over = fuelOver();\n const sats = Math.max(0, Math.floor(fuel.balanceMsats / 1000));\n const d = new Date(), end = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1), days = Math.max(1, Math.ceil((end - Date.now()) / 86400000));\n if (fuel.outOfFuel) { $(\"#h-fuel\").textContent = \"0 sats\"; $(\"#h-fuel-label\").textContent = \"out of fuel\"; $(\"#h-fuel-sub\").textContent = \"writes are paused\"; }\n else if (over) { $(\"#h-fuel\").textContent = sats.toLocaleString() + \" sats\"; $(\"#h-fuel-label\").textContent = \"fuel left\"; $(\"#h-fuel-sub\").textContent = \"past the free allowance\"; }\n else { $(\"#h-fuel\").textContent = days + (days === 1 ? \" day\" : \" days\"); $(\"#h-fuel-label\").textContent = \"free allowance left\"; $(\"#h-fuel-sub\").textContent = sats ? \"then \" + sats.toLocaleString() + \" sats\" : \"then a top-up is needed\"; }\n $(\"#usage\").innerHTML = [[\"events stored\", fmtBytes(fuel.eventBytes)], [\"files stored\", fmtBytes(fuel.mediaBytes)], [\"awake this month\", fmtHours(fuel.activeMs)], [\"rows written\", fuel.rowsWritten.toLocaleString()], [\"rows read\", fuel.rowsRead.toLocaleString()], [\"received this month\", fmtBytes(fuel.bytesIn)], [\"served this month\", fmtBytes(fuel.bytesOut)], [\"charged\", Math.floor(fuel.chargedMsats / 1000).toLocaleString() + \" sats\"]]\n .map(([k, v]) => \"
                                                                        \" + k + \"\" + v + \"
                                                                        \").join(\"\");\n }\n\n function renderHealth(stats) {\n $(\"#h-last\").textContent = stats.newest ? ago(stats.newest) : \"none\";\n $(\"#h-last-sub\").textContent = stats.newest ? fmtTime(stats.newest) : \"no events yet\";\n $(\"#h-conns\").textContent = stats.connections;\n const buckets = [[\"notes\", \"k1\", [1]], [\"reactions\", \"k2\", [7]], [\"DMs\", \"k3\", [4, 14, 1059]], [\"long-form\", \"k4\", [30023]], [\"other\", \"k5\", null]];\n const sums = buckets.map(() => 0); let total = 0;\n for (const { kind, n } of stats.kinds || []) { total += n; const i = buckets.findIndex((b) => b[2] && b[2].includes(kind)); sums[i < 0 ? 4 : i] += n; }\n const parts = buckets.map((b, i) => [b[0], b[1], sums[i]]).filter((p) => p[2] > 0);\n $(\"#h-kinds\").innerHTML = total ? parts.map(([name, cls, n]) => '').join(\"\") : '';\n $(\"#h-kinds-legend\").innerHTML = total ? parts.map(([name, cls, n]) => '' + name + \" \" + Math.round(n * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n renderFuelTile();\n }\n\n async function loadAdmin() {\n const isOwner = !!me && me === owner;\n myRole = isOwner ? \"owner\" : \"\";\n let stats = null, p = null;\n // A signed-in member may be a moderator; the relay says by answering stats.\n if (me && owner && !isOwner) { try { [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]); myRole = \"moderator\"; } catch { myRole = \"\"; } }\n $(\"#console\").classList.toggle(\"hidden\", !myRole);\n loadMine();\n $(\"#console\").classList.toggle(\"mod\", myRole === \"moderator\");\n if (!myRole) { policy = null; renderCare(); return; }\n if (isOwner) [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]);\n policy = p;\n renderHealth(stats);\n renderCare();\n const fa = $(\"#access\"), fi = $(\"#identity\");\n fa.writes.value = p.writes; fa.reads.value = p.reads; fa.openKinds.value = (p.openKinds || []).join(\", \"); fa.guestReplies.checked = !!p.guestReplies;\n $(\"#wordsform\").words.value = (p.blockedWords || []).join(\"\\n\"); $(\"#wordsform\").inTags.checked = !!p.blockedWordsInTags; $(\"#thresholdform\").reportThreshold.value = p.reportThreshold || 0; fa.minPow.value = p.minPow; fa.maxFuture.value = p.maxFuture; fa.maxLimit.value = p.maxLimit; fa.maxSubs.value = p.maxSubs; fa.maxMessageKB.value = p.maxMessageKB;\n fa.eventsPerMinute.value = p.eventsPerMinute; fa.reqsPerMinute.value = p.reqsPerMinute; fa.maxBlobMB.value = p.maxBlobMB;\n renderFeatures(p.features || {});\n $(\"#push-policy-form\").elements.origins.value = (p.pushCallbacks || []).join(\"\\n\");\n $(\"#push-policy-form\").elements.lettered.checked = !!p.letteredNips;\n fi.name.value = p.name; fi.contact.value = p.contact; fi.description.value = p.description; fi.icon.value = p.icon;\n const fj = $(\"#joinform\"); fj.joinTerms.value = p.joinTerms; fj.directoryPublic.checked = !!p.directoryPublic;\n loadCard();\n fi.banner.value = p.banner || \"\"; fi.postingPolicy.value = p.postingPolicy || \"\"; fi.privacyPolicy.value = p.privacyPolicy || \"\";\n fi.tags.value = (p.tags || []).join(\", \"); fi.languageTags.value = (p.languageTags || []).join(\", \"); fi.relayCountries.value = (p.relayCountries || []).join(\", \");\n const fn = $(\"#notify\"), nt = p.notify || {};\n fn.reports.checked = !!nt.reports; fn.fuel.checked = !!nt.fuel; fn.jobs.checked = !!nt.jobs; fn.succession.checked = !!nt.succession; fn.digest.checked = !!nt.digest;\n showTab(location.hash.slice(1));\n if (isOwner) { await renderPresets(); renderWire(); renderSuccession(); loadDomains(); }\n await Promise.all([loadLists(), loadEvents(true), loadPins(), ...(isOwner ? [loadStorage()] : [])]);\n }\n\n async function loadAudit(before) {\n const rows = await rpc(\"listaudit\", before || 0);\n const html = rows.map((r) => '' + esc(fmtTime(r.at)) + '' + av(r.actor) + key(r.actor) + '' + esc(r.action) + '' + (/^[0-9a-f]{64}$/.test(r.target) ? key(r.target) : esc(r.target)) + '' + esc(r.detail) + '').join(\"\");\n const tb = $(\"#audit tbody\");\n if (before) tb.insertAdjacentHTML(\"beforeend\", html); else tb.innerHTML = html || 'Nothing yet';\n $(\"#audit-more\").style.display = rows.length < 100 ? \"none\" : \"\";\n }\n $(\"#audit-more\").onclick = guard(async () => { const last = $(\"#audit tbody tr:last-child\"); await loadAudit(last ? +last.dataset.seq : 0); });\n async function loadLists() {\n loadAudit(0);\n const [mem, bans, bannedEvents, allow, block, invites, reports, blobs, blocks, sites] = await Promise.all([rpc(\"listmembers\"), rpc(\"listbannedpubkeys\"), rpc(\"listbannedevents\"), rpc(\"listallowedkinds\"), rpc(\"listblockedkinds\"), rpc(\"listinvites\"), rpc(\"listreports\"), rpc(\"listblobs\", 100), rpc(\"listblockedips\"), rpc(\"listsites\")]);\n const members = mem.members;\n window.__members = members;\n const roleCell = (m) => m.role === \"owner\" ? ' owner' : myRole === \"owner\" ? ' ' : m.role === \"moderator\" ? ' moderator' : \"\";\n const untouchable = (m) => m.role === \"owner\" || (myRole !== \"owner\" && m.role === \"moderator\");\n // The tree: everyone under whoever invited them, the owner and the owner's own additions at the root.\n const known = new Set(members.map((m) => m.pubkey));\n const byInviter = new Map();\n for (const m of members) { const k = m.role !== \"owner\" && known.has(m.invited_by) ? m.invited_by : \"\"; if (!byInviter.has(k)) byInviter.set(k, []); byInviter.get(k).push(m); }\n const ordered = [], placed = new Set();\n const walk = (k, depth) => { for (const m of byInviter.get(k) || []) { if (placed.has(m.pubkey)) continue; placed.add(m.pubkey); m.depth = depth; ordered.push(m); walk(m.pubkey, depth + 1); } };\n walk(\"\", 0);\n for (const m of members) if (!placed.has(m.pubkey)) { m.depth = 0; ordered.push(m); }\n const nameOf = (pk) => { const x = members.find((m) => m.pubkey === pk); return x && x.name ? x.name : short(pk); };\n const limits = (m) => m.role === \"owner\" ? \"\" : myRole === \"owner\"\n ? ''\n : '' + (m.keep_days ? m.keep_days + \" d\" : \"\") + (m.max_bytes ? \" \" + fmtBytes(m.max_bytes) : \"\") + \"\";\n $(\"#members tbody\").innerHTML = ordered.map((m) => '' + av(m.pubkey) + key(m.pubkey) + roleCell(m) + (m.depth ? ' via ' + esc(nameOf(m.invited_by)) + \"\" : \"\") + (m.invites ? ' ' + m.invites + \" inv\" : \"\") + '' + limits(m) + '' + esc(fmtDay(m.joined_at)) + \", \" + esc(m.via) + '' + ib(\"check\", \"Save\", \"savemember\", m.pubkey) + (untouchable(m) ? \"\" : ib(\"x\", \"Remove\", \"removemember\", m.pubkey) + ib(\"banuser\", \"Ban\", \"banpubkey\", m.pubkey, \"danger\")) + \"\").join(\"\");\n const tf = $(\"#treeform\");\n tf.classList.toggle(\"hidden\", myRole !== \"owner\");\n if (policy && policy.memberInvites) { tf.depth.value = policy.memberInvites.depth; tf.quota.value = policy.memberInvites.quota; }\n $(\"#transfer [name=pubkey]\").innerHTML = members.filter((m) => m.role !== \"owner\").map((m) => '\").join(\"\");\n $(\"#succession [name=heir]\").innerHTML = $(\"#transfer [name=pubkey]\").innerHTML;\n $(\"#tc-people\").textContent = members.length;\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#invites\").innerHTML = invites.length ? invites.map((i) => '
                                                                      • inv…' + i.code.slice(-8) + \" \" + esc(i.note || \"\") + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
                                                                      • \").join(\"\") : '
                                                                      • no invites
                                                                      • ';\n const person = (r, icon, label, act) => \"
                                                                      • \" + av(r.pubkey) + '' + short(r.pubkey) + \" \" + esc(r.reason || \"\") + \"\" + ib(icon, label, act, r.pubkey) + \"
                                                                      • \";\n $(\"#bans\").innerHTML = bans.length ? bans.map((r) => person(r, \"undo\", \"Unban\", \"unrulepubkey\")).join(\"\") : '
                                                                      • nobody banned
                                                                      • ';\n $(\"#blocks\").innerHTML = blocks.length ? blocks.map((r) => '
                                                                      • ip' + esc(r.ip) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"undo\", \"Unblock\", \"unblockip\", r.ip) + \"
                                                                      • \").join(\"\") : '
                                                                      • no addresses blocked
                                                                      • ';\n $(\"#banned-events\").innerHTML = bannedEvents.map((r) => '
                                                                      • ev' + key(r.id) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"check\", \"Allow again\", \"allowevent\", r.id) + \"
                                                                      • \").join(\"\");\n $(\"#reports tbody\").innerHTML = reports.length ? reports.map((r) => '' + esc(fmtTime(r.at)) + '' + esc(r.type || \"report\") + '' + av(r.target_pubkey) + key(r.target_pubkey) + (r.target_event ? (r.blob ? ' file ' + key(r.target_event) + \"\" : ' ev ' + key(r.target_event) + (r.hidden ? ' hidden' : \"\") + \"\") : \"\") + '' + esc(r.content) + '' + ib(\"check\", \"Dismiss\", \"resolve:dismiss\", r.id) + ib(\"trash\", r.blob ? \"Delete the file\" : \"Delete the event\", \"resolve:delete\", r.id) + ib(\"banuser\", \"Ban the author\", \"resolve:ban\", r.id, \"danger\") + \"\").join(\"\") : 'nothing reported';\n $(\"#reports-count\").textContent = reports.length || \"\"; $(\"#tc-reports\").textContent = reports.length || \"\";\n $(\"#blobs tbody\").innerHTML = blobs.length ? blobs.map((b) => '' + esc(fmtTime(b.uploaded)) + '' + key(b.sha256) + ' ' + esc(b.type) + '' + fmtBytes(b.size) + '' + av(b.uploader) + key(b.uploader) + '' + ib(\"trash\", \"Delete file\", \"deleteblob\", b.sha256, \"danger\") + \"\").join(\"\") : 'no uploads';\n $(\"#blobs-count\").textContent = blobs.length || \"\";\n $(\"#sites tbody\").innerHTML = sites.length ? sites.map((s) => '' + av(s.author) + key(s.author) + '' + esc(s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + '' + esc(s.url) + '' + esc(s.paths) + (s.missing ? ' ' + esc(s.missing) + ' missing' : '') + '' + fmtBytes(s.size) + '' + esc(s.expires_at ? fmtTime(s.expires_at) : \"never\") + '' + ib(\"trash\", \"Delete site\", \"deleteevent\", s.id, \"danger\") + '').join(\"\") : 'no sites yet';\n $(\"#sites-count\").textContent = sites.length || \"\";\n const tag = (k, cls) => '' + k + ib(\"x\", \"Remove rule\", \"unrulekind\", String(k)) + \"\";\n $(\"#kinds-allow\").innerHTML = allow.length ? allow.map((k) => tag(k, \"ok\")).join(\"\") : 'all';\n $(\"#kinds-block\").innerHTML = block.length ? block.map((k) => tag(k, \"blk\")).join(\"\") : 'none';\n await loadPeople();\n }\n\n const KIND_NAMES = { 0: \"profiles\", 1: \"notes\", 3: \"contacts\", 4: \"DMs\", 5: \"deletions\", 6: \"reposts\", 7: \"reactions\", 16: \"reposts\", 1059: \"gift wraps\", 1063: \"file headers\", 1111: \"comments\", 1984: \"reports\", 9734: \"zap requests\", 9735: \"zap receipts\", 9802: \"highlights\", 10002: \"relay lists\", 13534: \"roster\", 9000: \"group adds\", 9001: \"group removals\", 9021: \"join requests\", 9022: \"leave requests\", 39000: \"group info\", 39001: \"group admins\", 39002: \"group members\", 39003: \"group roles\", 30023: \"articles\", 30024: \"drafts\", 30078: \"app data\", 30311: \"live events\", 30818: \"wiki\" };\n const kindName = (k) => KIND_NAMES[k] || (k >= 20000 && k < 30000 ? \"ephemeral\" : k >= 30000 && k < 40000 ? \"addressable\" : k >= 10000 && k < 20000 ? \"replaceable\" : \"kind \" + k);\n const SYS_KINDS = new Set([0, 3, 10002, 9735, 13534, 8000, 8001, 9000, 9001, 39000, 39001, 39002, 39003]);\n let storage = null;\n\n // Features (settings.ts): a select each; search has three modes, the rest on or off.\n const FEATURES = [\n [\"search\", \"Search\", \"NIP-50. Prose indexes notes, threads, comments, highlights, articles and wiki pages; full indexes every public kind with content. A change applies to events from then on.\", [\"prose:prose\", \"full:full\", \"off:off\"]],\n [\"sync\", \"Sync\", \"NIP-77 reconciliation, which reads the whole matching set per sync.\"],\n [\"count\", \"Counts\", \"NIP-45 COUNT, with HLL sketches.\"],\n [\"discovery\", \"Discovery record\", \"NIP-66: the record the relay signs about itself, for crawlers.\"],\n [\"names\", \"Names\", \"NIP-05 addresses under this relay's domain.\"],\n [\"files\", \"Files\", \"Blossom and NIP-96: uploads, downloads and listings.\"],\n [\"pages\", \"Pages and feed\", \"Notes and articles as pages, and the Atom feed.\"],\n [\"sites\", \"Static websites\", \"NIP-5A sites on their own hostnames. Mirroring copies missing files into this relay and costs fuel.\", [\"mirror:on, mirror files\", \"proxy:on, fetch as needed\", \"off:off\"]],\n [\"marmot\", \"Marmot transport\", \"Signed KeyPackages and encrypted group messages, with account admission for ephemeral authors.\"],\n [\"grasp\", \"Git repositories\", \"GRASP Git hosting with admitted repository state. The prototype backend has bounded storage and compute limits.\"],\n [\"push\", \"Relay push\", \"NIP-9a callback delivery for members and the owner. Requires approved callback origins; advertises lettered NIP identifiers.\"],\n [\"signer\", \"Signer traffic\", \"NIP-46 remote signing carried for anyone, never stored.\"],\n ];\n function renderFeatures(f) {\n $(\"#features\").innerHTML = FEATURES.map(([k, title, about, modes]) => {\n const cur = k === \"sites\" ? (f.sites?.enabled === false ? \"off\" : f.sites?.mirror === false ? \"proxy\" : \"mirror\") : modes ? String(f[k] || \"prose\") : String(f[k] !== false);\n const opts = (modes || [\"true:on\", \"false:off\"]).map((m) => { const [v, l] = m.split(\":\"); return '\"; }).join(\"\");\n return \"\";\n }).join(\"\");\n }\n $(\"#features\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-feature]\"); if (!sel) return;\n const k = sel.dataset.feature, v = k === \"search\" || k === \"sites\" ? sel.value : sel.value === \"true\";\n policy = await rpc(\"setpolicy\", { features: { [k]: k === \"sites\" ? { enabled: v !== \"off\", mirror: v === \"mirror\" } : v } });\n toast(k === \"search\" ? \"Search: \" + v : (v ? \"Switched on \" : \"Switched off \") + k); await loadInfo();\n }));\n\n $(\"#push-policy-form\").addEventListener(\"submit\", guard(async (ev) => {\n ev.preventDefault();\n const form = ev.target;\n const origins = form.elements.origins.value.split(/\\s+/).filter(Boolean);\n const updated = await rpc(\"setpolicy\", { pushCallbacks: origins, letteredNips: form.elements.lettered.checked });\n if (JSON.stringify(updated.pushCallbacks) !== JSON.stringify([...new Set(origins.map((s) => s.replace(/\\/$/, \"\")))])) throw new Error(\"Use up to sixteen exact HTTPS origins, with no path or credentials.\");\n policy = updated;\n toast(\"Saved callback policy\"); await loadInfo();\n }));\n\n async function loadViews() {\n let views;\n try { views = await rpc(\"listviews\"); } catch { return; }\n $(\"#views\").innerHTML = views.map((v) => {\n const runs = v.trigger === \"off\" ? \"off\" : v.trigger === \"live\" ? \"live, from memory\" : v.trigger === \"write\" ? \"on write and daily\" : v.trigger;\n const label = (c) => (c === \"off\" ? \"off\" : c === \"write\" ? \"on write\" : c === \"live\" ? \"on\" : c);\n const choices = [...v.choices, v.default].filter((c, i, a) => a.indexOf(c) === i);\n const pick = '\";\n const who = v.audience === \"members\" ? \"members\" + (v.stored ? \"\" : \", on request\") : \"anyone\";\n const last = v.trigger === \"live\" ? \"\" : v.last ? \"last run \" + fmtTime(v.last.at) : \"not run yet\";\n const rows = v.trigger === \"live\" ? \"no rows\" : v.last ? v.last.rows.toLocaleString() + \" rows\" : \"\";\n const meta = [runs, who, last, rows].filter(Boolean).map((x) => \"\" + esc(x) + \"\").join('|');\n return '
                                                                        ' + esc(v.name) + '
                                                                        ' + esc(v.about) + '
                                                                        ' + pick + (v.on ? 'Open' : \"\") + '
                                                                        ' + meta + \"
                                                                        \";\n }).join(\"\");\n }\n $(\"#views\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-view]\"); if (!sel) return;\n const value = sel.value === \"live\" ? true : sel.value;\n await rpc(\"setpolicy\", { views: { [sel.dataset.view]: value } });\n toast(sel.value === \"off\" ? \"Took down \" + sel.dataset.view : sel.dataset.view + \": \" + (sel.value === \"live\" ? \"on\" : sel.value === \"write\" ? \"on write\" : sel.value)); await loadViews(); await loadInfo();\n }));\n async function loadDumps() {\n const list = await rpc(\"listdumps\");\n const f = $(\"#dumpform\");\n if (policy) { f.dumps.value = policy.dumps || \"off\"; f.keep.value = policy.dumpsKeep || 7; }\n $(\"#dumps-count\").textContent = list.length || \"\";\n $(\"#dumps\").innerHTML = list.length ? list.map((d) => '
                                                                      • jsonl' + esc(d.name) + ' ' + d.events.toLocaleString() + \" events, \" + fmtBytes(d.bytes) + \"\" + ib(\"copy\", \"Download\", \"downloaddump\", d.name) + ib(\"trash\", \"Delete\", \"deletedump\", d.name, \"danger\") + \"
                                                                      • \").join(\"\") : '
                                                                      • no dumps yet
                                                                      • ';\n }\n // A dump is fetched with a signed request and handed to the browser as a file.\n async function downloadDump(name) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const url = location.origin + \"/dumps/\" + name;\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"GET\"]] });\n const resp = await fetch(url, { headers: { authorization: \"Nostr \" + btoa(JSON.stringify(ev)) } });\n if (!resp.ok) throw new Error((await resp.json()).error || \"download failed\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(\".\")[0] + \"-\" + name; a.click(); URL.revokeObjectURL(a.href);\n }\n // A plain member's own invites, when the owner lets members invite.\n async function loadMine() {\n const sec = $(\"#myinvites\");\n if (!me || !owner || myRole || !signer.ready()) { sec.classList.add(\"hidden\"); return; }\n let mine;\n try { mine = await rpc(\"listinvites\"); } catch { sec.classList.add(\"hidden\"); return; }\n sec.classList.remove(\"hidden\");\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#mine\").innerHTML = mine.length ? mine.map((i) => '
                                                                      • inv…' + i.code.slice(-8) + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
                                                                      • \").join(\"\") : '
                                                                      • no invites yet
                                                                      • ';\n }\n async function loadStorage() {\n storage = await rpc(\"storagestats\");\n pollJobs();\n loadViews();\n loadDumps().catch(() => {});\n const st = storage;\n const top = st.kinds.slice(0, 4), rest = st.kinds.slice(4).reduce((a, k) => a + k.bytes, 0);\n const parts = [...top.map((k, i) => [k.kind + \" \" + kindName(k.kind), \"k\" + (i + 1), k.bytes]), ...(rest ? [[\"other\", \"k5\", rest]] : [])];\n const total = st.eventBytes || 1;\n $(\"#s-bar\").innerHTML = parts.length ? parts.map(([n, c, b]) => '').join(\"\") : '';\n $(\"#s-legend\").innerHTML = parts.length ? parts.map(([n, c, b]) => '' + esc(n) + \" \" + Math.round(b * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n const overhead = Math.max(0, st.databaseBytes - st.eventBytes);\n $(\"#s-totals\").innerHTML = [[\"events\", st.events.toLocaleString() + \" \" + fmtBytes(st.eventBytes) + \"\"], [\"index and overhead\", fmtBytes(overhead)], [\"files\", st.blobs.toLocaleString() + \" \" + fmtBytes(st.mediaBytes) + \"\"]]\n .map(([k, v]) => \"
                                                                        \" + k + \"\" + v + \"
                                                                        \").join(\"\");\n const any = st.retention.find((r) => r.kind === null);\n const own = (kind) => { const r = st.retention.find((x) => x.kind === kind); return r ? r.days : \"\"; };\n const row = (k) => {\n const key = k.kind === null ? \"\" : String(k.kind);\n const placeholder = k.kind === null ? \"forever\" : k.replaceable || !any ? \"forever\" : any.days + \" (everything else)\";\n const pill = k.kind === null ? \"everything else\" : '' + k.kind + \" \" + esc(kindName(k.kind));\n const cells = k.protected\n ? 'required'\n : ' days' + ib(\"check\", \"Save keep-for rule\", \"saveretention\", key) + ib(\"trash\", \"Purge\", \"purgekind\", key, \"danger\") + \"\";\n return '' + pill + '' + (k.n === undefined ? \"\" : k.n.toLocaleString()) + '' + (k.bytes === undefined ? \"\" : fmtBytes(k.bytes)) + '' + (k.oldest ? esc(fmtDay(k.oldest)) : \"\") + \"\" + cells + \"\";\n };\n $(\"#kinds tbody\").innerHTML = [...st.kinds, { kind: null, days: any ? any.days : 0 }].map(row).join(\"\");\n loadListHistory().catch(() => {});\n }\n\n async function loadListHistory() {\n const rows = await rpc(\"listlisthistory\");\n const labels = { 3: \"follows\", 10002: \"relay list\", 10003: \"bookmarks\", 30003: \"bookmark list\" };\n $(\"#listhistory tbody\").innerHTML = rows.length ? rows.map((r) => '' + esc((labels[r.kind] || (\"kind \" + r.kind)) + (r.d ? \" / \" + r.d : \"\")) + '' + esc(fmtTime(r.created_at)) + '' + esc(fmtTime(r.saved_at)) + '' + ib(\"undo\", \"Restore this version\", \"restorelist\", r.event_id) + '').join(\"\") : 'no older list versions yet';\n }\n\n let searchQuery = \"\";\n async function loadEvents(reset) {\n const list = searchQuery ? await rpc(\"searchevents\", searchQuery, 200) : await rpc(\"listrecentevents\", reset ? 50 : 200);\n $(\"#events tbody\").innerHTML = list.length ? list.map((e) => '' + esc(fmtTime(e.created_at)) + '' + e.kind + '' + av(e.pubkey) + key(e.pubkey) + '' + esc(e.content) + '' + ib(\"pin\", \"Pin\", \"pinevent\", e.id) + ib(\"trash\", \"Delete event\", \"deleteevent\", e.id) + ib(\"ban\", \"Ban event\", \"banevent\", e.id, \"danger\") + ib(\"banuser\", \"Ban author\", \"banpubkey\", e.pubkey, \"danger\") + \"\").join(\"\") : 'no events yet';\n $(\"#more\").classList.toggle(\"hidden\", searchQuery || list.length < 50);\n }\n $(\"#searchform\").onsubmit = guard(async (ev) => {\n searchQuery = ev.target.q.value.trim();\n $(\"#searchclear\").classList.toggle(\"hidden\", !searchQuery);\n await loadEvents(true);\n });\n $(\"#searchclear\").onclick = guard(async () => {\n searchQuery = \"\"; $(\"#searchform\").reset(); $(\"#searchclear\").classList.add(\"hidden\");\n await loadEvents(true);\n });\n async function loadPins() {\n const pins = await rpc(\"listpins\");\n $(\"#pins-count\").textContent = pins.length || \"\";\n $(\"#pins\").innerHTML = pins.length ? pins.map((t) => '
                                                                      • ' + (t[0] === \"e\" ? \"event\" : \"address\") + '' + esc(t[1].length > 40 ? t[1].slice(0, 16) + \"\\u2026\" + t[1].slice(-8) : t[1]) + \"\" + ib(\"copy\", \"Copy\", \"copy\", t[1]) + ib(\"x\", \"Unpin\", \"unpinevent\", t[1]) + \"
                                                                      • \").join(\"\") : '
                                                                      • nothing pinned
                                                                      • ';\n }\n $(\"#pinform\").onsubmit = guard(async (ev) => {\n await rpc(\"pinevent\", ev.target.id.value.trim()); ev.target.reset(); toast(\"Pinned\"); await loadPins();\n });\n\n // ---- actions ----\n const refresh = () => Promise.all([loadLists(), loadEvents(true), loadStorage(), loadPins()]);\n\n $(\"#copy\").onclick = async () => { await navigator.clipboard.writeText(wsURL); toast(\"copied \" + wsURL); };\n $(\"#signin\").onclick = guard(async () => {\n if (!window.nostr) throw new Error(\"No nostr extension found. Install one (Alby, nos2x, …) and reload, or use a remote signer.\");\n me = await window.nostr.getPublicKey();\n localStorage.setItem(\"me\", me);\n renderHeader(); await loadAdmin(); await loadPeople();\n });\n $(\"#signin46\").onclick = () => showRemote(null);\n $$(\".remote\").forEach((b) => { b.onclick = () => showRemote($(\"#\" + b.dataset.note)); });\n $(\"#nccopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#nclink\").href); toast(\"Link copied; paste it into your signer app\"); };\n $(\"#bunkerform\").onsubmit = guard(async (ev) => {\n $(\"#remotenote\").textContent = \"Connecting…\";\n try { await connectBunker(ev.target.url.value); } catch (e) { $(\"#remotenote\").textContent = e.message; throw e; }\n $(\"#remotenote\").textContent = \"\"; ev.target.reset();\n await remoteDone();\n });\n $(\"#signout\").onclick = async () => {\n if (remote) { const r = remote; remote = null; try { await withTimeout(r.logout(), 5000, \"The signer\"); } catch { /* the session is gone either way */ } }\n localStorage.removeItem(\"nip46\");\n me = null; localStorage.removeItem(\"me\"); renderHeader(); await loadAdmin(); await loadPeople();\n };\n const claimNow = async (note) => {\n if (!signer.ready()) { note.textContent = \"Sign the claim with a nostr extension or a remote signer.\"; showRemote(note); return; }\n me = await signer.getPublicKey();\n const r = await rpc(\"claim\");\n if (!r.claimed) throw new Error(\"Somebody else claimed it first.\");\n localStorage.setItem(\"me\", me);\n toast(\"It's yours.\");\n await loadInfo(); await loadFuel(); await loadAdmin(); await loadPeople();\n if (r.converted && confirm(\"This relay began as a temporary one: anyone can write, and everything is deleted after 14 days. Switch to the default rules and keep everything from now on? Each rule can be changed later on the Rules and Storage tabs.\")) {\n await rpc(\"resetrules\"); toast(\"Rules reset\"); await loadInfo(); await loadAdmin();\n }\n };\n $(\"#claim\").onclick = guard(() => claimNow($(\"#claimnote\")));\n $(\"#claimlease\").onclick = guard(() => claimNow($(\"#leasenote\")));\n // Jobs run in the background; the table follows them while the tab is open.\n let jobsTimer = 0;\n const fmtJob = (j) => {\n const what = j.kind === \"mirror\" ? \"mirror site\" : j.kind === \"import\" ? \"import\" : j.label === \"backfill\" ? \"fetch my history\" : j.kind === \"pull\" ? \"pull\" : \"rebroadcast\";\n const f = [];\n if (j.filter.authors) f.push(j.filter.authors.length === 1 && j.filter.authors[0] === me ? \"my events\" : j.filter.authors.length + \" authors\");\n if (j.filter.kinds) f.push(\"kinds \" + j.filter.kinds.join(\", \"));\n if (j.filter.since) f.push(\"since \" + fmtDay(j.filter.since));\n const when = j.every ? \"every \" + (j.every === 24 ? \"day\" : j.every + \" h\") + (j.nextRun ? \", next \" + fmtTime(j.nextRun) : \"\") : \"once\";\n const l = j.last;\n const count = (stored, blobs, sent, refused) => (j.kind === \"mirror\" ? blobs.toLocaleString() + \" files mirrored\" : j.kind === \"import\" ? stored.toLocaleString() + \" events\" + ((j.last ? j.last.duplicates : j.duplicates) ? \", \" + (j.last ? j.last.duplicates : j.duplicates) + \" already here\" : \"\") : j.kind === \"pull\" ? stored.toLocaleString() + \" events\" + (blobs ? \", \" + blobs + \" files\" : \"\") : sent.toLocaleString() + \" sent\" + (refused ? \", \" + refused + \" refused\" : \"\"));\n const res = j.running ? \"running: \" + count(j.stored, j.blobs, j.sent, j.refused) + \"...\" : !l ? \"waiting\" : l.error ? \"failed: \" + l.error : count(l.stored, l.blobs, l.sent, l.refused) + (l.skipped ? \", \" + l.skipped + \" skipped\" : \"\") + \", \" + fmtTime(l.finishedAt);\n const sources = j.running ? j.pullSources : l?.sources;\n const details = sources?.length ? '
                                                                        Source results' + sources.map((s) => '

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

                                                                        ').join('') + '
                                                                        ' : '';\n return \"\" + what + \"\" + j.relays.map(esc).join(\"
                                                                        \") + \"\" + (f.join(\", \") || \"everything\") + \"\" + when + \"\" + esc(res) + details + \"\" + (j.running ? \"\" : ib(\"undo\", \"Run now\", \"runjob\", j.id)) + ib(\"x\", \"Remove\", \"removejob\", j.id) + \"\";\n };\n async function pollJobs() {\n clearTimeout(jobsTimer);\n let jobs;\n try { jobs = await rpc(\"listjobs\"); } catch { return; }\n $(\"#jobs tbody\").innerHTML = jobs.length ? jobs.map(fmtJob).join(\"\") : 'no jobs yet';\n if (jobs.some((j) => j.running || (j.nextRun && j.nextRun <= Math.floor(Date.now() / 1000) + 1))) jobsTimer = setTimeout(pollJobs, 3000);\n }\n const urls = (s) => s.split(/[\\s,]+/).map((u) => u.trim()).filter(Boolean);\n const kindsOf = (s) => s.split(/[\\s,]+/).map((k) => parseInt(k, 10)).filter((k) => Number.isInteger(k) && k >= 0);\n $(\"#pullform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"addjob\", { kind: \"pull\", relays: [f.url.value.trim()], every: +f.every.value });\n toast(+f.every.value ? \"Mirror scheduled\" : \"Pull started\"); f.reset(); await pollJobs();\n });\n $(\"#backfillform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"backfill\", urls(f.relays.value));\n toast(\"Fetching your history\"); f.reset(); await pollJobs();\n });\n $(\"#pushform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const filter = {};\n const kinds = kindsOf(f.kinds.value); if (kinds.length) filter.kinds = kinds;\n const days = +f.days.value; if (days > 0) filter.since = Math.floor(Date.now() / 1000) - days * 86400;\n await rpc(\"addjob\", { kind: \"push\", relays: urls(f.targets.value), filter, every: +f.every.value });\n toast(\"Rebroadcast started\"); f.reset(); await pollJobs();\n });\n // ---- presets: writes, reads, kinds and keep-for rules in one click ----\n let presets = null;\n async function renderPresets() {\n if (!presets) { try { presets = await rpc(\"listpresets\"); } catch { presets = []; } }\n $(\"#presets\").innerHTML = presets.map((p) => '\").join(\"\");\n $(\"#presetsourcerow\").classList.toggle(\"hidden\", !presets.some((p) => p.source));\n }\n $(\"#presets\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-preset]\"); if (!b) return;\n const p = (presets || []).find((x) => x.name === b.dataset.preset); if (!p) return;\n if (!confirm(p.title + \": \" + p.about + \"\\n\\nThis replaces the writes and reads rules, the directory setting, the kind rules and the keep-for rules. Limits, identity, people and bans stay.\")) return;\n const source = $(\"#presetsource\").value.trim();\n if (p.source === \"required\" && !source) { $(\"#presetnote\").textContent = p.title + \" needs a source relay to mirror; enter its wss:// URL first.\"; return; }\n b.disabled = true;\n try { policy = await rpc(\"applypreset\", p.name, p.source && source ? { source } : undefined); $(\"#presetnote\").textContent = \"Now: \" + p.about + (policy.job ? \" Mirroring \" + source + \" every \" + policy.job.every + \" h.\" : \"\"); toast(p.title + \" applied\"); await loadInfo(); await loadAdmin(); }\n catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // ---- wire me in: this relay in the owner's own lists ----\n // The lists are replaceable, so a fresh list with only this relay would\n // clobber the real one once it spread. Every publish starts from the\n // newest copy found here or on the indexers, verified when the signer\n // library is around, and adds or removes this relay in it.\n const INDEXERS = [\"wss://purplepag.es\", \"wss://relay.nostr.band\", \"wss://relay.damus.io\", \"wss://nos.lol\"];\n const LISTS = [\n { kind: 10002, tag: \"r\", title: \"Relay list\", nip: \"NIP-65\", about: \"where clients read your notes and send you mentions\" },\n { kind: 10050, tag: \"relay\", title: \"DM inbox\", nip: \"NIP-17\", about: \"where people send you private messages\" },\n { kind: 10007, tag: \"relay\", title: \"Search relays\", nip: \"NIP-51\", about: \"where clients run your searches\" },\n { kind: 10063, tag: \"server\", title: \"Blossom servers\", nip: \"BUD-03\", about: \"where clients upload and look for your files\" },\n ];\n const mineFor = (l) => (l.tag === \"server\" ? location.origin : wsURL);\n const normURL = (u) => { try { const x = new URL(String(u).trim()); return (x.host + x.pathname).replace(/\\/+$/, \"\").toLowerCase(); } catch { return String(u).trim().toLowerCase(); } };\n const isMine = (l, t) => t[0] === l.tag && normURL(t[1] || \"\") === normURL(mineFor(l));\n const listHas = (list, l) => !!list && list.tags.some((t) => isMine(l, t));\n // mergeList is pure: the newest list with this relay put first or taken out, every other tag kept.\n function mergeList(list, l, include) {\n const tags = (list ? list.tags : []).filter((t) => !isMine(l, t));\n if (include) tags.unshift([l.tag, mineFor(l)]);\n return { kind: l.kind, created_at: Math.floor(Date.now() / 1000), content: list ? list.content : \"\", tags };\n }\n const relaysIn = (list) => list.tags.filter((t) => (t[0] === \"r\" || t[0] === \"relay\") && /^wss?:\\/\\//i.test(t[1] || \"\")).map((t) => t[1].trim());\n // overWS opens one socket, sends one message, feeds answers to onMessage until it says done or time runs out.\n function overWS(url, ms, first, onMessage) {\n return new Promise((res) => {\n let ws = null, done = false, out = null;\n const finish = () => { if (done) return; done = true; clearTimeout(timer); try { if (ws) ws.close(); } catch { /* closed */ } res(out); };\n const timer = setTimeout(finish, ms);\n try { ws = new WebSocket(url); } catch { return finish(); }\n ws.onopen = () => ws.send(JSON.stringify(first));\n ws.onmessage = (m) => { let d; try { d = JSON.parse(m.data); } catch { return; } if (!Array.isArray(d)) return; if (onMessage(d, (v) => { out = v; })) finish(); };\n ws.onerror = finish; ws.onclose = finish;\n });\n }\n function fetchNewest(url, filter, ms) {\n let best = null;\n return overWS(url, ms, [\"REQ\", \"w\", filter], (d, set) => {\n if (d[0] === \"EVENT\" && d[1] === \"w\" && d[2] && (!best || d[2].created_at > best.created_at)) { best = d[2]; set(best); }\n return (d[0] === \"EOSE\" || d[0] === \"CLOSED\") && d[1] === \"w\";\n });\n }\n function publishTo(url, event, ms) {\n return overWS(url, ms, [\"EVENT\", event], (d, set) => { if (d[0] === \"OK\" && d[1] === event.id) { set({ url, ok: !!d[2], msg: d[3] || \"\" }); return true; } return false; })\n .then((r) => r || { url, ok: false, msg: \"no answer\" });\n }\n // bridge signs a NIP-98 request to this relay's HTTP door, so the owner's own reads and writes pass any read rule.\n async function bridge(path, body) {\n const url = location.origin + path, raw = JSON.stringify(body);\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"POST\"], [\"payload\", await sha256hex(raw)]] });\n const resp = await fetch(url, { method: \"POST\", headers: { \"content-type\": \"application/json\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body: raw });\n const json = await resp.json();\n if (!resp.ok) throw new Error(json.error || \"HTTP \" + resp.status);\n return json;\n }\n const wire = {};\n function renderWire() {\n const pill = (state) => {\n const cls = state === \"listed\" ? \"on\" : state === \"failed\" ? \"bad\" : state === \"not listed\" ? \"off\" : \"\";\n const label = state === \"checking\" || state === \"publishing\" ? state + \"\\u2026\" : state;\n return '' + esc(label) + \"\";\n };\n $(\"#wire\").innerHTML = LISTS.map((l) => {\n const w = wire[l.kind] || { state: \"\", results: [], list: null };\n const busy = w.state === \"checking\" || w.state === \"publishing\";\n const btn = (act, cls, label) => '\";\n const buttons = w.state === \"listed\" ? btn(\"remove\", \"\", \"Remove this relay\") : btn(\"add\", \"pri\", \"Add this relay\") + btn(\"check\", \"\", w.state ? \"Check again\" : \"Check\");\n const n = w.list ? w.list.tags.filter((t) => t[0] === l.tag).length : 0;\n const from = w.list ? \"\" + (n === 1 ? \"1 entry\" : n + \" entries\") + \", \" + fmtTime(w.list.created_at) + \"\" : w.state && !busy ? \"no list found\" : \"\";\n const sent = (w.results || []).map((r) => '' + esc(r.url.replace(/^wss?:\\/\\//, \"\")) + (r.ok ? \"\" : \" failed\") + \"\").join(\"\");\n const meta = (w.state ? pill(w.state) : \"\") + from + sent;\n return '
                                                                        ' + l.title + ' ' + l.nip + \"
                                                                        \" + l.about + '
                                                                        ' + buttons + '
                                                                        ' + meta + \"
                                                                        \";\n }).join(\"\");\n }\n async function wireCheck(l) {\n const w = (wire[l.kind] = { state: \"checking\", list: null, results: [] }); renderWire();\n const filter = { kinds: [l.kind], authors: [me], limit: 1 };\n const found = [];\n try { found.push(...(await bridge(\"/query\", [filter]))); } catch { /* the relay may hold none */ }\n const remote = await Promise.all(INDEXERS.map((u) => fetchNewest(u, filter, 4000)));\n let lib = null; try { lib = await signerLib(); } catch { /* unverified lists are still the owner's own, by pubkey */ }\n for (const e of remote) if (e && e.pubkey === me && e.kind === l.kind && Array.isArray(e.tags) && (!lib || lib.verifyEvent(e))) found.push(e);\n w.list = found.sort((a, b) => b.created_at - a.created_at)[0] || null;\n w.state = listHas(w.list, l) ? \"listed\" : \"not listed\"; renderWire();\n }\n async function wirePublish(l, include) {\n if (!wire[l.kind] || !wire[l.kind].state || wire[l.kind].state === \"failed\") await wireCheck(l);\n const w = wire[l.kind]; w.state = \"publishing\"; w.results = []; renderWire();\n try {\n const signed = await signer.signEvent(mergeList(w.list, l, include));\n const here = await bridge(\"/events\", signed).then((r) => ({ url: wsURL, ok: !!r.accepted, msg: r.message || \"\" })).catch((e) => ({ url: wsURL, ok: false, msg: e.message }));\n const targets = [...new Set([...relaysIn(signed), ...INDEXERS])].filter((u) => normURL(u) !== normURL(wsURL));\n const rest = await Promise.all(targets.map((u) => publishTo(u, signed, 6000)));\n w.results = [here, ...rest]; w.list = signed; w.state = include ? \"listed\" : \"not listed\";\n toast((include ? \"Added to your \" : \"Removed from your \") + l.title.toLowerCase());\n } catch (e) { w.state = \"failed\"; w.results = [{ url: wsURL, ok: false, msg: e.message }]; toast(e.message); }\n renderWire();\n }\n $(\"#wire\").addEventListener(\"click\", (ev) => {\n const b = ev.target.closest(\"button[data-wire]\"); if (!b) return;\n const l = LISTS.find((x) => String(x.kind) === b.dataset.wire); if (!l) return;\n if (b.dataset.do === \"check\") wireCheck(l); else wirePublish(l, b.dataset.do === \"add\");\n });\n\n // ---- custom domains: this relay under a hostname the owner controls ----\n let domainSites = [];\n const domainOptions = (current = \"\") => [{ label: \"\", title: \"This relay\" }, ...domainSites.map((s) => ({ label: s.label, title: (s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + \" / \" + key(s.author) })), ...(current && !domainSites.some((s) => s.label === current) ? [{ label: current, title: \"Site \" + current }] : [])].map((s) => '\").join(\"\");\n let domains = null; // null: not enabled on this host\n function renderDomains() {\n $(\"#domains\").innerHTML = (domains || []).map((d) => {\n const state = d.ready ? \"active\" : \"hostname \" + d.status.replace(/_/g, \" \") + \", certificate \" + d.sslStatus.replace(/_/g, \" \");\n const btn = (act, cls, label) => '\";\n const rows = d.ready ? \"\" : '
                                                                        ' + d.records.map((r) => \"\").join(\"\") + \"
                                                                        TypeNameValue
                                                                        \" + esc(r.type) + '' + esc(r.name) + '' + esc(r.value) + \"\" + esc(r.note) + \"
                                                                        \";\n return '
                                                                        ' + esc(d.host) + '' + esc(state) + \"\" + btn(\"check\", \"\", \"Check\") + btn(\"remove\", \"danger\", \"Remove\") + '
                                                                        \" + rows;\n }).join(\"\");\n }\n async function loadDomains() {\n try { [domains, domainSites] = await Promise.all([rpc(\"listdomains\"), rpc(\"listsites\")]); $(\"#adddomain select[name=site]\").innerHTML = domainOptions(); $(\"#domainnote\").textContent = domains.length ? \"\" : \"No custom domain yet.\"; $(\"#adddomain\").classList.remove(\"hidden\"); }\n catch (e) { domains = null; $(\"#domainnote\").textContent = /^unsupported/.test(e.message) ? \"Custom domains are not enabled on this host.\" : e.message; $(\"#adddomain\").classList.add(\"hidden\"); }\n renderDomains();\n }\n $(\"#adddomain\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"adddomain\", f.host.value.trim(), f.site.value); f.reset(); toast(\"Domain added; now create the CNAME\"); await loadDomains();\n });\n $(\"#domains\").addEventListener(\"change\", guard(async (ev) => {\n const select = ev.target.closest(\"select[data-domain-site]\"); if (!select) return;\n await rpc(\"setdomainsite\", select.dataset.domainSite, select.value);\n toast(\"Domain destination saved\"); await loadDomains();\n }));\n $(\"#domains\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-domain]\"); if (!b) return;\n const host = b.dataset.domain;\n if (b.dataset.do === \"remove\" && !confirm(\"Remove \" + host + \"? Its certificate goes with it and the name stops answering.\")) return;\n b.disabled = true;\n try {\n if (b.dataset.do === \"remove\") { await rpc(\"removedomain\", host); toast(\"Removed\"); }\n else { const d = await rpc(\"checkdomain\", host); toast(d.ready ? host + \" is live\" : \"Not yet: \" + (d.status === \"active\" ? \"certificate pending\" : \"waiting for the CNAME\")); }\n await loadDomains();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n $(\"#access\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const openKinds = f.openKinds.value.split(/[\\s,]+/).filter(Boolean).map(Number);\n if (openKinds.some((k) => !Number.isInteger(k) || k < 0 || k > 65535)) throw new Error(\"Open kinds must be whole numbers.\");\n policy = await rpc(\"setpolicy\", { writes: f.writes.value, reads: f.reads.value, openKinds, guestReplies: f.guestReplies.checked, minPow: +f.minPow.value, maxFuture: +f.maxFuture.value, maxLimit: +f.maxLimit.value, maxSubs: +f.maxSubs.value, maxMessageKB: +f.maxMessageKB.value, eventsPerMinute: +f.eventsPerMinute.value, reqsPerMinute: +f.reqsPerMinute.value, maxBlobMB: +f.maxBlobMB.value });\n toast(\"Rules saved\"); await loadInfo();\n });\n $(\"#notify\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { notify: { reports: f.reports.checked, fuel: f.fuel.checked, jobs: f.jobs.checked, succession: f.succession.checked, digest: f.digest.checked } });\n toast(\"Notifications saved\");\n });\n $(\"#notifytest\").onclick = guard(async () => {\n const r = await rpc(\"notifytest\");\n toast(r.sent ? \"Sent. Look for a message from the relay in your DMs.\" : \"Could not send.\");\n });\n $(\"#identity\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const list = (v) => v.split(\",\").map((s) => s.trim()).filter(Boolean);\n policy = await rpc(\"setpolicy\", { name: f.name.value, contact: f.contact.value, description: f.description.value, icon: f.icon.value,\n banner: f.banner.value, postingPolicy: f.postingPolicy.value, privacyPolicy: f.privacyPolicy.value, tags: list(f.tags.value), languageTags: list(f.languageTags.value), relayCountries: list(f.relayCountries.value) });\n toast(\"Identity saved\"); await loadInfo(); await loadPeople(); loadCard();\n });\n $(\"#addmember\").onsubmit = guard(async (ev) => {\n const f = ev.target; const pk = npubToHex(f.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"setmember\", pk, { name: f.name.value.trim() || null, note: f.note.value }); f.reset(); toast(\"Member added\"); await loadLists();\n });\n $(\"#addblock\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"blockip\", f.ip.value.trim(), f.reason.value); f.reset(); toast(\"Blocked\"); await loadLists();\n });\n $(\"#wordsform\").onsubmit = guard(async (ev) => {\n const words = ev.target.words.value.split(\"\\n\").map((s) => s.trim()).filter(Boolean);\n const kept = await rpc(\"setblockedwords\", words);\n policy = await rpc(\"setpolicy\", { blockedWordsInTags: ev.target.inTags.checked });\n ev.target.words.value = kept.join(\"\\n\"); toast(kept.length ? kept.length + \" words blocked\" : \"No words blocked\");\n });\n $(\"#thresholdform\").onsubmit = guard(async (ev) => {\n policy = await rpc(\"setpolicy\", { reportThreshold: +ev.target.reportThreshold.value });\n toast(policy.reportThreshold ? \"Hidden after \" + policy.reportThreshold + \" reports\" : \"Never hidden by reports\"); await loadLists();\n });\n $(\"#addban\").onsubmit = guard(async (ev) => {\n const pk = npubToHex(ev.target.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"banpubkey\", pk, ev.target.reason.value, ev.target.erase.checked); ev.target.reset(); toast(ev.target.erase.checked ? \"Banned and erased\" : \"Banned\"); await loadLists();\n });\n $(\"#mintinvite\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const inv = await rpc(\"createinvite\", +f.ttl.value, +f.max.value, f.note.value);\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n f.note.value = \"\"; toast(\"Invite created and copied\"); await loadLists();\n });\n $(\"#kindform\").onsubmit = guard(async (ev) => {\n const rule = ev.submitter.value; const k = +ev.target.kind.value;\n await rpc(rule === \"allow\" ? \"allowkind\" : \"disallowkind\", k); ev.target.reset(); toast((rule === \"allow\" ? \"Allowed kind \" : \"Blocked kind \") + k); await loadLists();\n });\n $(\"#more\").onclick = guard(() => loadEvents(false));\n $(\"#dumpform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { dumps: f.dumps.value, dumpsKeep: Math.max(1, Math.min(60, Math.floor(+f.keep.value || 7))) });\n toast(policy.dumps === \"off\" ? \"Dumps off\" : \"Dumping \" + policy.dumps); await loadDumps();\n });\n $(\"#importform\").onsubmit = guard(async (ev) => {\n const file = ev.target.file.files[0]; if (!file) return;\n if (!signer.ready()) throw new Error(NO_SIGNER);\n if (file.size > 64 * 1024 * 1024) throw new Error(\"At most 64 MB per import.\");\n const body = await file.text();\n const url = location.origin + \"/import?name=\" + encodeURIComponent(file.name);\n const token = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"PUT\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(url, { method: \"PUT\", headers: { authorization: \"Nostr \" + btoa(JSON.stringify(token)), \"content-type\": \"application/x-ndjson\" }, body });\n const r = await resp.json();\n if (!resp.ok) throw new Error(r.error || \"import failed\");\n ev.target.reset(); toast(\"Importing \" + fmtBytes(r.bytes)); await pollJobs();\n });\n $(\"#dumpnow\").onclick = guard(async () => { const d = await rpc(\"dumpnow\"); toast(\"Dumped \" + d.events.toLocaleString() + \" events\"); await loadStorage(); });\n $(\"#treeform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { memberInvites: { depth: Math.max(0, Math.floor(+f.depth.value || 0)), quota: Math.max(0, Math.floor(+f.quota.value || 0)) } });\n toast(policy.memberInvites.depth ? \"Members may invite\" : \"Only you and moderators invite\");\n });\n $(\"#mintmine\").onclick = guard(async () => {\n const inv = await rpc(\"createinvite\", 259200, 1, \"\");\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n toast(\"Invite created and copied\"); await loadMine();\n });\n $(\"#exportcfg\").onclick = guard(async () => {\n const cfg = await rpc(\"exportconfig\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(new Blob([JSON.stringify(cfg, null, 2)], { type: \"application/json\" }));\n a.download = host.split(\".\")[0] + \".bind.ws.config.json\";\n a.click(); URL.revokeObjectURL(a.href);\n });\n $(\"#importcfg\").onclick = () => $(\"#cfgfile\").click();\n $(\"#cfgfile\").onchange = guard(async (ev) => {\n const file = ev.target.files[0]; if (!file) return;\n const cfg = JSON.parse(await file.text());\n const check = await rpc(\"importconfig\", cfg, { dryRun: true });\n const lines = check.changes.summary.length ? check.changes.summary.join(\"\\n\") : \"Nothing would change.\";\n const dropped = check.warnings.length ? \"\\n\\nNot taken:\\n\" + check.warnings.join(\"\\n\") : \"\";\n if (!confirm(\"Apply \" + file.name + \" to this relay?\\n\\n\" + lines + dropped)) { ev.target.value = \"\"; return; }\n await rpc(\"importconfig\", cfg); ev.target.value = \"\"; toast(\"Configuration imported\"); await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // Succession: the status line under the heir form, from successionstatus.\n async function renderSuccession() {\n const el = $(\"#successionnote\"), f = $(\"#succession\");\n let st;\n try { st = await rpc(\"successionstatus\"); } catch { el.textContent = \"\"; return; }\n const sc = st.succession;\n $(\"#clearsuccession\").classList.toggle(\"hidden\", !sc);\n f.querySelector(\"button.btn:not(#clearsuccession)\").textContent = sc ? \"Change heir\" : \"Set heir\";\n if (!sc) { el.textContent = \"No heir named. Last signed in \" + fmtTime(st.ownerSeenAt) + \".\"; return; }\n if (f.heir.querySelector('[value=\"' + sc.heir + '\"]')) f.heir.value = sc.heir;\n f.afterDays.value = String(sc.afterDays);\n const who = (window.__members || []).find((m) => m.pubkey === sc.heir);\n const heir = who && who.name ? who.name + \"@\" + host : short(sc.heir);\n el.textContent = \"Heir: \" + heir + \". Last signed in \" + fmtTime(st.ownerSeenAt) + (st.silentDays ? \" (\" + st.silentDays + \" days ago)\" : \"\") + \". \" +\n (st.warning ? \"The warning month is running: the relay goes to \" + heir + \" on \" + fmtDay(st.handoverAt) + \" unless you sign in.\" : \"If you stay away, it goes to \" + heir + \" around \" + fmtDay(st.handoverAt) + \".\") +\n (st.log && st.log.length ? \" Handed over before: \" + st.log.map((l) => fmtDay(l.at) + \" to \" + short(l.to)).join(\", \") + \".\" : \"\");\n }\n $(\"#succession\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"setsuccession\", { heir: f.heir.value, afterDays: +f.afterDays.value });\n toast(\"Heir set\"); await loadAdmin();\n });\n $(\"#clearsuccession\").onclick = guard(async () => {\n await rpc(\"clearsuccession\");\n toast(\"Heir cleared\"); await loadAdmin();\n });\n $(\"#transfer\").onsubmit = guard(async (ev) => {\n const pk = ev.target.pubkey.value; if (!pk) return;\n const name = host.split(\".\")[0];\n const typed = prompt(\"This hands \" + host + \" to \" + short(pk) + \" for good. You stay on as a moderator. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing changed.\"); return; }\n await rpc(\"transferowner\", pk);\n toast(\"Transferred\");\n await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // ---- fork: a new name with this relay's events, claim reserved for a key ----\n $(\"#forkform\").scope.onchange = (ev) => $(\"#forkkinds\").classList.toggle(\"hidden\", ev.target.value !== \"kinds\");\n $(\"#joinform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { joinTerms: f.joinTerms.value, directoryPublic: f.directoryPublic.checked });\n toast(\"Saved\"); await loadInfo(); await loadPeople();\n });\n $(\"#forkform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const opts = { people: f.people.checked };\n if (f.name.value.trim()) opts.name = f.name.value.trim().toLowerCase();\n if (f.holder.value.trim()) { const pk = npubToHex(f.holder.value); if (!pk) throw new Error(\"That is not a pubkey.\"); opts.holder = pk; }\n if (f.scope.value === \"mine\") opts.filter = { authors: [me] };\n if (f.scope.value === \"kinds\") { const kinds = f.kinds.value.split(/[\\s,]+/).filter(Boolean).map(Number); if (!kinds.length || kinds.some((k) => !Number.isInteger(k) || k < 0)) throw new Error(\"Give kinds as numbers.\"); opts.filter = { kinds }; }\n if (!confirm(\"Fork this relay into a new name\" + (opts.name ? \" (\" + opts.name + \")\" : \"\") + \"? It pulls \" + (f.scope.value === \"all\" ? \"everything\" : f.scope.value === \"mine\" ? \"your events\" : \"the chosen kinds\") + (opts.people ? \" and the people\" : \"\") + \", and only \" + (opts.holder ? \"that key\" : \"you\") + \" can claim it.\")) return;\n const r = await rpc(\"forkrelay\", opts);\n $(\"#forknote\").textContent = r.handover + \" Expires \" + fmtTime(r.expires_at) + \".\";\n $(\"#forkurl\").textContent = r.console;\n $(\"#forkresult\").classList.remove(\"hidden\");\n toast(\"Forked to \" + r.name);\n });\n $(\"#forkcopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#forkurl\").textContent); toast(\"copied\"); };\n $(\"#deleterelay\").onclick = guard(async () => {\n const name = host.split(\".\")[0];\n const typed = prompt(\"This deletes everything on \" + host + \" and gives the name up. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing was deleted.\"); return; }\n await rpc(\"deleterelay\", name);\n localStorage.removeItem(\"me\");\n location.href = \"/\";\n });\n $(\"#topup\").onsubmit = guard((ev) => topUp(+ev.target.sats.value));\n $(\"#inv-copy\").onclick = async () => { await navigator.clipboard.writeText($(\"#inv-text\").value); toast(\"invoice copied\"); };\n $$(\"button[data-copy]\").forEach((b) => { b.onclick = async () => { await navigator.clipboard.writeText(b.dataset.copy === \"ws\" ? wsURL : location.origin); toast(\"copied\"); }; });\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-act]\"); if (!b) return;\n const act = b.dataset.act, id = b.dataset.id;\n if (act === \"copy\") { await navigator.clipboard.writeText(id); toast(\"copied\"); return; }\n if ((act === \"banpubkey\" || act === \"banevent\" || act === \"resolve:ban\") && !confirm(act === \"banevent\" ? \"Delete this event and refuse it forever?\" : \"Ban this author and refuse everything they post?\")) return;\n const erase = (act === \"banpubkey\" || act === \"resolve:ban\") && confirm(\"Also erase everything they wrote and uploaded here?\");\n if (act === \"deleteblob\" && !confirm(\"Delete this file for good?\")) return;\n if (act === \"deletedump\" && !confirm(\"Delete this dump?\")) return;\n if (act === \"downloaddump\") { b.disabled = true; try { await downloadDump(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }\n if (act === \"restorelist\") {\n if (!signer.ready()) { toast(NO_SIGNER); return; }\n try {\n const preview = await rpc(\"restorelist\", id);\n const d = preview.diff || {};\n const added = (d.addedTags || []).map((t) => \"+ \" + JSON.stringify(t)).join(\"\\n\");\n const removed = (d.removedTags || []).map((t) => \"- \" + JSON.stringify(t)).join(\"\\n\");\n const changes = [added, removed, d.contentChanged ? \"content changed\" : \"content unchanged\"].filter(Boolean).join(\"\\n\");\n if (!confirm(\"Restore this list version?\\n\\n\" + (changes || \"No tag or content changes\") + \"\\n\\nIt will be signed and published as the newest version.\")) return;\n const signed = await signer.signEvent(preview.draft);\n const result = await bridge(\"/events\", signed);\n if (!result.accepted) throw new Error(result.message || \"The relay refused the restored list.\");\n toast(\"List restored\"); await loadListHistory(); await loadStorage();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if ((act === \"removemember\" || act === \"banpubkey\") && (window.__members || []).some((m) => m.invited_by === id) && confirm(\"Also remove everyone this member invited, and everyone they invited in turn?\")) {\n b.disabled = true;\n try { const r = await rpc(\"removesubtree\", id); if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase); toast(\"Removed \" + r.removed.length); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"removejob\" && !confirm(\"Remove this job?\")) return;\n if (act === \"purgekind\") {\n const kind = id === \"\" ? null : +id;\n const label = kind === null ? \"everything without its own rule\" : kindName(kind) + \" (kind \" + kind + \")\";\n const typed = prompt(\"Purge \" + label + \" older than how many days? 0 purges all of them. This cannot be undone.\", \"30\");\n if (typed === null) return;\n const days = Math.max(0, Math.floor(+typed || 0));\n b.disabled = true;\n try { const r = await rpc(\"purgekind\", kind, days); toast(\"Purged \" + r.deleted.toLocaleString() + \" events\"); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"saveretention\") {\n const kind = id === \"\" ? null : +id;\n const days = Math.max(0, Math.floor(+b.closest(\"tr\").querySelector(\".days\").value || 0));\n b.disabled = true;\n try { await rpc(\"setretention\", kind, days); toast(days ? \"Kept for \" + days + \" days\" : \"Kept forever\"); await loadStorage(); await loadInfo(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n b.disabled = true;\n try {\n if (act === \"savemember\") { const tr = b.closest(\"tr\"), role = tr.querySelector(\"select.role\"), keep = tr.querySelector(\".keep\"), cap = tr.querySelector(\".cap\"); await rpc(\"setmember\", id, { name: tr.querySelector(\".name\").value.trim() || null, note: tr.querySelector(\".note\").value, ...(role ? { role: role.value } : {}), ...(keep ? { keepDays: Math.max(0, Math.floor(+keep.value || 0)), maxBytes: Math.max(0, Math.floor(+cap.value || 0)) * 1024 } : {}) }); }\n else if (act.startsWith(\"resolve:\")) await rpc(\"resolvereport\", id, act.slice(8), erase);\n else if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase);\n else await rpc(act, act === \"unrulekind\" ? +id : id, \"\");\n toast(\"Done\"); await (myRole ? refresh() : loadMine());\n if (act === \"pinevent\" || act === \"unpinevent\") await loadPins();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // The share block: the card picture, the group naddr and its QR.\n let card = null;\n async function loadCard() {\n try {\n card = await (await fetch(\"/card.json\", { cache: \"no-store\" })).json();\n $(\"#cardimg\").src = \"/card.svg?t=\" + Date.now();\n $(\"#naddr\").textContent = card.naddr || \"\";\n const q = $(\"#naddrqr\");\n if (card.naddr) { q.src = \"/qr.svg?text=\" + encodeURIComponent(card.naddr); q.classList.remove(\"hidden\"); } else q.classList.add(\"hidden\");\n } catch { /* the card is decoration */ }\n renderApps();\n }\n // The app rows on the Connect section: one per client people actually use,\n // with the link that lands on this relay in it. Relay apps take the relay\n // or the group address; feed apps take the owner's profile with this relay\n // as the hint, since they have no notion of opening a relay.\n function renderApps() {\n const el = $(\"#apps\"); if (!el) return;\n const enc = encodeURIComponent;\n const nprofile = card && card.nprofile ? card.nprofile : \"\";\n const naddr = card && card.naddr ? card.naddr : \"\";\n const whose = me && owner && me === owner ? \"your\" : \"the owner's\";\n const link = (label, href) => '' + label + \"\";\n const app = (label, uri) => '' + label + \"\";\n const copy = (label, text) => '\";\n const row = (name, where, note, acts) => '
                                                                        ' + name + \"\" + where + \"

                                                                        \" + note + '

                                                                        ' + acts.filter(Boolean).join(\"\") + \"
                                                                        \";\n const profileNote = \"Opens \" + whose + \" profile with this relay attached.\";\n const groups = [\n [\"As a place\", \"These open the relay itself: its feed, its people, its group.\", [\n row(\"Jumble\", \"web\", \"A feed of everything on this relay.\", [link(\"Open\", \"https://jumble.social/?r=\" + enc(wsURL))]),\n row(\"Coracle\", \"web\", \"The relay's page: its feed and its people.\", [link(\"Open\", \"https://coracle.social/relays/\" + enc(host))]),\n row(\"Flotilla\", \"web, phone\", \"The relay as a space, with the group as a room.\", [link(\"Open\", \"https://app.flotilla.social/spaces/\" + enc(host)), naddr && app(\"Open group\", \"nostr:\" + naddr)]),\n row(\"0xchat\", \"phone\", \"The group, in a chat app.\", [naddr && app(\"Open group\", \"nostr:\" + naddr), naddr && copy(\"Copy naddr\", naddr)]),\n row(\"noStrudel\", \"web\", \"Relays, add this one, then open its page.\", [copy(\"Copy relay URL\", wsURL)]),\n ]],\n [\"Find me here\", \"Feed apps have no relay pages. They meet this relay through a profile link that names it, then keep it once it is in the relay settings.\", [\n row(\"Primal\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://primal.net/p/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"YakiHonne\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://yakihonne.com/profile/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"Damus\", \"iPhone\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Amethyst\", \"Android\", profileNote + \" Then Relays in the drawer.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Nostur\", \"iPhone, Mac\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n ]],\n ];\n if (info?.supported_grasps?.includes(\"GRASP-01\")) groups.push([\n \"Git repositories\", \"Use this relay with a Git client.\", [\n row(\"GitWorkshop\", \"web\", \"Browse this relay's Git repositories.\", [link(\"Open in app\", \"https://gitworkshop.dev/relay/\" + enc((wsURL.startsWith(\"ws://\") ? \"ws:\" : \"\") + host)), copy(\"Copy relay URL\", wsURL)]),\n ],\n ]);\n el.innerHTML = groups.map(([h, note, rows]) => '

                                                                        ' + h + '

                                                                        ' + note + '

                                                                        ' + rows.join(\"\") + \"
                                                                        \").join(\"\");\n $(\"#apps-ws\").textContent = wsURL;\n const tile = (label, text) => '
                                                                        ' + label + '\"QR
                                                                        ';\n $(\"#phones\").innerHTML = [nprofile && tile(\"Find me here, for a phone\", \"nostr:\" + nprofile), naddr && tile(\"The group, for a phone\", \"nostr:\" + naddr)].filter(Boolean).join(\"\");\n }\n // The folds are a group: opening one closes the rest. Browsers with the\n // details name attribute do this themselves; this covers the others.\n $$(\".folds details\").forEach((d) => d.addEventListener(\"toggle\", () => { if (d.open) $$(\".folds details\").forEach((o) => { if (o !== d && o.open) o.open = false; }); }));\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-copytext]\"); if (!b) return;\n try { await navigator.clipboard.writeText(b.dataset.copytext); toast(\"copied\"); } catch { /* no clipboard */ }\n });\n $(\"#copynaddr\").onclick = async () => { if (!card || !card.naddr) return; await navigator.clipboard.writeText(card.naddr); toast(\"copied naddr\"); };\n $(\"#copyembed\").onclick = async () => { await navigator.clipboard.writeText('\"''); toast(\"copied embed\"); };\n\n // A click on a key copies it; a double-click selects the whole key so the usual copy shortcut takes it too.\n document.addEventListener(\"dblclick\", (ev) => {\n const k = ev.target.closest(\".key\"); if (!k) return;\n const s = window.getSelection(), r = document.createRange(); r.selectNodeContents(k.querySelector(\".full\")); s.removeAllRanges(); s.addRange(r);\n });\n document.addEventListener(\"click\", async (ev) => {\n const k = ev.target.closest(\".key\"); if (!k || ev.detail > 1 || String(window.getSelection())) return;\n try { await navigator.clipboard.writeText(k.title); toast(\"copied \" + k.dataset.short); } catch { /* no clipboard */ }\n });\n // ---- boot ----\n try { await loadInfo(); } catch { renderHeader(); }\n await loadFuel();\n await loadCard();\n me = localStorage.getItem(\"me\");\n if (me && await resumeRemote()) { /* a remote session answers for itself when first used */ }\n else if (me && window.nostr) { try { const pk = await window.nostr.getPublicKey(); if (pk !== me) me = null; } catch { me = null; } }\n else if (me) me = null;\n renderHeader();\n renderApps();\n await loadAdmin();\n await loadPeople();\n})();\n"; From 4d6ab47d48f237968f5d9a687c0ffad454f37cde Mon Sep 17 00:00:00 2001 From: Dami Date: Fri, 4 Sep 2026 18:06:54 -0600 Subject: [PATCH 05/14] feat: add opt-in NIP-65 delivery --- docs/27-nip65-delivery.md | 23 +++++++++ src/console/console.html | 4 +- src/console/console.js | 6 ++- src/delivery.ts | 102 ++++++++++++++++++++++++++++++++++++++ src/gen/console.ts | 4 +- src/imports.ts | 2 +- src/manage.ts | 2 + src/pull.ts | 2 +- src/relay.ts | 8 ++- src/settings.ts | 7 +++ 10 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 docs/27-nip65-delivery.md create mode 100644 src/delivery.ts diff --git a/docs/27-nip65-delivery.md b/docs/27-nip65-delivery.md new file mode 100644 index 0000000..74ee856 --- /dev/null +++ b/docs/27-nip65-delivery.md @@ -0,0 +1,23 @@ +--- +title: NIP-65 delivery +audience: owners and developers +--- + +## Automatic delivery + +An owner may enable automatic NIP-65 delivery in the console or with +`setpolicy`. For each locally accepted public event, bind.ws reads the +author's current kind `10002` write relays and the read relays of people named +by `p` tags. It queues each relay independently and sends a normal Nostr +`EVENT` message. + +Delivery is off by default and is bounded to eight targets per event (the +owner may choose one through sixteen). Private, protected, imported and +relay-generated events are not routed automatically. The relay keeps only a +small durable queue; each target has its own accepted, rejected or pending +status, retry count and last error. `deliverystatus` exposes that status to the +owner. A target that fails does not advance another target's progress. + +The relay applies current bans, visibility and policy checks before delivery. +Delivery is best effort and costs fuel. Network work runs from the alarm after +event admission, so a slow destination cannot hold up publishing here. diff --git a/src/console/console.html b/src/console/console.html index cc101c4..1e37ed2 100644 --- a/src/console/console.html +++ b/src/console/console.html @@ -235,7 +235,9 @@

                                                                        Features

                                                                        -
                                                                        + + +
                                                                        diff --git a/src/console/console.js b/src/console/console.js index 5a3b203..12be4fb 100644 --- a/src/console/console.js +++ b/src/console/console.js @@ -335,6 +335,8 @@ renderFeatures(p.features || {}); $("#push-policy-form").elements.origins.value = (p.pushCallbacks || []).join("\n"); $("#push-policy-form").elements.lettered.checked = !!p.letteredNips; + $("#push-policy-form").elements.delivery.checked = !!p.delivery?.enabled; + $("#push-policy-form").elements.deliveryMax.value = p.delivery?.maxTargets || 8; fi.name.value = p.name; fi.contact.value = p.contact; fi.description.value = p.description; fi.icon.value = p.icon; const fj = $("#joinform"); fj.joinTerms.value = p.joinTerms; fj.directoryPublic.checked = !!p.directoryPublic; loadCard(); @@ -437,10 +439,10 @@ ev.preventDefault(); const form = ev.target; const origins = form.elements.origins.value.split(/\s+/).filter(Boolean); - const updated = await rpc("setpolicy", { pushCallbacks: origins, letteredNips: form.elements.lettered.checked }); + const updated = await rpc("setpolicy", { pushCallbacks: origins, letteredNips: form.elements.lettered.checked, delivery: { enabled: form.elements.delivery.checked, maxTargets: Math.max(1, Math.min(16, Math.floor(+form.elements.deliveryMax.value || 8))) } }); if (JSON.stringify(updated.pushCallbacks) !== JSON.stringify([...new Set(origins.map((s) => s.replace(/\/$/, "")))])) throw new Error("Use up to sixteen exact HTTPS origins, with no path or credentials."); policy = updated; - toast("Saved callback policy"); await loadInfo(); + toast("Saved delivery policy"); await loadInfo(); })); async function loadViews() { diff --git a/src/delivery.ts b/src/delivery.ts new file mode 100644 index 0000000..a67ea72 --- /dev/null +++ b/src/delivery.ts @@ -0,0 +1,102 @@ +// NIP-65 delivery: a small, durable, opt-in fanout queue. This is deliberately +// separate from NIP-9a callbacks: targets are Nostr relays and receive EVENT. +import { isPrivate, now, tagValues, type Event } from "./event.ts"; +import { dial, Socket, checkPullURL } from "./pull.ts"; +import type { Relay } from "./relay.ts"; + +export const DELIVERY_SCHEMA = ` +CREATE TABLE IF NOT EXISTS delivery_queue ( + event_id TEXT NOT NULL, target TEXT NOT NULL, author TEXT NOT NULL, + due INTEGER NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'pending', error TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL, PRIMARY KEY(event_id,target) +); +CREATE INDEX IF NOT EXISTS delivery_due ON delivery_queue(status,due); +`; +const MAX_QUEUE = 512, MAX_ATTEMPTS = 4, TIMEOUT = 5000, BATCH = 4; +const rows = >(r: Relay, q: string, ...a: any[]): T[] => r.sql.exec(q, ...a).toArray(); + +function list(r: Relay, pk: string): { read: string[]; write: string[] } { + const row = r.store.query({ kinds: [10002], authors: [pk], tags: {} }, { pubkeys: [], all: true }, 1, now()).rows[0]; + const out = { read: [] as string[], write: [] as string[] }; + if (!row) return out; + try { + for (const t of (JSON.parse(row) as Event).tags) { + if (t[0] !== "r" || !t[1]) continue; + let u: URL; try { u = new URL(t[1]); } catch { continue; } + if (u.protocol !== "wss:" && u.protocol !== "ws:") continue; + const url = u.toString().replace(/\/$/, ""); + const marker = t[2] === "read" ? "read" : t[2] === "write" ? "write" : "both"; + if ((marker === "read" || marker === "both") && !out.read.includes(url)) out.read.push(url); + if ((marker === "write" || marker === "both") && !out.write.includes(url)) out.write.push(url); + } + } catch { /* malformed historical list */ } + return out; +} + +function targets(r: Relay, e: Event): string[] { + const max = r.settings.policy.delivery?.maxTargets ?? 8; + const out: string[] = []; + const add = (u: string) => { + if (out.length >= max || out.includes(u)) return; + if (checkPullURL(u, r.slug, r.domain)) return; + out.push(u); + }; + for (const u of list(r, e.pubkey).write) add(u); + for (const pk of tagValues(e, "p").slice(0, 32)) for (const u of list(r, pk).read) add(u); + return out; +} + +export function queueDelivery(r: Relay, e: Event): boolean { + const p = r.settings.policy.delivery; + if (!p?.enabled || e.pubkey === r.identity.pubkey || isPrivate(e.kind) || e.tags.some((t: string[]) => t[0] === "-")) return false; + const ts = targets(r, e); + if (!ts.length) return false; + let n = rows<{ n: number }>(r, `SELECT count(*) n FROM delivery_queue WHERE status='pending'`)[0]?.n ?? 0; + let added = false; + for (const target of ts) { + if (n >= MAX_QUEUE) break; + r.sql.exec(`INSERT OR IGNORE INTO delivery_queue(event_id,target,author,due,attempts,status,error,updated_at) VALUES(?,?,?,?,0,'pending','',?)`, e.id, target, e.pubkey, now(), now()); + if (r.sql.exec(`SELECT changes() AS n`).one().n) { n++; added = true; } + } + return added; +} + +async function send(r: Relay, target: string, e: Event): Promise<{ ok: boolean; error: string }> { + let s: Socket | null = null; + try { + s = new Socket(await dial(r, target)); + s.send("EVENT", e); + const end = Date.now() + TIMEOUT; + while (Date.now() < end) { + const m = await Promise.race([s.recv(), new Promise((_, rej) => setTimeout(() => rej(new Error("timeout")), Math.max(1, end - Date.now()))) ]); + if (m[0] !== "OK" || m[1] !== e.id) continue; + if (m[2] === true || String(m[3] ?? "").startsWith("duplicate:")) return { ok: true, error: "" }; + return { ok: false, error: String(m[3] ?? "rejected") }; + } + return { ok: false, error: "timeout" }; + } catch (e) { return { ok: false, error: e instanceof Error ? e.message : String(e) }; } + finally { s?.close(); } +} + +export async function deliveryTick(r: Relay): Promise { + if (!r.settings.policy.delivery?.enabled) { r.sql.exec(`DELETE FROM delivery_queue`); return 0; } + const t = now(); + const jobs = rows<{ event_id: string; target: string; attempts: number }>(r, `SELECT event_id,target,attempts FROM delivery_queue WHERE status='pending' AND due<=? ORDER BY due LIMIT ?`, t, BATCH); + for (const j of jobs) { + const ev = rows<{ raw: string }>(r, `SELECT raw FROM events WHERE id=? AND pubkey=? AND kind NOT IN (4,1059,21059,24133)`, j.event_id, rows<{ author: string }>(r, `SELECT author FROM delivery_queue WHERE event_id=? AND target=?`, j.event_id, j.target)[0]?.author ?? "")[0]; + if (!ev) { r.sql.exec(`UPDATE delivery_queue SET status='rejected',error='event unavailable',updated_at=? WHERE event_id=? AND target=?`, t, j.event_id, j.target); continue; } + r.sql.exec(`UPDATE delivery_queue SET attempts=attempts+1,due=?,updated_at=? WHERE event_id=? AND target=?`, t + 60, t, j.event_id, j.target); + const result = await send(r, j.target, JSON.parse(ev.raw) as Event); + const attempts = j.attempts + 1; + if (result.ok || attempts >= MAX_ATTEMPTS || (result.error !== "timeout" && !/5\d\d|tempor/i.test(result.error))) { + r.sql.exec(`UPDATE delivery_queue SET status=?,error=?,updated_at=? WHERE event_id=? AND target=?`, result.ok ? "accepted" : "rejected", result.error, now(), j.event_id, j.target); + } else r.sql.exec(`UPDATE delivery_queue SET due=?,error=?,updated_at=? WHERE event_id=? AND target=?`, now() + [30, 120, 600][Math.min(attempts - 1, 2)], result.error, now(), j.event_id, j.target); + } + const next = rows<{ next: number | null }>(r, `SELECT min(due) next FROM delivery_queue WHERE status='pending'`)[0]?.next ?? 0; + return next; +} + +export function deliveryStatus(r: Relay) { + return rows<{ event_id: string; target: string; status: string; attempts: number; error: string; updated_at: number }>(r, `SELECT event_id,target,status,attempts,error,updated_at FROM delivery_queue ORDER BY updated_at DESC LIMIT 100`); +} diff --git a/src/gen/console.ts b/src/gen/console.ts index e3f4ffd..a1ac465 100644 --- a/src/gen/console.ts +++ b/src/gen/console.ts @@ -1,4 +1,4 @@ // Generated by scripts/build/build-console.mjs from src/console. Do not edit; run npm run build:console. -export const CONSOLE_HTML = "
                                                                        \n
                                                                        \n
                                                                        \"\"
                                                                        \n
                                                                        \"\"

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

                                                                        Nobody owns this relay yet.

                                                                        \n

                                                                        Claim it and it's yours: you decide who can post, who can read, and what stays. One signature with a nostr browser extension; no account, no email, no card.

                                                                        \n
                                                                        \n
                                                                        \n\n
                                                                        \n

                                                                        A temporary relay, for now.

                                                                        \n

                                                                        Anyone can read and write here until . Then everything on it is deleted and the name is freed. Claim it and it stays, events and files included: one signature with a nostr browser extension. Or claim a new name and pull this one into it from its Storage tab.

                                                                        \n
                                                                        \n
                                                                        \n\n
                                                                        \n

                                                                        Connect a remote signer.

                                                                        \n

                                                                        Your key stays in a signer app such as Amber or nsec.app; this page asks it to sign. On a phone, open the link and approve there. On a computer, paste the bunker:// URL the app gives you.

                                                                        \n \n
                                                                        \n \"QR\n

                                                                        \n
                                                                        \n\n
                                                                        \n
                                                                        \n

                                                                        \n

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

                                                                        Git repositories, ntig

                                                                        \n
                                                                        \n

                                                                        Use ordinary Git to clone a repository hosted here.

                                                                        \n
                                                                        Clone a repository
                                                                        \n

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

                                                                        \n

                                                                        To host your own, use a Nostr Git client to publish your repository and signed branch state to this relay before pushing to the same remote. Git hosting guide.

                                                                        \n
                                                                        \n
                                                                        \n
                                                                        \n

                                                                        Open it in an app

                                                                        \n

                                                                        Relay apps open this relay as a place. Feed apps do not: they find the owner here through a profile link that carries this relay as the hint, and learn the relay from that. Either way, add under the app's relay settings to post here.

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

                                                                        People

                                                                        \n

                                                                        Hidden from visitors. Only you see this list.

                                                                        \n
                                                                        \n
                                                                        \n\n
                                                                        \n

                                                                        Fuel

                                                                        \n

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

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

                                                                        \n
                                                                        sats
                                                                        \n
                                                                        \n

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

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

                                                                        Your invites

                                                                        \n

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

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

                                                                          People

                                                                          \n

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

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

                                                                          Invites

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

                                                                            Joining

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

                                                                            Moderation

                                                                            \n

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

                                                                            \n
                                                                            \n

                                                                            Reports

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

                                                                            Log

                                                                            \n

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

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

                                                                            Bans

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

                                                                                Blocked addresses

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

                                                                                  Recent events

                                                                                  \n

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

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

                                                                                  Pinned

                                                                                  \n

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

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

                                                                                    Rules

                                                                                    \n

                                                                                    Bans apply regardless of these.

                                                                                    \n
                                                                                    \n

                                                                                    Presets

                                                                                    \n

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

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

                                                                                    \n
                                                                                    \n
                                                                                    \n
                                                                                    \n

                                                                                    Writes

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

                                                                                    Reads

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

                                                                                    Kinds

                                                                                    \n

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

                                                                                    \n
                                                                                    \n

                                                                                    Allowed:

                                                                                    \n

                                                                                    Blocked:

                                                                                    \n
                                                                                    \n
                                                                                    \n

                                                                                    Features

                                                                                    \n

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

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

                                                                                    Blocked words

                                                                                    \n

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

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

                                                                                    Identity

                                                                                    \n
                                                                                    \n

                                                                                    Profile

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

                                                                                    For directories

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

                                                                                    Your own domain

                                                                                    \n

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

                                                                                    \n
                                                                                    \n
                                                                                    \n

                                                                                    \n
                                                                                    \n
                                                                                    \n

                                                                                    Your relay lists

                                                                                    \n

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

                                                                                    \n
                                                                                    \n
                                                                                    \n
                                                                                    \n

                                                                                    Share

                                                                                    \n

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

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

                                                                                    Data

                                                                                    \n

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

                                                                                    \n
                                                                                    \n

                                                                                    Recover your lists

                                                                                    \n

                                                                                    Older signed versions of your follows, relay lists and bookmarks stay private here. Restore a version to review it, then sign and publish it from this relay.

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

                                                                                    By kind

                                                                                    \n
                                                                                    KindCountSizeOldestKeep for
                                                                                    \n
                                                                                    \n
                                                                                    \n

                                                                                    Files

                                                                                    \n
                                                                                    TimeFileSizeUploader
                                                                                    \n
                                                                                    \n
                                                                                    \n

                                                                                    Sites

                                                                                    \n

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

                                                                                    \n
                                                                                    AuthorNameURLFilesSizeExpiry
                                                                                    \n
                                                                                    \n
                                                                                    \n

                                                                                    Dumps

                                                                                    \n

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

                                                                                    \n
                                                                                    \n
                                                                                      \n
                                                                                      \n
                                                                                      \n

                                                                                      Import a file

                                                                                      \n

                                                                                      A JSONL of events, one per line, such as a dump or a strfry export, or a JSON array. Up to 64 MB. Signatures are checked; bans and kind rules apply; the write rule does not. Progress shows under Jobs.

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

                                                                                      Sync

                                                                                      \n

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

                                                                                      \n
                                                                                      \n

                                                                                      Jobs

                                                                                      \n

                                                                                      Work the relay does on its own, one round at a time. A pull copies what another relay has and this one lacks. Fetch my history pulls your own events from the relays in your relay list. Rebroadcast sends what is here to other relays. Bans and kind rules apply to what arrives.

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

                                                                                      Fork this relay

                                                                                      \n

                                                                                      A new name, temporary until claimed, filled from this relay. Yours to split a name by job, or somebody else's to hand them a community with its history. One fork an hour.

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

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

                                                                                      Views

                                                                                      \n

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

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

                                                                                      Health

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

                                                                                      Zaps received

                                                                                      \n
                                                                                      WhenFromSats
                                                                                      \n
                                                                                      \n
                                                                                      \n

                                                                                      Notifications

                                                                                      \n

                                                                                      The relay writes you a private message (NIP-17) with its own key. It lands here, in your inbox on your own relay, and on your DM relays when this relay holds your kind 10050.

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

                                                                                      Owner

                                                                                      \n

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

                                                                                      \n
                                                                                      \n

                                                                                      Configuration

                                                                                      \n

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

                                                                                      \n
                                                                                      \n
                                                                                      \n
                                                                                      \n

                                                                                      Transfer ownership

                                                                                      \n

                                                                                      Hands the relay to a member. You stay on as a moderator. The relay's key, events, files and fuel do not change. There is no undo.

                                                                                      \n
                                                                                      \n
                                                                                      \n
                                                                                      \n

                                                                                      If I lose my key

                                                                                      \n

                                                                                      Name a member as your heir. If you do not sign in here for the time you pick, the relay writes to you once a week for a month, then hands itself to the heir and keeps you on as a moderator. Any signed action on the relay resets the clock.

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

                                                                                      \n
                                                                                      \n
                                                                                      \n

                                                                                      Delete this relay

                                                                                      \n

                                                                                      Deletes every event, file, member, invite and setting, closes every connection, and returns the name to unclaimed for anyone to take. There is no undo.

                                                                                      \n \n
                                                                                      \n
                                                                                      \n
                                                                                      \n\n \n
                                                                                      \n"; +export const CONSOLE_HTML = "
                                                                                      \n
                                                                                      \n
                                                                                      \"\"
                                                                                      \n
                                                                                      \"\"

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

                                                                                      Nobody owns this relay yet.

                                                                                      \n

                                                                                      Claim it and it's yours: you decide who can post, who can read, and what stays. One signature with a nostr browser extension; no account, no email, no card.

                                                                                      \n
                                                                                      \n
                                                                                      \n\n
                                                                                      \n

                                                                                      A temporary relay, for now.

                                                                                      \n

                                                                                      Anyone can read and write here until . Then everything on it is deleted and the name is freed. Claim it and it stays, events and files included: one signature with a nostr browser extension. Or claim a new name and pull this one into it from its Storage tab.

                                                                                      \n
                                                                                      \n
                                                                                      \n\n
                                                                                      \n

                                                                                      Connect a remote signer.

                                                                                      \n

                                                                                      Your key stays in a signer app such as Amber or nsec.app; this page asks it to sign. On a phone, open the link and approve there. On a computer, paste the bunker:// URL the app gives you.

                                                                                      \n \n
                                                                                      \n \"QR\n

                                                                                      \n
                                                                                      \n\n
                                                                                      \n
                                                                                      \n

                                                                                      \n

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

                                                                                      Git repositories, ntig

                                                                                      \n
                                                                                      \n

                                                                                      Use ordinary Git to clone a repository hosted here.

                                                                                      \n
                                                                                      Clone a repository
                                                                                      \n

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

                                                                                      \n

                                                                                      To host your own, use a Nostr Git client to publish your repository and signed branch state to this relay before pushing to the same remote. Git hosting guide.

                                                                                      \n
                                                                                      \n
                                                                                      \n
                                                                                      \n

                                                                                      Open it in an app

                                                                                      \n

                                                                                      Relay apps open this relay as a place. Feed apps do not: they find the owner here through a profile link that carries this relay as the hint, and learn the relay from that. Either way, add under the app's relay settings to post here.

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

                                                                                      People

                                                                                      \n

                                                                                      Hidden from visitors. Only you see this list.

                                                                                      \n
                                                                                      \n
                                                                                      \n\n
                                                                                      \n

                                                                                      Fuel

                                                                                      \n

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

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

                                                                                      \n
                                                                                      sats
                                                                                      \n
                                                                                      \n

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

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

                                                                                      Your invites

                                                                                      \n

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

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

                                                                                        People

                                                                                        \n

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

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

                                                                                        Invites

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

                                                                                          Joining

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

                                                                                          Moderation

                                                                                          \n

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

                                                                                          \n
                                                                                          \n

                                                                                          Reports

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

                                                                                          Log

                                                                                          \n

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

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

                                                                                          Bans

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

                                                                                              Blocked addresses

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

                                                                                                Recent events

                                                                                                \n

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

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

                                                                                                Pinned

                                                                                                \n

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

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

                                                                                                  Rules

                                                                                                  \n

                                                                                                  Bans apply regardless of these.

                                                                                                  \n
                                                                                                  \n

                                                                                                  Presets

                                                                                                  \n

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

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

                                                                                                  \n
                                                                                                  \n
                                                                                                  \n
                                                                                                  \n

                                                                                                  Writes

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

                                                                                                  Reads

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

                                                                                                  Kinds

                                                                                                  \n

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

                                                                                                  \n
                                                                                                  \n

                                                                                                  Allowed:

                                                                                                  \n

                                                                                                  Blocked:

                                                                                                  \n
                                                                                                  \n
                                                                                                  \n

                                                                                                  Features

                                                                                                  \n

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

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

                                                                                                  Blocked words

                                                                                                  \n

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

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

                                                                                                  Identity

                                                                                                  \n
                                                                                                  \n

                                                                                                  Profile

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

                                                                                                  For directories

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

                                                                                                  Your own domain

                                                                                                  \n

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

                                                                                                  \n
                                                                                                  \n
                                                                                                  \n

                                                                                                  \n
                                                                                                  \n
                                                                                                  \n

                                                                                                  Your relay lists

                                                                                                  \n

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

                                                                                                  \n
                                                                                                  \n
                                                                                                  \n
                                                                                                  \n

                                                                                                  Share

                                                                                                  \n

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

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

                                                                                                  Data

                                                                                                  \n

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

                                                                                                  \n
                                                                                                  \n

                                                                                                  Recover your lists

                                                                                                  \n

                                                                                                  Older signed versions of your follows, relay lists and bookmarks stay private here. Restore a version to review it, then sign and publish it from this relay.

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

                                                                                                  By kind

                                                                                                  \n
                                                                                                  KindCountSizeOldestKeep for
                                                                                                  \n
                                                                                                  \n
                                                                                                  \n

                                                                                                  Files

                                                                                                  \n
                                                                                                  TimeFileSizeUploader
                                                                                                  \n
                                                                                                  \n
                                                                                                  \n

                                                                                                  Sites

                                                                                                  \n

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

                                                                                                  \n
                                                                                                  AuthorNameURLFilesSizeExpiry
                                                                                                  \n
                                                                                                  \n
                                                                                                  \n

                                                                                                  Dumps

                                                                                                  \n

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

                                                                                                  \n
                                                                                                  \n
                                                                                                    \n
                                                                                                    \n
                                                                                                    \n

                                                                                                    Import a file

                                                                                                    \n

                                                                                                    A JSONL of events, one per line, such as a dump or a strfry export, or a JSON array. Up to 64 MB. Signatures are checked; bans and kind rules apply; the write rule does not. Progress shows under Jobs.

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

                                                                                                    Sync

                                                                                                    \n

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

                                                                                                    \n
                                                                                                    \n

                                                                                                    Jobs

                                                                                                    \n

                                                                                                    Work the relay does on its own, one round at a time. A pull copies what another relay has and this one lacks. Fetch my history pulls your own events from the relays in your relay list. Rebroadcast sends what is here to other relays. Bans and kind rules apply to what arrives.

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

                                                                                                    Fork this relay

                                                                                                    \n

                                                                                                    A new name, temporary until claimed, filled from this relay. Yours to split a name by job, or somebody else's to hand them a community with its history. One fork an hour.

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

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

                                                                                                    Views

                                                                                                    \n

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

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

                                                                                                    Health

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

                                                                                                    Zaps received

                                                                                                    \n
                                                                                                    WhenFromSats
                                                                                                    \n
                                                                                                    \n
                                                                                                    \n

                                                                                                    Notifications

                                                                                                    \n

                                                                                                    The relay writes you a private message (NIP-17) with its own key. It lands here, in your inbox on your own relay, and on your DM relays when this relay holds your kind 10050.

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

                                                                                                    Owner

                                                                                                    \n

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

                                                                                                    \n
                                                                                                    \n

                                                                                                    Configuration

                                                                                                    \n

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

                                                                                                    \n
                                                                                                    \n
                                                                                                    \n
                                                                                                    \n

                                                                                                    Transfer ownership

                                                                                                    \n

                                                                                                    Hands the relay to a member. You stay on as a moderator. The relay's key, events, files and fuel do not change. There is no undo.

                                                                                                    \n
                                                                                                    \n
                                                                                                    \n
                                                                                                    \n

                                                                                                    If I lose my key

                                                                                                    \n

                                                                                                    Name a member as your heir. If you do not sign in here for the time you pick, the relay writes to you once a week for a month, then hands itself to the heir and keeps you on as a moderator. Any signed action on the relay resets the clock.

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

                                                                                                    \n
                                                                                                    \n
                                                                                                    \n

                                                                                                    Delete this relay

                                                                                                    \n

                                                                                                    Deletes every event, file, member, invite and setting, closes every connection, and returns the name to unclaimed for anyone to take. There is no undo.

                                                                                                    \n \n
                                                                                                    \n
                                                                                                    \n
                                                                                                    \n\n \n
                                                                                                    \n"; export const CONSOLE_CSS = "main { max-width: 64rem; }\n.mast { display: grid; grid-template-columns: 1fr auto; gap: 1.5rem 2rem; align-items: end; margin-bottom: 2rem; }\n.mast .wordmark { grid-column: 1 / -1; justify-self: center; margin-bottom: .5rem; }\n.mast .right { display: grid; gap: .7rem; justify-items: end; text-align: right; }\n.urlrow, .owner { display: inline-flex; align-items: center; gap: .4rem; color: var(--ink-2); }\n.owner b { color: var(--ink); } .owner em { font-style: normal; color: var(--forest); }\n.urlrow .ib, .owner .ib { width: 26px; height: 26px; margin-left: .2rem; box-shadow: 1px 1px 0 var(--ink); } .urlrow .ib svg, .owner .ib svg { width: 13px; height: 13px; }\n.care { display: flex; gap: .9rem; flex-wrap: wrap; }\n.care .g { display: grid; justify-items: center; gap: .3rem; width: 5.4rem; }\n.care .g i { display: grid; place-items: center; width: 44px; height: 44px; border: 2px solid var(--ink); border-radius: 10px; background: var(--paper); box-shadow: 2px 2px 0 var(--ink); position: relative; }\n.care .g i svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }\n.care .g.off i::after { content: \"\"; position: absolute; width: 3px; height: 40px; background: var(--ink); transform: rotate(45deg); border-radius: 2px; box-shadow: 0 0 0 2px var(--paper); }\n.care .g small { font: 500 10px/1.2 var(--mono); text-transform: uppercase; letter-spacing: .04em; color: var(--ink-2); text-align: center; }\n#unclaimed { background: var(--butter); }\n.doors { display: grid; grid-template-columns: repeat(4, 1fr); gap: .8rem; } @media (max-width: 52rem) { .doors { grid-template-columns: 1fr 1fr; } } @media (max-width: 30rem) { .doors { grid-template-columns: 1fr; } }\n.door { display: grid; grid-template-rows: auto 1fr; align-content: start; gap: .35rem; padding: .8rem .9rem; border: 2px solid var(--ink); border-radius: 12px; background: var(--paper); box-shadow: 3px 3px 0 var(--ink); min-width: 0; }\n.apps { display: grid; gap: 1.4rem; margin-top: 1rem; } .appgroup h4 { margin: 0 0 .2rem; font-size: 1rem; } .appgroup > .note { margin-top: 0; }\n.appgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: .8rem; } @media (max-width: 52rem) { .appgrid { grid-template-columns: 1fr 1fr; } } @media (max-width: 30rem) { .appgrid { grid-template-columns: 1fr; } }\n.app { display: grid; align-content: start; gap: .4rem; padding: .8rem .9rem; border: 2px solid var(--ink); border-radius: 12px; background: var(--paper); box-shadow: 3px 3px 0 var(--ink); min-width: 0; }\n.app-head { display: flex; align-items: baseline; gap: .5rem; flex-wrap: wrap; } .app-head b { font-size: 15px; } .app-head small { color: var(--ink-3); } .app p { margin: 0; font-size: 13px; color: var(--ink-2); }\n.app-acts { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .2rem; } .app-acts .btn { padding: .3rem .7rem; font-size: 13px; }\n.phones { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, max-content)); gap: .8rem; margin-top: 1rem; } .phones img { display: block; width: 150px; height: 150px; } .phones .door { justify-items: start; }\n.door small { font: 700 11px var(--sans); text-transform: uppercase; letter-spacing: .06em; color: var(--ink-2); }\n.door .v { display: grid; grid-template-columns: 1fr auto; align-items: start; gap: .5rem; font: 500 13px/1.45 var(--mono); } .door .v span { min-width: 0; overflow-wrap: anywhere; }\n.door .ib { width: 24px; height: 24px; box-shadow: 1px 1px 0 var(--ink); flex: 0 0 auto; } .door .ib svg { width: 12px; height: 12px; }\n#peoplesec { background: var(--peach); }\n.dir { display: flex; flex-wrap: wrap; gap: .5rem; }\n.who { display: inline-flex; align-items: center; gap: .1rem; padding: .3rem .7rem .3rem .4rem; border: 2px solid var(--ink); border-radius: 999px; background: var(--paper); box-shadow: 2px 2px 0 var(--ink); font-size: 14px; }\n.who .role { font: 700 10px var(--mono); text-transform: uppercase; letter-spacing: .05em; color: var(--forest); margin-left: .4rem; }\n.who.me { background: var(--sun); }\n#fuelsec { background: var(--mint); }\n.gauges { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }\n.gauge small { display: block; font-weight: 600; font-size: 13px; }\n.gauge .bar { position: relative; height: 22px; border: 2px solid var(--ink); border-radius: 999px; margin: .5rem 0 .35rem; overflow: hidden; padding: 3px; background: var(--paper) linear-gradient(90deg, transparent calc(25% - 1px), var(--line) calc(25% - 1px) 25%, transparent 25% calc(50% - 1px), var(--line) calc(50% - 1px) 50%, transparent 50% calc(75% - 1px), var(--line) calc(75% - 1px) 75%, transparent 75%); }\n.gauge .bar b { position: absolute; right: .6rem; top: 0; bottom: 0; display: flex; align-items: center; font: 600 11px var(--mono); color: var(--ink-3); pointer-events: none; }\n.gauge .bar i { display: block; height: 100%; width: 0; min-width: 14px; border-radius: 999px; background: repeating-linear-gradient(-45deg, var(--forest) 0 8px, var(--forest-2) 8px 16px); transition: width .4s; }\n.gauge .bar i.warm { background: repeating-linear-gradient(-45deg, #d9a52a 0 8px, var(--sun) 8px 16px); }\n.gauge .bar i.over { background: repeating-linear-gradient(-45deg, var(--red) 0 8px, #d4614c 8px 16px); }\n.gauge span { font-size: 13px; color: var(--ink-3); }\n.balance { margin: 1.2rem 0 0; color: var(--ink-2); } .balance b { color: var(--ink); font-weight: 700; }\n.sats { display: inline-flex; align-items: center; width: auto; } .sats input { font: 15px var(--sans); width: 6rem; text-align: right; border: 0; outline: none; padding: 0; background: transparent; color: var(--ink); } .sats span { color: var(--ink-3); padding-left: .4rem; }\n.topup { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; margin-top: .8rem; }\n.invoice { margin-top: 1rem; border: 2px dashed var(--ink); border-radius: 12px; padding: .9rem 1rem; background: var(--paper); }\n.invoice p { margin: 0 0 .6rem; color: var(--ink-2); font-size: 14px; } .invoice .state { font-size: 13px; color: var(--forest); }\n.invoice textarea { margin-top: .7rem; min-height: 3.4rem; font-family: var(--mono); font-size: 11.5px; color: var(--ink-3); }\ndetails.disclosure summary { cursor: pointer; font: 400 1.6rem/1 var(--display); list-style: none; display: flex; align-items: center; gap: .5rem; }\ndetails.disclosure summary::-webkit-details-marker { display: none; }\n.folds details + details, .folds #frontsec:not(.hidden) + details { border-top: 2px solid var(--line); margin-top: 1.2rem; padding-top: 1.2rem; } .folds #frontsec .metarow { margin-bottom: 0; } .folds .doors { margin-top: 1rem; } .folds > details > .block:first-of-type { margin-top: 1.4rem; }\ndetails.disclosure summary::before { content: \"+\"; font: 700 1.4rem var(--mono); width: 1.4rem; } details.disclosure[open] summary::before { content: \"–\"; }\n.about { display: grid; grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); gap: .6rem 1.2rem; margin-top: 1rem; font-size: 14px; }\n.about small { display: block; color: var(--ink-3); font-size: 12px; } .about div > div { overflow-wrap: anywhere; }\n.console { margin-top: 3rem; }\n.tabs { display: flex; gap: .45rem; flex-wrap: nowrap; align-items: flex-end; margin: 0 0 -2px 1rem; padding: 2px 3rem 2px 0; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; }\n.tabs.fade-r { -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 2.5rem), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 2.5rem), transparent); }\n.tabs.fade-l { -webkit-mask-image: linear-gradient(90deg, transparent, #000 2.5rem); mask-image: linear-gradient(90deg, transparent, #000 2.5rem); }\n.tabs.fade-l.fade-r { -webkit-mask-image: linear-gradient(90deg, transparent, #000 2.5rem, #000 calc(100% - 2.5rem), transparent); mask-image: linear-gradient(90deg, transparent, #000 2.5rem, #000 calc(100% - 2.5rem), transparent); } .tabs::-webkit-scrollbar { display: none; } .tabs a { flex: 0 0 auto; white-space: nowrap; }\n.tabs a { font: 600 14px var(--sans); padding: .55rem 1rem; border: 2px solid var(--ink); border-bottom: 0; border-radius: 12px 12px 0 0; background: var(--head); color: var(--ink-2); text-decoration: none; margin-bottom: -2px; position: relative; z-index: 1; }\n.tabs a.on { color: var(--ink); z-index: 3; padding-top: .7rem; } .tabs a.on:nth-child(4n+1) { background: var(--sun); } .tabs a.on:nth-child(4n+2) { background: var(--peach); } .tabs a.on:nth-child(4n+3) { background: var(--sky); } .tabs a.on:nth-child(4n+4) { background: var(--mint); }\n.count { display: inline-grid; place-items: center; min-width: 1.5em; height: 1.5em; padding: 0 .4em; margin-left: .45rem; border: 1.5px solid var(--ink); border-radius: 999px; background: var(--sun); color: var(--ink); font: 700 11px/1 var(--mono); vertical-align: middle; box-shadow: 1px 1px 0 var(--ink); } .count:empty { display: none; } h2 .count { font-size: 12px; vertical-align: .45em; }\n.panel { display: none; margin: 0; border-radius: 0 18px 18px 18px; position: relative; z-index: 2; }\n.panel.on { display: block; }\n.panel h2 { font-size: 2rem; }\n.block { margin-top: 1.8rem; }\n.two { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; } .two > * { min-width: 0; }\n.actions { display: flex; justify-content: flex-end; margin-top: 1.1rem; } .actions.left { justify-content: flex-start; }\n.addrow { display: flex; gap: .5rem; align-items: center; margin-bottom: .9rem; } .addrow input { flex: 1; min-width: 0; } .addrow label { display: inline-flex; align-items: center; gap: .35rem; flex: 0 0 auto; white-space: nowrap; font-size: 14px; color: var(--ink-2); cursor: pointer; } .addrow label input { flex: none; width: auto; margin: 0; accent-color: var(--forest); } .addrow .ib { width: 38px; height: 38px; flex: 0 0 auto; border-radius: 10px; }\n.choices { display: grid; gap: .5rem; }\n.choices label { display: grid; grid-template-columns: auto 1fr; gap: 0 .7rem; cursor: pointer; padding: .7rem .8rem; border: 2px solid var(--line-2); border-radius: 12px; background: var(--paper); }\n.choices label:has(:checked) { border-color: var(--ink); box-shadow: 3px 3px 0 var(--ink); }\n.choices input { grid-row: span 2; margin: .2rem 0 0; accent-color: var(--forest); }\n.choices b { font-weight: 600; } .choices small { color: var(--ink-2); font-size: 13px; }\n.limits { display: grid; grid-template-columns: 1fr 1fr; gap: .6rem 1.5rem; margin-top: 1.3rem; }\n.limits label { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: .6rem; } .limits label small { color: var(--ink-3); }\n.form { display: grid; grid-template-columns: 1fr 1fr; gap: .9rem 1.5rem; } .form label { display: grid; gap: .3rem; font-weight: 600; font-size: 14px; } .form .wide { grid-column: 1 / -1; }\n.danger-zone { border: 2px solid var(--red); border-radius: 12px; padding: 1rem 1.1rem; background: var(--red-soft); } .danger-zone h3 { color: var(--red); } .form label.switch { display: flex; flex-direction: row; align-items: center; gap: .6rem; font-weight: 600; font-size: 14px; } .switch input { accent-color: var(--forest); width: 18px; height: 18px; }\ntable { width: 100%; border-collapse: collapse; }\nth { text-align: left; font-size: 12px; font-weight: 600; color: var(--ink-3); padding: .4rem .5rem; border-bottom: 2px solid var(--ink); white-space: nowrap; }\ntd { padding: .5rem .5rem; vertical-align: middle; border-bottom: 1px solid var(--line); }\ntbody tr:nth-child(even) td { background: rgba(255,255,255,.55); }\nth.r, td.r { text-align: right; white-space: nowrap; } td.mono, td.dim { white-space: nowrap; } .events td.c:empty::before { content: \"(no content)\"; color: var(--ink-3); }\ntd .ib { width: 26px; height: 26px; box-shadow: 1px 1px 0 var(--ink); } td .ib svg { width: 13px; height: 13px; }\ntd input.txt { padding: .25rem .5rem; font-size: 14px; } td select.role { width: auto; min-width: 7rem; padding: .25rem .4rem; font-size: 13px; } .people-table input.name { width: 7rem; } .people-table input.note { width: 9.5rem; }\n.kind { font-family: var(--mono); font-size: 12px; background: var(--sun); border: 1.5px solid var(--ink); padding: 0 .5rem; border-radius: 999px; font-weight: 500; white-space: nowrap; }\n.events td.c { max-width: 0; width: 100%; overflow-x: auto; white-space: nowrap; scrollbar-width: thin; -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 2rem), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 2rem), transparent); }\n.people-table td.name { width: 12rem; }\n.ev { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 4px; vertical-align: -5px; margin-right: .45rem; font: 500 9px var(--mono); background: var(--line); color: var(--ink-2); font-style: normal; }\n.plain { list-style: none; margin: 0; padding: 0; } .plain li { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: .5rem; padding: .5rem 0; border-bottom: 2px dotted var(--line-2); } .plain li > span { min-width: 0; overflow-wrap: anywhere; } .plain li:last-child { border-bottom: 0; } .plain li.empty { grid-template-columns: 1fr; color: var(--ink-3); }\n.kindline { margin: .4rem 0; color: var(--ink-2); display: flex; flex-wrap: wrap; align-items: center; gap: .35rem; }\n.tag { display: inline-flex; align-items: center; gap: .2rem; font-family: var(--mono); font-size: 13px; padding: .05rem .2rem .05rem .55rem; border: 2px solid var(--ink); border-radius: 999px; background: var(--paper); box-shadow: 1px 1px 0 var(--ink); }\n.tag.plain { padding-right: .55rem; box-shadow: none; border-color: var(--line-2); color: var(--ink-3); } .tag.blk { color: var(--red); }\n.tag .ib { width: 18px; height: 18px; border-width: 1.5px; box-shadow: none; margin-left: .2rem; } .tag .ib svg { width: 9px; height: 9px; }\n.counters { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1.2rem; margin-bottom: 1.6rem; }\n.counter { display: grid; justify-items: center; align-content: center; gap: .15rem; min-height: 7.2rem; text-align: center; padding: 1rem .8rem; border: 2px solid var(--ink); border-radius: 16px; box-shadow: 4px 4px 0 var(--ink); background: var(--paper); }\n.counter:nth-child(1) { background: var(--sun); } .counter:nth-child(2) { background: var(--peach); } .counter:nth-child(3) { background: var(--sky); } .counter:nth-child(4) { background: var(--mint); }\n.counter .big { font: 400 2.6rem/1 var(--display); white-space: nowrap; } .counter small { display: block; margin-top: .35rem; font: 700 12px var(--sans); text-transform: uppercase; letter-spacing: .08em; } .counter .sub { font-size: 12px; color: var(--ink-2); }\n.kbar { display: flex; width: 100%; height: 22px; border: 2px solid var(--ink); border-radius: 999px; overflow: hidden; background: var(--paper); margin-bottom: .35rem; } .kbar i { display: block; height: 100%; border-right: 2px solid var(--ink); } .kbar i:last-child { border-right: 0; }\n.k1 { background: var(--ink); } .k2 { background: var(--forest); } .k3 { background: var(--id-3); } .k4 { background: var(--id-2); } .k5 { background: var(--line-2); }\n.legend { display: flex; flex-wrap: wrap; justify-content: center; gap: .1rem .6rem; font-family: var(--mono); font-size: 10.5px; text-transform: uppercase; } .legend i { display: inline-block; width: 9px; height: 9px; border: 1.5px solid var(--ink); border-radius: 2px; margin-right: .3rem; vertical-align: -1px; }\n.usage { display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: .8rem; } .usage div { padding: .7rem .9rem; border: 2px solid var(--ink); border-radius: 12px; background: var(--paper); } .usage small { display: block; color: var(--ink-3); font-size: 12px; } .usage b { font: 400 1.5rem/1.2 var(--display); }\n@media (max-width: 52rem) { .two, .gauges, .limits, .form, .counters { grid-template-columns: 1fr !important; } .addrow { flex-wrap: wrap; } .mast { grid-template-columns: 1fr; } .mast .right { justify-items: start; text-align: left; } }\n@media (max-width: 40rem) { .events:not(#kinds):not(#jobs) th:first-child, .events:not(#kinds):not(#jobs) td:first-child { display: none; } }\n\n.slegend { justify-content: flex-start; margin: 0 0 1.2rem; }\n#s-totals { margin-bottom: .4rem; }\ntd.keep { white-space: nowrap; } td.keep input { width: 5.5rem; display: inline-block; margin-right: .3rem; } td.keep input::placeholder { color: var(--ink-3); }\n#kinds tr.any td:first-child { font-weight: 600; }\n#kinds td:first-child .kind { display: inline-block; min-width: 5.2em; text-align: center; margin-right: .35rem; }\n/* Tables keep their columns and scroll inside the card on narrow screens instead of pushing the page sideways. */\n.scroll { overflow-x: auto; max-width: 100%; scrollbar-width: thin; } .scroll table { min-width: 100%; }\nsection, .card { overflow-x: clip; }\n.kind.sys { background: var(--mint); border-color: var(--forest); color: var(--forest); }\ntd.keep.sys { color: var(--ink-3); font-size: 13px; white-space: normal; }\n\n#console.mod .tabs a:not([data-tab=people]):not([data-tab=moderation]) { display: none; } #console.mod #thresholdform { display: none; }\n#members select.role { width: auto; padding: .1rem .3rem; font-size: 12px; margin-left: .3rem; }\n.wire-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .5rem 1.2rem; padding: .8rem 0; border-bottom: 1px solid var(--line); align-items: start; } .wire-row:first-child { border-top: 2px solid var(--ink); }\n.wire-main { min-width: 0; } .wire-side { display: grid; justify-items: end; gap: .45rem; max-width: 26rem; }\n.wire-acts { display: flex; gap: .4rem; white-space: nowrap; } .wire-acts .btn { padding: .35rem .75rem; font-size: 13px; }\n.wire-meta { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .35rem .5rem; align-items: center; font-size: 13px; color: var(--ink-2); text-align: right; } .wire-meta:empty { display: none; }\n.pill { display: inline-block; padding: .1rem .6rem; border: 1.5px solid var(--ink); border-radius: 999px; font: 600 12px var(--sans); color: var(--ink); background: var(--paper); } .pill.on { background: var(--mint); } .pill.off { color: var(--ink-3); } .pill.bad { color: var(--red); }\n.chip { display: inline-block; padding: .05rem .45rem; border: 1px solid var(--forest); border-radius: 6px; font: 12px var(--mono); color: var(--forest); } .chip.bad { border-color: var(--red); color: var(--red); }\n.share { display: grid; grid-template-columns: minmax(0, 3fr) minmax(14rem, 2fr); gap: 1.4rem; align-items: start; } .share #cardimg { width: 100%; height: auto; display: block; }\n.share-side { display: grid; gap: .8rem; justify-items: start; } .naddr { display: block; font-size: 12px; line-height: 1.5; word-break: break-all; color: var(--ink-2); }\n@media (max-width: 52rem) { .share { grid-template-columns: 1fr; } .wire-side { display: contents; } .wire-acts { justify-self: end; } .wire-meta { grid-column: 1 / -1; justify-content: flex-start; text-align: left; } }\n#presets .btn { margin: 0 .4rem .4rem 0; }\n.panel form h3.gap { margin-top: 1.4rem; }\n.key { position: relative; display: inline-block; font-family: var(--mono); } .key::before { content: attr(data-short); } .key .full { position: absolute; left: 0; top: 0; width: 1px; height: 1px; overflow: hidden; opacity: 0; white-space: nowrap; }\n.mast .banner { grid-column: 1 / -1; width: 100%; aspect-ratio: 4 / 1; overflow: hidden; border: 2px solid var(--ink); border-radius: 18px; box-shadow: 4px 4px 0 var(--ink); background: var(--paper); margin-bottom: .5rem; } .mast .banner img { width: 100%; height: 100%; object-fit: cover; display: block; }\n.mast .wordmark { display: flex; align-items: center; gap: 1rem; } .mast .icon { width: 64px; height: 64px; border-radius: 50%; border: 2px solid var(--ink); box-shadow: 3px 3px 0 var(--ink); background: var(--paper); object-fit: cover; }\n.metarow { display: flex; flex-wrap: wrap; gap: .4rem .6rem; align-items: center; margin: -.4rem 0 1rem; font-size: 13px; color: var(--ink-2); }\n.metarow .tag { display: inline-block; padding: .1rem .6rem; border: 1.5px solid var(--ink); border-radius: 999px; font: 600 12px var(--sans); color: var(--ink); background: var(--sun); }\n.metarow .sep { color: var(--ink-3); } .metarow a { color: var(--forest); text-decoration: none; border-bottom: 1.5px solid var(--forest); } .metarow a:hover { border-bottom-width: 2px; }\n.about a { color: var(--forest); text-decoration: none; border-bottom: 1.5px solid var(--forest); }\n.wire-acts label { display: inline-flex; align-items: center; gap: .35rem; font-size: 14px; color: var(--ink-2); cursor: pointer; } .wire-acts label input { margin: 0; accent-color: var(--forest); } .wire-row.dim .wire-main { color: var(--ink-3); }\n"; -export const CONSOLE_JS = "(async () => {\n const $ = (s) => document.querySelector(s);\n const $$ = (s) => document.querySelectorAll(s);\n const host = location.host;\n const wsURL = (location.protocol === \"https:\" ? \"wss://\" : \"ws://\") + host;\n const rpcURL = location.origin + \"/\";\n let info = null, me = null, owner = \"\", policy = null, fuel = null, people = null, myRole = \"\";\n\n const toast = (msg) => { const t = $(\"#toast\"); t.textContent = msg; t.classList.add(\"show\"); clearTimeout(t._t); t._t = setTimeout(() => t.classList.remove(\"show\"), 2600); };\n // guard wraps a handler: disables the button, shows errors as toasts. Defined first, since handlers below use it.\n const guard = (fn) => async (ev) => { ev.preventDefault(); const b = ev.submitter || ev.target; if (b) b.disabled = true; try { await fn(ev); } catch (e) { toast(e.message); } finally { if (b) b.disabled = false; } };\n const short = (hex) => hex ? hex.slice(0, 8) + \"…\" + hex.slice(-4) : \"\";\n // key shows a key short but keeps the whole of it in the DOM: selecting, double-clicking or clicking it copies the full hex.\n const key = (hex) => hex ? '' + hex + '' : '';\n const hue = (hex) => (parseInt(hex.slice(0, 2), 16) * 360 / 256).toFixed(0) + \"deg\";\n const av = (hex) => '';\n const fmtBytes = (n) => n < 1e6 ? (n / 1e3).toFixed(0) + \" KB\" : n < 1e9 ? (n / 1e6).toFixed(1) + \" MB\" : (n / 1e9).toFixed(2) + \" GB\";\n const fmtHours = (ms) => ms < 3600e3 ? Math.round(ms / 60e3) + \" min\" : (ms / 3600e3).toFixed(ms < 36e6 ? 1 : 0) + \" h\";\n const fuelOver = () => !!fuel && (fuel.eventBytes > fuel.freeEventBytes || fuel.mediaBytes > fuel.freeMediaBytes || fuel.activeMs > fuel.freeActiveMs || fuel.rowsWritten > fuel.freeRowsWritten);\n const fmtTime = (t) => t ? new Date(t * 1000).toLocaleString(undefined, { month: \"short\", day: \"numeric\", hour: \"2-digit\", minute: \"2-digit\" }) : \"–\";\n const fmtDay = (t) => t ? new Date(t * 1000).toLocaleDateString(undefined, { month: \"short\", day: \"numeric\" }) : \"–\";\n const ago = (t) => { const s = Math.max(0, Math.floor(Date.now() / 1000) - t); return s < 60 ? \"just now\" : s < 3600 ? Math.floor(s / 60) + \" min\" : s < 86400 ? Math.floor(s / 3600) + \" h\" : Math.floor(s / 86400) + \" d\"; };\n const esc = (s) => String(s ?? \"\").replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c]);\n\n const IC = {\n x: '',\n undo: '',\n check: '',\n trash: '',\n ban: '',\n banuser: '',\n eye: '',\n lock: '',\n pen: '',\n people: '',\n person: '',\n gauge: '',\n bolt: '',\n copy: '',\n pin: '',\n };\n const ib = (icon, label, act, id, extra) => '\";\n\n const CH = \"qpzry9x8gf2tvdw0s3jn54khce6mua7l\";\n function npubToHex(s) {\n s = s.trim().toLowerCase();\n if (/^[0-9a-f]{64}$/.test(s)) return s;\n if (!s.startsWith(\"npub1\")) return null;\n const data = s.slice(5, -6).split(\"\").map((c) => CH.indexOf(c));\n if (data.some((d) => d < 0)) return null;\n let bits = 0, acc = 0, out = [];\n for (const d of data) { acc = (acc << 5) | d; bits += 5; if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 255); } }\n return out.length === 32 ? out.map((b) => b.toString(16).padStart(2, \"0\")).join(\"\") : null;\n }\n\n async function sha256hex(s) {\n const b = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(s));\n return [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, \"0\")).join(\"\");\n }\n // ---- signing: a NIP-07 extension, or a NIP-46 remote signer ----\n // The remote path loads the bundled library on first use; its session\n // (client key, signer pubkey, relays) lives in localStorage until sign-out.\n let remote = null, lib = null, pendingNote = null;\n const NO_SIGNER = \"Install a nostr extension (Alby, nos2x, …) or connect a remote signer.\";\n const withTimeout = (p, ms, what) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(what + \" did not answer; is the signer app open?\")), ms))]);\n const signer = {\n ready: () => !!remote || !!window.nostr,\n async getPublicKey() { if (remote) return withTimeout(remote.getPublicKey(), 20000, \"The remote signer\"); if (window.nostr) return window.nostr.getPublicKey(); throw new Error(NO_SIGNER); },\n async signEvent(ev) { if (remote) return withTimeout(remote.signEvent(ev), 60000, \"The remote signer\"); if (window.nostr) return window.nostr.signEvent(ev); throw new Error(NO_SIGNER); },\n };\n async function signerLib() {\n if (lib) return lib;\n if (!window.NostrSigner) await new Promise((res, rej) => { const s = document.createElement(\"script\"); s.src = window.SIGNER_URL || \"/signer.js\"; s.onload = res; s.onerror = () => rej(new Error(\"Could not load the signer library.\")); document.head.appendChild(s); });\n return (lib = window.NostrSigner);\n }\n const onauth = (u) => window.open(u, \"_blank\");\n // The signer conversation rides this relay, and a relay may ask the socket to\n // authenticate before it delivers kind 24133 to its parties; answer with the\n // session's own key.\n const authPool = (L, sk) => new L.SimplePool({ automaticallyAuth: () => (evt) => Promise.resolve(L.finalizeEvent(evt, sk)) });\n const saveSession = (sk, s, secret) => localStorage.setItem(\"nip46\", JSON.stringify({ sk: lib.bytesToHex(sk), pubkey: s.bp.pubkey, relays: s.bp.relays, secret: secret || null }));\n async function resumeRemote() {\n const raw = localStorage.getItem(\"nip46\");\n if (!raw) return false;\n try {\n const s = JSON.parse(raw); const L = await signerLib();\n remote = L.BunkerSigner.fromBunker(L.hexToBytes(s.sk), { pubkey: s.pubkey, relays: s.relays, secret: s.secret }, { onauth, pool: authPool(L, L.hexToBytes(s.sk)) });\n return true;\n } catch { localStorage.removeItem(\"nip46\"); return false; }\n }\n async function connectBunker(input) {\n const L = await signerLib();\n const bp = await L.parseBunkerInput(input.trim());\n if (!bp) throw new Error(\"That is not a bunker:// URL.\");\n if (!bp.relays.length) throw new Error(\"The bunker URL names no relay.\");\n const sk = L.generateSecretKey();\n const s = L.BunkerSigner.fromBunker(sk, bp, { onauth, pool: authPool(L, sk) });\n await withTimeout(s.connect({ name: host, url: location.origin }), 60000, \"The signer\");\n await Promise.race([s.switchRelays(), new Promise((r) => setTimeout(r, 3000))]);\n remote = s; saveSession(sk, s, bp.secret);\n }\n // The nostrconnect:// flow: this relay carries the traffic, so no third relay is involved.\n let ncPending = null;\n async function offerNostrConnect() {\n if (ncPending) return ncPending;\n const L = await signerLib();\n const sk = L.generateSecretKey();\n const secret = L.bytesToHex(crypto.getRandomValues(new Uint8Array(8)));\n const uri = L.createNostrConnectURI({ clientPubkey: L.getPublicKey(sk), relays: [wsURL], secret, name: host, url: location.origin, perms: [\"sign_event:27235\", \"sign_event:9734\"] });\n $(\"#nclink\").href = uri; $(\"#ncnote\").textContent = \"\";\n $(\"#ncqr\").src = \"/qr.svg?text=\" + encodeURIComponent(uri); $(\"#ncqr\").classList.remove(\"hidden\");\n ncPending = L.BunkerSigner.fromURI(sk, uri, { onauth, pool: authPool(L, sk) }, 600000).then((s) => { remote = s; saveSession(sk, s, secret); ncPending = null; return s; }, (e) => { ncPending = null; throw e; });\n return ncPending;\n }\n async function remoteDone() {\n me = await signer.getPublicKey();\n localStorage.setItem(\"me\", me);\n $(\"#remotesec\").classList.add(\"hidden\");\n toast(\"Remote signer connected\");\n if (pendingNote && !owner) { const n = pendingNote; pendingNote = null; await claimNow(n); return; }\n pendingNote = null;\n renderHeader(); await loadAdmin(); await loadPeople();\n }\n function showRemote(note) {\n pendingNote = note || null;\n $(\"#remotesec\").classList.remove(\"hidden\");\n $(\"#remotesec\").scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n offerNostrConnect().then(remoteDone).catch((e) => { $(\"#ncnote\").textContent = e.message; });\n }\n\n async function rpc(method, ...params) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const body = JSON.stringify({ method, params });\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", rpcURL], [\"method\", \"POST\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(rpcURL, { method: \"POST\", headers: { \"content-type\": \"application/nostr+json+rpc\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body });\n const json = await resp.json();\n if (json.error) throw new Error(json.error);\n return json.result;\n }\n\n // ---- front of house ----\n // urlish shows a URL as a short link: the host and the start of the path, the whole thing on hover and as the target.\n class Html { constructor(s) { this.s = s; } }\n const urlish = (u) => {\n if (!u) return \"\";\n let label = u; try { const x = new URL(u); label = x.host + (x.pathname.length > 1 ? x.pathname.slice(0, 18) + (x.pathname.length > 18 ? \"\\u2026\" : \"\") : \"\"); } catch { /* not a URL */ }\n if (!/^https?:\\/\\//.test(u)) return u;\n return new Html('' + esc(label) + \"\");\n };\n function renderHeader() {\n const name = (info && info.name) || host.split(\".\")[0];\n $(\"#title\").textContent = name;\n document.title = name + \" - relay\";\n $(\"#url\").textContent = wsURL;\n $(\"#d-ws\").textContent = wsURL; $(\"#d-blossom\").textContent = location.origin; $(\"#d-nip05\").textContent = \"you@\" + host;\n $(\"#nip05-example\").textContent = \"alice@\" + host;\n const desc = (info && info.description) || \"\";\n $(\"#desc\").textContent = desc; $(\"#desc\").classList.toggle(\"hidden\", !desc);\n // What the owner declared about the relay, on the page, not only in the document clients read.\n const img = (id, url) => { const el = $(id); if (url) el.src = url; el.classList.toggle(\"hidden\", !url); };\n img(\"#bannerimg\", info?.banner); $(\"#banner\").classList.toggle(\"hidden\", !info?.banner);\n img(\"#iconimg\", info?.icon);\n const meta = [];\n for (const t of info?.tags || []) meta.push('' + esc(t) + \"\");\n const where = [...(info?.language_tags || []), ...(info?.relay_countries || [])];\n if (where.length) meta.push(\"\" + esc(where.join(\", \")) + \"\");\n const link = (label, href) => href ? '' + label + \"\" : \"\";\n const contact = info?.contact ? (/^(mailto:|https?:\\/\\/)/.test(info.contact) ? link(\"Contact\", info.contact) : \"\" + esc(info.contact) + \"\") : \"\";\n for (const x of [contact, link(\"Terms\", info?.terms_of_service), link(\"Posting policy\", info?.posting_policy), link(\"Privacy\", info?.privacy_policy)]) if (x) meta.push(x);\n $(\"#metarow\").innerHTML = meta.join('|'); $(\"#metarow\").classList.toggle(\"hidden\", meta.length === 0);\n $(\"#frontsec\").classList.toggle(\"hidden\", !desc && meta.length === 0);\n owner = (info && info.pubkey) || \"\";\n const isOwner = !!me && me === owner;\n const lease = !owner && info && info.lease ? info.lease : null;\n $(\"#ownerline\").innerHTML = owner ? 'run by ' + key(owner) + \"\" : lease ? \"temporary until \" + fmtDay(lease.expires_at) : \"unclaimed\";\n $(\"#owner-av\").classList.toggle(\"hidden\", !owner);\n if (owner) $(\"#owner-av\").style.setProperty(\"--h\", hue(owner));\n $(\"#who\").textContent = me ? (isOwner ? \"(that's you)\" : \"\") : \"\";\n $(\"#unclaimed\").classList.toggle(\"hidden\", !!owner || !!lease);\n $(\"#leased\").classList.toggle(\"hidden\", !lease);\n if (lease) {\n $(\"#lease-until\").textContent = fmtTime(lease.expires_at);\n $(\"#leasenote\").textContent = lease.holder ? \"Reserved for the key that asked for it; sign with that key.\" : \"\";\n }\n $(\"#signin\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signin46\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signout\").classList.toggle(\"hidden\", !me);\n $(\"#about\").innerHTML = [\n [\"Name\", info?.name], [\"Description\", info?.description], [\"Contact\", info?.contact], [\"Owner\", owner ? short(owner) : \"\"], [\"Relay key\", info?.self ? short(info.self) : \"\"], [\"Software\", urlish(info?.software)], [\"Version\", info?.version],\n [\"Max query\", info?.limitation?.max_limit], [\"Auth required\", String(!!info?.limitation?.auth_required)], [\"Restricted writes\", String(!!info?.limitation?.restricted_writes)], [\"Min PoW\", info?.limitation?.min_pow_difficulty || 0],\n [\"Tags\", info?.tags?.join(\", \")], [\"Languages\", info?.language_tags?.join(\", \")], [\"Countries\", info?.relay_countries?.join(\", \")],\n [\"Terms\", urlish(info?.terms_of_service)], [\"Posting policy\", urlish(info?.posting_policy)], [\"Privacy policy\", urlish(info?.privacy_policy)], [\"Icon\", urlish(info?.icon)], [\"Banner\", urlish(info?.banner)],\n [\"NIPs\", info?.supported_nips?.join(\" \")],\n ].map(([k, v]) => \"
                                                                                                    \" + k + \"\" + (v === undefined || v === \"\" ? '' : v instanceof Html ? v.s : esc(v)) + \"
                                                                                                    \").join(\"\");\n renderCare();\n }\n\n function renderCare() {\n const g = (icon, label, off) => '' + IC[icon] + \"\" + label + \"\";\n const writes = policy ? policy.writes : (info?.limitation?.restricted_writes ? \"allowlist\" : \"open\");\n const reads = policy ? policy.reads : (info?.limitation?.auth_required ? \"auth\" : \"open\");\n let out = writes === \"owner\" ? g(\"person\", \"only owner writes\") : writes === \"allowlist\" ? g(\"people\", \"members write\") : writes === \"wot\" ? g(\"people\", \"members and follows write\") : g(\"pen\", \"anyone writes\");\n out += reads === \"members\" ? g(\"people\", \"members read\") : reads === \"auth\" ? g(\"lock\", \"sign in to read\") : g(\"eye\", \"anyone reads\");\n if (fuel) {\n const over = fuelOver();\n out += fuel.outOfFuel ? g(\"gauge\", \"out of fuel\", true) : over ? g(\"bolt\", \"burning sats\") : g(\"gauge\", \"on free allowance\");\n }\n $(\"#care\").innerHTML = out;\n }\n\n async function loadPeople() {\n try { people = await (await fetch(\"/people\")).json(); } catch { return; }\n const isOwner = !!me && me === owner;\n let list = people.people || [];\n if (!people.public && isOwner && policy) list = (window.__members || []);\n $(\"#peoplesec\").classList.toggle(\"hidden\", !owner || list.length === 0);\n $(\"#people-note\").classList.toggle(\"hidden\", people.public);\n $(\"#people-count\").textContent = list.length || \"\";\n $(\"#dir\").innerHTML = list.map((m) => '' + av(m.pubkey) + '' + (m.name ? esc(m.name) + \"@\" + host : key(m.pubkey)) + \"\" + (m.role === \"owner\" ? 'owner' : \"\") + \"\").join(\"\");\n }\n\n async function loadInfo() {\n info = await (await fetch(\"/\", { headers: { accept: \"application/nostr+json\" } })).json();\n renderHeader();\n const git = info.supported_grasps?.includes(\"GRASP-01\");\n $(\"#git-connect\").classList.toggle(\"hidden\", !git);\n const clone = \"git clone '\" + location.origin + \"//.git'\";\n $(\"#git-clone\").textContent = clone;\n $(\"#git-copy\").dataset.copytext = clone;\n renderApps();\n }\n\n async function loadFuel() {\n try { fuel = await (await fetch(\"/fuel\")).json(); } catch { return; }\n $(\"#fuelsec\").classList.toggle(\"hidden\", !owner);\n const pct = (used, free) => Math.min(100, Math.round((used / Math.max(free, 1)) * 100));\n const gauge = (id, used, free, text) => { const g = $(\"#g-\" + id), share = free ? used / free : 0; g.style.width = pct(used, free) + \"%\"; g.classList.toggle(\"over\", share > 1); g.classList.toggle(\"warm\", share > 0.75 && share <= 1); $(\"#p-\" + id).textContent = share > 1 ? \"over\" : Math.round(share * 100) + \"%\"; $(\"#t-\" + id).textContent = text; };\n gauge(\"events\", fuel.eventBytes, fuel.freeEventBytes, fmtBytes(fuel.eventBytes) + \" of \" + fmtBytes(fuel.freeEventBytes) + \" free\");\n gauge(\"media\", fuel.mediaBytes, fuel.freeMediaBytes, fmtBytes(fuel.mediaBytes) + \" of \" + fmtBytes(fuel.freeMediaBytes) + \" free\");\n gauge(\"active\", fuel.activeMs, fuel.freeActiveMs, fmtHours(fuel.activeMs) + \" of \" + fmtHours(fuel.freeActiveMs) + \" free\");\n gauge(\"rows\", fuel.rowsWritten, fuel.freeRowsWritten, fuel.rowsWritten.toLocaleString() + \" of \" + fuel.freeRowsWritten.toLocaleString() + \" free\");\n const sats = Math.floor(fuel.balanceMsats / 1000);\n const r = fuel.rates;\n $(\"#fuel-balance\").innerHTML = fuel.outOfFuel\n ? 'out of fuel Writes are paused until someone tops up.'\n : \"Balance \" + sats.toLocaleString() + \" sats. Past the allowances, prices track what the hosting costs: \" + r.satsPerGBMonthEvents.toLocaleString() + \" sats per GB-month of events, \" + r.satsPerGBMonthMedia.toLocaleString() + \" per GB-month of files, \" + r.satsPerActiveHour.toLocaleString() + \" per hour awake, \" + r.satsPerMillionRows.toLocaleString() + \" per million rows written. Traffic is free.\";\n $(\"#topup\").classList.toggle(\"hidden\", !fuel.enabled);\n // Who paid is the owner's to see: it comes with the signed stats call.\n let credits = [];\n if (owner && signer.ready()) { try { credits = (await rpc(\"stats\")).credits || []; } catch { credits = []; } }\n $(\"#credits tbody\").innerHTML = credits.length ? credits.map((c) => '' + esc(fmtTime(c.at)) + '' + av(c.payer) + key(c.payer) + '' + Math.floor(c.msats / 1000).toLocaleString() + \"\").join(\"\") : 'no zaps yet';\n if (!fuel.enabled) $(\"#topup-note\").textContent = \"Top-ups are not enabled on this service yet.\";\n renderCare();\n renderFuelTile();\n }\n\n async function topUp(sats) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const msats = Math.round(sats * 1000);\n const zapRequest = await signer.signEvent({ kind: 9734, created_at: Math.floor(Date.now() / 1000), content: \"fuel for \" + host, tags: [[\"p\", fuel.servicePubkey], [\"amount\", String(msats)], [\"relays\", wsURL]] });\n const r = await (await fetch(\"/fuel/invoice\", { method: \"POST\", body: JSON.stringify({ zapRequest }) })).json();\n if (r.error) throw new Error(r.error);\n const inv = r.invoice;\n $(\"#invoice\").classList.remove(\"hidden\");\n $(\"#inv-text\").value = inv; $(\"#inv-link\").href = \"lightning:\" + inv; $(\"#inv-state\").textContent = \"waiting for payment…\";\n if (window.webln) { try { await window.webln.enable(); await window.webln.sendPayment(inv); } catch (e) { toast(e.message || \"wallet declined\"); } }\n const before = fuel.creditedMsats;\n for (let i = 0; i < 120; i++) {\n await new Promise((r) => setTimeout(r, 2500));\n await loadFuel();\n if (fuel.creditedMsats > before) { $(\"#inv-state\").textContent = \"paid, thank you\"; toast(\"Fuel credited\"); return; }\n }\n $(\"#inv-state\").textContent = \"no receipt yet; it can take a minute after paying\";\n }\n\n // ---- console ----\n function showTab(name) {\n if (name === \"content\" || name === \"storage\") name = \"data\";\n const known = myRole === \"moderator\" ? [\"people\", \"moderation\"] : [\"people\", \"moderation\", \"rules\", \"identity\", \"data\", \"sync\", \"views\", \"health\", \"owner\"];\n if (!known.includes(name)) name = \"people\";\n $$(\".tabs a\").forEach((a) => a.classList.toggle(\"on\", a.dataset.tab === name));\n $$(\".panel\").forEach((p) => p.classList.toggle(\"on\", p.dataset.panel === name));\n }\n // Tabs switch in place. A fragment still opens a tab when someone arrives with one,\n // but clicking never writes one, and any fragment already there is dropped.\n $$(\".tabs a\").forEach((a) => a.addEventListener(\"click\", (ev) => { ev.preventDefault(); showTab(a.dataset.tab); if (location.hash) history.replaceState(null, \"\", location.pathname + location.search); }));\n window.addEventListener(\"hashchange\", () => showTab(location.hash.slice(1)));\n // The strip fades on whichever side has more tabs off screen.\n const tabsFade = () => { const t = $(\"#tabs\"); t.classList.toggle(\"fade-l\", t.scrollLeft > 4); t.classList.toggle(\"fade-r\", t.scrollLeft + t.clientWidth < t.scrollWidth - 4); };\n $(\"#tabs\").addEventListener(\"scroll\", tabsFade); window.addEventListener(\"resize\", tabsFade); new ResizeObserver(tabsFade).observe($(\"#tabs\"));\n\n function renderFuelTile() {\n if (!fuel) return;\n const over = fuelOver();\n const sats = Math.max(0, Math.floor(fuel.balanceMsats / 1000));\n const d = new Date(), end = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1), days = Math.max(1, Math.ceil((end - Date.now()) / 86400000));\n if (fuel.outOfFuel) { $(\"#h-fuel\").textContent = \"0 sats\"; $(\"#h-fuel-label\").textContent = \"out of fuel\"; $(\"#h-fuel-sub\").textContent = \"writes are paused\"; }\n else if (over) { $(\"#h-fuel\").textContent = sats.toLocaleString() + \" sats\"; $(\"#h-fuel-label\").textContent = \"fuel left\"; $(\"#h-fuel-sub\").textContent = \"past the free allowance\"; }\n else { $(\"#h-fuel\").textContent = days + (days === 1 ? \" day\" : \" days\"); $(\"#h-fuel-label\").textContent = \"free allowance left\"; $(\"#h-fuel-sub\").textContent = sats ? \"then \" + sats.toLocaleString() + \" sats\" : \"then a top-up is needed\"; }\n $(\"#usage\").innerHTML = [[\"events stored\", fmtBytes(fuel.eventBytes)], [\"files stored\", fmtBytes(fuel.mediaBytes)], [\"awake this month\", fmtHours(fuel.activeMs)], [\"rows written\", fuel.rowsWritten.toLocaleString()], [\"rows read\", fuel.rowsRead.toLocaleString()], [\"received this month\", fmtBytes(fuel.bytesIn)], [\"served this month\", fmtBytes(fuel.bytesOut)], [\"charged\", Math.floor(fuel.chargedMsats / 1000).toLocaleString() + \" sats\"]]\n .map(([k, v]) => \"
                                                                                                    \" + k + \"\" + v + \"
                                                                                                    \").join(\"\");\n }\n\n function renderHealth(stats) {\n $(\"#h-last\").textContent = stats.newest ? ago(stats.newest) : \"none\";\n $(\"#h-last-sub\").textContent = stats.newest ? fmtTime(stats.newest) : \"no events yet\";\n $(\"#h-conns\").textContent = stats.connections;\n const buckets = [[\"notes\", \"k1\", [1]], [\"reactions\", \"k2\", [7]], [\"DMs\", \"k3\", [4, 14, 1059]], [\"long-form\", \"k4\", [30023]], [\"other\", \"k5\", null]];\n const sums = buckets.map(() => 0); let total = 0;\n for (const { kind, n } of stats.kinds || []) { total += n; const i = buckets.findIndex((b) => b[2] && b[2].includes(kind)); sums[i < 0 ? 4 : i] += n; }\n const parts = buckets.map((b, i) => [b[0], b[1], sums[i]]).filter((p) => p[2] > 0);\n $(\"#h-kinds\").innerHTML = total ? parts.map(([name, cls, n]) => '').join(\"\") : '';\n $(\"#h-kinds-legend\").innerHTML = total ? parts.map(([name, cls, n]) => '' + name + \" \" + Math.round(n * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n renderFuelTile();\n }\n\n async function loadAdmin() {\n const isOwner = !!me && me === owner;\n myRole = isOwner ? \"owner\" : \"\";\n let stats = null, p = null;\n // A signed-in member may be a moderator; the relay says by answering stats.\n if (me && owner && !isOwner) { try { [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]); myRole = \"moderator\"; } catch { myRole = \"\"; } }\n $(\"#console\").classList.toggle(\"hidden\", !myRole);\n loadMine();\n $(\"#console\").classList.toggle(\"mod\", myRole === \"moderator\");\n if (!myRole) { policy = null; renderCare(); return; }\n if (isOwner) [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]);\n policy = p;\n renderHealth(stats);\n renderCare();\n const fa = $(\"#access\"), fi = $(\"#identity\");\n fa.writes.value = p.writes; fa.reads.value = p.reads; fa.openKinds.value = (p.openKinds || []).join(\", \"); fa.guestReplies.checked = !!p.guestReplies;\n $(\"#wordsform\").words.value = (p.blockedWords || []).join(\"\\n\"); $(\"#wordsform\").inTags.checked = !!p.blockedWordsInTags; $(\"#thresholdform\").reportThreshold.value = p.reportThreshold || 0; fa.minPow.value = p.minPow; fa.maxFuture.value = p.maxFuture; fa.maxLimit.value = p.maxLimit; fa.maxSubs.value = p.maxSubs; fa.maxMessageKB.value = p.maxMessageKB;\n fa.eventsPerMinute.value = p.eventsPerMinute; fa.reqsPerMinute.value = p.reqsPerMinute; fa.maxBlobMB.value = p.maxBlobMB;\n renderFeatures(p.features || {});\n $(\"#push-policy-form\").elements.origins.value = (p.pushCallbacks || []).join(\"\\n\");\n $(\"#push-policy-form\").elements.lettered.checked = !!p.letteredNips;\n fi.name.value = p.name; fi.contact.value = p.contact; fi.description.value = p.description; fi.icon.value = p.icon;\n const fj = $(\"#joinform\"); fj.joinTerms.value = p.joinTerms; fj.directoryPublic.checked = !!p.directoryPublic;\n loadCard();\n fi.banner.value = p.banner || \"\"; fi.postingPolicy.value = p.postingPolicy || \"\"; fi.privacyPolicy.value = p.privacyPolicy || \"\";\n fi.tags.value = (p.tags || []).join(\", \"); fi.languageTags.value = (p.languageTags || []).join(\", \"); fi.relayCountries.value = (p.relayCountries || []).join(\", \");\n const fn = $(\"#notify\"), nt = p.notify || {};\n fn.reports.checked = !!nt.reports; fn.fuel.checked = !!nt.fuel; fn.jobs.checked = !!nt.jobs; fn.succession.checked = !!nt.succession; fn.digest.checked = !!nt.digest;\n showTab(location.hash.slice(1));\n if (isOwner) { await renderPresets(); renderWire(); renderSuccession(); loadDomains(); }\n await Promise.all([loadLists(), loadEvents(true), loadPins(), ...(isOwner ? [loadStorage()] : [])]);\n }\n\n async function loadAudit(before) {\n const rows = await rpc(\"listaudit\", before || 0);\n const html = rows.map((r) => '' + esc(fmtTime(r.at)) + '' + av(r.actor) + key(r.actor) + '' + esc(r.action) + '' + (/^[0-9a-f]{64}$/.test(r.target) ? key(r.target) : esc(r.target)) + '' + esc(r.detail) + '').join(\"\");\n const tb = $(\"#audit tbody\");\n if (before) tb.insertAdjacentHTML(\"beforeend\", html); else tb.innerHTML = html || 'Nothing yet';\n $(\"#audit-more\").style.display = rows.length < 100 ? \"none\" : \"\";\n }\n $(\"#audit-more\").onclick = guard(async () => { const last = $(\"#audit tbody tr:last-child\"); await loadAudit(last ? +last.dataset.seq : 0); });\n async function loadLists() {\n loadAudit(0);\n const [mem, bans, bannedEvents, allow, block, invites, reports, blobs, blocks, sites] = await Promise.all([rpc(\"listmembers\"), rpc(\"listbannedpubkeys\"), rpc(\"listbannedevents\"), rpc(\"listallowedkinds\"), rpc(\"listblockedkinds\"), rpc(\"listinvites\"), rpc(\"listreports\"), rpc(\"listblobs\", 100), rpc(\"listblockedips\"), rpc(\"listsites\")]);\n const members = mem.members;\n window.__members = members;\n const roleCell = (m) => m.role === \"owner\" ? ' owner' : myRole === \"owner\" ? ' ' : m.role === \"moderator\" ? ' moderator' : \"\";\n const untouchable = (m) => m.role === \"owner\" || (myRole !== \"owner\" && m.role === \"moderator\");\n // The tree: everyone under whoever invited them, the owner and the owner's own additions at the root.\n const known = new Set(members.map((m) => m.pubkey));\n const byInviter = new Map();\n for (const m of members) { const k = m.role !== \"owner\" && known.has(m.invited_by) ? m.invited_by : \"\"; if (!byInviter.has(k)) byInviter.set(k, []); byInviter.get(k).push(m); }\n const ordered = [], placed = new Set();\n const walk = (k, depth) => { for (const m of byInviter.get(k) || []) { if (placed.has(m.pubkey)) continue; placed.add(m.pubkey); m.depth = depth; ordered.push(m); walk(m.pubkey, depth + 1); } };\n walk(\"\", 0);\n for (const m of members) if (!placed.has(m.pubkey)) { m.depth = 0; ordered.push(m); }\n const nameOf = (pk) => { const x = members.find((m) => m.pubkey === pk); return x && x.name ? x.name : short(pk); };\n const limits = (m) => m.role === \"owner\" ? \"\" : myRole === \"owner\"\n ? ''\n : '' + (m.keep_days ? m.keep_days + \" d\" : \"\") + (m.max_bytes ? \" \" + fmtBytes(m.max_bytes) : \"\") + \"\";\n $(\"#members tbody\").innerHTML = ordered.map((m) => '' + av(m.pubkey) + key(m.pubkey) + roleCell(m) + (m.depth ? ' via ' + esc(nameOf(m.invited_by)) + \"\" : \"\") + (m.invites ? ' ' + m.invites + \" inv\" : \"\") + '' + limits(m) + '' + esc(fmtDay(m.joined_at)) + \", \" + esc(m.via) + '' + ib(\"check\", \"Save\", \"savemember\", m.pubkey) + (untouchable(m) ? \"\" : ib(\"x\", \"Remove\", \"removemember\", m.pubkey) + ib(\"banuser\", \"Ban\", \"banpubkey\", m.pubkey, \"danger\")) + \"\").join(\"\");\n const tf = $(\"#treeform\");\n tf.classList.toggle(\"hidden\", myRole !== \"owner\");\n if (policy && policy.memberInvites) { tf.depth.value = policy.memberInvites.depth; tf.quota.value = policy.memberInvites.quota; }\n $(\"#transfer [name=pubkey]\").innerHTML = members.filter((m) => m.role !== \"owner\").map((m) => '\").join(\"\");\n $(\"#succession [name=heir]\").innerHTML = $(\"#transfer [name=pubkey]\").innerHTML;\n $(\"#tc-people\").textContent = members.length;\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#invites\").innerHTML = invites.length ? invites.map((i) => '
                                                                                                  • inv…' + i.code.slice(-8) + \" \" + esc(i.note || \"\") + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
                                                                                                  • \").join(\"\") : '
                                                                                                  • no invites
                                                                                                  • ';\n const person = (r, icon, label, act) => \"
                                                                                                  • \" + av(r.pubkey) + '' + short(r.pubkey) + \" \" + esc(r.reason || \"\") + \"\" + ib(icon, label, act, r.pubkey) + \"
                                                                                                  • \";\n $(\"#bans\").innerHTML = bans.length ? bans.map((r) => person(r, \"undo\", \"Unban\", \"unrulepubkey\")).join(\"\") : '
                                                                                                  • nobody banned
                                                                                                  • ';\n $(\"#blocks\").innerHTML = blocks.length ? blocks.map((r) => '
                                                                                                  • ip' + esc(r.ip) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"undo\", \"Unblock\", \"unblockip\", r.ip) + \"
                                                                                                  • \").join(\"\") : '
                                                                                                  • no addresses blocked
                                                                                                  • ';\n $(\"#banned-events\").innerHTML = bannedEvents.map((r) => '
                                                                                                  • ev' + key(r.id) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"check\", \"Allow again\", \"allowevent\", r.id) + \"
                                                                                                  • \").join(\"\");\n $(\"#reports tbody\").innerHTML = reports.length ? reports.map((r) => '' + esc(fmtTime(r.at)) + '' + esc(r.type || \"report\") + '' + av(r.target_pubkey) + key(r.target_pubkey) + (r.target_event ? (r.blob ? ' file ' + key(r.target_event) + \"\" : ' ev ' + key(r.target_event) + (r.hidden ? ' hidden' : \"\") + \"\") : \"\") + '' + esc(r.content) + '' + ib(\"check\", \"Dismiss\", \"resolve:dismiss\", r.id) + ib(\"trash\", r.blob ? \"Delete the file\" : \"Delete the event\", \"resolve:delete\", r.id) + ib(\"banuser\", \"Ban the author\", \"resolve:ban\", r.id, \"danger\") + \"\").join(\"\") : 'nothing reported';\n $(\"#reports-count\").textContent = reports.length || \"\"; $(\"#tc-reports\").textContent = reports.length || \"\";\n $(\"#blobs tbody\").innerHTML = blobs.length ? blobs.map((b) => '' + esc(fmtTime(b.uploaded)) + '' + key(b.sha256) + ' ' + esc(b.type) + '' + fmtBytes(b.size) + '' + av(b.uploader) + key(b.uploader) + '' + ib(\"trash\", \"Delete file\", \"deleteblob\", b.sha256, \"danger\") + \"\").join(\"\") : 'no uploads';\n $(\"#blobs-count\").textContent = blobs.length || \"\";\n $(\"#sites tbody\").innerHTML = sites.length ? sites.map((s) => '' + av(s.author) + key(s.author) + '' + esc(s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + '' + esc(s.url) + '' + esc(s.paths) + (s.missing ? ' ' + esc(s.missing) + ' missing' : '') + '' + fmtBytes(s.size) + '' + esc(s.expires_at ? fmtTime(s.expires_at) : \"never\") + '' + ib(\"trash\", \"Delete site\", \"deleteevent\", s.id, \"danger\") + '').join(\"\") : 'no sites yet';\n $(\"#sites-count\").textContent = sites.length || \"\";\n const tag = (k, cls) => '' + k + ib(\"x\", \"Remove rule\", \"unrulekind\", String(k)) + \"\";\n $(\"#kinds-allow\").innerHTML = allow.length ? allow.map((k) => tag(k, \"ok\")).join(\"\") : 'all';\n $(\"#kinds-block\").innerHTML = block.length ? block.map((k) => tag(k, \"blk\")).join(\"\") : 'none';\n await loadPeople();\n }\n\n const KIND_NAMES = { 0: \"profiles\", 1: \"notes\", 3: \"contacts\", 4: \"DMs\", 5: \"deletions\", 6: \"reposts\", 7: \"reactions\", 16: \"reposts\", 1059: \"gift wraps\", 1063: \"file headers\", 1111: \"comments\", 1984: \"reports\", 9734: \"zap requests\", 9735: \"zap receipts\", 9802: \"highlights\", 10002: \"relay lists\", 13534: \"roster\", 9000: \"group adds\", 9001: \"group removals\", 9021: \"join requests\", 9022: \"leave requests\", 39000: \"group info\", 39001: \"group admins\", 39002: \"group members\", 39003: \"group roles\", 30023: \"articles\", 30024: \"drafts\", 30078: \"app data\", 30311: \"live events\", 30818: \"wiki\" };\n const kindName = (k) => KIND_NAMES[k] || (k >= 20000 && k < 30000 ? \"ephemeral\" : k >= 30000 && k < 40000 ? \"addressable\" : k >= 10000 && k < 20000 ? \"replaceable\" : \"kind \" + k);\n const SYS_KINDS = new Set([0, 3, 10002, 9735, 13534, 8000, 8001, 9000, 9001, 39000, 39001, 39002, 39003]);\n let storage = null;\n\n // Features (settings.ts): a select each; search has three modes, the rest on or off.\n const FEATURES = [\n [\"search\", \"Search\", \"NIP-50. Prose indexes notes, threads, comments, highlights, articles and wiki pages; full indexes every public kind with content. A change applies to events from then on.\", [\"prose:prose\", \"full:full\", \"off:off\"]],\n [\"sync\", \"Sync\", \"NIP-77 reconciliation, which reads the whole matching set per sync.\"],\n [\"count\", \"Counts\", \"NIP-45 COUNT, with HLL sketches.\"],\n [\"discovery\", \"Discovery record\", \"NIP-66: the record the relay signs about itself, for crawlers.\"],\n [\"names\", \"Names\", \"NIP-05 addresses under this relay's domain.\"],\n [\"files\", \"Files\", \"Blossom and NIP-96: uploads, downloads and listings.\"],\n [\"pages\", \"Pages and feed\", \"Notes and articles as pages, and the Atom feed.\"],\n [\"sites\", \"Static websites\", \"NIP-5A sites on their own hostnames. Mirroring copies missing files into this relay and costs fuel.\", [\"mirror:on, mirror files\", \"proxy:on, fetch as needed\", \"off:off\"]],\n [\"marmot\", \"Marmot transport\", \"Signed KeyPackages and encrypted group messages, with account admission for ephemeral authors.\"],\n [\"grasp\", \"Git repositories\", \"GRASP Git hosting with admitted repository state. The prototype backend has bounded storage and compute limits.\"],\n [\"push\", \"Relay push\", \"NIP-9a callback delivery for members and the owner. Requires approved callback origins; advertises lettered NIP identifiers.\"],\n [\"signer\", \"Signer traffic\", \"NIP-46 remote signing carried for anyone, never stored.\"],\n ];\n function renderFeatures(f) {\n $(\"#features\").innerHTML = FEATURES.map(([k, title, about, modes]) => {\n const cur = k === \"sites\" ? (f.sites?.enabled === false ? \"off\" : f.sites?.mirror === false ? \"proxy\" : \"mirror\") : modes ? String(f[k] || \"prose\") : String(f[k] !== false);\n const opts = (modes || [\"true:on\", \"false:off\"]).map((m) => { const [v, l] = m.split(\":\"); return '\"; }).join(\"\");\n return \"\";\n }).join(\"\");\n }\n $(\"#features\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-feature]\"); if (!sel) return;\n const k = sel.dataset.feature, v = k === \"search\" || k === \"sites\" ? sel.value : sel.value === \"true\";\n policy = await rpc(\"setpolicy\", { features: { [k]: k === \"sites\" ? { enabled: v !== \"off\", mirror: v === \"mirror\" } : v } });\n toast(k === \"search\" ? \"Search: \" + v : (v ? \"Switched on \" : \"Switched off \") + k); await loadInfo();\n }));\n\n $(\"#push-policy-form\").addEventListener(\"submit\", guard(async (ev) => {\n ev.preventDefault();\n const form = ev.target;\n const origins = form.elements.origins.value.split(/\\s+/).filter(Boolean);\n const updated = await rpc(\"setpolicy\", { pushCallbacks: origins, letteredNips: form.elements.lettered.checked });\n if (JSON.stringify(updated.pushCallbacks) !== JSON.stringify([...new Set(origins.map((s) => s.replace(/\\/$/, \"\")))])) throw new Error(\"Use up to sixteen exact HTTPS origins, with no path or credentials.\");\n policy = updated;\n toast(\"Saved callback policy\"); await loadInfo();\n }));\n\n async function loadViews() {\n let views;\n try { views = await rpc(\"listviews\"); } catch { return; }\n $(\"#views\").innerHTML = views.map((v) => {\n const runs = v.trigger === \"off\" ? \"off\" : v.trigger === \"live\" ? \"live, from memory\" : v.trigger === \"write\" ? \"on write and daily\" : v.trigger;\n const label = (c) => (c === \"off\" ? \"off\" : c === \"write\" ? \"on write\" : c === \"live\" ? \"on\" : c);\n const choices = [...v.choices, v.default].filter((c, i, a) => a.indexOf(c) === i);\n const pick = '\";\n const who = v.audience === \"members\" ? \"members\" + (v.stored ? \"\" : \", on request\") : \"anyone\";\n const last = v.trigger === \"live\" ? \"\" : v.last ? \"last run \" + fmtTime(v.last.at) : \"not run yet\";\n const rows = v.trigger === \"live\" ? \"no rows\" : v.last ? v.last.rows.toLocaleString() + \" rows\" : \"\";\n const meta = [runs, who, last, rows].filter(Boolean).map((x) => \"\" + esc(x) + \"\").join('|');\n return '
                                                                                                    ' + esc(v.name) + '
                                                                                                    ' + esc(v.about) + '
                                                                                                    ' + pick + (v.on ? 'Open' : \"\") + '
                                                                                                    ' + meta + \"
                                                                                                    \";\n }).join(\"\");\n }\n $(\"#views\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-view]\"); if (!sel) return;\n const value = sel.value === \"live\" ? true : sel.value;\n await rpc(\"setpolicy\", { views: { [sel.dataset.view]: value } });\n toast(sel.value === \"off\" ? \"Took down \" + sel.dataset.view : sel.dataset.view + \": \" + (sel.value === \"live\" ? \"on\" : sel.value === \"write\" ? \"on write\" : sel.value)); await loadViews(); await loadInfo();\n }));\n async function loadDumps() {\n const list = await rpc(\"listdumps\");\n const f = $(\"#dumpform\");\n if (policy) { f.dumps.value = policy.dumps || \"off\"; f.keep.value = policy.dumpsKeep || 7; }\n $(\"#dumps-count\").textContent = list.length || \"\";\n $(\"#dumps\").innerHTML = list.length ? list.map((d) => '
                                                                                                  • jsonl' + esc(d.name) + ' ' + d.events.toLocaleString() + \" events, \" + fmtBytes(d.bytes) + \"\" + ib(\"copy\", \"Download\", \"downloaddump\", d.name) + ib(\"trash\", \"Delete\", \"deletedump\", d.name, \"danger\") + \"
                                                                                                  • \").join(\"\") : '
                                                                                                  • no dumps yet
                                                                                                  • ';\n }\n // A dump is fetched with a signed request and handed to the browser as a file.\n async function downloadDump(name) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const url = location.origin + \"/dumps/\" + name;\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"GET\"]] });\n const resp = await fetch(url, { headers: { authorization: \"Nostr \" + btoa(JSON.stringify(ev)) } });\n if (!resp.ok) throw new Error((await resp.json()).error || \"download failed\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(\".\")[0] + \"-\" + name; a.click(); URL.revokeObjectURL(a.href);\n }\n // A plain member's own invites, when the owner lets members invite.\n async function loadMine() {\n const sec = $(\"#myinvites\");\n if (!me || !owner || myRole || !signer.ready()) { sec.classList.add(\"hidden\"); return; }\n let mine;\n try { mine = await rpc(\"listinvites\"); } catch { sec.classList.add(\"hidden\"); return; }\n sec.classList.remove(\"hidden\");\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#mine\").innerHTML = mine.length ? mine.map((i) => '
                                                                                                  • inv…' + i.code.slice(-8) + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
                                                                                                  • \").join(\"\") : '
                                                                                                  • no invites yet
                                                                                                  • ';\n }\n async function loadStorage() {\n storage = await rpc(\"storagestats\");\n pollJobs();\n loadViews();\n loadDumps().catch(() => {});\n const st = storage;\n const top = st.kinds.slice(0, 4), rest = st.kinds.slice(4).reduce((a, k) => a + k.bytes, 0);\n const parts = [...top.map((k, i) => [k.kind + \" \" + kindName(k.kind), \"k\" + (i + 1), k.bytes]), ...(rest ? [[\"other\", \"k5\", rest]] : [])];\n const total = st.eventBytes || 1;\n $(\"#s-bar\").innerHTML = parts.length ? parts.map(([n, c, b]) => '').join(\"\") : '';\n $(\"#s-legend\").innerHTML = parts.length ? parts.map(([n, c, b]) => '' + esc(n) + \" \" + Math.round(b * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n const overhead = Math.max(0, st.databaseBytes - st.eventBytes);\n $(\"#s-totals\").innerHTML = [[\"events\", st.events.toLocaleString() + \" \" + fmtBytes(st.eventBytes) + \"\"], [\"index and overhead\", fmtBytes(overhead)], [\"files\", st.blobs.toLocaleString() + \" \" + fmtBytes(st.mediaBytes) + \"\"]]\n .map(([k, v]) => \"
                                                                                                    \" + k + \"\" + v + \"
                                                                                                    \").join(\"\");\n const any = st.retention.find((r) => r.kind === null);\n const own = (kind) => { const r = st.retention.find((x) => x.kind === kind); return r ? r.days : \"\"; };\n const row = (k) => {\n const key = k.kind === null ? \"\" : String(k.kind);\n const placeholder = k.kind === null ? \"forever\" : k.replaceable || !any ? \"forever\" : any.days + \" (everything else)\";\n const pill = k.kind === null ? \"everything else\" : '' + k.kind + \" \" + esc(kindName(k.kind));\n const cells = k.protected\n ? 'required'\n : ' days' + ib(\"check\", \"Save keep-for rule\", \"saveretention\", key) + ib(\"trash\", \"Purge\", \"purgekind\", key, \"danger\") + \"\";\n return '' + pill + '' + (k.n === undefined ? \"\" : k.n.toLocaleString()) + '' + (k.bytes === undefined ? \"\" : fmtBytes(k.bytes)) + '' + (k.oldest ? esc(fmtDay(k.oldest)) : \"\") + \"\" + cells + \"\";\n };\n $(\"#kinds tbody\").innerHTML = [...st.kinds, { kind: null, days: any ? any.days : 0 }].map(row).join(\"\");\n loadListHistory().catch(() => {});\n }\n\n async function loadListHistory() {\n const rows = await rpc(\"listlisthistory\");\n const labels = { 3: \"follows\", 10002: \"relay list\", 10003: \"bookmarks\", 30003: \"bookmark list\" };\n $(\"#listhistory tbody\").innerHTML = rows.length ? rows.map((r) => '' + esc((labels[r.kind] || (\"kind \" + r.kind)) + (r.d ? \" / \" + r.d : \"\")) + '' + esc(fmtTime(r.created_at)) + '' + esc(fmtTime(r.saved_at)) + '' + ib(\"undo\", \"Restore this version\", \"restorelist\", r.event_id) + '').join(\"\") : 'no older list versions yet';\n }\n\n let searchQuery = \"\";\n async function loadEvents(reset) {\n const list = searchQuery ? await rpc(\"searchevents\", searchQuery, 200) : await rpc(\"listrecentevents\", reset ? 50 : 200);\n $(\"#events tbody\").innerHTML = list.length ? list.map((e) => '' + esc(fmtTime(e.created_at)) + '' + e.kind + '' + av(e.pubkey) + key(e.pubkey) + '' + esc(e.content) + '' + ib(\"pin\", \"Pin\", \"pinevent\", e.id) + ib(\"trash\", \"Delete event\", \"deleteevent\", e.id) + ib(\"ban\", \"Ban event\", \"banevent\", e.id, \"danger\") + ib(\"banuser\", \"Ban author\", \"banpubkey\", e.pubkey, \"danger\") + \"\").join(\"\") : 'no events yet';\n $(\"#more\").classList.toggle(\"hidden\", searchQuery || list.length < 50);\n }\n $(\"#searchform\").onsubmit = guard(async (ev) => {\n searchQuery = ev.target.q.value.trim();\n $(\"#searchclear\").classList.toggle(\"hidden\", !searchQuery);\n await loadEvents(true);\n });\n $(\"#searchclear\").onclick = guard(async () => {\n searchQuery = \"\"; $(\"#searchform\").reset(); $(\"#searchclear\").classList.add(\"hidden\");\n await loadEvents(true);\n });\n async function loadPins() {\n const pins = await rpc(\"listpins\");\n $(\"#pins-count\").textContent = pins.length || \"\";\n $(\"#pins\").innerHTML = pins.length ? pins.map((t) => '
                                                                                                  • ' + (t[0] === \"e\" ? \"event\" : \"address\") + '' + esc(t[1].length > 40 ? t[1].slice(0, 16) + \"\\u2026\" + t[1].slice(-8) : t[1]) + \"\" + ib(\"copy\", \"Copy\", \"copy\", t[1]) + ib(\"x\", \"Unpin\", \"unpinevent\", t[1]) + \"
                                                                                                  • \").join(\"\") : '
                                                                                                  • nothing pinned
                                                                                                  • ';\n }\n $(\"#pinform\").onsubmit = guard(async (ev) => {\n await rpc(\"pinevent\", ev.target.id.value.trim()); ev.target.reset(); toast(\"Pinned\"); await loadPins();\n });\n\n // ---- actions ----\n const refresh = () => Promise.all([loadLists(), loadEvents(true), loadStorage(), loadPins()]);\n\n $(\"#copy\").onclick = async () => { await navigator.clipboard.writeText(wsURL); toast(\"copied \" + wsURL); };\n $(\"#signin\").onclick = guard(async () => {\n if (!window.nostr) throw new Error(\"No nostr extension found. Install one (Alby, nos2x, …) and reload, or use a remote signer.\");\n me = await window.nostr.getPublicKey();\n localStorage.setItem(\"me\", me);\n renderHeader(); await loadAdmin(); await loadPeople();\n });\n $(\"#signin46\").onclick = () => showRemote(null);\n $$(\".remote\").forEach((b) => { b.onclick = () => showRemote($(\"#\" + b.dataset.note)); });\n $(\"#nccopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#nclink\").href); toast(\"Link copied; paste it into your signer app\"); };\n $(\"#bunkerform\").onsubmit = guard(async (ev) => {\n $(\"#remotenote\").textContent = \"Connecting…\";\n try { await connectBunker(ev.target.url.value); } catch (e) { $(\"#remotenote\").textContent = e.message; throw e; }\n $(\"#remotenote\").textContent = \"\"; ev.target.reset();\n await remoteDone();\n });\n $(\"#signout\").onclick = async () => {\n if (remote) { const r = remote; remote = null; try { await withTimeout(r.logout(), 5000, \"The signer\"); } catch { /* the session is gone either way */ } }\n localStorage.removeItem(\"nip46\");\n me = null; localStorage.removeItem(\"me\"); renderHeader(); await loadAdmin(); await loadPeople();\n };\n const claimNow = async (note) => {\n if (!signer.ready()) { note.textContent = \"Sign the claim with a nostr extension or a remote signer.\"; showRemote(note); return; }\n me = await signer.getPublicKey();\n const r = await rpc(\"claim\");\n if (!r.claimed) throw new Error(\"Somebody else claimed it first.\");\n localStorage.setItem(\"me\", me);\n toast(\"It's yours.\");\n await loadInfo(); await loadFuel(); await loadAdmin(); await loadPeople();\n if (r.converted && confirm(\"This relay began as a temporary one: anyone can write, and everything is deleted after 14 days. Switch to the default rules and keep everything from now on? Each rule can be changed later on the Rules and Storage tabs.\")) {\n await rpc(\"resetrules\"); toast(\"Rules reset\"); await loadInfo(); await loadAdmin();\n }\n };\n $(\"#claim\").onclick = guard(() => claimNow($(\"#claimnote\")));\n $(\"#claimlease\").onclick = guard(() => claimNow($(\"#leasenote\")));\n // Jobs run in the background; the table follows them while the tab is open.\n let jobsTimer = 0;\n const fmtJob = (j) => {\n const what = j.kind === \"mirror\" ? \"mirror site\" : j.kind === \"import\" ? \"import\" : j.label === \"backfill\" ? \"fetch my history\" : j.kind === \"pull\" ? \"pull\" : \"rebroadcast\";\n const f = [];\n if (j.filter.authors) f.push(j.filter.authors.length === 1 && j.filter.authors[0] === me ? \"my events\" : j.filter.authors.length + \" authors\");\n if (j.filter.kinds) f.push(\"kinds \" + j.filter.kinds.join(\", \"));\n if (j.filter.since) f.push(\"since \" + fmtDay(j.filter.since));\n const when = j.every ? \"every \" + (j.every === 24 ? \"day\" : j.every + \" h\") + (j.nextRun ? \", next \" + fmtTime(j.nextRun) : \"\") : \"once\";\n const l = j.last;\n const count = (stored, blobs, sent, refused) => (j.kind === \"mirror\" ? blobs.toLocaleString() + \" files mirrored\" : j.kind === \"import\" ? stored.toLocaleString() + \" events\" + ((j.last ? j.last.duplicates : j.duplicates) ? \", \" + (j.last ? j.last.duplicates : j.duplicates) + \" already here\" : \"\") : j.kind === \"pull\" ? stored.toLocaleString() + \" events\" + (blobs ? \", \" + blobs + \" files\" : \"\") : sent.toLocaleString() + \" sent\" + (refused ? \", \" + refused + \" refused\" : \"\"));\n const res = j.running ? \"running: \" + count(j.stored, j.blobs, j.sent, j.refused) + \"...\" : !l ? \"waiting\" : l.error ? \"failed: \" + l.error : count(l.stored, l.blobs, l.sent, l.refused) + (l.skipped ? \", \" + l.skipped + \" skipped\" : \"\") + \", \" + fmtTime(l.finishedAt);\n const sources = j.running ? j.pullSources : l?.sources;\n const details = sources?.length ? '
                                                                                                    Source results' + sources.map((s) => '

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

                                                                                                    ').join('') + '
                                                                                                    ' : '';\n return \"\" + what + \"\" + j.relays.map(esc).join(\"
                                                                                                    \") + \"\" + (f.join(\", \") || \"everything\") + \"\" + when + \"\" + esc(res) + details + \"\" + (j.running ? \"\" : ib(\"undo\", \"Run now\", \"runjob\", j.id)) + ib(\"x\", \"Remove\", \"removejob\", j.id) + \"\";\n };\n async function pollJobs() {\n clearTimeout(jobsTimer);\n let jobs;\n try { jobs = await rpc(\"listjobs\"); } catch { return; }\n $(\"#jobs tbody\").innerHTML = jobs.length ? jobs.map(fmtJob).join(\"\") : 'no jobs yet';\n if (jobs.some((j) => j.running || (j.nextRun && j.nextRun <= Math.floor(Date.now() / 1000) + 1))) jobsTimer = setTimeout(pollJobs, 3000);\n }\n const urls = (s) => s.split(/[\\s,]+/).map((u) => u.trim()).filter(Boolean);\n const kindsOf = (s) => s.split(/[\\s,]+/).map((k) => parseInt(k, 10)).filter((k) => Number.isInteger(k) && k >= 0);\n $(\"#pullform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"addjob\", { kind: \"pull\", relays: [f.url.value.trim()], every: +f.every.value });\n toast(+f.every.value ? \"Mirror scheduled\" : \"Pull started\"); f.reset(); await pollJobs();\n });\n $(\"#backfillform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"backfill\", urls(f.relays.value));\n toast(\"Fetching your history\"); f.reset(); await pollJobs();\n });\n $(\"#pushform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const filter = {};\n const kinds = kindsOf(f.kinds.value); if (kinds.length) filter.kinds = kinds;\n const days = +f.days.value; if (days > 0) filter.since = Math.floor(Date.now() / 1000) - days * 86400;\n await rpc(\"addjob\", { kind: \"push\", relays: urls(f.targets.value), filter, every: +f.every.value });\n toast(\"Rebroadcast started\"); f.reset(); await pollJobs();\n });\n // ---- presets: writes, reads, kinds and keep-for rules in one click ----\n let presets = null;\n async function renderPresets() {\n if (!presets) { try { presets = await rpc(\"listpresets\"); } catch { presets = []; } }\n $(\"#presets\").innerHTML = presets.map((p) => '\").join(\"\");\n $(\"#presetsourcerow\").classList.toggle(\"hidden\", !presets.some((p) => p.source));\n }\n $(\"#presets\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-preset]\"); if (!b) return;\n const p = (presets || []).find((x) => x.name === b.dataset.preset); if (!p) return;\n if (!confirm(p.title + \": \" + p.about + \"\\n\\nThis replaces the writes and reads rules, the directory setting, the kind rules and the keep-for rules. Limits, identity, people and bans stay.\")) return;\n const source = $(\"#presetsource\").value.trim();\n if (p.source === \"required\" && !source) { $(\"#presetnote\").textContent = p.title + \" needs a source relay to mirror; enter its wss:// URL first.\"; return; }\n b.disabled = true;\n try { policy = await rpc(\"applypreset\", p.name, p.source && source ? { source } : undefined); $(\"#presetnote\").textContent = \"Now: \" + p.about + (policy.job ? \" Mirroring \" + source + \" every \" + policy.job.every + \" h.\" : \"\"); toast(p.title + \" applied\"); await loadInfo(); await loadAdmin(); }\n catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // ---- wire me in: this relay in the owner's own lists ----\n // The lists are replaceable, so a fresh list with only this relay would\n // clobber the real one once it spread. Every publish starts from the\n // newest copy found here or on the indexers, verified when the signer\n // library is around, and adds or removes this relay in it.\n const INDEXERS = [\"wss://purplepag.es\", \"wss://relay.nostr.band\", \"wss://relay.damus.io\", \"wss://nos.lol\"];\n const LISTS = [\n { kind: 10002, tag: \"r\", title: \"Relay list\", nip: \"NIP-65\", about: \"where clients read your notes and send you mentions\" },\n { kind: 10050, tag: \"relay\", title: \"DM inbox\", nip: \"NIP-17\", about: \"where people send you private messages\" },\n { kind: 10007, tag: \"relay\", title: \"Search relays\", nip: \"NIP-51\", about: \"where clients run your searches\" },\n { kind: 10063, tag: \"server\", title: \"Blossom servers\", nip: \"BUD-03\", about: \"where clients upload and look for your files\" },\n ];\n const mineFor = (l) => (l.tag === \"server\" ? location.origin : wsURL);\n const normURL = (u) => { try { const x = new URL(String(u).trim()); return (x.host + x.pathname).replace(/\\/+$/, \"\").toLowerCase(); } catch { return String(u).trim().toLowerCase(); } };\n const isMine = (l, t) => t[0] === l.tag && normURL(t[1] || \"\") === normURL(mineFor(l));\n const listHas = (list, l) => !!list && list.tags.some((t) => isMine(l, t));\n // mergeList is pure: the newest list with this relay put first or taken out, every other tag kept.\n function mergeList(list, l, include) {\n const tags = (list ? list.tags : []).filter((t) => !isMine(l, t));\n if (include) tags.unshift([l.tag, mineFor(l)]);\n return { kind: l.kind, created_at: Math.floor(Date.now() / 1000), content: list ? list.content : \"\", tags };\n }\n const relaysIn = (list) => list.tags.filter((t) => (t[0] === \"r\" || t[0] === \"relay\") && /^wss?:\\/\\//i.test(t[1] || \"\")).map((t) => t[1].trim());\n // overWS opens one socket, sends one message, feeds answers to onMessage until it says done or time runs out.\n function overWS(url, ms, first, onMessage) {\n return new Promise((res) => {\n let ws = null, done = false, out = null;\n const finish = () => { if (done) return; done = true; clearTimeout(timer); try { if (ws) ws.close(); } catch { /* closed */ } res(out); };\n const timer = setTimeout(finish, ms);\n try { ws = new WebSocket(url); } catch { return finish(); }\n ws.onopen = () => ws.send(JSON.stringify(first));\n ws.onmessage = (m) => { let d; try { d = JSON.parse(m.data); } catch { return; } if (!Array.isArray(d)) return; if (onMessage(d, (v) => { out = v; })) finish(); };\n ws.onerror = finish; ws.onclose = finish;\n });\n }\n function fetchNewest(url, filter, ms) {\n let best = null;\n return overWS(url, ms, [\"REQ\", \"w\", filter], (d, set) => {\n if (d[0] === \"EVENT\" && d[1] === \"w\" && d[2] && (!best || d[2].created_at > best.created_at)) { best = d[2]; set(best); }\n return (d[0] === \"EOSE\" || d[0] === \"CLOSED\") && d[1] === \"w\";\n });\n }\n function publishTo(url, event, ms) {\n return overWS(url, ms, [\"EVENT\", event], (d, set) => { if (d[0] === \"OK\" && d[1] === event.id) { set({ url, ok: !!d[2], msg: d[3] || \"\" }); return true; } return false; })\n .then((r) => r || { url, ok: false, msg: \"no answer\" });\n }\n // bridge signs a NIP-98 request to this relay's HTTP door, so the owner's own reads and writes pass any read rule.\n async function bridge(path, body) {\n const url = location.origin + path, raw = JSON.stringify(body);\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"POST\"], [\"payload\", await sha256hex(raw)]] });\n const resp = await fetch(url, { method: \"POST\", headers: { \"content-type\": \"application/json\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body: raw });\n const json = await resp.json();\n if (!resp.ok) throw new Error(json.error || \"HTTP \" + resp.status);\n return json;\n }\n const wire = {};\n function renderWire() {\n const pill = (state) => {\n const cls = state === \"listed\" ? \"on\" : state === \"failed\" ? \"bad\" : state === \"not listed\" ? \"off\" : \"\";\n const label = state === \"checking\" || state === \"publishing\" ? state + \"\\u2026\" : state;\n return '' + esc(label) + \"\";\n };\n $(\"#wire\").innerHTML = LISTS.map((l) => {\n const w = wire[l.kind] || { state: \"\", results: [], list: null };\n const busy = w.state === \"checking\" || w.state === \"publishing\";\n const btn = (act, cls, label) => '\";\n const buttons = w.state === \"listed\" ? btn(\"remove\", \"\", \"Remove this relay\") : btn(\"add\", \"pri\", \"Add this relay\") + btn(\"check\", \"\", w.state ? \"Check again\" : \"Check\");\n const n = w.list ? w.list.tags.filter((t) => t[0] === l.tag).length : 0;\n const from = w.list ? \"\" + (n === 1 ? \"1 entry\" : n + \" entries\") + \", \" + fmtTime(w.list.created_at) + \"\" : w.state && !busy ? \"no list found\" : \"\";\n const sent = (w.results || []).map((r) => '' + esc(r.url.replace(/^wss?:\\/\\//, \"\")) + (r.ok ? \"\" : \" failed\") + \"\").join(\"\");\n const meta = (w.state ? pill(w.state) : \"\") + from + sent;\n return '
                                                                                                    ' + l.title + ' ' + l.nip + \"
                                                                                                    \" + l.about + '
                                                                                                    ' + buttons + '
                                                                                                    ' + meta + \"
                                                                                                    \";\n }).join(\"\");\n }\n async function wireCheck(l) {\n const w = (wire[l.kind] = { state: \"checking\", list: null, results: [] }); renderWire();\n const filter = { kinds: [l.kind], authors: [me], limit: 1 };\n const found = [];\n try { found.push(...(await bridge(\"/query\", [filter]))); } catch { /* the relay may hold none */ }\n const remote = await Promise.all(INDEXERS.map((u) => fetchNewest(u, filter, 4000)));\n let lib = null; try { lib = await signerLib(); } catch { /* unverified lists are still the owner's own, by pubkey */ }\n for (const e of remote) if (e && e.pubkey === me && e.kind === l.kind && Array.isArray(e.tags) && (!lib || lib.verifyEvent(e))) found.push(e);\n w.list = found.sort((a, b) => b.created_at - a.created_at)[0] || null;\n w.state = listHas(w.list, l) ? \"listed\" : \"not listed\"; renderWire();\n }\n async function wirePublish(l, include) {\n if (!wire[l.kind] || !wire[l.kind].state || wire[l.kind].state === \"failed\") await wireCheck(l);\n const w = wire[l.kind]; w.state = \"publishing\"; w.results = []; renderWire();\n try {\n const signed = await signer.signEvent(mergeList(w.list, l, include));\n const here = await bridge(\"/events\", signed).then((r) => ({ url: wsURL, ok: !!r.accepted, msg: r.message || \"\" })).catch((e) => ({ url: wsURL, ok: false, msg: e.message }));\n const targets = [...new Set([...relaysIn(signed), ...INDEXERS])].filter((u) => normURL(u) !== normURL(wsURL));\n const rest = await Promise.all(targets.map((u) => publishTo(u, signed, 6000)));\n w.results = [here, ...rest]; w.list = signed; w.state = include ? \"listed\" : \"not listed\";\n toast((include ? \"Added to your \" : \"Removed from your \") + l.title.toLowerCase());\n } catch (e) { w.state = \"failed\"; w.results = [{ url: wsURL, ok: false, msg: e.message }]; toast(e.message); }\n renderWire();\n }\n $(\"#wire\").addEventListener(\"click\", (ev) => {\n const b = ev.target.closest(\"button[data-wire]\"); if (!b) return;\n const l = LISTS.find((x) => String(x.kind) === b.dataset.wire); if (!l) return;\n if (b.dataset.do === \"check\") wireCheck(l); else wirePublish(l, b.dataset.do === \"add\");\n });\n\n // ---- custom domains: this relay under a hostname the owner controls ----\n let domainSites = [];\n const domainOptions = (current = \"\") => [{ label: \"\", title: \"This relay\" }, ...domainSites.map((s) => ({ label: s.label, title: (s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + \" / \" + key(s.author) })), ...(current && !domainSites.some((s) => s.label === current) ? [{ label: current, title: \"Site \" + current }] : [])].map((s) => '\").join(\"\");\n let domains = null; // null: not enabled on this host\n function renderDomains() {\n $(\"#domains\").innerHTML = (domains || []).map((d) => {\n const state = d.ready ? \"active\" : \"hostname \" + d.status.replace(/_/g, \" \") + \", certificate \" + d.sslStatus.replace(/_/g, \" \");\n const btn = (act, cls, label) => '\";\n const rows = d.ready ? \"\" : '
                                                                                                    ' + d.records.map((r) => \"\").join(\"\") + \"
                                                                                                    TypeNameValue
                                                                                                    \" + esc(r.type) + '' + esc(r.name) + '' + esc(r.value) + \"\" + esc(r.note) + \"
                                                                                                    \";\n return '
                                                                                                    ' + esc(d.host) + '' + esc(state) + \"\" + btn(\"check\", \"\", \"Check\") + btn(\"remove\", \"danger\", \"Remove\") + '
                                                                                                    \" + rows;\n }).join(\"\");\n }\n async function loadDomains() {\n try { [domains, domainSites] = await Promise.all([rpc(\"listdomains\"), rpc(\"listsites\")]); $(\"#adddomain select[name=site]\").innerHTML = domainOptions(); $(\"#domainnote\").textContent = domains.length ? \"\" : \"No custom domain yet.\"; $(\"#adddomain\").classList.remove(\"hidden\"); }\n catch (e) { domains = null; $(\"#domainnote\").textContent = /^unsupported/.test(e.message) ? \"Custom domains are not enabled on this host.\" : e.message; $(\"#adddomain\").classList.add(\"hidden\"); }\n renderDomains();\n }\n $(\"#adddomain\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"adddomain\", f.host.value.trim(), f.site.value); f.reset(); toast(\"Domain added; now create the CNAME\"); await loadDomains();\n });\n $(\"#domains\").addEventListener(\"change\", guard(async (ev) => {\n const select = ev.target.closest(\"select[data-domain-site]\"); if (!select) return;\n await rpc(\"setdomainsite\", select.dataset.domainSite, select.value);\n toast(\"Domain destination saved\"); await loadDomains();\n }));\n $(\"#domains\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-domain]\"); if (!b) return;\n const host = b.dataset.domain;\n if (b.dataset.do === \"remove\" && !confirm(\"Remove \" + host + \"? Its certificate goes with it and the name stops answering.\")) return;\n b.disabled = true;\n try {\n if (b.dataset.do === \"remove\") { await rpc(\"removedomain\", host); toast(\"Removed\"); }\n else { const d = await rpc(\"checkdomain\", host); toast(d.ready ? host + \" is live\" : \"Not yet: \" + (d.status === \"active\" ? \"certificate pending\" : \"waiting for the CNAME\")); }\n await loadDomains();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n $(\"#access\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const openKinds = f.openKinds.value.split(/[\\s,]+/).filter(Boolean).map(Number);\n if (openKinds.some((k) => !Number.isInteger(k) || k < 0 || k > 65535)) throw new Error(\"Open kinds must be whole numbers.\");\n policy = await rpc(\"setpolicy\", { writes: f.writes.value, reads: f.reads.value, openKinds, guestReplies: f.guestReplies.checked, minPow: +f.minPow.value, maxFuture: +f.maxFuture.value, maxLimit: +f.maxLimit.value, maxSubs: +f.maxSubs.value, maxMessageKB: +f.maxMessageKB.value, eventsPerMinute: +f.eventsPerMinute.value, reqsPerMinute: +f.reqsPerMinute.value, maxBlobMB: +f.maxBlobMB.value });\n toast(\"Rules saved\"); await loadInfo();\n });\n $(\"#notify\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { notify: { reports: f.reports.checked, fuel: f.fuel.checked, jobs: f.jobs.checked, succession: f.succession.checked, digest: f.digest.checked } });\n toast(\"Notifications saved\");\n });\n $(\"#notifytest\").onclick = guard(async () => {\n const r = await rpc(\"notifytest\");\n toast(r.sent ? \"Sent. Look for a message from the relay in your DMs.\" : \"Could not send.\");\n });\n $(\"#identity\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const list = (v) => v.split(\",\").map((s) => s.trim()).filter(Boolean);\n policy = await rpc(\"setpolicy\", { name: f.name.value, contact: f.contact.value, description: f.description.value, icon: f.icon.value,\n banner: f.banner.value, postingPolicy: f.postingPolicy.value, privacyPolicy: f.privacyPolicy.value, tags: list(f.tags.value), languageTags: list(f.languageTags.value), relayCountries: list(f.relayCountries.value) });\n toast(\"Identity saved\"); await loadInfo(); await loadPeople(); loadCard();\n });\n $(\"#addmember\").onsubmit = guard(async (ev) => {\n const f = ev.target; const pk = npubToHex(f.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"setmember\", pk, { name: f.name.value.trim() || null, note: f.note.value }); f.reset(); toast(\"Member added\"); await loadLists();\n });\n $(\"#addblock\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"blockip\", f.ip.value.trim(), f.reason.value); f.reset(); toast(\"Blocked\"); await loadLists();\n });\n $(\"#wordsform\").onsubmit = guard(async (ev) => {\n const words = ev.target.words.value.split(\"\\n\").map((s) => s.trim()).filter(Boolean);\n const kept = await rpc(\"setblockedwords\", words);\n policy = await rpc(\"setpolicy\", { blockedWordsInTags: ev.target.inTags.checked });\n ev.target.words.value = kept.join(\"\\n\"); toast(kept.length ? kept.length + \" words blocked\" : \"No words blocked\");\n });\n $(\"#thresholdform\").onsubmit = guard(async (ev) => {\n policy = await rpc(\"setpolicy\", { reportThreshold: +ev.target.reportThreshold.value });\n toast(policy.reportThreshold ? \"Hidden after \" + policy.reportThreshold + \" reports\" : \"Never hidden by reports\"); await loadLists();\n });\n $(\"#addban\").onsubmit = guard(async (ev) => {\n const pk = npubToHex(ev.target.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"banpubkey\", pk, ev.target.reason.value, ev.target.erase.checked); ev.target.reset(); toast(ev.target.erase.checked ? \"Banned and erased\" : \"Banned\"); await loadLists();\n });\n $(\"#mintinvite\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const inv = await rpc(\"createinvite\", +f.ttl.value, +f.max.value, f.note.value);\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n f.note.value = \"\"; toast(\"Invite created and copied\"); await loadLists();\n });\n $(\"#kindform\").onsubmit = guard(async (ev) => {\n const rule = ev.submitter.value; const k = +ev.target.kind.value;\n await rpc(rule === \"allow\" ? \"allowkind\" : \"disallowkind\", k); ev.target.reset(); toast((rule === \"allow\" ? \"Allowed kind \" : \"Blocked kind \") + k); await loadLists();\n });\n $(\"#more\").onclick = guard(() => loadEvents(false));\n $(\"#dumpform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { dumps: f.dumps.value, dumpsKeep: Math.max(1, Math.min(60, Math.floor(+f.keep.value || 7))) });\n toast(policy.dumps === \"off\" ? \"Dumps off\" : \"Dumping \" + policy.dumps); await loadDumps();\n });\n $(\"#importform\").onsubmit = guard(async (ev) => {\n const file = ev.target.file.files[0]; if (!file) return;\n if (!signer.ready()) throw new Error(NO_SIGNER);\n if (file.size > 64 * 1024 * 1024) throw new Error(\"At most 64 MB per import.\");\n const body = await file.text();\n const url = location.origin + \"/import?name=\" + encodeURIComponent(file.name);\n const token = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"PUT\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(url, { method: \"PUT\", headers: { authorization: \"Nostr \" + btoa(JSON.stringify(token)), \"content-type\": \"application/x-ndjson\" }, body });\n const r = await resp.json();\n if (!resp.ok) throw new Error(r.error || \"import failed\");\n ev.target.reset(); toast(\"Importing \" + fmtBytes(r.bytes)); await pollJobs();\n });\n $(\"#dumpnow\").onclick = guard(async () => { const d = await rpc(\"dumpnow\"); toast(\"Dumped \" + d.events.toLocaleString() + \" events\"); await loadStorage(); });\n $(\"#treeform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { memberInvites: { depth: Math.max(0, Math.floor(+f.depth.value || 0)), quota: Math.max(0, Math.floor(+f.quota.value || 0)) } });\n toast(policy.memberInvites.depth ? \"Members may invite\" : \"Only you and moderators invite\");\n });\n $(\"#mintmine\").onclick = guard(async () => {\n const inv = await rpc(\"createinvite\", 259200, 1, \"\");\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n toast(\"Invite created and copied\"); await loadMine();\n });\n $(\"#exportcfg\").onclick = guard(async () => {\n const cfg = await rpc(\"exportconfig\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(new Blob([JSON.stringify(cfg, null, 2)], { type: \"application/json\" }));\n a.download = host.split(\".\")[0] + \".bind.ws.config.json\";\n a.click(); URL.revokeObjectURL(a.href);\n });\n $(\"#importcfg\").onclick = () => $(\"#cfgfile\").click();\n $(\"#cfgfile\").onchange = guard(async (ev) => {\n const file = ev.target.files[0]; if (!file) return;\n const cfg = JSON.parse(await file.text());\n const check = await rpc(\"importconfig\", cfg, { dryRun: true });\n const lines = check.changes.summary.length ? check.changes.summary.join(\"\\n\") : \"Nothing would change.\";\n const dropped = check.warnings.length ? \"\\n\\nNot taken:\\n\" + check.warnings.join(\"\\n\") : \"\";\n if (!confirm(\"Apply \" + file.name + \" to this relay?\\n\\n\" + lines + dropped)) { ev.target.value = \"\"; return; }\n await rpc(\"importconfig\", cfg); ev.target.value = \"\"; toast(\"Configuration imported\"); await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // Succession: the status line under the heir form, from successionstatus.\n async function renderSuccession() {\n const el = $(\"#successionnote\"), f = $(\"#succession\");\n let st;\n try { st = await rpc(\"successionstatus\"); } catch { el.textContent = \"\"; return; }\n const sc = st.succession;\n $(\"#clearsuccession\").classList.toggle(\"hidden\", !sc);\n f.querySelector(\"button.btn:not(#clearsuccession)\").textContent = sc ? \"Change heir\" : \"Set heir\";\n if (!sc) { el.textContent = \"No heir named. Last signed in \" + fmtTime(st.ownerSeenAt) + \".\"; return; }\n if (f.heir.querySelector('[value=\"' + sc.heir + '\"]')) f.heir.value = sc.heir;\n f.afterDays.value = String(sc.afterDays);\n const who = (window.__members || []).find((m) => m.pubkey === sc.heir);\n const heir = who && who.name ? who.name + \"@\" + host : short(sc.heir);\n el.textContent = \"Heir: \" + heir + \". Last signed in \" + fmtTime(st.ownerSeenAt) + (st.silentDays ? \" (\" + st.silentDays + \" days ago)\" : \"\") + \". \" +\n (st.warning ? \"The warning month is running: the relay goes to \" + heir + \" on \" + fmtDay(st.handoverAt) + \" unless you sign in.\" : \"If you stay away, it goes to \" + heir + \" around \" + fmtDay(st.handoverAt) + \".\") +\n (st.log && st.log.length ? \" Handed over before: \" + st.log.map((l) => fmtDay(l.at) + \" to \" + short(l.to)).join(\", \") + \".\" : \"\");\n }\n $(\"#succession\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"setsuccession\", { heir: f.heir.value, afterDays: +f.afterDays.value });\n toast(\"Heir set\"); await loadAdmin();\n });\n $(\"#clearsuccession\").onclick = guard(async () => {\n await rpc(\"clearsuccession\");\n toast(\"Heir cleared\"); await loadAdmin();\n });\n $(\"#transfer\").onsubmit = guard(async (ev) => {\n const pk = ev.target.pubkey.value; if (!pk) return;\n const name = host.split(\".\")[0];\n const typed = prompt(\"This hands \" + host + \" to \" + short(pk) + \" for good. You stay on as a moderator. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing changed.\"); return; }\n await rpc(\"transferowner\", pk);\n toast(\"Transferred\");\n await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // ---- fork: a new name with this relay's events, claim reserved for a key ----\n $(\"#forkform\").scope.onchange = (ev) => $(\"#forkkinds\").classList.toggle(\"hidden\", ev.target.value !== \"kinds\");\n $(\"#joinform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { joinTerms: f.joinTerms.value, directoryPublic: f.directoryPublic.checked });\n toast(\"Saved\"); await loadInfo(); await loadPeople();\n });\n $(\"#forkform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const opts = { people: f.people.checked };\n if (f.name.value.trim()) opts.name = f.name.value.trim().toLowerCase();\n if (f.holder.value.trim()) { const pk = npubToHex(f.holder.value); if (!pk) throw new Error(\"That is not a pubkey.\"); opts.holder = pk; }\n if (f.scope.value === \"mine\") opts.filter = { authors: [me] };\n if (f.scope.value === \"kinds\") { const kinds = f.kinds.value.split(/[\\s,]+/).filter(Boolean).map(Number); if (!kinds.length || kinds.some((k) => !Number.isInteger(k) || k < 0)) throw new Error(\"Give kinds as numbers.\"); opts.filter = { kinds }; }\n if (!confirm(\"Fork this relay into a new name\" + (opts.name ? \" (\" + opts.name + \")\" : \"\") + \"? It pulls \" + (f.scope.value === \"all\" ? \"everything\" : f.scope.value === \"mine\" ? \"your events\" : \"the chosen kinds\") + (opts.people ? \" and the people\" : \"\") + \", and only \" + (opts.holder ? \"that key\" : \"you\") + \" can claim it.\")) return;\n const r = await rpc(\"forkrelay\", opts);\n $(\"#forknote\").textContent = r.handover + \" Expires \" + fmtTime(r.expires_at) + \".\";\n $(\"#forkurl\").textContent = r.console;\n $(\"#forkresult\").classList.remove(\"hidden\");\n toast(\"Forked to \" + r.name);\n });\n $(\"#forkcopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#forkurl\").textContent); toast(\"copied\"); };\n $(\"#deleterelay\").onclick = guard(async () => {\n const name = host.split(\".\")[0];\n const typed = prompt(\"This deletes everything on \" + host + \" and gives the name up. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing was deleted.\"); return; }\n await rpc(\"deleterelay\", name);\n localStorage.removeItem(\"me\");\n location.href = \"/\";\n });\n $(\"#topup\").onsubmit = guard((ev) => topUp(+ev.target.sats.value));\n $(\"#inv-copy\").onclick = async () => { await navigator.clipboard.writeText($(\"#inv-text\").value); toast(\"invoice copied\"); };\n $$(\"button[data-copy]\").forEach((b) => { b.onclick = async () => { await navigator.clipboard.writeText(b.dataset.copy === \"ws\" ? wsURL : location.origin); toast(\"copied\"); }; });\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-act]\"); if (!b) return;\n const act = b.dataset.act, id = b.dataset.id;\n if (act === \"copy\") { await navigator.clipboard.writeText(id); toast(\"copied\"); return; }\n if ((act === \"banpubkey\" || act === \"banevent\" || act === \"resolve:ban\") && !confirm(act === \"banevent\" ? \"Delete this event and refuse it forever?\" : \"Ban this author and refuse everything they post?\")) return;\n const erase = (act === \"banpubkey\" || act === \"resolve:ban\") && confirm(\"Also erase everything they wrote and uploaded here?\");\n if (act === \"deleteblob\" && !confirm(\"Delete this file for good?\")) return;\n if (act === \"deletedump\" && !confirm(\"Delete this dump?\")) return;\n if (act === \"downloaddump\") { b.disabled = true; try { await downloadDump(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }\n if (act === \"restorelist\") {\n if (!signer.ready()) { toast(NO_SIGNER); return; }\n try {\n const preview = await rpc(\"restorelist\", id);\n const d = preview.diff || {};\n const added = (d.addedTags || []).map((t) => \"+ \" + JSON.stringify(t)).join(\"\\n\");\n const removed = (d.removedTags || []).map((t) => \"- \" + JSON.stringify(t)).join(\"\\n\");\n const changes = [added, removed, d.contentChanged ? \"content changed\" : \"content unchanged\"].filter(Boolean).join(\"\\n\");\n if (!confirm(\"Restore this list version?\\n\\n\" + (changes || \"No tag or content changes\") + \"\\n\\nIt will be signed and published as the newest version.\")) return;\n const signed = await signer.signEvent(preview.draft);\n const result = await bridge(\"/events\", signed);\n if (!result.accepted) throw new Error(result.message || \"The relay refused the restored list.\");\n toast(\"List restored\"); await loadListHistory(); await loadStorage();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if ((act === \"removemember\" || act === \"banpubkey\") && (window.__members || []).some((m) => m.invited_by === id) && confirm(\"Also remove everyone this member invited, and everyone they invited in turn?\")) {\n b.disabled = true;\n try { const r = await rpc(\"removesubtree\", id); if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase); toast(\"Removed \" + r.removed.length); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"removejob\" && !confirm(\"Remove this job?\")) return;\n if (act === \"purgekind\") {\n const kind = id === \"\" ? null : +id;\n const label = kind === null ? \"everything without its own rule\" : kindName(kind) + \" (kind \" + kind + \")\";\n const typed = prompt(\"Purge \" + label + \" older than how many days? 0 purges all of them. This cannot be undone.\", \"30\");\n if (typed === null) return;\n const days = Math.max(0, Math.floor(+typed || 0));\n b.disabled = true;\n try { const r = await rpc(\"purgekind\", kind, days); toast(\"Purged \" + r.deleted.toLocaleString() + \" events\"); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"saveretention\") {\n const kind = id === \"\" ? null : +id;\n const days = Math.max(0, Math.floor(+b.closest(\"tr\").querySelector(\".days\").value || 0));\n b.disabled = true;\n try { await rpc(\"setretention\", kind, days); toast(days ? \"Kept for \" + days + \" days\" : \"Kept forever\"); await loadStorage(); await loadInfo(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n b.disabled = true;\n try {\n if (act === \"savemember\") { const tr = b.closest(\"tr\"), role = tr.querySelector(\"select.role\"), keep = tr.querySelector(\".keep\"), cap = tr.querySelector(\".cap\"); await rpc(\"setmember\", id, { name: tr.querySelector(\".name\").value.trim() || null, note: tr.querySelector(\".note\").value, ...(role ? { role: role.value } : {}), ...(keep ? { keepDays: Math.max(0, Math.floor(+keep.value || 0)), maxBytes: Math.max(0, Math.floor(+cap.value || 0)) * 1024 } : {}) }); }\n else if (act.startsWith(\"resolve:\")) await rpc(\"resolvereport\", id, act.slice(8), erase);\n else if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase);\n else await rpc(act, act === \"unrulekind\" ? +id : id, \"\");\n toast(\"Done\"); await (myRole ? refresh() : loadMine());\n if (act === \"pinevent\" || act === \"unpinevent\") await loadPins();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // The share block: the card picture, the group naddr and its QR.\n let card = null;\n async function loadCard() {\n try {\n card = await (await fetch(\"/card.json\", { cache: \"no-store\" })).json();\n $(\"#cardimg\").src = \"/card.svg?t=\" + Date.now();\n $(\"#naddr\").textContent = card.naddr || \"\";\n const q = $(\"#naddrqr\");\n if (card.naddr) { q.src = \"/qr.svg?text=\" + encodeURIComponent(card.naddr); q.classList.remove(\"hidden\"); } else q.classList.add(\"hidden\");\n } catch { /* the card is decoration */ }\n renderApps();\n }\n // The app rows on the Connect section: one per client people actually use,\n // with the link that lands on this relay in it. Relay apps take the relay\n // or the group address; feed apps take the owner's profile with this relay\n // as the hint, since they have no notion of opening a relay.\n function renderApps() {\n const el = $(\"#apps\"); if (!el) return;\n const enc = encodeURIComponent;\n const nprofile = card && card.nprofile ? card.nprofile : \"\";\n const naddr = card && card.naddr ? card.naddr : \"\";\n const whose = me && owner && me === owner ? \"your\" : \"the owner's\";\n const link = (label, href) => '' + label + \"\";\n const app = (label, uri) => '' + label + \"\";\n const copy = (label, text) => '\";\n const row = (name, where, note, acts) => '
                                                                                                    ' + name + \"\" + where + \"

                                                                                                    \" + note + '

                                                                                                    ' + acts.filter(Boolean).join(\"\") + \"
                                                                                                    \";\n const profileNote = \"Opens \" + whose + \" profile with this relay attached.\";\n const groups = [\n [\"As a place\", \"These open the relay itself: its feed, its people, its group.\", [\n row(\"Jumble\", \"web\", \"A feed of everything on this relay.\", [link(\"Open\", \"https://jumble.social/?r=\" + enc(wsURL))]),\n row(\"Coracle\", \"web\", \"The relay's page: its feed and its people.\", [link(\"Open\", \"https://coracle.social/relays/\" + enc(host))]),\n row(\"Flotilla\", \"web, phone\", \"The relay as a space, with the group as a room.\", [link(\"Open\", \"https://app.flotilla.social/spaces/\" + enc(host)), naddr && app(\"Open group\", \"nostr:\" + naddr)]),\n row(\"0xchat\", \"phone\", \"The group, in a chat app.\", [naddr && app(\"Open group\", \"nostr:\" + naddr), naddr && copy(\"Copy naddr\", naddr)]),\n row(\"noStrudel\", \"web\", \"Relays, add this one, then open its page.\", [copy(\"Copy relay URL\", wsURL)]),\n ]],\n [\"Find me here\", \"Feed apps have no relay pages. They meet this relay through a profile link that names it, then keep it once it is in the relay settings.\", [\n row(\"Primal\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://primal.net/p/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"YakiHonne\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://yakihonne.com/profile/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"Damus\", \"iPhone\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Amethyst\", \"Android\", profileNote + \" Then Relays in the drawer.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Nostur\", \"iPhone, Mac\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n ]],\n ];\n if (info?.supported_grasps?.includes(\"GRASP-01\")) groups.push([\n \"Git repositories\", \"Use this relay with a Git client.\", [\n row(\"GitWorkshop\", \"web\", \"Browse this relay's Git repositories.\", [link(\"Open in app\", \"https://gitworkshop.dev/relay/\" + enc((wsURL.startsWith(\"ws://\") ? \"ws:\" : \"\") + host)), copy(\"Copy relay URL\", wsURL)]),\n ],\n ]);\n el.innerHTML = groups.map(([h, note, rows]) => '

                                                                                                    ' + h + '

                                                                                                    ' + note + '

                                                                                                    ' + rows.join(\"\") + \"
                                                                                                    \").join(\"\");\n $(\"#apps-ws\").textContent = wsURL;\n const tile = (label, text) => '
                                                                                                    ' + label + '\"QR
                                                                                                    ';\n $(\"#phones\").innerHTML = [nprofile && tile(\"Find me here, for a phone\", \"nostr:\" + nprofile), naddr && tile(\"The group, for a phone\", \"nostr:\" + naddr)].filter(Boolean).join(\"\");\n }\n // The folds are a group: opening one closes the rest. Browsers with the\n // details name attribute do this themselves; this covers the others.\n $$(\".folds details\").forEach((d) => d.addEventListener(\"toggle\", () => { if (d.open) $$(\".folds details\").forEach((o) => { if (o !== d && o.open) o.open = false; }); }));\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-copytext]\"); if (!b) return;\n try { await navigator.clipboard.writeText(b.dataset.copytext); toast(\"copied\"); } catch { /* no clipboard */ }\n });\n $(\"#copynaddr\").onclick = async () => { if (!card || !card.naddr) return; await navigator.clipboard.writeText(card.naddr); toast(\"copied naddr\"); };\n $(\"#copyembed\").onclick = async () => { await navigator.clipboard.writeText('\"''); toast(\"copied embed\"); };\n\n // A click on a key copies it; a double-click selects the whole key so the usual copy shortcut takes it too.\n document.addEventListener(\"dblclick\", (ev) => {\n const k = ev.target.closest(\".key\"); if (!k) return;\n const s = window.getSelection(), r = document.createRange(); r.selectNodeContents(k.querySelector(\".full\")); s.removeAllRanges(); s.addRange(r);\n });\n document.addEventListener(\"click\", async (ev) => {\n const k = ev.target.closest(\".key\"); if (!k || ev.detail > 1 || String(window.getSelection())) return;\n try { await navigator.clipboard.writeText(k.title); toast(\"copied \" + k.dataset.short); } catch { /* no clipboard */ }\n });\n // ---- boot ----\n try { await loadInfo(); } catch { renderHeader(); }\n await loadFuel();\n await loadCard();\n me = localStorage.getItem(\"me\");\n if (me && await resumeRemote()) { /* a remote session answers for itself when first used */ }\n else if (me && window.nostr) { try { const pk = await window.nostr.getPublicKey(); if (pk !== me) me = null; } catch { me = null; } }\n else if (me) me = null;\n renderHeader();\n renderApps();\n await loadAdmin();\n await loadPeople();\n})();\n"; +export const CONSOLE_JS = "(async () => {\n const $ = (s) => document.querySelector(s);\n const $$ = (s) => document.querySelectorAll(s);\n const host = location.host;\n const wsURL = (location.protocol === \"https:\" ? \"wss://\" : \"ws://\") + host;\n const rpcURL = location.origin + \"/\";\n let info = null, me = null, owner = \"\", policy = null, fuel = null, people = null, myRole = \"\";\n\n const toast = (msg) => { const t = $(\"#toast\"); t.textContent = msg; t.classList.add(\"show\"); clearTimeout(t._t); t._t = setTimeout(() => t.classList.remove(\"show\"), 2600); };\n // guard wraps a handler: disables the button, shows errors as toasts. Defined first, since handlers below use it.\n const guard = (fn) => async (ev) => { ev.preventDefault(); const b = ev.submitter || ev.target; if (b) b.disabled = true; try { await fn(ev); } catch (e) { toast(e.message); } finally { if (b) b.disabled = false; } };\n const short = (hex) => hex ? hex.slice(0, 8) + \"…\" + hex.slice(-4) : \"\";\n // key shows a key short but keeps the whole of it in the DOM: selecting, double-clicking or clicking it copies the full hex.\n const key = (hex) => hex ? '' + hex + '' : '';\n const hue = (hex) => (parseInt(hex.slice(0, 2), 16) * 360 / 256).toFixed(0) + \"deg\";\n const av = (hex) => '';\n const fmtBytes = (n) => n < 1e6 ? (n / 1e3).toFixed(0) + \" KB\" : n < 1e9 ? (n / 1e6).toFixed(1) + \" MB\" : (n / 1e9).toFixed(2) + \" GB\";\n const fmtHours = (ms) => ms < 3600e3 ? Math.round(ms / 60e3) + \" min\" : (ms / 3600e3).toFixed(ms < 36e6 ? 1 : 0) + \" h\";\n const fuelOver = () => !!fuel && (fuel.eventBytes > fuel.freeEventBytes || fuel.mediaBytes > fuel.freeMediaBytes || fuel.activeMs > fuel.freeActiveMs || fuel.rowsWritten > fuel.freeRowsWritten);\n const fmtTime = (t) => t ? new Date(t * 1000).toLocaleString(undefined, { month: \"short\", day: \"numeric\", hour: \"2-digit\", minute: \"2-digit\" }) : \"–\";\n const fmtDay = (t) => t ? new Date(t * 1000).toLocaleDateString(undefined, { month: \"short\", day: \"numeric\" }) : \"–\";\n const ago = (t) => { const s = Math.max(0, Math.floor(Date.now() / 1000) - t); return s < 60 ? \"just now\" : s < 3600 ? Math.floor(s / 60) + \" min\" : s < 86400 ? Math.floor(s / 3600) + \" h\" : Math.floor(s / 86400) + \" d\"; };\n const esc = (s) => String(s ?? \"\").replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c]);\n\n const IC = {\n x: '',\n undo: '',\n check: '',\n trash: '',\n ban: '',\n banuser: '',\n eye: '',\n lock: '',\n pen: '',\n people: '',\n person: '',\n gauge: '',\n bolt: '',\n copy: '',\n pin: '',\n };\n const ib = (icon, label, act, id, extra) => '\";\n\n const CH = \"qpzry9x8gf2tvdw0s3jn54khce6mua7l\";\n function npubToHex(s) {\n s = s.trim().toLowerCase();\n if (/^[0-9a-f]{64}$/.test(s)) return s;\n if (!s.startsWith(\"npub1\")) return null;\n const data = s.slice(5, -6).split(\"\").map((c) => CH.indexOf(c));\n if (data.some((d) => d < 0)) return null;\n let bits = 0, acc = 0, out = [];\n for (const d of data) { acc = (acc << 5) | d; bits += 5; if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 255); } }\n return out.length === 32 ? out.map((b) => b.toString(16).padStart(2, \"0\")).join(\"\") : null;\n }\n\n async function sha256hex(s) {\n const b = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(s));\n return [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, \"0\")).join(\"\");\n }\n // ---- signing: a NIP-07 extension, or a NIP-46 remote signer ----\n // The remote path loads the bundled library on first use; its session\n // (client key, signer pubkey, relays) lives in localStorage until sign-out.\n let remote = null, lib = null, pendingNote = null;\n const NO_SIGNER = \"Install a nostr extension (Alby, nos2x, …) or connect a remote signer.\";\n const withTimeout = (p, ms, what) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(what + \" did not answer; is the signer app open?\")), ms))]);\n const signer = {\n ready: () => !!remote || !!window.nostr,\n async getPublicKey() { if (remote) return withTimeout(remote.getPublicKey(), 20000, \"The remote signer\"); if (window.nostr) return window.nostr.getPublicKey(); throw new Error(NO_SIGNER); },\n async signEvent(ev) { if (remote) return withTimeout(remote.signEvent(ev), 60000, \"The remote signer\"); if (window.nostr) return window.nostr.signEvent(ev); throw new Error(NO_SIGNER); },\n };\n async function signerLib() {\n if (lib) return lib;\n if (!window.NostrSigner) await new Promise((res, rej) => { const s = document.createElement(\"script\"); s.src = window.SIGNER_URL || \"/signer.js\"; s.onload = res; s.onerror = () => rej(new Error(\"Could not load the signer library.\")); document.head.appendChild(s); });\n return (lib = window.NostrSigner);\n }\n const onauth = (u) => window.open(u, \"_blank\");\n // The signer conversation rides this relay, and a relay may ask the socket to\n // authenticate before it delivers kind 24133 to its parties; answer with the\n // session's own key.\n const authPool = (L, sk) => new L.SimplePool({ automaticallyAuth: () => (evt) => Promise.resolve(L.finalizeEvent(evt, sk)) });\n const saveSession = (sk, s, secret) => localStorage.setItem(\"nip46\", JSON.stringify({ sk: lib.bytesToHex(sk), pubkey: s.bp.pubkey, relays: s.bp.relays, secret: secret || null }));\n async function resumeRemote() {\n const raw = localStorage.getItem(\"nip46\");\n if (!raw) return false;\n try {\n const s = JSON.parse(raw); const L = await signerLib();\n remote = L.BunkerSigner.fromBunker(L.hexToBytes(s.sk), { pubkey: s.pubkey, relays: s.relays, secret: s.secret }, { onauth, pool: authPool(L, L.hexToBytes(s.sk)) });\n return true;\n } catch { localStorage.removeItem(\"nip46\"); return false; }\n }\n async function connectBunker(input) {\n const L = await signerLib();\n const bp = await L.parseBunkerInput(input.trim());\n if (!bp) throw new Error(\"That is not a bunker:// URL.\");\n if (!bp.relays.length) throw new Error(\"The bunker URL names no relay.\");\n const sk = L.generateSecretKey();\n const s = L.BunkerSigner.fromBunker(sk, bp, { onauth, pool: authPool(L, sk) });\n await withTimeout(s.connect({ name: host, url: location.origin }), 60000, \"The signer\");\n await Promise.race([s.switchRelays(), new Promise((r) => setTimeout(r, 3000))]);\n remote = s; saveSession(sk, s, bp.secret);\n }\n // The nostrconnect:// flow: this relay carries the traffic, so no third relay is involved.\n let ncPending = null;\n async function offerNostrConnect() {\n if (ncPending) return ncPending;\n const L = await signerLib();\n const sk = L.generateSecretKey();\n const secret = L.bytesToHex(crypto.getRandomValues(new Uint8Array(8)));\n const uri = L.createNostrConnectURI({ clientPubkey: L.getPublicKey(sk), relays: [wsURL], secret, name: host, url: location.origin, perms: [\"sign_event:27235\", \"sign_event:9734\"] });\n $(\"#nclink\").href = uri; $(\"#ncnote\").textContent = \"\";\n $(\"#ncqr\").src = \"/qr.svg?text=\" + encodeURIComponent(uri); $(\"#ncqr\").classList.remove(\"hidden\");\n ncPending = L.BunkerSigner.fromURI(sk, uri, { onauth, pool: authPool(L, sk) }, 600000).then((s) => { remote = s; saveSession(sk, s, secret); ncPending = null; return s; }, (e) => { ncPending = null; throw e; });\n return ncPending;\n }\n async function remoteDone() {\n me = await signer.getPublicKey();\n localStorage.setItem(\"me\", me);\n $(\"#remotesec\").classList.add(\"hidden\");\n toast(\"Remote signer connected\");\n if (pendingNote && !owner) { const n = pendingNote; pendingNote = null; await claimNow(n); return; }\n pendingNote = null;\n renderHeader(); await loadAdmin(); await loadPeople();\n }\n function showRemote(note) {\n pendingNote = note || null;\n $(\"#remotesec\").classList.remove(\"hidden\");\n $(\"#remotesec\").scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n offerNostrConnect().then(remoteDone).catch((e) => { $(\"#ncnote\").textContent = e.message; });\n }\n\n async function rpc(method, ...params) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const body = JSON.stringify({ method, params });\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", rpcURL], [\"method\", \"POST\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(rpcURL, { method: \"POST\", headers: { \"content-type\": \"application/nostr+json+rpc\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body });\n const json = await resp.json();\n if (json.error) throw new Error(json.error);\n return json.result;\n }\n\n // ---- front of house ----\n // urlish shows a URL as a short link: the host and the start of the path, the whole thing on hover and as the target.\n class Html { constructor(s) { this.s = s; } }\n const urlish = (u) => {\n if (!u) return \"\";\n let label = u; try { const x = new URL(u); label = x.host + (x.pathname.length > 1 ? x.pathname.slice(0, 18) + (x.pathname.length > 18 ? \"\\u2026\" : \"\") : \"\"); } catch { /* not a URL */ }\n if (!/^https?:\\/\\//.test(u)) return u;\n return new Html('' + esc(label) + \"\");\n };\n function renderHeader() {\n const name = (info && info.name) || host.split(\".\")[0];\n $(\"#title\").textContent = name;\n document.title = name + \" - relay\";\n $(\"#url\").textContent = wsURL;\n $(\"#d-ws\").textContent = wsURL; $(\"#d-blossom\").textContent = location.origin; $(\"#d-nip05\").textContent = \"you@\" + host;\n $(\"#nip05-example\").textContent = \"alice@\" + host;\n const desc = (info && info.description) || \"\";\n $(\"#desc\").textContent = desc; $(\"#desc\").classList.toggle(\"hidden\", !desc);\n // What the owner declared about the relay, on the page, not only in the document clients read.\n const img = (id, url) => { const el = $(id); if (url) el.src = url; el.classList.toggle(\"hidden\", !url); };\n img(\"#bannerimg\", info?.banner); $(\"#banner\").classList.toggle(\"hidden\", !info?.banner);\n img(\"#iconimg\", info?.icon);\n const meta = [];\n for (const t of info?.tags || []) meta.push('' + esc(t) + \"\");\n const where = [...(info?.language_tags || []), ...(info?.relay_countries || [])];\n if (where.length) meta.push(\"\" + esc(where.join(\", \")) + \"\");\n const link = (label, href) => href ? '' + label + \"\" : \"\";\n const contact = info?.contact ? (/^(mailto:|https?:\\/\\/)/.test(info.contact) ? link(\"Contact\", info.contact) : \"\" + esc(info.contact) + \"\") : \"\";\n for (const x of [contact, link(\"Terms\", info?.terms_of_service), link(\"Posting policy\", info?.posting_policy), link(\"Privacy\", info?.privacy_policy)]) if (x) meta.push(x);\n $(\"#metarow\").innerHTML = meta.join('|'); $(\"#metarow\").classList.toggle(\"hidden\", meta.length === 0);\n $(\"#frontsec\").classList.toggle(\"hidden\", !desc && meta.length === 0);\n owner = (info && info.pubkey) || \"\";\n const isOwner = !!me && me === owner;\n const lease = !owner && info && info.lease ? info.lease : null;\n $(\"#ownerline\").innerHTML = owner ? 'run by ' + key(owner) + \"\" : lease ? \"temporary until \" + fmtDay(lease.expires_at) : \"unclaimed\";\n $(\"#owner-av\").classList.toggle(\"hidden\", !owner);\n if (owner) $(\"#owner-av\").style.setProperty(\"--h\", hue(owner));\n $(\"#who\").textContent = me ? (isOwner ? \"(that's you)\" : \"\") : \"\";\n $(\"#unclaimed\").classList.toggle(\"hidden\", !!owner || !!lease);\n $(\"#leased\").classList.toggle(\"hidden\", !lease);\n if (lease) {\n $(\"#lease-until\").textContent = fmtTime(lease.expires_at);\n $(\"#leasenote\").textContent = lease.holder ? \"Reserved for the key that asked for it; sign with that key.\" : \"\";\n }\n $(\"#signin\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signin46\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signout\").classList.toggle(\"hidden\", !me);\n $(\"#about\").innerHTML = [\n [\"Name\", info?.name], [\"Description\", info?.description], [\"Contact\", info?.contact], [\"Owner\", owner ? short(owner) : \"\"], [\"Relay key\", info?.self ? short(info.self) : \"\"], [\"Software\", urlish(info?.software)], [\"Version\", info?.version],\n [\"Max query\", info?.limitation?.max_limit], [\"Auth required\", String(!!info?.limitation?.auth_required)], [\"Restricted writes\", String(!!info?.limitation?.restricted_writes)], [\"Min PoW\", info?.limitation?.min_pow_difficulty || 0],\n [\"Tags\", info?.tags?.join(\", \")], [\"Languages\", info?.language_tags?.join(\", \")], [\"Countries\", info?.relay_countries?.join(\", \")],\n [\"Terms\", urlish(info?.terms_of_service)], [\"Posting policy\", urlish(info?.posting_policy)], [\"Privacy policy\", urlish(info?.privacy_policy)], [\"Icon\", urlish(info?.icon)], [\"Banner\", urlish(info?.banner)],\n [\"NIPs\", info?.supported_nips?.join(\" \")],\n ].map(([k, v]) => \"
                                                                                                    \" + k + \"\" + (v === undefined || v === \"\" ? '' : v instanceof Html ? v.s : esc(v)) + \"
                                                                                                    \").join(\"\");\n renderCare();\n }\n\n function renderCare() {\n const g = (icon, label, off) => '' + IC[icon] + \"\" + label + \"\";\n const writes = policy ? policy.writes : (info?.limitation?.restricted_writes ? \"allowlist\" : \"open\");\n const reads = policy ? policy.reads : (info?.limitation?.auth_required ? \"auth\" : \"open\");\n let out = writes === \"owner\" ? g(\"person\", \"only owner writes\") : writes === \"allowlist\" ? g(\"people\", \"members write\") : writes === \"wot\" ? g(\"people\", \"members and follows write\") : g(\"pen\", \"anyone writes\");\n out += reads === \"members\" ? g(\"people\", \"members read\") : reads === \"auth\" ? g(\"lock\", \"sign in to read\") : g(\"eye\", \"anyone reads\");\n if (fuel) {\n const over = fuelOver();\n out += fuel.outOfFuel ? g(\"gauge\", \"out of fuel\", true) : over ? g(\"bolt\", \"burning sats\") : g(\"gauge\", \"on free allowance\");\n }\n $(\"#care\").innerHTML = out;\n }\n\n async function loadPeople() {\n try { people = await (await fetch(\"/people\")).json(); } catch { return; }\n const isOwner = !!me && me === owner;\n let list = people.people || [];\n if (!people.public && isOwner && policy) list = (window.__members || []);\n $(\"#peoplesec\").classList.toggle(\"hidden\", !owner || list.length === 0);\n $(\"#people-note\").classList.toggle(\"hidden\", people.public);\n $(\"#people-count\").textContent = list.length || \"\";\n $(\"#dir\").innerHTML = list.map((m) => '' + av(m.pubkey) + '' + (m.name ? esc(m.name) + \"@\" + host : key(m.pubkey)) + \"\" + (m.role === \"owner\" ? 'owner' : \"\") + \"\").join(\"\");\n }\n\n async function loadInfo() {\n info = await (await fetch(\"/\", { headers: { accept: \"application/nostr+json\" } })).json();\n renderHeader();\n const git = info.supported_grasps?.includes(\"GRASP-01\");\n $(\"#git-connect\").classList.toggle(\"hidden\", !git);\n const clone = \"git clone '\" + location.origin + \"//.git'\";\n $(\"#git-clone\").textContent = clone;\n $(\"#git-copy\").dataset.copytext = clone;\n renderApps();\n }\n\n async function loadFuel() {\n try { fuel = await (await fetch(\"/fuel\")).json(); } catch { return; }\n $(\"#fuelsec\").classList.toggle(\"hidden\", !owner);\n const pct = (used, free) => Math.min(100, Math.round((used / Math.max(free, 1)) * 100));\n const gauge = (id, used, free, text) => { const g = $(\"#g-\" + id), share = free ? used / free : 0; g.style.width = pct(used, free) + \"%\"; g.classList.toggle(\"over\", share > 1); g.classList.toggle(\"warm\", share > 0.75 && share <= 1); $(\"#p-\" + id).textContent = share > 1 ? \"over\" : Math.round(share * 100) + \"%\"; $(\"#t-\" + id).textContent = text; };\n gauge(\"events\", fuel.eventBytes, fuel.freeEventBytes, fmtBytes(fuel.eventBytes) + \" of \" + fmtBytes(fuel.freeEventBytes) + \" free\");\n gauge(\"media\", fuel.mediaBytes, fuel.freeMediaBytes, fmtBytes(fuel.mediaBytes) + \" of \" + fmtBytes(fuel.freeMediaBytes) + \" free\");\n gauge(\"active\", fuel.activeMs, fuel.freeActiveMs, fmtHours(fuel.activeMs) + \" of \" + fmtHours(fuel.freeActiveMs) + \" free\");\n gauge(\"rows\", fuel.rowsWritten, fuel.freeRowsWritten, fuel.rowsWritten.toLocaleString() + \" of \" + fuel.freeRowsWritten.toLocaleString() + \" free\");\n const sats = Math.floor(fuel.balanceMsats / 1000);\n const r = fuel.rates;\n $(\"#fuel-balance\").innerHTML = fuel.outOfFuel\n ? 'out of fuel Writes are paused until someone tops up.'\n : \"Balance \" + sats.toLocaleString() + \" sats. Past the allowances, prices track what the hosting costs: \" + r.satsPerGBMonthEvents.toLocaleString() + \" sats per GB-month of events, \" + r.satsPerGBMonthMedia.toLocaleString() + \" per GB-month of files, \" + r.satsPerActiveHour.toLocaleString() + \" per hour awake, \" + r.satsPerMillionRows.toLocaleString() + \" per million rows written. Traffic is free.\";\n $(\"#topup\").classList.toggle(\"hidden\", !fuel.enabled);\n // Who paid is the owner's to see: it comes with the signed stats call.\n let credits = [];\n if (owner && signer.ready()) { try { credits = (await rpc(\"stats\")).credits || []; } catch { credits = []; } }\n $(\"#credits tbody\").innerHTML = credits.length ? credits.map((c) => '' + esc(fmtTime(c.at)) + '' + av(c.payer) + key(c.payer) + '' + Math.floor(c.msats / 1000).toLocaleString() + \"\").join(\"\") : 'no zaps yet';\n if (!fuel.enabled) $(\"#topup-note\").textContent = \"Top-ups are not enabled on this service yet.\";\n renderCare();\n renderFuelTile();\n }\n\n async function topUp(sats) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const msats = Math.round(sats * 1000);\n const zapRequest = await signer.signEvent({ kind: 9734, created_at: Math.floor(Date.now() / 1000), content: \"fuel for \" + host, tags: [[\"p\", fuel.servicePubkey], [\"amount\", String(msats)], [\"relays\", wsURL]] });\n const r = await (await fetch(\"/fuel/invoice\", { method: \"POST\", body: JSON.stringify({ zapRequest }) })).json();\n if (r.error) throw new Error(r.error);\n const inv = r.invoice;\n $(\"#invoice\").classList.remove(\"hidden\");\n $(\"#inv-text\").value = inv; $(\"#inv-link\").href = \"lightning:\" + inv; $(\"#inv-state\").textContent = \"waiting for payment…\";\n if (window.webln) { try { await window.webln.enable(); await window.webln.sendPayment(inv); } catch (e) { toast(e.message || \"wallet declined\"); } }\n const before = fuel.creditedMsats;\n for (let i = 0; i < 120; i++) {\n await new Promise((r) => setTimeout(r, 2500));\n await loadFuel();\n if (fuel.creditedMsats > before) { $(\"#inv-state\").textContent = \"paid, thank you\"; toast(\"Fuel credited\"); return; }\n }\n $(\"#inv-state\").textContent = \"no receipt yet; it can take a minute after paying\";\n }\n\n // ---- console ----\n function showTab(name) {\n if (name === \"content\" || name === \"storage\") name = \"data\";\n const known = myRole === \"moderator\" ? [\"people\", \"moderation\"] : [\"people\", \"moderation\", \"rules\", \"identity\", \"data\", \"sync\", \"views\", \"health\", \"owner\"];\n if (!known.includes(name)) name = \"people\";\n $$(\".tabs a\").forEach((a) => a.classList.toggle(\"on\", a.dataset.tab === name));\n $$(\".panel\").forEach((p) => p.classList.toggle(\"on\", p.dataset.panel === name));\n }\n // Tabs switch in place. A fragment still opens a tab when someone arrives with one,\n // but clicking never writes one, and any fragment already there is dropped.\n $$(\".tabs a\").forEach((a) => a.addEventListener(\"click\", (ev) => { ev.preventDefault(); showTab(a.dataset.tab); if (location.hash) history.replaceState(null, \"\", location.pathname + location.search); }));\n window.addEventListener(\"hashchange\", () => showTab(location.hash.slice(1)));\n // The strip fades on whichever side has more tabs off screen.\n const tabsFade = () => { const t = $(\"#tabs\"); t.classList.toggle(\"fade-l\", t.scrollLeft > 4); t.classList.toggle(\"fade-r\", t.scrollLeft + t.clientWidth < t.scrollWidth - 4); };\n $(\"#tabs\").addEventListener(\"scroll\", tabsFade); window.addEventListener(\"resize\", tabsFade); new ResizeObserver(tabsFade).observe($(\"#tabs\"));\n\n function renderFuelTile() {\n if (!fuel) return;\n const over = fuelOver();\n const sats = Math.max(0, Math.floor(fuel.balanceMsats / 1000));\n const d = new Date(), end = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1), days = Math.max(1, Math.ceil((end - Date.now()) / 86400000));\n if (fuel.outOfFuel) { $(\"#h-fuel\").textContent = \"0 sats\"; $(\"#h-fuel-label\").textContent = \"out of fuel\"; $(\"#h-fuel-sub\").textContent = \"writes are paused\"; }\n else if (over) { $(\"#h-fuel\").textContent = sats.toLocaleString() + \" sats\"; $(\"#h-fuel-label\").textContent = \"fuel left\"; $(\"#h-fuel-sub\").textContent = \"past the free allowance\"; }\n else { $(\"#h-fuel\").textContent = days + (days === 1 ? \" day\" : \" days\"); $(\"#h-fuel-label\").textContent = \"free allowance left\"; $(\"#h-fuel-sub\").textContent = sats ? \"then \" + sats.toLocaleString() + \" sats\" : \"then a top-up is needed\"; }\n $(\"#usage\").innerHTML = [[\"events stored\", fmtBytes(fuel.eventBytes)], [\"files stored\", fmtBytes(fuel.mediaBytes)], [\"awake this month\", fmtHours(fuel.activeMs)], [\"rows written\", fuel.rowsWritten.toLocaleString()], [\"rows read\", fuel.rowsRead.toLocaleString()], [\"received this month\", fmtBytes(fuel.bytesIn)], [\"served this month\", fmtBytes(fuel.bytesOut)], [\"charged\", Math.floor(fuel.chargedMsats / 1000).toLocaleString() + \" sats\"]]\n .map(([k, v]) => \"
                                                                                                    \" + k + \"\" + v + \"
                                                                                                    \").join(\"\");\n }\n\n function renderHealth(stats) {\n $(\"#h-last\").textContent = stats.newest ? ago(stats.newest) : \"none\";\n $(\"#h-last-sub\").textContent = stats.newest ? fmtTime(stats.newest) : \"no events yet\";\n $(\"#h-conns\").textContent = stats.connections;\n const buckets = [[\"notes\", \"k1\", [1]], [\"reactions\", \"k2\", [7]], [\"DMs\", \"k3\", [4, 14, 1059]], [\"long-form\", \"k4\", [30023]], [\"other\", \"k5\", null]];\n const sums = buckets.map(() => 0); let total = 0;\n for (const { kind, n } of stats.kinds || []) { total += n; const i = buckets.findIndex((b) => b[2] && b[2].includes(kind)); sums[i < 0 ? 4 : i] += n; }\n const parts = buckets.map((b, i) => [b[0], b[1], sums[i]]).filter((p) => p[2] > 0);\n $(\"#h-kinds\").innerHTML = total ? parts.map(([name, cls, n]) => '').join(\"\") : '';\n $(\"#h-kinds-legend\").innerHTML = total ? parts.map(([name, cls, n]) => '' + name + \" \" + Math.round(n * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n renderFuelTile();\n }\n\n async function loadAdmin() {\n const isOwner = !!me && me === owner;\n myRole = isOwner ? \"owner\" : \"\";\n let stats = null, p = null;\n // A signed-in member may be a moderator; the relay says by answering stats.\n if (me && owner && !isOwner) { try { [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]); myRole = \"moderator\"; } catch { myRole = \"\"; } }\n $(\"#console\").classList.toggle(\"hidden\", !myRole);\n loadMine();\n $(\"#console\").classList.toggle(\"mod\", myRole === \"moderator\");\n if (!myRole) { policy = null; renderCare(); return; }\n if (isOwner) [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]);\n policy = p;\n renderHealth(stats);\n renderCare();\n const fa = $(\"#access\"), fi = $(\"#identity\");\n fa.writes.value = p.writes; fa.reads.value = p.reads; fa.openKinds.value = (p.openKinds || []).join(\", \"); fa.guestReplies.checked = !!p.guestReplies;\n $(\"#wordsform\").words.value = (p.blockedWords || []).join(\"\\n\"); $(\"#wordsform\").inTags.checked = !!p.blockedWordsInTags; $(\"#thresholdform\").reportThreshold.value = p.reportThreshold || 0; fa.minPow.value = p.minPow; fa.maxFuture.value = p.maxFuture; fa.maxLimit.value = p.maxLimit; fa.maxSubs.value = p.maxSubs; fa.maxMessageKB.value = p.maxMessageKB;\n fa.eventsPerMinute.value = p.eventsPerMinute; fa.reqsPerMinute.value = p.reqsPerMinute; fa.maxBlobMB.value = p.maxBlobMB;\n renderFeatures(p.features || {});\n $(\"#push-policy-form\").elements.origins.value = (p.pushCallbacks || []).join(\"\\n\");\n $(\"#push-policy-form\").elements.lettered.checked = !!p.letteredNips;\n $(\"#push-policy-form\").elements.delivery.checked = !!p.delivery?.enabled;\n $(\"#push-policy-form\").elements.deliveryMax.value = p.delivery?.maxTargets || 8;\n fi.name.value = p.name; fi.contact.value = p.contact; fi.description.value = p.description; fi.icon.value = p.icon;\n const fj = $(\"#joinform\"); fj.joinTerms.value = p.joinTerms; fj.directoryPublic.checked = !!p.directoryPublic;\n loadCard();\n fi.banner.value = p.banner || \"\"; fi.postingPolicy.value = p.postingPolicy || \"\"; fi.privacyPolicy.value = p.privacyPolicy || \"\";\n fi.tags.value = (p.tags || []).join(\", \"); fi.languageTags.value = (p.languageTags || []).join(\", \"); fi.relayCountries.value = (p.relayCountries || []).join(\", \");\n const fn = $(\"#notify\"), nt = p.notify || {};\n fn.reports.checked = !!nt.reports; fn.fuel.checked = !!nt.fuel; fn.jobs.checked = !!nt.jobs; fn.succession.checked = !!nt.succession; fn.digest.checked = !!nt.digest;\n showTab(location.hash.slice(1));\n if (isOwner) { await renderPresets(); renderWire(); renderSuccession(); loadDomains(); }\n await Promise.all([loadLists(), loadEvents(true), loadPins(), ...(isOwner ? [loadStorage()] : [])]);\n }\n\n async function loadAudit(before) {\n const rows = await rpc(\"listaudit\", before || 0);\n const html = rows.map((r) => '' + esc(fmtTime(r.at)) + '' + av(r.actor) + key(r.actor) + '' + esc(r.action) + '' + (/^[0-9a-f]{64}$/.test(r.target) ? key(r.target) : esc(r.target)) + '' + esc(r.detail) + '').join(\"\");\n const tb = $(\"#audit tbody\");\n if (before) tb.insertAdjacentHTML(\"beforeend\", html); else tb.innerHTML = html || 'Nothing yet';\n $(\"#audit-more\").style.display = rows.length < 100 ? \"none\" : \"\";\n }\n $(\"#audit-more\").onclick = guard(async () => { const last = $(\"#audit tbody tr:last-child\"); await loadAudit(last ? +last.dataset.seq : 0); });\n async function loadLists() {\n loadAudit(0);\n const [mem, bans, bannedEvents, allow, block, invites, reports, blobs, blocks, sites] = await Promise.all([rpc(\"listmembers\"), rpc(\"listbannedpubkeys\"), rpc(\"listbannedevents\"), rpc(\"listallowedkinds\"), rpc(\"listblockedkinds\"), rpc(\"listinvites\"), rpc(\"listreports\"), rpc(\"listblobs\", 100), rpc(\"listblockedips\"), rpc(\"listsites\")]);\n const members = mem.members;\n window.__members = members;\n const roleCell = (m) => m.role === \"owner\" ? ' owner' : myRole === \"owner\" ? ' ' : m.role === \"moderator\" ? ' moderator' : \"\";\n const untouchable = (m) => m.role === \"owner\" || (myRole !== \"owner\" && m.role === \"moderator\");\n // The tree: everyone under whoever invited them, the owner and the owner's own additions at the root.\n const known = new Set(members.map((m) => m.pubkey));\n const byInviter = new Map();\n for (const m of members) { const k = m.role !== \"owner\" && known.has(m.invited_by) ? m.invited_by : \"\"; if (!byInviter.has(k)) byInviter.set(k, []); byInviter.get(k).push(m); }\n const ordered = [], placed = new Set();\n const walk = (k, depth) => { for (const m of byInviter.get(k) || []) { if (placed.has(m.pubkey)) continue; placed.add(m.pubkey); m.depth = depth; ordered.push(m); walk(m.pubkey, depth + 1); } };\n walk(\"\", 0);\n for (const m of members) if (!placed.has(m.pubkey)) { m.depth = 0; ordered.push(m); }\n const nameOf = (pk) => { const x = members.find((m) => m.pubkey === pk); return x && x.name ? x.name : short(pk); };\n const limits = (m) => m.role === \"owner\" ? \"\" : myRole === \"owner\"\n ? ''\n : '' + (m.keep_days ? m.keep_days + \" d\" : \"\") + (m.max_bytes ? \" \" + fmtBytes(m.max_bytes) : \"\") + \"\";\n $(\"#members tbody\").innerHTML = ordered.map((m) => '' + av(m.pubkey) + key(m.pubkey) + roleCell(m) + (m.depth ? ' via ' + esc(nameOf(m.invited_by)) + \"\" : \"\") + (m.invites ? ' ' + m.invites + \" inv\" : \"\") + '' + limits(m) + '' + esc(fmtDay(m.joined_at)) + \", \" + esc(m.via) + '' + ib(\"check\", \"Save\", \"savemember\", m.pubkey) + (untouchable(m) ? \"\" : ib(\"x\", \"Remove\", \"removemember\", m.pubkey) + ib(\"banuser\", \"Ban\", \"banpubkey\", m.pubkey, \"danger\")) + \"\").join(\"\");\n const tf = $(\"#treeform\");\n tf.classList.toggle(\"hidden\", myRole !== \"owner\");\n if (policy && policy.memberInvites) { tf.depth.value = policy.memberInvites.depth; tf.quota.value = policy.memberInvites.quota; }\n $(\"#transfer [name=pubkey]\").innerHTML = members.filter((m) => m.role !== \"owner\").map((m) => '\").join(\"\");\n $(\"#succession [name=heir]\").innerHTML = $(\"#transfer [name=pubkey]\").innerHTML;\n $(\"#tc-people\").textContent = members.length;\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#invites\").innerHTML = invites.length ? invites.map((i) => '
                                                                                                  • inv…' + i.code.slice(-8) + \" \" + esc(i.note || \"\") + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
                                                                                                  • \").join(\"\") : '
                                                                                                  • no invites
                                                                                                  • ';\n const person = (r, icon, label, act) => \"
                                                                                                  • \" + av(r.pubkey) + '' + short(r.pubkey) + \" \" + esc(r.reason || \"\") + \"\" + ib(icon, label, act, r.pubkey) + \"
                                                                                                  • \";\n $(\"#bans\").innerHTML = bans.length ? bans.map((r) => person(r, \"undo\", \"Unban\", \"unrulepubkey\")).join(\"\") : '
                                                                                                  • nobody banned
                                                                                                  • ';\n $(\"#blocks\").innerHTML = blocks.length ? blocks.map((r) => '
                                                                                                  • ip' + esc(r.ip) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"undo\", \"Unblock\", \"unblockip\", r.ip) + \"
                                                                                                  • \").join(\"\") : '
                                                                                                  • no addresses blocked
                                                                                                  • ';\n $(\"#banned-events\").innerHTML = bannedEvents.map((r) => '
                                                                                                  • ev' + key(r.id) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"check\", \"Allow again\", \"allowevent\", r.id) + \"
                                                                                                  • \").join(\"\");\n $(\"#reports tbody\").innerHTML = reports.length ? reports.map((r) => '' + esc(fmtTime(r.at)) + '' + esc(r.type || \"report\") + '' + av(r.target_pubkey) + key(r.target_pubkey) + (r.target_event ? (r.blob ? ' file ' + key(r.target_event) + \"\" : ' ev ' + key(r.target_event) + (r.hidden ? ' hidden' : \"\") + \"\") : \"\") + '' + esc(r.content) + '' + ib(\"check\", \"Dismiss\", \"resolve:dismiss\", r.id) + ib(\"trash\", r.blob ? \"Delete the file\" : \"Delete the event\", \"resolve:delete\", r.id) + ib(\"banuser\", \"Ban the author\", \"resolve:ban\", r.id, \"danger\") + \"\").join(\"\") : 'nothing reported';\n $(\"#reports-count\").textContent = reports.length || \"\"; $(\"#tc-reports\").textContent = reports.length || \"\";\n $(\"#blobs tbody\").innerHTML = blobs.length ? blobs.map((b) => '' + esc(fmtTime(b.uploaded)) + '' + key(b.sha256) + ' ' + esc(b.type) + '' + fmtBytes(b.size) + '' + av(b.uploader) + key(b.uploader) + '' + ib(\"trash\", \"Delete file\", \"deleteblob\", b.sha256, \"danger\") + \"\").join(\"\") : 'no uploads';\n $(\"#blobs-count\").textContent = blobs.length || \"\";\n $(\"#sites tbody\").innerHTML = sites.length ? sites.map((s) => '' + av(s.author) + key(s.author) + '' + esc(s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + '' + esc(s.url) + '' + esc(s.paths) + (s.missing ? ' ' + esc(s.missing) + ' missing' : '') + '' + fmtBytes(s.size) + '' + esc(s.expires_at ? fmtTime(s.expires_at) : \"never\") + '' + ib(\"trash\", \"Delete site\", \"deleteevent\", s.id, \"danger\") + '').join(\"\") : 'no sites yet';\n $(\"#sites-count\").textContent = sites.length || \"\";\n const tag = (k, cls) => '' + k + ib(\"x\", \"Remove rule\", \"unrulekind\", String(k)) + \"\";\n $(\"#kinds-allow\").innerHTML = allow.length ? allow.map((k) => tag(k, \"ok\")).join(\"\") : 'all';\n $(\"#kinds-block\").innerHTML = block.length ? block.map((k) => tag(k, \"blk\")).join(\"\") : 'none';\n await loadPeople();\n }\n\n const KIND_NAMES = { 0: \"profiles\", 1: \"notes\", 3: \"contacts\", 4: \"DMs\", 5: \"deletions\", 6: \"reposts\", 7: \"reactions\", 16: \"reposts\", 1059: \"gift wraps\", 1063: \"file headers\", 1111: \"comments\", 1984: \"reports\", 9734: \"zap requests\", 9735: \"zap receipts\", 9802: \"highlights\", 10002: \"relay lists\", 13534: \"roster\", 9000: \"group adds\", 9001: \"group removals\", 9021: \"join requests\", 9022: \"leave requests\", 39000: \"group info\", 39001: \"group admins\", 39002: \"group members\", 39003: \"group roles\", 30023: \"articles\", 30024: \"drafts\", 30078: \"app data\", 30311: \"live events\", 30818: \"wiki\" };\n const kindName = (k) => KIND_NAMES[k] || (k >= 20000 && k < 30000 ? \"ephemeral\" : k >= 30000 && k < 40000 ? \"addressable\" : k >= 10000 && k < 20000 ? \"replaceable\" : \"kind \" + k);\n const SYS_KINDS = new Set([0, 3, 10002, 9735, 13534, 8000, 8001, 9000, 9001, 39000, 39001, 39002, 39003]);\n let storage = null;\n\n // Features (settings.ts): a select each; search has three modes, the rest on or off.\n const FEATURES = [\n [\"search\", \"Search\", \"NIP-50. Prose indexes notes, threads, comments, highlights, articles and wiki pages; full indexes every public kind with content. A change applies to events from then on.\", [\"prose:prose\", \"full:full\", \"off:off\"]],\n [\"sync\", \"Sync\", \"NIP-77 reconciliation, which reads the whole matching set per sync.\"],\n [\"count\", \"Counts\", \"NIP-45 COUNT, with HLL sketches.\"],\n [\"discovery\", \"Discovery record\", \"NIP-66: the record the relay signs about itself, for crawlers.\"],\n [\"names\", \"Names\", \"NIP-05 addresses under this relay's domain.\"],\n [\"files\", \"Files\", \"Blossom and NIP-96: uploads, downloads and listings.\"],\n [\"pages\", \"Pages and feed\", \"Notes and articles as pages, and the Atom feed.\"],\n [\"sites\", \"Static websites\", \"NIP-5A sites on their own hostnames. Mirroring copies missing files into this relay and costs fuel.\", [\"mirror:on, mirror files\", \"proxy:on, fetch as needed\", \"off:off\"]],\n [\"marmot\", \"Marmot transport\", \"Signed KeyPackages and encrypted group messages, with account admission for ephemeral authors.\"],\n [\"grasp\", \"Git repositories\", \"GRASP Git hosting with admitted repository state. The prototype backend has bounded storage and compute limits.\"],\n [\"push\", \"Relay push\", \"NIP-9a callback delivery for members and the owner. Requires approved callback origins; advertises lettered NIP identifiers.\"],\n [\"signer\", \"Signer traffic\", \"NIP-46 remote signing carried for anyone, never stored.\"],\n ];\n function renderFeatures(f) {\n $(\"#features\").innerHTML = FEATURES.map(([k, title, about, modes]) => {\n const cur = k === \"sites\" ? (f.sites?.enabled === false ? \"off\" : f.sites?.mirror === false ? \"proxy\" : \"mirror\") : modes ? String(f[k] || \"prose\") : String(f[k] !== false);\n const opts = (modes || [\"true:on\", \"false:off\"]).map((m) => { const [v, l] = m.split(\":\"); return '\"; }).join(\"\");\n return \"\";\n }).join(\"\");\n }\n $(\"#features\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-feature]\"); if (!sel) return;\n const k = sel.dataset.feature, v = k === \"search\" || k === \"sites\" ? sel.value : sel.value === \"true\";\n policy = await rpc(\"setpolicy\", { features: { [k]: k === \"sites\" ? { enabled: v !== \"off\", mirror: v === \"mirror\" } : v } });\n toast(k === \"search\" ? \"Search: \" + v : (v ? \"Switched on \" : \"Switched off \") + k); await loadInfo();\n }));\n\n $(\"#push-policy-form\").addEventListener(\"submit\", guard(async (ev) => {\n ev.preventDefault();\n const form = ev.target;\n const origins = form.elements.origins.value.split(/\\s+/).filter(Boolean);\n const updated = await rpc(\"setpolicy\", { pushCallbacks: origins, letteredNips: form.elements.lettered.checked, delivery: { enabled: form.elements.delivery.checked, maxTargets: Math.max(1, Math.min(16, Math.floor(+form.elements.deliveryMax.value || 8))) } });\n if (JSON.stringify(updated.pushCallbacks) !== JSON.stringify([...new Set(origins.map((s) => s.replace(/\\/$/, \"\")))])) throw new Error(\"Use up to sixteen exact HTTPS origins, with no path or credentials.\");\n policy = updated;\n toast(\"Saved delivery policy\"); await loadInfo();\n }));\n\n async function loadViews() {\n let views;\n try { views = await rpc(\"listviews\"); } catch { return; }\n $(\"#views\").innerHTML = views.map((v) => {\n const runs = v.trigger === \"off\" ? \"off\" : v.trigger === \"live\" ? \"live, from memory\" : v.trigger === \"write\" ? \"on write and daily\" : v.trigger;\n const label = (c) => (c === \"off\" ? \"off\" : c === \"write\" ? \"on write\" : c === \"live\" ? \"on\" : c);\n const choices = [...v.choices, v.default].filter((c, i, a) => a.indexOf(c) === i);\n const pick = '\";\n const who = v.audience === \"members\" ? \"members\" + (v.stored ? \"\" : \", on request\") : \"anyone\";\n const last = v.trigger === \"live\" ? \"\" : v.last ? \"last run \" + fmtTime(v.last.at) : \"not run yet\";\n const rows = v.trigger === \"live\" ? \"no rows\" : v.last ? v.last.rows.toLocaleString() + \" rows\" : \"\";\n const meta = [runs, who, last, rows].filter(Boolean).map((x) => \"\" + esc(x) + \"\").join('|');\n return '
                                                                                                    ' + esc(v.name) + '
                                                                                                    ' + esc(v.about) + '
                                                                                                    ' + pick + (v.on ? 'Open' : \"\") + '
                                                                                                    ' + meta + \"
                                                                                                    \";\n }).join(\"\");\n }\n $(\"#views\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-view]\"); if (!sel) return;\n const value = sel.value === \"live\" ? true : sel.value;\n await rpc(\"setpolicy\", { views: { [sel.dataset.view]: value } });\n toast(sel.value === \"off\" ? \"Took down \" + sel.dataset.view : sel.dataset.view + \": \" + (sel.value === \"live\" ? \"on\" : sel.value === \"write\" ? \"on write\" : sel.value)); await loadViews(); await loadInfo();\n }));\n async function loadDumps() {\n const list = await rpc(\"listdumps\");\n const f = $(\"#dumpform\");\n if (policy) { f.dumps.value = policy.dumps || \"off\"; f.keep.value = policy.dumpsKeep || 7; }\n $(\"#dumps-count\").textContent = list.length || \"\";\n $(\"#dumps\").innerHTML = list.length ? list.map((d) => '
                                                                                                  • jsonl' + esc(d.name) + ' ' + d.events.toLocaleString() + \" events, \" + fmtBytes(d.bytes) + \"\" + ib(\"copy\", \"Download\", \"downloaddump\", d.name) + ib(\"trash\", \"Delete\", \"deletedump\", d.name, \"danger\") + \"
                                                                                                  • \").join(\"\") : '
                                                                                                  • no dumps yet
                                                                                                  • ';\n }\n // A dump is fetched with a signed request and handed to the browser as a file.\n async function downloadDump(name) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const url = location.origin + \"/dumps/\" + name;\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"GET\"]] });\n const resp = await fetch(url, { headers: { authorization: \"Nostr \" + btoa(JSON.stringify(ev)) } });\n if (!resp.ok) throw new Error((await resp.json()).error || \"download failed\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(\".\")[0] + \"-\" + name; a.click(); URL.revokeObjectURL(a.href);\n }\n // A plain member's own invites, when the owner lets members invite.\n async function loadMine() {\n const sec = $(\"#myinvites\");\n if (!me || !owner || myRole || !signer.ready()) { sec.classList.add(\"hidden\"); return; }\n let mine;\n try { mine = await rpc(\"listinvites\"); } catch { sec.classList.add(\"hidden\"); return; }\n sec.classList.remove(\"hidden\");\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#mine\").innerHTML = mine.length ? mine.map((i) => '
                                                                                                  • inv…' + i.code.slice(-8) + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
                                                                                                  • \").join(\"\") : '
                                                                                                  • no invites yet
                                                                                                  • ';\n }\n async function loadStorage() {\n storage = await rpc(\"storagestats\");\n pollJobs();\n loadViews();\n loadDumps().catch(() => {});\n const st = storage;\n const top = st.kinds.slice(0, 4), rest = st.kinds.slice(4).reduce((a, k) => a + k.bytes, 0);\n const parts = [...top.map((k, i) => [k.kind + \" \" + kindName(k.kind), \"k\" + (i + 1), k.bytes]), ...(rest ? [[\"other\", \"k5\", rest]] : [])];\n const total = st.eventBytes || 1;\n $(\"#s-bar\").innerHTML = parts.length ? parts.map(([n, c, b]) => '').join(\"\") : '';\n $(\"#s-legend\").innerHTML = parts.length ? parts.map(([n, c, b]) => '' + esc(n) + \" \" + Math.round(b * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n const overhead = Math.max(0, st.databaseBytes - st.eventBytes);\n $(\"#s-totals\").innerHTML = [[\"events\", st.events.toLocaleString() + \" \" + fmtBytes(st.eventBytes) + \"\"], [\"index and overhead\", fmtBytes(overhead)], [\"files\", st.blobs.toLocaleString() + \" \" + fmtBytes(st.mediaBytes) + \"\"]]\n .map(([k, v]) => \"
                                                                                                    \" + k + \"\" + v + \"
                                                                                                    \").join(\"\");\n const any = st.retention.find((r) => r.kind === null);\n const own = (kind) => { const r = st.retention.find((x) => x.kind === kind); return r ? r.days : \"\"; };\n const row = (k) => {\n const key = k.kind === null ? \"\" : String(k.kind);\n const placeholder = k.kind === null ? \"forever\" : k.replaceable || !any ? \"forever\" : any.days + \" (everything else)\";\n const pill = k.kind === null ? \"everything else\" : '' + k.kind + \" \" + esc(kindName(k.kind));\n const cells = k.protected\n ? 'required'\n : ' days' + ib(\"check\", \"Save keep-for rule\", \"saveretention\", key) + ib(\"trash\", \"Purge\", \"purgekind\", key, \"danger\") + \"\";\n return '' + pill + '' + (k.n === undefined ? \"\" : k.n.toLocaleString()) + '' + (k.bytes === undefined ? \"\" : fmtBytes(k.bytes)) + '' + (k.oldest ? esc(fmtDay(k.oldest)) : \"\") + \"\" + cells + \"\";\n };\n $(\"#kinds tbody\").innerHTML = [...st.kinds, { kind: null, days: any ? any.days : 0 }].map(row).join(\"\");\n loadListHistory().catch(() => {});\n }\n\n async function loadListHistory() {\n const rows = await rpc(\"listlisthistory\");\n const labels = { 3: \"follows\", 10002: \"relay list\", 10003: \"bookmarks\", 30003: \"bookmark list\" };\n $(\"#listhistory tbody\").innerHTML = rows.length ? rows.map((r) => '' + esc((labels[r.kind] || (\"kind \" + r.kind)) + (r.d ? \" / \" + r.d : \"\")) + '' + esc(fmtTime(r.created_at)) + '' + esc(fmtTime(r.saved_at)) + '' + ib(\"undo\", \"Restore this version\", \"restorelist\", r.event_id) + '').join(\"\") : 'no older list versions yet';\n }\n\n let searchQuery = \"\";\n async function loadEvents(reset) {\n const list = searchQuery ? await rpc(\"searchevents\", searchQuery, 200) : await rpc(\"listrecentevents\", reset ? 50 : 200);\n $(\"#events tbody\").innerHTML = list.length ? list.map((e) => '' + esc(fmtTime(e.created_at)) + '' + e.kind + '' + av(e.pubkey) + key(e.pubkey) + '' + esc(e.content) + '' + ib(\"pin\", \"Pin\", \"pinevent\", e.id) + ib(\"trash\", \"Delete event\", \"deleteevent\", e.id) + ib(\"ban\", \"Ban event\", \"banevent\", e.id, \"danger\") + ib(\"banuser\", \"Ban author\", \"banpubkey\", e.pubkey, \"danger\") + \"\").join(\"\") : 'no events yet';\n $(\"#more\").classList.toggle(\"hidden\", searchQuery || list.length < 50);\n }\n $(\"#searchform\").onsubmit = guard(async (ev) => {\n searchQuery = ev.target.q.value.trim();\n $(\"#searchclear\").classList.toggle(\"hidden\", !searchQuery);\n await loadEvents(true);\n });\n $(\"#searchclear\").onclick = guard(async () => {\n searchQuery = \"\"; $(\"#searchform\").reset(); $(\"#searchclear\").classList.add(\"hidden\");\n await loadEvents(true);\n });\n async function loadPins() {\n const pins = await rpc(\"listpins\");\n $(\"#pins-count\").textContent = pins.length || \"\";\n $(\"#pins\").innerHTML = pins.length ? pins.map((t) => '
                                                                                                  • ' + (t[0] === \"e\" ? \"event\" : \"address\") + '' + esc(t[1].length > 40 ? t[1].slice(0, 16) + \"\\u2026\" + t[1].slice(-8) : t[1]) + \"\" + ib(\"copy\", \"Copy\", \"copy\", t[1]) + ib(\"x\", \"Unpin\", \"unpinevent\", t[1]) + \"
                                                                                                  • \").join(\"\") : '
                                                                                                  • nothing pinned
                                                                                                  • ';\n }\n $(\"#pinform\").onsubmit = guard(async (ev) => {\n await rpc(\"pinevent\", ev.target.id.value.trim()); ev.target.reset(); toast(\"Pinned\"); await loadPins();\n });\n\n // ---- actions ----\n const refresh = () => Promise.all([loadLists(), loadEvents(true), loadStorage(), loadPins()]);\n\n $(\"#copy\").onclick = async () => { await navigator.clipboard.writeText(wsURL); toast(\"copied \" + wsURL); };\n $(\"#signin\").onclick = guard(async () => {\n if (!window.nostr) throw new Error(\"No nostr extension found. Install one (Alby, nos2x, …) and reload, or use a remote signer.\");\n me = await window.nostr.getPublicKey();\n localStorage.setItem(\"me\", me);\n renderHeader(); await loadAdmin(); await loadPeople();\n });\n $(\"#signin46\").onclick = () => showRemote(null);\n $$(\".remote\").forEach((b) => { b.onclick = () => showRemote($(\"#\" + b.dataset.note)); });\n $(\"#nccopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#nclink\").href); toast(\"Link copied; paste it into your signer app\"); };\n $(\"#bunkerform\").onsubmit = guard(async (ev) => {\n $(\"#remotenote\").textContent = \"Connecting…\";\n try { await connectBunker(ev.target.url.value); } catch (e) { $(\"#remotenote\").textContent = e.message; throw e; }\n $(\"#remotenote\").textContent = \"\"; ev.target.reset();\n await remoteDone();\n });\n $(\"#signout\").onclick = async () => {\n if (remote) { const r = remote; remote = null; try { await withTimeout(r.logout(), 5000, \"The signer\"); } catch { /* the session is gone either way */ } }\n localStorage.removeItem(\"nip46\");\n me = null; localStorage.removeItem(\"me\"); renderHeader(); await loadAdmin(); await loadPeople();\n };\n const claimNow = async (note) => {\n if (!signer.ready()) { note.textContent = \"Sign the claim with a nostr extension or a remote signer.\"; showRemote(note); return; }\n me = await signer.getPublicKey();\n const r = await rpc(\"claim\");\n if (!r.claimed) throw new Error(\"Somebody else claimed it first.\");\n localStorage.setItem(\"me\", me);\n toast(\"It's yours.\");\n await loadInfo(); await loadFuel(); await loadAdmin(); await loadPeople();\n if (r.converted && confirm(\"This relay began as a temporary one: anyone can write, and everything is deleted after 14 days. Switch to the default rules and keep everything from now on? Each rule can be changed later on the Rules and Storage tabs.\")) {\n await rpc(\"resetrules\"); toast(\"Rules reset\"); await loadInfo(); await loadAdmin();\n }\n };\n $(\"#claim\").onclick = guard(() => claimNow($(\"#claimnote\")));\n $(\"#claimlease\").onclick = guard(() => claimNow($(\"#leasenote\")));\n // Jobs run in the background; the table follows them while the tab is open.\n let jobsTimer = 0;\n const fmtJob = (j) => {\n const what = j.kind === \"mirror\" ? \"mirror site\" : j.kind === \"import\" ? \"import\" : j.label === \"backfill\" ? \"fetch my history\" : j.kind === \"pull\" ? \"pull\" : \"rebroadcast\";\n const f = [];\n if (j.filter.authors) f.push(j.filter.authors.length === 1 && j.filter.authors[0] === me ? \"my events\" : j.filter.authors.length + \" authors\");\n if (j.filter.kinds) f.push(\"kinds \" + j.filter.kinds.join(\", \"));\n if (j.filter.since) f.push(\"since \" + fmtDay(j.filter.since));\n const when = j.every ? \"every \" + (j.every === 24 ? \"day\" : j.every + \" h\") + (j.nextRun ? \", next \" + fmtTime(j.nextRun) : \"\") : \"once\";\n const l = j.last;\n const count = (stored, blobs, sent, refused) => (j.kind === \"mirror\" ? blobs.toLocaleString() + \" files mirrored\" : j.kind === \"import\" ? stored.toLocaleString() + \" events\" + ((j.last ? j.last.duplicates : j.duplicates) ? \", \" + (j.last ? j.last.duplicates : j.duplicates) + \" already here\" : \"\") : j.kind === \"pull\" ? stored.toLocaleString() + \" events\" + (blobs ? \", \" + blobs + \" files\" : \"\") : sent.toLocaleString() + \" sent\" + (refused ? \", \" + refused + \" refused\" : \"\"));\n const res = j.running ? \"running: \" + count(j.stored, j.blobs, j.sent, j.refused) + \"...\" : !l ? \"waiting\" : l.error ? \"failed: \" + l.error : count(l.stored, l.blobs, l.sent, l.refused) + (l.skipped ? \", \" + l.skipped + \" skipped\" : \"\") + \", \" + fmtTime(l.finishedAt);\n const sources = j.running ? j.pullSources : l?.sources;\n const details = sources?.length ? '
                                                                                                    Source results' + sources.map((s) => '

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

                                                                                                    ').join('') + '
                                                                                                    ' : '';\n return \"\" + what + \"\" + j.relays.map(esc).join(\"
                                                                                                    \") + \"\" + (f.join(\", \") || \"everything\") + \"\" + when + \"\" + esc(res) + details + \"\" + (j.running ? \"\" : ib(\"undo\", \"Run now\", \"runjob\", j.id)) + ib(\"x\", \"Remove\", \"removejob\", j.id) + \"\";\n };\n async function pollJobs() {\n clearTimeout(jobsTimer);\n let jobs;\n try { jobs = await rpc(\"listjobs\"); } catch { return; }\n $(\"#jobs tbody\").innerHTML = jobs.length ? jobs.map(fmtJob).join(\"\") : 'no jobs yet';\n if (jobs.some((j) => j.running || (j.nextRun && j.nextRun <= Math.floor(Date.now() / 1000) + 1))) jobsTimer = setTimeout(pollJobs, 3000);\n }\n const urls = (s) => s.split(/[\\s,]+/).map((u) => u.trim()).filter(Boolean);\n const kindsOf = (s) => s.split(/[\\s,]+/).map((k) => parseInt(k, 10)).filter((k) => Number.isInteger(k) && k >= 0);\n $(\"#pullform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"addjob\", { kind: \"pull\", relays: [f.url.value.trim()], every: +f.every.value });\n toast(+f.every.value ? \"Mirror scheduled\" : \"Pull started\"); f.reset(); await pollJobs();\n });\n $(\"#backfillform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"backfill\", urls(f.relays.value));\n toast(\"Fetching your history\"); f.reset(); await pollJobs();\n });\n $(\"#pushform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const filter = {};\n const kinds = kindsOf(f.kinds.value); if (kinds.length) filter.kinds = kinds;\n const days = +f.days.value; if (days > 0) filter.since = Math.floor(Date.now() / 1000) - days * 86400;\n await rpc(\"addjob\", { kind: \"push\", relays: urls(f.targets.value), filter, every: +f.every.value });\n toast(\"Rebroadcast started\"); f.reset(); await pollJobs();\n });\n // ---- presets: writes, reads, kinds and keep-for rules in one click ----\n let presets = null;\n async function renderPresets() {\n if (!presets) { try { presets = await rpc(\"listpresets\"); } catch { presets = []; } }\n $(\"#presets\").innerHTML = presets.map((p) => '\").join(\"\");\n $(\"#presetsourcerow\").classList.toggle(\"hidden\", !presets.some((p) => p.source));\n }\n $(\"#presets\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-preset]\"); if (!b) return;\n const p = (presets || []).find((x) => x.name === b.dataset.preset); if (!p) return;\n if (!confirm(p.title + \": \" + p.about + \"\\n\\nThis replaces the writes and reads rules, the directory setting, the kind rules and the keep-for rules. Limits, identity, people and bans stay.\")) return;\n const source = $(\"#presetsource\").value.trim();\n if (p.source === \"required\" && !source) { $(\"#presetnote\").textContent = p.title + \" needs a source relay to mirror; enter its wss:// URL first.\"; return; }\n b.disabled = true;\n try { policy = await rpc(\"applypreset\", p.name, p.source && source ? { source } : undefined); $(\"#presetnote\").textContent = \"Now: \" + p.about + (policy.job ? \" Mirroring \" + source + \" every \" + policy.job.every + \" h.\" : \"\"); toast(p.title + \" applied\"); await loadInfo(); await loadAdmin(); }\n catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // ---- wire me in: this relay in the owner's own lists ----\n // The lists are replaceable, so a fresh list with only this relay would\n // clobber the real one once it spread. Every publish starts from the\n // newest copy found here or on the indexers, verified when the signer\n // library is around, and adds or removes this relay in it.\n const INDEXERS = [\"wss://purplepag.es\", \"wss://relay.nostr.band\", \"wss://relay.damus.io\", \"wss://nos.lol\"];\n const LISTS = [\n { kind: 10002, tag: \"r\", title: \"Relay list\", nip: \"NIP-65\", about: \"where clients read your notes and send you mentions\" },\n { kind: 10050, tag: \"relay\", title: \"DM inbox\", nip: \"NIP-17\", about: \"where people send you private messages\" },\n { kind: 10007, tag: \"relay\", title: \"Search relays\", nip: \"NIP-51\", about: \"where clients run your searches\" },\n { kind: 10063, tag: \"server\", title: \"Blossom servers\", nip: \"BUD-03\", about: \"where clients upload and look for your files\" },\n ];\n const mineFor = (l) => (l.tag === \"server\" ? location.origin : wsURL);\n const normURL = (u) => { try { const x = new URL(String(u).trim()); return (x.host + x.pathname).replace(/\\/+$/, \"\").toLowerCase(); } catch { return String(u).trim().toLowerCase(); } };\n const isMine = (l, t) => t[0] === l.tag && normURL(t[1] || \"\") === normURL(mineFor(l));\n const listHas = (list, l) => !!list && list.tags.some((t) => isMine(l, t));\n // mergeList is pure: the newest list with this relay put first or taken out, every other tag kept.\n function mergeList(list, l, include) {\n const tags = (list ? list.tags : []).filter((t) => !isMine(l, t));\n if (include) tags.unshift([l.tag, mineFor(l)]);\n return { kind: l.kind, created_at: Math.floor(Date.now() / 1000), content: list ? list.content : \"\", tags };\n }\n const relaysIn = (list) => list.tags.filter((t) => (t[0] === \"r\" || t[0] === \"relay\") && /^wss?:\\/\\//i.test(t[1] || \"\")).map((t) => t[1].trim());\n // overWS opens one socket, sends one message, feeds answers to onMessage until it says done or time runs out.\n function overWS(url, ms, first, onMessage) {\n return new Promise((res) => {\n let ws = null, done = false, out = null;\n const finish = () => { if (done) return; done = true; clearTimeout(timer); try { if (ws) ws.close(); } catch { /* closed */ } res(out); };\n const timer = setTimeout(finish, ms);\n try { ws = new WebSocket(url); } catch { return finish(); }\n ws.onopen = () => ws.send(JSON.stringify(first));\n ws.onmessage = (m) => { let d; try { d = JSON.parse(m.data); } catch { return; } if (!Array.isArray(d)) return; if (onMessage(d, (v) => { out = v; })) finish(); };\n ws.onerror = finish; ws.onclose = finish;\n });\n }\n function fetchNewest(url, filter, ms) {\n let best = null;\n return overWS(url, ms, [\"REQ\", \"w\", filter], (d, set) => {\n if (d[0] === \"EVENT\" && d[1] === \"w\" && d[2] && (!best || d[2].created_at > best.created_at)) { best = d[2]; set(best); }\n return (d[0] === \"EOSE\" || d[0] === \"CLOSED\") && d[1] === \"w\";\n });\n }\n function publishTo(url, event, ms) {\n return overWS(url, ms, [\"EVENT\", event], (d, set) => { if (d[0] === \"OK\" && d[1] === event.id) { set({ url, ok: !!d[2], msg: d[3] || \"\" }); return true; } return false; })\n .then((r) => r || { url, ok: false, msg: \"no answer\" });\n }\n // bridge signs a NIP-98 request to this relay's HTTP door, so the owner's own reads and writes pass any read rule.\n async function bridge(path, body) {\n const url = location.origin + path, raw = JSON.stringify(body);\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"POST\"], [\"payload\", await sha256hex(raw)]] });\n const resp = await fetch(url, { method: \"POST\", headers: { \"content-type\": \"application/json\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body: raw });\n const json = await resp.json();\n if (!resp.ok) throw new Error(json.error || \"HTTP \" + resp.status);\n return json;\n }\n const wire = {};\n function renderWire() {\n const pill = (state) => {\n const cls = state === \"listed\" ? \"on\" : state === \"failed\" ? \"bad\" : state === \"not listed\" ? \"off\" : \"\";\n const label = state === \"checking\" || state === \"publishing\" ? state + \"\\u2026\" : state;\n return '' + esc(label) + \"\";\n };\n $(\"#wire\").innerHTML = LISTS.map((l) => {\n const w = wire[l.kind] || { state: \"\", results: [], list: null };\n const busy = w.state === \"checking\" || w.state === \"publishing\";\n const btn = (act, cls, label) => '\";\n const buttons = w.state === \"listed\" ? btn(\"remove\", \"\", \"Remove this relay\") : btn(\"add\", \"pri\", \"Add this relay\") + btn(\"check\", \"\", w.state ? \"Check again\" : \"Check\");\n const n = w.list ? w.list.tags.filter((t) => t[0] === l.tag).length : 0;\n const from = w.list ? \"\" + (n === 1 ? \"1 entry\" : n + \" entries\") + \", \" + fmtTime(w.list.created_at) + \"\" : w.state && !busy ? \"no list found\" : \"\";\n const sent = (w.results || []).map((r) => '' + esc(r.url.replace(/^wss?:\\/\\//, \"\")) + (r.ok ? \"\" : \" failed\") + \"\").join(\"\");\n const meta = (w.state ? pill(w.state) : \"\") + from + sent;\n return '
                                                                                                    ' + l.title + ' ' + l.nip + \"
                                                                                                    \" + l.about + '
                                                                                                    ' + buttons + '
                                                                                                    ' + meta + \"
                                                                                                    \";\n }).join(\"\");\n }\n async function wireCheck(l) {\n const w = (wire[l.kind] = { state: \"checking\", list: null, results: [] }); renderWire();\n const filter = { kinds: [l.kind], authors: [me], limit: 1 };\n const found = [];\n try { found.push(...(await bridge(\"/query\", [filter]))); } catch { /* the relay may hold none */ }\n const remote = await Promise.all(INDEXERS.map((u) => fetchNewest(u, filter, 4000)));\n let lib = null; try { lib = await signerLib(); } catch { /* unverified lists are still the owner's own, by pubkey */ }\n for (const e of remote) if (e && e.pubkey === me && e.kind === l.kind && Array.isArray(e.tags) && (!lib || lib.verifyEvent(e))) found.push(e);\n w.list = found.sort((a, b) => b.created_at - a.created_at)[0] || null;\n w.state = listHas(w.list, l) ? \"listed\" : \"not listed\"; renderWire();\n }\n async function wirePublish(l, include) {\n if (!wire[l.kind] || !wire[l.kind].state || wire[l.kind].state === \"failed\") await wireCheck(l);\n const w = wire[l.kind]; w.state = \"publishing\"; w.results = []; renderWire();\n try {\n const signed = await signer.signEvent(mergeList(w.list, l, include));\n const here = await bridge(\"/events\", signed).then((r) => ({ url: wsURL, ok: !!r.accepted, msg: r.message || \"\" })).catch((e) => ({ url: wsURL, ok: false, msg: e.message }));\n const targets = [...new Set([...relaysIn(signed), ...INDEXERS])].filter((u) => normURL(u) !== normURL(wsURL));\n const rest = await Promise.all(targets.map((u) => publishTo(u, signed, 6000)));\n w.results = [here, ...rest]; w.list = signed; w.state = include ? \"listed\" : \"not listed\";\n toast((include ? \"Added to your \" : \"Removed from your \") + l.title.toLowerCase());\n } catch (e) { w.state = \"failed\"; w.results = [{ url: wsURL, ok: false, msg: e.message }]; toast(e.message); }\n renderWire();\n }\n $(\"#wire\").addEventListener(\"click\", (ev) => {\n const b = ev.target.closest(\"button[data-wire]\"); if (!b) return;\n const l = LISTS.find((x) => String(x.kind) === b.dataset.wire); if (!l) return;\n if (b.dataset.do === \"check\") wireCheck(l); else wirePublish(l, b.dataset.do === \"add\");\n });\n\n // ---- custom domains: this relay under a hostname the owner controls ----\n let domainSites = [];\n const domainOptions = (current = \"\") => [{ label: \"\", title: \"This relay\" }, ...domainSites.map((s) => ({ label: s.label, title: (s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + \" / \" + key(s.author) })), ...(current && !domainSites.some((s) => s.label === current) ? [{ label: current, title: \"Site \" + current }] : [])].map((s) => '\").join(\"\");\n let domains = null; // null: not enabled on this host\n function renderDomains() {\n $(\"#domains\").innerHTML = (domains || []).map((d) => {\n const state = d.ready ? \"active\" : \"hostname \" + d.status.replace(/_/g, \" \") + \", certificate \" + d.sslStatus.replace(/_/g, \" \");\n const btn = (act, cls, label) => '\";\n const rows = d.ready ? \"\" : '
                                                                                                    ' + d.records.map((r) => \"\").join(\"\") + \"
                                                                                                    TypeNameValue
                                                                                                    \" + esc(r.type) + '' + esc(r.name) + '' + esc(r.value) + \"\" + esc(r.note) + \"
                                                                                                    \";\n return '
                                                                                                    ' + esc(d.host) + '' + esc(state) + \"\" + btn(\"check\", \"\", \"Check\") + btn(\"remove\", \"danger\", \"Remove\") + '
                                                                                                    \" + rows;\n }).join(\"\");\n }\n async function loadDomains() {\n try { [domains, domainSites] = await Promise.all([rpc(\"listdomains\"), rpc(\"listsites\")]); $(\"#adddomain select[name=site]\").innerHTML = domainOptions(); $(\"#domainnote\").textContent = domains.length ? \"\" : \"No custom domain yet.\"; $(\"#adddomain\").classList.remove(\"hidden\"); }\n catch (e) { domains = null; $(\"#domainnote\").textContent = /^unsupported/.test(e.message) ? \"Custom domains are not enabled on this host.\" : e.message; $(\"#adddomain\").classList.add(\"hidden\"); }\n renderDomains();\n }\n $(\"#adddomain\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"adddomain\", f.host.value.trim(), f.site.value); f.reset(); toast(\"Domain added; now create the CNAME\"); await loadDomains();\n });\n $(\"#domains\").addEventListener(\"change\", guard(async (ev) => {\n const select = ev.target.closest(\"select[data-domain-site]\"); if (!select) return;\n await rpc(\"setdomainsite\", select.dataset.domainSite, select.value);\n toast(\"Domain destination saved\"); await loadDomains();\n }));\n $(\"#domains\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-domain]\"); if (!b) return;\n const host = b.dataset.domain;\n if (b.dataset.do === \"remove\" && !confirm(\"Remove \" + host + \"? Its certificate goes with it and the name stops answering.\")) return;\n b.disabled = true;\n try {\n if (b.dataset.do === \"remove\") { await rpc(\"removedomain\", host); toast(\"Removed\"); }\n else { const d = await rpc(\"checkdomain\", host); toast(d.ready ? host + \" is live\" : \"Not yet: \" + (d.status === \"active\" ? \"certificate pending\" : \"waiting for the CNAME\")); }\n await loadDomains();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n $(\"#access\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const openKinds = f.openKinds.value.split(/[\\s,]+/).filter(Boolean).map(Number);\n if (openKinds.some((k) => !Number.isInteger(k) || k < 0 || k > 65535)) throw new Error(\"Open kinds must be whole numbers.\");\n policy = await rpc(\"setpolicy\", { writes: f.writes.value, reads: f.reads.value, openKinds, guestReplies: f.guestReplies.checked, minPow: +f.minPow.value, maxFuture: +f.maxFuture.value, maxLimit: +f.maxLimit.value, maxSubs: +f.maxSubs.value, maxMessageKB: +f.maxMessageKB.value, eventsPerMinute: +f.eventsPerMinute.value, reqsPerMinute: +f.reqsPerMinute.value, maxBlobMB: +f.maxBlobMB.value });\n toast(\"Rules saved\"); await loadInfo();\n });\n $(\"#notify\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { notify: { reports: f.reports.checked, fuel: f.fuel.checked, jobs: f.jobs.checked, succession: f.succession.checked, digest: f.digest.checked } });\n toast(\"Notifications saved\");\n });\n $(\"#notifytest\").onclick = guard(async () => {\n const r = await rpc(\"notifytest\");\n toast(r.sent ? \"Sent. Look for a message from the relay in your DMs.\" : \"Could not send.\");\n });\n $(\"#identity\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const list = (v) => v.split(\",\").map((s) => s.trim()).filter(Boolean);\n policy = await rpc(\"setpolicy\", { name: f.name.value, contact: f.contact.value, description: f.description.value, icon: f.icon.value,\n banner: f.banner.value, postingPolicy: f.postingPolicy.value, privacyPolicy: f.privacyPolicy.value, tags: list(f.tags.value), languageTags: list(f.languageTags.value), relayCountries: list(f.relayCountries.value) });\n toast(\"Identity saved\"); await loadInfo(); await loadPeople(); loadCard();\n });\n $(\"#addmember\").onsubmit = guard(async (ev) => {\n const f = ev.target; const pk = npubToHex(f.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"setmember\", pk, { name: f.name.value.trim() || null, note: f.note.value }); f.reset(); toast(\"Member added\"); await loadLists();\n });\n $(\"#addblock\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"blockip\", f.ip.value.trim(), f.reason.value); f.reset(); toast(\"Blocked\"); await loadLists();\n });\n $(\"#wordsform\").onsubmit = guard(async (ev) => {\n const words = ev.target.words.value.split(\"\\n\").map((s) => s.trim()).filter(Boolean);\n const kept = await rpc(\"setblockedwords\", words);\n policy = await rpc(\"setpolicy\", { blockedWordsInTags: ev.target.inTags.checked });\n ev.target.words.value = kept.join(\"\\n\"); toast(kept.length ? kept.length + \" words blocked\" : \"No words blocked\");\n });\n $(\"#thresholdform\").onsubmit = guard(async (ev) => {\n policy = await rpc(\"setpolicy\", { reportThreshold: +ev.target.reportThreshold.value });\n toast(policy.reportThreshold ? \"Hidden after \" + policy.reportThreshold + \" reports\" : \"Never hidden by reports\"); await loadLists();\n });\n $(\"#addban\").onsubmit = guard(async (ev) => {\n const pk = npubToHex(ev.target.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"banpubkey\", pk, ev.target.reason.value, ev.target.erase.checked); ev.target.reset(); toast(ev.target.erase.checked ? \"Banned and erased\" : \"Banned\"); await loadLists();\n });\n $(\"#mintinvite\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const inv = await rpc(\"createinvite\", +f.ttl.value, +f.max.value, f.note.value);\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n f.note.value = \"\"; toast(\"Invite created and copied\"); await loadLists();\n });\n $(\"#kindform\").onsubmit = guard(async (ev) => {\n const rule = ev.submitter.value; const k = +ev.target.kind.value;\n await rpc(rule === \"allow\" ? \"allowkind\" : \"disallowkind\", k); ev.target.reset(); toast((rule === \"allow\" ? \"Allowed kind \" : \"Blocked kind \") + k); await loadLists();\n });\n $(\"#more\").onclick = guard(() => loadEvents(false));\n $(\"#dumpform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { dumps: f.dumps.value, dumpsKeep: Math.max(1, Math.min(60, Math.floor(+f.keep.value || 7))) });\n toast(policy.dumps === \"off\" ? \"Dumps off\" : \"Dumping \" + policy.dumps); await loadDumps();\n });\n $(\"#importform\").onsubmit = guard(async (ev) => {\n const file = ev.target.file.files[0]; if (!file) return;\n if (!signer.ready()) throw new Error(NO_SIGNER);\n if (file.size > 64 * 1024 * 1024) throw new Error(\"At most 64 MB per import.\");\n const body = await file.text();\n const url = location.origin + \"/import?name=\" + encodeURIComponent(file.name);\n const token = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"PUT\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(url, { method: \"PUT\", headers: { authorization: \"Nostr \" + btoa(JSON.stringify(token)), \"content-type\": \"application/x-ndjson\" }, body });\n const r = await resp.json();\n if (!resp.ok) throw new Error(r.error || \"import failed\");\n ev.target.reset(); toast(\"Importing \" + fmtBytes(r.bytes)); await pollJobs();\n });\n $(\"#dumpnow\").onclick = guard(async () => { const d = await rpc(\"dumpnow\"); toast(\"Dumped \" + d.events.toLocaleString() + \" events\"); await loadStorage(); });\n $(\"#treeform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { memberInvites: { depth: Math.max(0, Math.floor(+f.depth.value || 0)), quota: Math.max(0, Math.floor(+f.quota.value || 0)) } });\n toast(policy.memberInvites.depth ? \"Members may invite\" : \"Only you and moderators invite\");\n });\n $(\"#mintmine\").onclick = guard(async () => {\n const inv = await rpc(\"createinvite\", 259200, 1, \"\");\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n toast(\"Invite created and copied\"); await loadMine();\n });\n $(\"#exportcfg\").onclick = guard(async () => {\n const cfg = await rpc(\"exportconfig\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(new Blob([JSON.stringify(cfg, null, 2)], { type: \"application/json\" }));\n a.download = host.split(\".\")[0] + \".bind.ws.config.json\";\n a.click(); URL.revokeObjectURL(a.href);\n });\n $(\"#importcfg\").onclick = () => $(\"#cfgfile\").click();\n $(\"#cfgfile\").onchange = guard(async (ev) => {\n const file = ev.target.files[0]; if (!file) return;\n const cfg = JSON.parse(await file.text());\n const check = await rpc(\"importconfig\", cfg, { dryRun: true });\n const lines = check.changes.summary.length ? check.changes.summary.join(\"\\n\") : \"Nothing would change.\";\n const dropped = check.warnings.length ? \"\\n\\nNot taken:\\n\" + check.warnings.join(\"\\n\") : \"\";\n if (!confirm(\"Apply \" + file.name + \" to this relay?\\n\\n\" + lines + dropped)) { ev.target.value = \"\"; return; }\n await rpc(\"importconfig\", cfg); ev.target.value = \"\"; toast(\"Configuration imported\"); await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // Succession: the status line under the heir form, from successionstatus.\n async function renderSuccession() {\n const el = $(\"#successionnote\"), f = $(\"#succession\");\n let st;\n try { st = await rpc(\"successionstatus\"); } catch { el.textContent = \"\"; return; }\n const sc = st.succession;\n $(\"#clearsuccession\").classList.toggle(\"hidden\", !sc);\n f.querySelector(\"button.btn:not(#clearsuccession)\").textContent = sc ? \"Change heir\" : \"Set heir\";\n if (!sc) { el.textContent = \"No heir named. Last signed in \" + fmtTime(st.ownerSeenAt) + \".\"; return; }\n if (f.heir.querySelector('[value=\"' + sc.heir + '\"]')) f.heir.value = sc.heir;\n f.afterDays.value = String(sc.afterDays);\n const who = (window.__members || []).find((m) => m.pubkey === sc.heir);\n const heir = who && who.name ? who.name + \"@\" + host : short(sc.heir);\n el.textContent = \"Heir: \" + heir + \". Last signed in \" + fmtTime(st.ownerSeenAt) + (st.silentDays ? \" (\" + st.silentDays + \" days ago)\" : \"\") + \". \" +\n (st.warning ? \"The warning month is running: the relay goes to \" + heir + \" on \" + fmtDay(st.handoverAt) + \" unless you sign in.\" : \"If you stay away, it goes to \" + heir + \" around \" + fmtDay(st.handoverAt) + \".\") +\n (st.log && st.log.length ? \" Handed over before: \" + st.log.map((l) => fmtDay(l.at) + \" to \" + short(l.to)).join(\", \") + \".\" : \"\");\n }\n $(\"#succession\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"setsuccession\", { heir: f.heir.value, afterDays: +f.afterDays.value });\n toast(\"Heir set\"); await loadAdmin();\n });\n $(\"#clearsuccession\").onclick = guard(async () => {\n await rpc(\"clearsuccession\");\n toast(\"Heir cleared\"); await loadAdmin();\n });\n $(\"#transfer\").onsubmit = guard(async (ev) => {\n const pk = ev.target.pubkey.value; if (!pk) return;\n const name = host.split(\".\")[0];\n const typed = prompt(\"This hands \" + host + \" to \" + short(pk) + \" for good. You stay on as a moderator. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing changed.\"); return; }\n await rpc(\"transferowner\", pk);\n toast(\"Transferred\");\n await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // ---- fork: a new name with this relay's events, claim reserved for a key ----\n $(\"#forkform\").scope.onchange = (ev) => $(\"#forkkinds\").classList.toggle(\"hidden\", ev.target.value !== \"kinds\");\n $(\"#joinform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { joinTerms: f.joinTerms.value, directoryPublic: f.directoryPublic.checked });\n toast(\"Saved\"); await loadInfo(); await loadPeople();\n });\n $(\"#forkform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const opts = { people: f.people.checked };\n if (f.name.value.trim()) opts.name = f.name.value.trim().toLowerCase();\n if (f.holder.value.trim()) { const pk = npubToHex(f.holder.value); if (!pk) throw new Error(\"That is not a pubkey.\"); opts.holder = pk; }\n if (f.scope.value === \"mine\") opts.filter = { authors: [me] };\n if (f.scope.value === \"kinds\") { const kinds = f.kinds.value.split(/[\\s,]+/).filter(Boolean).map(Number); if (!kinds.length || kinds.some((k) => !Number.isInteger(k) || k < 0)) throw new Error(\"Give kinds as numbers.\"); opts.filter = { kinds }; }\n if (!confirm(\"Fork this relay into a new name\" + (opts.name ? \" (\" + opts.name + \")\" : \"\") + \"? It pulls \" + (f.scope.value === \"all\" ? \"everything\" : f.scope.value === \"mine\" ? \"your events\" : \"the chosen kinds\") + (opts.people ? \" and the people\" : \"\") + \", and only \" + (opts.holder ? \"that key\" : \"you\") + \" can claim it.\")) return;\n const r = await rpc(\"forkrelay\", opts);\n $(\"#forknote\").textContent = r.handover + \" Expires \" + fmtTime(r.expires_at) + \".\";\n $(\"#forkurl\").textContent = r.console;\n $(\"#forkresult\").classList.remove(\"hidden\");\n toast(\"Forked to \" + r.name);\n });\n $(\"#forkcopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#forkurl\").textContent); toast(\"copied\"); };\n $(\"#deleterelay\").onclick = guard(async () => {\n const name = host.split(\".\")[0];\n const typed = prompt(\"This deletes everything on \" + host + \" and gives the name up. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing was deleted.\"); return; }\n await rpc(\"deleterelay\", name);\n localStorage.removeItem(\"me\");\n location.href = \"/\";\n });\n $(\"#topup\").onsubmit = guard((ev) => topUp(+ev.target.sats.value));\n $(\"#inv-copy\").onclick = async () => { await navigator.clipboard.writeText($(\"#inv-text\").value); toast(\"invoice copied\"); };\n $$(\"button[data-copy]\").forEach((b) => { b.onclick = async () => { await navigator.clipboard.writeText(b.dataset.copy === \"ws\" ? wsURL : location.origin); toast(\"copied\"); }; });\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-act]\"); if (!b) return;\n const act = b.dataset.act, id = b.dataset.id;\n if (act === \"copy\") { await navigator.clipboard.writeText(id); toast(\"copied\"); return; }\n if ((act === \"banpubkey\" || act === \"banevent\" || act === \"resolve:ban\") && !confirm(act === \"banevent\" ? \"Delete this event and refuse it forever?\" : \"Ban this author and refuse everything they post?\")) return;\n const erase = (act === \"banpubkey\" || act === \"resolve:ban\") && confirm(\"Also erase everything they wrote and uploaded here?\");\n if (act === \"deleteblob\" && !confirm(\"Delete this file for good?\")) return;\n if (act === \"deletedump\" && !confirm(\"Delete this dump?\")) return;\n if (act === \"downloaddump\") { b.disabled = true; try { await downloadDump(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }\n if (act === \"restorelist\") {\n if (!signer.ready()) { toast(NO_SIGNER); return; }\n try {\n const preview = await rpc(\"restorelist\", id);\n const d = preview.diff || {};\n const added = (d.addedTags || []).map((t) => \"+ \" + JSON.stringify(t)).join(\"\\n\");\n const removed = (d.removedTags || []).map((t) => \"- \" + JSON.stringify(t)).join(\"\\n\");\n const changes = [added, removed, d.contentChanged ? \"content changed\" : \"content unchanged\"].filter(Boolean).join(\"\\n\");\n if (!confirm(\"Restore this list version?\\n\\n\" + (changes || \"No tag or content changes\") + \"\\n\\nIt will be signed and published as the newest version.\")) return;\n const signed = await signer.signEvent(preview.draft);\n const result = await bridge(\"/events\", signed);\n if (!result.accepted) throw new Error(result.message || \"The relay refused the restored list.\");\n toast(\"List restored\"); await loadListHistory(); await loadStorage();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if ((act === \"removemember\" || act === \"banpubkey\") && (window.__members || []).some((m) => m.invited_by === id) && confirm(\"Also remove everyone this member invited, and everyone they invited in turn?\")) {\n b.disabled = true;\n try { const r = await rpc(\"removesubtree\", id); if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase); toast(\"Removed \" + r.removed.length); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"removejob\" && !confirm(\"Remove this job?\")) return;\n if (act === \"purgekind\") {\n const kind = id === \"\" ? null : +id;\n const label = kind === null ? \"everything without its own rule\" : kindName(kind) + \" (kind \" + kind + \")\";\n const typed = prompt(\"Purge \" + label + \" older than how many days? 0 purges all of them. This cannot be undone.\", \"30\");\n if (typed === null) return;\n const days = Math.max(0, Math.floor(+typed || 0));\n b.disabled = true;\n try { const r = await rpc(\"purgekind\", kind, days); toast(\"Purged \" + r.deleted.toLocaleString() + \" events\"); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"saveretention\") {\n const kind = id === \"\" ? null : +id;\n const days = Math.max(0, Math.floor(+b.closest(\"tr\").querySelector(\".days\").value || 0));\n b.disabled = true;\n try { await rpc(\"setretention\", kind, days); toast(days ? \"Kept for \" + days + \" days\" : \"Kept forever\"); await loadStorage(); await loadInfo(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n b.disabled = true;\n try {\n if (act === \"savemember\") { const tr = b.closest(\"tr\"), role = tr.querySelector(\"select.role\"), keep = tr.querySelector(\".keep\"), cap = tr.querySelector(\".cap\"); await rpc(\"setmember\", id, { name: tr.querySelector(\".name\").value.trim() || null, note: tr.querySelector(\".note\").value, ...(role ? { role: role.value } : {}), ...(keep ? { keepDays: Math.max(0, Math.floor(+keep.value || 0)), maxBytes: Math.max(0, Math.floor(+cap.value || 0)) * 1024 } : {}) }); }\n else if (act.startsWith(\"resolve:\")) await rpc(\"resolvereport\", id, act.slice(8), erase);\n else if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase);\n else await rpc(act, act === \"unrulekind\" ? +id : id, \"\");\n toast(\"Done\"); await (myRole ? refresh() : loadMine());\n if (act === \"pinevent\" || act === \"unpinevent\") await loadPins();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // The share block: the card picture, the group naddr and its QR.\n let card = null;\n async function loadCard() {\n try {\n card = await (await fetch(\"/card.json\", { cache: \"no-store\" })).json();\n $(\"#cardimg\").src = \"/card.svg?t=\" + Date.now();\n $(\"#naddr\").textContent = card.naddr || \"\";\n const q = $(\"#naddrqr\");\n if (card.naddr) { q.src = \"/qr.svg?text=\" + encodeURIComponent(card.naddr); q.classList.remove(\"hidden\"); } else q.classList.add(\"hidden\");\n } catch { /* the card is decoration */ }\n renderApps();\n }\n // The app rows on the Connect section: one per client people actually use,\n // with the link that lands on this relay in it. Relay apps take the relay\n // or the group address; feed apps take the owner's profile with this relay\n // as the hint, since they have no notion of opening a relay.\n function renderApps() {\n const el = $(\"#apps\"); if (!el) return;\n const enc = encodeURIComponent;\n const nprofile = card && card.nprofile ? card.nprofile : \"\";\n const naddr = card && card.naddr ? card.naddr : \"\";\n const whose = me && owner && me === owner ? \"your\" : \"the owner's\";\n const link = (label, href) => '' + label + \"\";\n const app = (label, uri) => '' + label + \"\";\n const copy = (label, text) => '\";\n const row = (name, where, note, acts) => '
                                                                                                    ' + name + \"\" + where + \"

                                                                                                    \" + note + '

                                                                                                    ' + acts.filter(Boolean).join(\"\") + \"
                                                                                                    \";\n const profileNote = \"Opens \" + whose + \" profile with this relay attached.\";\n const groups = [\n [\"As a place\", \"These open the relay itself: its feed, its people, its group.\", [\n row(\"Jumble\", \"web\", \"A feed of everything on this relay.\", [link(\"Open\", \"https://jumble.social/?r=\" + enc(wsURL))]),\n row(\"Coracle\", \"web\", \"The relay's page: its feed and its people.\", [link(\"Open\", \"https://coracle.social/relays/\" + enc(host))]),\n row(\"Flotilla\", \"web, phone\", \"The relay as a space, with the group as a room.\", [link(\"Open\", \"https://app.flotilla.social/spaces/\" + enc(host)), naddr && app(\"Open group\", \"nostr:\" + naddr)]),\n row(\"0xchat\", \"phone\", \"The group, in a chat app.\", [naddr && app(\"Open group\", \"nostr:\" + naddr), naddr && copy(\"Copy naddr\", naddr)]),\n row(\"noStrudel\", \"web\", \"Relays, add this one, then open its page.\", [copy(\"Copy relay URL\", wsURL)]),\n ]],\n [\"Find me here\", \"Feed apps have no relay pages. They meet this relay through a profile link that names it, then keep it once it is in the relay settings.\", [\n row(\"Primal\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://primal.net/p/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"YakiHonne\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://yakihonne.com/profile/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"Damus\", \"iPhone\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Amethyst\", \"Android\", profileNote + \" Then Relays in the drawer.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Nostur\", \"iPhone, Mac\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n ]],\n ];\n if (info?.supported_grasps?.includes(\"GRASP-01\")) groups.push([\n \"Git repositories\", \"Use this relay with a Git client.\", [\n row(\"GitWorkshop\", \"web\", \"Browse this relay's Git repositories.\", [link(\"Open in app\", \"https://gitworkshop.dev/relay/\" + enc((wsURL.startsWith(\"ws://\") ? \"ws:\" : \"\") + host)), copy(\"Copy relay URL\", wsURL)]),\n ],\n ]);\n el.innerHTML = groups.map(([h, note, rows]) => '

                                                                                                    ' + h + '

                                                                                                    ' + note + '

                                                                                                    ' + rows.join(\"\") + \"
                                                                                                    \").join(\"\");\n $(\"#apps-ws\").textContent = wsURL;\n const tile = (label, text) => '
                                                                                                    ' + label + '\"QR
                                                                                                    ';\n $(\"#phones\").innerHTML = [nprofile && tile(\"Find me here, for a phone\", \"nostr:\" + nprofile), naddr && tile(\"The group, for a phone\", \"nostr:\" + naddr)].filter(Boolean).join(\"\");\n }\n // The folds are a group: opening one closes the rest. Browsers with the\n // details name attribute do this themselves; this covers the others.\n $$(\".folds details\").forEach((d) => d.addEventListener(\"toggle\", () => { if (d.open) $$(\".folds details\").forEach((o) => { if (o !== d && o.open) o.open = false; }); }));\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-copytext]\"); if (!b) return;\n try { await navigator.clipboard.writeText(b.dataset.copytext); toast(\"copied\"); } catch { /* no clipboard */ }\n });\n $(\"#copynaddr\").onclick = async () => { if (!card || !card.naddr) return; await navigator.clipboard.writeText(card.naddr); toast(\"copied naddr\"); };\n $(\"#copyembed\").onclick = async () => { await navigator.clipboard.writeText('\"''); toast(\"copied embed\"); };\n\n // A click on a key copies it; a double-click selects the whole key so the usual copy shortcut takes it too.\n document.addEventListener(\"dblclick\", (ev) => {\n const k = ev.target.closest(\".key\"); if (!k) return;\n const s = window.getSelection(), r = document.createRange(); r.selectNodeContents(k.querySelector(\".full\")); s.removeAllRanges(); s.addRange(r);\n });\n document.addEventListener(\"click\", async (ev) => {\n const k = ev.target.closest(\".key\"); if (!k || ev.detail > 1 || String(window.getSelection())) return;\n try { await navigator.clipboard.writeText(k.title); toast(\"copied \" + k.dataset.short); } catch { /* no clipboard */ }\n });\n // ---- boot ----\n try { await loadInfo(); } catch { renderHeader(); }\n await loadFuel();\n await loadCard();\n me = localStorage.getItem(\"me\");\n if (me && await resumeRemote()) { /* a remote session answers for itself when first used */ }\n else if (me && window.nostr) { try { const pk = await window.nostr.getPublicKey(); if (pk !== me) me = null; } catch { me = null; } }\n else if (me) me = null;\n renderHeader();\n renderApps();\n await loadAdmin();\n await loadPeople();\n})();\n"; diff --git a/src/imports.ts b/src/imports.ts index 92fe51a..67f8e64 100644 --- a/src/imports.ts +++ b/src/imports.ts @@ -137,7 +137,7 @@ function take(relay: Relay, line: string, job: Job, t: number) { const r = relay.accept(e, null); if (r.stored) { job.stored++; - relay.broadcast(e); + relay.broadcast(e, false); } else if (r.msg === ERR_DUPLICATE) job.duplicates = (job.duplicates ?? 0) + 1; else job.skipped++; void t; diff --git a/src/manage.ts b/src/manage.ts index 07f0e70..904f8e9 100644 --- a/src/manage.ts +++ b/src/manage.ts @@ -11,6 +11,7 @@ import type { Relay } from "./relay.ts"; import { CODE_RE, inviteCreator, listClaims, listInvites, memberInviteGate, mintInvite, revokeInvite } from "./invites.ts"; import { descriptor, type Blob } from "./blossom.ts"; import { badBlockedWord, blockedWords, policyPatch, type Policy, type Settings } from "./settings.ts"; +import { deliveryStatus } from "./delivery.ts"; import { applyConfig, exportConfig, parseConfig, planConfig } from "./config.ts"; import { isReplaceable, isProtected, validIP } from "./settings.ts"; import { checkPullURL } from "./pull.ts"; @@ -631,6 +632,7 @@ export const METHODS: Record = { }, pullstatus: { action: "jobs", reads: true, run: async ({ relay, reply }) => reply({ result: await relay.pullStatus() }) }, listjobs: { action: "jobs", reads: true, run: async ({ relay, reply }) => reply({ result: await relay.jobs() }) }, + deliverystatus: { action: "jobs", reads: true, run: ({ relay, reply }) => reply({ result: deliveryStatus(relay) }) }, addjob: { action: "jobs", run: async ({ relay, params, reply }) => { diff --git a/src/pull.ts b/src/pull.ts index 65f1da4..5078a9d 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -294,7 +294,7 @@ function matchesPull(e: Event, f?: PullFilter): boolean { function storePulled(relay: Relay, e: Event, job: PullJob) { if (!relay.settings.kindAllowed(e.kind)) { job.skipped++; markRejected(job); return; } const r = relay.accept(e, null); - if (r.stored) { job.stored++; relay.broadcast(e); } + if (r.stored) { job.stored++; relay.broadcast(e, false); } else if (r.msg !== ERR_DUPLICATE) { job.skipped++; markRejected(job); } } diff --git a/src/relay.ts b/src/relay.ts index f5b0b07..3883936 100644 --- a/src/relay.ts +++ b/src/relay.ts @@ -3,6 +3,7 @@ // relay.go; policy is per relay and owner-managed (see manage.ts). import { callbackOrigins } from "./push-policy.ts"; import { pushTick, queuePush, nextPush, PUSH_SCHEMA } from "./push.ts"; +import { DELIVERY_SCHEMA, deliveryTick, queueDelivery } from "./delivery.ts"; import { graspTick, isGitPath, graspCORS } from "./grasp.ts"; import { graspBytes, holdGrasp, graspVisible } from "./grasp-state.ts"; import { queueMirrors } from "./site-mirror.ts"; @@ -156,6 +157,7 @@ export class Relay extends DurableObject { this.store.init(); this.settings.load(); this.sql.exec(PUSH_SCHEMA); + this.sql.exec(DELIVERY_SCHEMA); this.store.hidden = this.settings.hiddenEvents; this.store.searchMode = () => this.settings.policy.features.search; this.fuel.init(); @@ -498,6 +500,7 @@ export class Relay extends DurableObject { this.store.init(); this.settings.load(); this.sql.exec(PUSH_SCHEMA); + this.sql.exec(DELIVERY_SCHEMA); this.store.hidden = this.settings.hiddenEvents; this.store.searchMode = () => this.settings.policy.features.search; this.fuel.init(); @@ -1120,6 +1123,7 @@ export class Relay extends DurableObject { async alarm() { await pushTick(this); + const deliveryAt = await deliveryTick(this); return this.repositoryAccess.run("alarm", async () => { this.touch(); const t = now(); @@ -1186,6 +1190,7 @@ export class Relay extends DurableObject { if (graspAt && graspAt < at) at = graspAt; const pushAt = nextPush(this); if (pushAt) at = Math.min(at, Math.max(pushAt, now() + 1)); + if (deliveryAt) at = Math.min(at, Math.max(deliveryAt, now() + 1)); await this.ctx.storage.setAlarm(at * 1000 + 500); }, async () => { await this.ctx.storage.setAlarm(Date.now() + 1000); }); } @@ -1227,8 +1232,9 @@ export class Relay extends DurableObject { return [...(f.authors ?? []), ...(f.tags.p ?? [])].some((k) => parties.includes(k)); } - broadcast(e: Event) { + broadcast(e: Event, route = true) { if (!graspVisible(this, e.id)) return; + if (route && queueDelivery(this, e)) this.ctx.waitUntil(this.ensureAlarm(now() + 1)); if (queuePush(this, e)) this.ctx.waitUntil(this.ensureAlarm(now() + 1)); const raw = canonical(e); for (const ws of this.ctx.getWebSockets()) { diff --git a/src/settings.ts b/src/settings.ts index 49eb4b3..6a0369e 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -77,6 +77,8 @@ export interface Policy { features: Features; pushCallbacks: string[]; // HTTPS origins approved by the owner and the host operator letteredNips: boolean; // opt into mixed NIP-11 identifiers; push also requires them + // Opt-in NIP-65 routing of locally-authored public events to their relays. + delivery: { enabled: boolean; maxTargets: number }; } export const VIEW_NAMES = ["profiles", "relays", "calendar", "moderation", "articles", "zaps", "presence"]; @@ -134,6 +136,10 @@ export function policyPatch(patch: Record, cur: Policy): Partia const clean: Record = {}; for (const k of ["name", "description", "icon", "contact"]) if (typeof patch[k] === "string") clean[k] = (patch[k] as string).slice(0, 2000); Object.assign(clean, publicFields(patch), dumpFields(patch), gateFields(patch), viewFields(patch, cur.views), featureFields(patch, cur.features)); + if (patch.delivery && typeof patch.delivery === "object") { + const d = patch.delivery as Record; + clean.delivery = { enabled: typeof d.enabled === "boolean" ? d.enabled : cur.delivery.enabled, maxTargets: Number.isInteger(d.maxTargets) ? Math.max(1, Math.min(16, d.maxTargets as number)) : cur.delivery.maxTargets }; + } if (isWriteRule(patch.writes)) clean.writes = patch.writes; if (patch.reads === "open" || patch.reads === "auth" || patch.reads === "members") clean.reads = patch.reads; if (typeof patch.joinTerms === "string") clean.joinTerms = patch.joinTerms.slice(0, 20000); @@ -201,6 +207,7 @@ export const DEFAULT_POLICY: Policy = { features: { ...DEFAULT_FEATURES }, pushCallbacks: [], letteredNips: false, + delivery: { enabled: false, maxTargets: 8 }, }; export const SETTINGS_SCHEMA = ` From e52024f0e6a8f256be490cae09cb8d2c1caabd5e Mon Sep 17 00:00:00 2001 From: Dami Date: Fri, 4 Sep 2026 18:11:22 -0600 Subject: [PATCH 06/14] fix: make relay delivery progress durable per target --- src/console/console.js | 3 ++- src/delivery.ts | 35 ++++++++++++++++++---------- src/gen/console.ts | 2 +- src/jobs.ts | 44 +++++++++++++++++++++--------------- test/object/delivery.test.ts | 40 ++++++++++++++++++++++++++++++++ 5 files changed, 92 insertions(+), 32 deletions(-) create mode 100644 test/object/delivery.test.ts diff --git a/src/console/console.js b/src/console/console.js index 12be4fb..3263baf 100644 --- a/src/console/console.js +++ b/src/console/console.js @@ -604,7 +604,8 @@ const res = j.running ? "running: " + count(j.stored, j.blobs, j.sent, j.refused) + "..." : !l ? "waiting" : l.error ? "failed: " + l.error : count(l.stored, l.blobs, l.sent, l.refused) + (l.skipped ? ", " + l.skipped + " skipped" : "") + ", " + fmtTime(l.finishedAt); const sources = j.running ? j.pullSources : l?.sources; const details = sources?.length ? '
                                                                                                    Source results' + sources.map((s) => '

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

                                                                                                    ').join('') + '
                                                                                                    ' : ''; - return "" + what + "" + j.relays.map(esc).join("
                                                                                                    ") + "" + (f.join(", ") || "everything") + "" + when + "" + esc(res) + details + "" + (j.running ? "" : ib("undo", "Run now", "runjob", j.id)) + ib("x", "Remove", "removejob", j.id) + ""; + const targets = j.kind === "push" && j.targetStatus ? "
                                                                                                    " + Object.entries(j.targetStatus).map(([u, s]) => esc(u) + ": " + esc(s.status)).join("
                                                                                                    ") + "
                                                                                                    " : ""; + return "" + what + "" + j.relays.map(esc).join("
                                                                                                    ") + targets + "" + (f.join(", ") || "everything") + "" + when + "" + esc(res) + details + "" + (j.running ? "" : ib("undo", "Run now", "runjob", j.id)) + ib("x", "Remove", "removejob", j.id) + ""; }; async function pollJobs() { clearTimeout(jobsTimer); diff --git a/src/delivery.ts b/src/delivery.ts index a67ea72..a6c9ad3 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -15,6 +15,7 @@ CREATE INDEX IF NOT EXISTS delivery_due ON delivery_queue(status,due); `; const MAX_QUEUE = 512, MAX_ATTEMPTS = 4, TIMEOUT = 5000, BATCH = 4; const rows = >(r: Relay, q: string, ...a: any[]): T[] => r.sql.exec(q, ...a).toArray(); +const exec = (r: Relay, q: string, ...a: any[]) => { const c = r.sql.exec(q, ...a); r.meterPush(c.rowsRead, c.rowsWritten); return c; }; function list(r: Relay, pk: string): { read: string[]; write: string[] } { const row = r.store.query({ kinds: [10002], authors: [pk], tags: {} }, { pubkeys: [], all: true }, 1, now()).rows[0]; @@ -25,8 +26,13 @@ function list(r: Relay, pk: string): { read: string[]; write: string[] } { if (t[0] !== "r" || !t[1]) continue; let u: URL; try { u = new URL(t[1]); } catch { continue; } if (u.protocol !== "wss:" && u.protocol !== "ws:") continue; + // NIP-65 is user supplied egress. Refuse obvious loopback/private + // destinations; public DNS names remain the interoperable path. + const h = u.hostname.toLowerCase(); + if (!/^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z][a-z0-9-]*$/.test(h) || /\.(?:localhost|local|internal|lan|home|test|invalid|onion)$/.test(h)) continue; const url = u.toString().replace(/\/$/, ""); - const marker = t[2] === "read" ? "read" : t[2] === "write" ? "write" : "both"; + const marker = !t[2] ? "both" : t[2] === "read" ? "read" : t[2] === "write" ? "write" : ""; + if (!marker) continue; if ((marker === "read" || marker === "both") && !out.read.includes(url)) out.read.push(url); if ((marker === "write" || marker === "both") && !out.write.includes(url)) out.write.push(url); } @@ -49,15 +55,15 @@ function targets(r: Relay, e: Event): string[] { export function queueDelivery(r: Relay, e: Event): boolean { const p = r.settings.policy.delivery; - if (!p?.enabled || e.pubkey === r.identity.pubkey || isPrivate(e.kind) || e.tags.some((t: string[]) => t[0] === "-")) return false; + if (!p?.enabled || e.pubkey === r.identity.pubkey || !r.settings.isAllowed(e.pubkey) || isPrivate(e.kind) || e.tags.some((t: string[]) => t[0] === "-")) return false; const ts = targets(r, e); if (!ts.length) return false; let n = rows<{ n: number }>(r, `SELECT count(*) n FROM delivery_queue WHERE status='pending'`)[0]?.n ?? 0; let added = false; for (const target of ts) { if (n >= MAX_QUEUE) break; - r.sql.exec(`INSERT OR IGNORE INTO delivery_queue(event_id,target,author,due,attempts,status,error,updated_at) VALUES(?,?,?,?,0,'pending','',?)`, e.id, target, e.pubkey, now(), now()); - if (r.sql.exec(`SELECT changes() AS n`).one().n) { n++; added = true; } + exec(r, `INSERT OR IGNORE INTO delivery_queue(event_id,target,author,due,attempts,status,error,updated_at) VALUES(?,?,?,?,0,'pending','',?)`, e.id, target, e.pubkey, now(), now()); + if (rows<{ n: number }>(r, `SELECT changes() AS n`)[0]?.n) { n++; added = true; } } return added; } @@ -80,18 +86,23 @@ async function send(r: Relay, target: string, e: Event): Promise<{ ok: boolean; } export async function deliveryTick(r: Relay): Promise { - if (!r.settings.policy.delivery?.enabled) { r.sql.exec(`DELETE FROM delivery_queue`); return 0; } + if (!r.settings.policy.delivery?.enabled) { exec(r, `DELETE FROM delivery_queue`); return 0; } const t = now(); + exec(r, `DELETE FROM delivery_queue WHERE status<>'pending' AND updated_at'pending' ORDER BY updated_at ASC LIMIT max(0,(SELECT count(*) FROM delivery_queue WHERE status<>'pending')-1024))`); const jobs = rows<{ event_id: string; target: string; attempts: number }>(r, `SELECT event_id,target,attempts FROM delivery_queue WHERE status='pending' AND due<=? ORDER BY due LIMIT ?`, t, BATCH); for (const j of jobs) { - const ev = rows<{ raw: string }>(r, `SELECT raw FROM events WHERE id=? AND pubkey=? AND kind NOT IN (4,1059,21059,24133)`, j.event_id, rows<{ author: string }>(r, `SELECT author FROM delivery_queue WHERE event_id=? AND target=?`, j.event_id, j.target)[0]?.author ?? "")[0]; - if (!ev) { r.sql.exec(`UPDATE delivery_queue SET status='rejected',error='event unavailable',updated_at=? WHERE event_id=? AND target=?`, t, j.event_id, j.target); continue; } - r.sql.exec(`UPDATE delivery_queue SET attempts=attempts+1,due=?,updated_at=? WHERE event_id=? AND target=?`, t + 60, t, j.event_id, j.target); - const result = await send(r, j.target, JSON.parse(ev.raw) as Event); + const author = rows<{ author: string }>(r, `SELECT author FROM delivery_queue WHERE event_id=? AND target=?`, j.event_id, j.target)[0]?.author ?? ""; + const ev = rows<{ raw: string }>(r, `SELECT raw FROM events WHERE id=? AND pubkey=? AND kind NOT IN (4,1059,21059,24133)`, j.event_id, author)[0]; + if (!ev) { exec(r, `UPDATE delivery_queue SET status='rejected',error='event unavailable',updated_at=? WHERE event_id=? AND target=?`, t, j.event_id, j.target); continue; } + const event = JSON.parse(ev.raw) as Event; + if (!r.settings.isAllowed(author) || !targets(r, event).includes(j.target)) { exec(r, `UPDATE delivery_queue SET status='rejected',error='routing no longer permitted',updated_at=? WHERE event_id=? AND target=?`, t, j.event_id, j.target); continue; } + exec(r, `UPDATE delivery_queue SET attempts=attempts+1,due=?,updated_at=? WHERE event_id=? AND target=?`, t + 60, t, j.event_id, j.target); + const result = await send(r, j.target, event); const attempts = j.attempts + 1; - if (result.ok || attempts >= MAX_ATTEMPTS || (result.error !== "timeout" && !/5\d\d|tempor/i.test(result.error))) { - r.sql.exec(`UPDATE delivery_queue SET status=?,error=?,updated_at=? WHERE event_id=? AND target=?`, result.ok ? "accepted" : "rejected", result.error, now(), j.event_id, j.target); - } else r.sql.exec(`UPDATE delivery_queue SET due=?,error=?,updated_at=? WHERE event_id=? AND target=?`, now() + [30, 120, 600][Math.min(attempts - 1, 2)], result.error, now(), j.event_id, j.target); + if (result.ok || attempts >= MAX_ATTEMPTS) { + exec(r, `UPDATE delivery_queue SET status=?,error=?,updated_at=? WHERE event_id=? AND target=?`, result.ok ? "accepted" : "rejected", result.error, now(), j.event_id, j.target); + } else exec(r, `UPDATE delivery_queue SET due=?,error=?,updated_at=? WHERE event_id=? AND target=?`, now() + [30, 120, 600][Math.min(attempts - 1, 2)], result.error, now(), j.event_id, j.target); } const next = rows<{ next: number | null }>(r, `SELECT min(due) next FROM delivery_queue WHERE status='pending'`)[0]?.next ?? 0; return next; diff --git a/src/gen/console.ts b/src/gen/console.ts index a1ac465..4ea1ec3 100644 --- a/src/gen/console.ts +++ b/src/gen/console.ts @@ -1,4 +1,4 @@ // Generated by scripts/build/build-console.mjs from src/console. Do not edit; run npm run build:console. export const CONSOLE_HTML = "
                                                                                                    \n
                                                                                                    \n
                                                                                                    \"\"
                                                                                                    \n
                                                                                                    \"\"

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

                                                                                                    Nobody owns this relay yet.

                                                                                                    \n

                                                                                                    Claim it and it's yours: you decide who can post, who can read, and what stays. One signature with a nostr browser extension; no account, no email, no card.

                                                                                                    \n
                                                                                                    \n
                                                                                                    \n\n
                                                                                                    \n

                                                                                                    A temporary relay, for now.

                                                                                                    \n

                                                                                                    Anyone can read and write here until . Then everything on it is deleted and the name is freed. Claim it and it stays, events and files included: one signature with a nostr browser extension. Or claim a new name and pull this one into it from its Storage tab.

                                                                                                    \n
                                                                                                    \n
                                                                                                    \n\n
                                                                                                    \n

                                                                                                    Connect a remote signer.

                                                                                                    \n

                                                                                                    Your key stays in a signer app such as Amber or nsec.app; this page asks it to sign. On a phone, open the link and approve there. On a computer, paste the bunker:// URL the app gives you.

                                                                                                    \n \n
                                                                                                    \n \"QR\n

                                                                                                    \n
                                                                                                    \n\n
                                                                                                    \n
                                                                                                    \n

                                                                                                    \n

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

                                                                                                    Git repositories, ntig

                                                                                                    \n
                                                                                                    \n

                                                                                                    Use ordinary Git to clone a repository hosted here.

                                                                                                    \n
                                                                                                    Clone a repository
                                                                                                    \n

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

                                                                                                    \n

                                                                                                    To host your own, use a Nostr Git client to publish your repository and signed branch state to this relay before pushing to the same remote. Git hosting guide.

                                                                                                    \n
                                                                                                    \n
                                                                                                    \n
                                                                                                    \n

                                                                                                    Open it in an app

                                                                                                    \n

                                                                                                    Relay apps open this relay as a place. Feed apps do not: they find the owner here through a profile link that carries this relay as the hint, and learn the relay from that. Either way, add under the app's relay settings to post here.

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

                                                                                                    People

                                                                                                    \n

                                                                                                    Hidden from visitors. Only you see this list.

                                                                                                    \n
                                                                                                    \n
                                                                                                    \n\n
                                                                                                    \n

                                                                                                    Fuel

                                                                                                    \n

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

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

                                                                                                    \n
                                                                                                    sats
                                                                                                    \n
                                                                                                    \n

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

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

                                                                                                    Your invites

                                                                                                    \n

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

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

                                                                                                      People

                                                                                                      \n

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

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

                                                                                                      Invites

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

                                                                                                        Joining

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

                                                                                                        Moderation

                                                                                                        \n

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

                                                                                                        \n
                                                                                                        \n

                                                                                                        Reports

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

                                                                                                        Log

                                                                                                        \n

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

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

                                                                                                        Bans

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

                                                                                                            Blocked addresses

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

                                                                                                              Recent events

                                                                                                              \n

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

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

                                                                                                              Pinned

                                                                                                              \n

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

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

                                                                                                                Rules

                                                                                                                \n

                                                                                                                Bans apply regardless of these.

                                                                                                                \n
                                                                                                                \n

                                                                                                                Presets

                                                                                                                \n

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

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

                                                                                                                \n
                                                                                                                \n
                                                                                                                \n
                                                                                                                \n

                                                                                                                Writes

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

                                                                                                                Reads

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

                                                                                                                Kinds

                                                                                                                \n

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

                                                                                                                \n
                                                                                                                \n

                                                                                                                Allowed:

                                                                                                                \n

                                                                                                                Blocked:

                                                                                                                \n
                                                                                                                \n
                                                                                                                \n

                                                                                                                Features

                                                                                                                \n

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

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

                                                                                                                Blocked words

                                                                                                                \n

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

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

                                                                                                                Identity

                                                                                                                \n
                                                                                                                \n

                                                                                                                Profile

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

                                                                                                                For directories

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

                                                                                                                Your own domain

                                                                                                                \n

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

                                                                                                                \n
                                                                                                                \n
                                                                                                                \n

                                                                                                                \n
                                                                                                                \n
                                                                                                                \n

                                                                                                                Your relay lists

                                                                                                                \n

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

                                                                                                                \n
                                                                                                                \n
                                                                                                                \n
                                                                                                                \n

                                                                                                                Share

                                                                                                                \n

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

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

                                                                                                                Data

                                                                                                                \n

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

                                                                                                                \n
                                                                                                                \n

                                                                                                                Recover your lists

                                                                                                                \n

                                                                                                                Older signed versions of your follows, relay lists and bookmarks stay private here. Restore a version to review it, then sign and publish it from this relay.

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

                                                                                                                By kind

                                                                                                                \n
                                                                                                                KindCountSizeOldestKeep for
                                                                                                                \n
                                                                                                                \n
                                                                                                                \n

                                                                                                                Files

                                                                                                                \n
                                                                                                                TimeFileSizeUploader
                                                                                                                \n
                                                                                                                \n
                                                                                                                \n

                                                                                                                Sites

                                                                                                                \n

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

                                                                                                                \n
                                                                                                                AuthorNameURLFilesSizeExpiry
                                                                                                                \n
                                                                                                                \n
                                                                                                                \n

                                                                                                                Dumps

                                                                                                                \n

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

                                                                                                                \n
                                                                                                                \n
                                                                                                                  \n
                                                                                                                  \n
                                                                                                                  \n

                                                                                                                  Import a file

                                                                                                                  \n

                                                                                                                  A JSONL of events, one per line, such as a dump or a strfry export, or a JSON array. Up to 64 MB. Signatures are checked; bans and kind rules apply; the write rule does not. Progress shows under Jobs.

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

                                                                                                                  Sync

                                                                                                                  \n

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

                                                                                                                  \n
                                                                                                                  \n

                                                                                                                  Jobs

                                                                                                                  \n

                                                                                                                  Work the relay does on its own, one round at a time. A pull copies what another relay has and this one lacks. Fetch my history pulls your own events from the relays in your relay list. Rebroadcast sends what is here to other relays. Bans and kind rules apply to what arrives.

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

                                                                                                                  Fork this relay

                                                                                                                  \n

                                                                                                                  A new name, temporary until claimed, filled from this relay. Yours to split a name by job, or somebody else's to hand them a community with its history. One fork an hour.

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

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

                                                                                                                  Views

                                                                                                                  \n

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

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

                                                                                                                  Health

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

                                                                                                                  Zaps received

                                                                                                                  \n
                                                                                                                  WhenFromSats
                                                                                                                  \n
                                                                                                                  \n
                                                                                                                  \n

                                                                                                                  Notifications

                                                                                                                  \n

                                                                                                                  The relay writes you a private message (NIP-17) with its own key. It lands here, in your inbox on your own relay, and on your DM relays when this relay holds your kind 10050.

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

                                                                                                                  Owner

                                                                                                                  \n

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

                                                                                                                  \n
                                                                                                                  \n

                                                                                                                  Configuration

                                                                                                                  \n

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

                                                                                                                  \n
                                                                                                                  \n
                                                                                                                  \n
                                                                                                                  \n

                                                                                                                  Transfer ownership

                                                                                                                  \n

                                                                                                                  Hands the relay to a member. You stay on as a moderator. The relay's key, events, files and fuel do not change. There is no undo.

                                                                                                                  \n
                                                                                                                  \n
                                                                                                                  \n
                                                                                                                  \n

                                                                                                                  If I lose my key

                                                                                                                  \n

                                                                                                                  Name a member as your heir. If you do not sign in here for the time you pick, the relay writes to you once a week for a month, then hands itself to the heir and keeps you on as a moderator. Any signed action on the relay resets the clock.

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

                                                                                                                  \n
                                                                                                                  \n
                                                                                                                  \n

                                                                                                                  Delete this relay

                                                                                                                  \n

                                                                                                                  Deletes every event, file, member, invite and setting, closes every connection, and returns the name to unclaimed for anyone to take. There is no undo.

                                                                                                                  \n \n
                                                                                                                  \n
                                                                                                                  \n
                                                                                                                  \n\n \n
                                                                                                                  \n"; export const CONSOLE_CSS = "main { max-width: 64rem; }\n.mast { display: grid; grid-template-columns: 1fr auto; gap: 1.5rem 2rem; align-items: end; margin-bottom: 2rem; }\n.mast .wordmark { grid-column: 1 / -1; justify-self: center; margin-bottom: .5rem; }\n.mast .right { display: grid; gap: .7rem; justify-items: end; text-align: right; }\n.urlrow, .owner { display: inline-flex; align-items: center; gap: .4rem; color: var(--ink-2); }\n.owner b { color: var(--ink); } .owner em { font-style: normal; color: var(--forest); }\n.urlrow .ib, .owner .ib { width: 26px; height: 26px; margin-left: .2rem; box-shadow: 1px 1px 0 var(--ink); } .urlrow .ib svg, .owner .ib svg { width: 13px; height: 13px; }\n.care { display: flex; gap: .9rem; flex-wrap: wrap; }\n.care .g { display: grid; justify-items: center; gap: .3rem; width: 5.4rem; }\n.care .g i { display: grid; place-items: center; width: 44px; height: 44px; border: 2px solid var(--ink); border-radius: 10px; background: var(--paper); box-shadow: 2px 2px 0 var(--ink); position: relative; }\n.care .g i svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }\n.care .g.off i::after { content: \"\"; position: absolute; width: 3px; height: 40px; background: var(--ink); transform: rotate(45deg); border-radius: 2px; box-shadow: 0 0 0 2px var(--paper); }\n.care .g small { font: 500 10px/1.2 var(--mono); text-transform: uppercase; letter-spacing: .04em; color: var(--ink-2); text-align: center; }\n#unclaimed { background: var(--butter); }\n.doors { display: grid; grid-template-columns: repeat(4, 1fr); gap: .8rem; } @media (max-width: 52rem) { .doors { grid-template-columns: 1fr 1fr; } } @media (max-width: 30rem) { .doors { grid-template-columns: 1fr; } }\n.door { display: grid; grid-template-rows: auto 1fr; align-content: start; gap: .35rem; padding: .8rem .9rem; border: 2px solid var(--ink); border-radius: 12px; background: var(--paper); box-shadow: 3px 3px 0 var(--ink); min-width: 0; }\n.apps { display: grid; gap: 1.4rem; margin-top: 1rem; } .appgroup h4 { margin: 0 0 .2rem; font-size: 1rem; } .appgroup > .note { margin-top: 0; }\n.appgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: .8rem; } @media (max-width: 52rem) { .appgrid { grid-template-columns: 1fr 1fr; } } @media (max-width: 30rem) { .appgrid { grid-template-columns: 1fr; } }\n.app { display: grid; align-content: start; gap: .4rem; padding: .8rem .9rem; border: 2px solid var(--ink); border-radius: 12px; background: var(--paper); box-shadow: 3px 3px 0 var(--ink); min-width: 0; }\n.app-head { display: flex; align-items: baseline; gap: .5rem; flex-wrap: wrap; } .app-head b { font-size: 15px; } .app-head small { color: var(--ink-3); } .app p { margin: 0; font-size: 13px; color: var(--ink-2); }\n.app-acts { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .2rem; } .app-acts .btn { padding: .3rem .7rem; font-size: 13px; }\n.phones { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, max-content)); gap: .8rem; margin-top: 1rem; } .phones img { display: block; width: 150px; height: 150px; } .phones .door { justify-items: start; }\n.door small { font: 700 11px var(--sans); text-transform: uppercase; letter-spacing: .06em; color: var(--ink-2); }\n.door .v { display: grid; grid-template-columns: 1fr auto; align-items: start; gap: .5rem; font: 500 13px/1.45 var(--mono); } .door .v span { min-width: 0; overflow-wrap: anywhere; }\n.door .ib { width: 24px; height: 24px; box-shadow: 1px 1px 0 var(--ink); flex: 0 0 auto; } .door .ib svg { width: 12px; height: 12px; }\n#peoplesec { background: var(--peach); }\n.dir { display: flex; flex-wrap: wrap; gap: .5rem; }\n.who { display: inline-flex; align-items: center; gap: .1rem; padding: .3rem .7rem .3rem .4rem; border: 2px solid var(--ink); border-radius: 999px; background: var(--paper); box-shadow: 2px 2px 0 var(--ink); font-size: 14px; }\n.who .role { font: 700 10px var(--mono); text-transform: uppercase; letter-spacing: .05em; color: var(--forest); margin-left: .4rem; }\n.who.me { background: var(--sun); }\n#fuelsec { background: var(--mint); }\n.gauges { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }\n.gauge small { display: block; font-weight: 600; font-size: 13px; }\n.gauge .bar { position: relative; height: 22px; border: 2px solid var(--ink); border-radius: 999px; margin: .5rem 0 .35rem; overflow: hidden; padding: 3px; background: var(--paper) linear-gradient(90deg, transparent calc(25% - 1px), var(--line) calc(25% - 1px) 25%, transparent 25% calc(50% - 1px), var(--line) calc(50% - 1px) 50%, transparent 50% calc(75% - 1px), var(--line) calc(75% - 1px) 75%, transparent 75%); }\n.gauge .bar b { position: absolute; right: .6rem; top: 0; bottom: 0; display: flex; align-items: center; font: 600 11px var(--mono); color: var(--ink-3); pointer-events: none; }\n.gauge .bar i { display: block; height: 100%; width: 0; min-width: 14px; border-radius: 999px; background: repeating-linear-gradient(-45deg, var(--forest) 0 8px, var(--forest-2) 8px 16px); transition: width .4s; }\n.gauge .bar i.warm { background: repeating-linear-gradient(-45deg, #d9a52a 0 8px, var(--sun) 8px 16px); }\n.gauge .bar i.over { background: repeating-linear-gradient(-45deg, var(--red) 0 8px, #d4614c 8px 16px); }\n.gauge span { font-size: 13px; color: var(--ink-3); }\n.balance { margin: 1.2rem 0 0; color: var(--ink-2); } .balance b { color: var(--ink); font-weight: 700; }\n.sats { display: inline-flex; align-items: center; width: auto; } .sats input { font: 15px var(--sans); width: 6rem; text-align: right; border: 0; outline: none; padding: 0; background: transparent; color: var(--ink); } .sats span { color: var(--ink-3); padding-left: .4rem; }\n.topup { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; margin-top: .8rem; }\n.invoice { margin-top: 1rem; border: 2px dashed var(--ink); border-radius: 12px; padding: .9rem 1rem; background: var(--paper); }\n.invoice p { margin: 0 0 .6rem; color: var(--ink-2); font-size: 14px; } .invoice .state { font-size: 13px; color: var(--forest); }\n.invoice textarea { margin-top: .7rem; min-height: 3.4rem; font-family: var(--mono); font-size: 11.5px; color: var(--ink-3); }\ndetails.disclosure summary { cursor: pointer; font: 400 1.6rem/1 var(--display); list-style: none; display: flex; align-items: center; gap: .5rem; }\ndetails.disclosure summary::-webkit-details-marker { display: none; }\n.folds details + details, .folds #frontsec:not(.hidden) + details { border-top: 2px solid var(--line); margin-top: 1.2rem; padding-top: 1.2rem; } .folds #frontsec .metarow { margin-bottom: 0; } .folds .doors { margin-top: 1rem; } .folds > details > .block:first-of-type { margin-top: 1.4rem; }\ndetails.disclosure summary::before { content: \"+\"; font: 700 1.4rem var(--mono); width: 1.4rem; } details.disclosure[open] summary::before { content: \"–\"; }\n.about { display: grid; grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); gap: .6rem 1.2rem; margin-top: 1rem; font-size: 14px; }\n.about small { display: block; color: var(--ink-3); font-size: 12px; } .about div > div { overflow-wrap: anywhere; }\n.console { margin-top: 3rem; }\n.tabs { display: flex; gap: .45rem; flex-wrap: nowrap; align-items: flex-end; margin: 0 0 -2px 1rem; padding: 2px 3rem 2px 0; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; }\n.tabs.fade-r { -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 2.5rem), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 2.5rem), transparent); }\n.tabs.fade-l { -webkit-mask-image: linear-gradient(90deg, transparent, #000 2.5rem); mask-image: linear-gradient(90deg, transparent, #000 2.5rem); }\n.tabs.fade-l.fade-r { -webkit-mask-image: linear-gradient(90deg, transparent, #000 2.5rem, #000 calc(100% - 2.5rem), transparent); mask-image: linear-gradient(90deg, transparent, #000 2.5rem, #000 calc(100% - 2.5rem), transparent); } .tabs::-webkit-scrollbar { display: none; } .tabs a { flex: 0 0 auto; white-space: nowrap; }\n.tabs a { font: 600 14px var(--sans); padding: .55rem 1rem; border: 2px solid var(--ink); border-bottom: 0; border-radius: 12px 12px 0 0; background: var(--head); color: var(--ink-2); text-decoration: none; margin-bottom: -2px; position: relative; z-index: 1; }\n.tabs a.on { color: var(--ink); z-index: 3; padding-top: .7rem; } .tabs a.on:nth-child(4n+1) { background: var(--sun); } .tabs a.on:nth-child(4n+2) { background: var(--peach); } .tabs a.on:nth-child(4n+3) { background: var(--sky); } .tabs a.on:nth-child(4n+4) { background: var(--mint); }\n.count { display: inline-grid; place-items: center; min-width: 1.5em; height: 1.5em; padding: 0 .4em; margin-left: .45rem; border: 1.5px solid var(--ink); border-radius: 999px; background: var(--sun); color: var(--ink); font: 700 11px/1 var(--mono); vertical-align: middle; box-shadow: 1px 1px 0 var(--ink); } .count:empty { display: none; } h2 .count { font-size: 12px; vertical-align: .45em; }\n.panel { display: none; margin: 0; border-radius: 0 18px 18px 18px; position: relative; z-index: 2; }\n.panel.on { display: block; }\n.panel h2 { font-size: 2rem; }\n.block { margin-top: 1.8rem; }\n.two { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; } .two > * { min-width: 0; }\n.actions { display: flex; justify-content: flex-end; margin-top: 1.1rem; } .actions.left { justify-content: flex-start; }\n.addrow { display: flex; gap: .5rem; align-items: center; margin-bottom: .9rem; } .addrow input { flex: 1; min-width: 0; } .addrow label { display: inline-flex; align-items: center; gap: .35rem; flex: 0 0 auto; white-space: nowrap; font-size: 14px; color: var(--ink-2); cursor: pointer; } .addrow label input { flex: none; width: auto; margin: 0; accent-color: var(--forest); } .addrow .ib { width: 38px; height: 38px; flex: 0 0 auto; border-radius: 10px; }\n.choices { display: grid; gap: .5rem; }\n.choices label { display: grid; grid-template-columns: auto 1fr; gap: 0 .7rem; cursor: pointer; padding: .7rem .8rem; border: 2px solid var(--line-2); border-radius: 12px; background: var(--paper); }\n.choices label:has(:checked) { border-color: var(--ink); box-shadow: 3px 3px 0 var(--ink); }\n.choices input { grid-row: span 2; margin: .2rem 0 0; accent-color: var(--forest); }\n.choices b { font-weight: 600; } .choices small { color: var(--ink-2); font-size: 13px; }\n.limits { display: grid; grid-template-columns: 1fr 1fr; gap: .6rem 1.5rem; margin-top: 1.3rem; }\n.limits label { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: .6rem; } .limits label small { color: var(--ink-3); }\n.form { display: grid; grid-template-columns: 1fr 1fr; gap: .9rem 1.5rem; } .form label { display: grid; gap: .3rem; font-weight: 600; font-size: 14px; } .form .wide { grid-column: 1 / -1; }\n.danger-zone { border: 2px solid var(--red); border-radius: 12px; padding: 1rem 1.1rem; background: var(--red-soft); } .danger-zone h3 { color: var(--red); } .form label.switch { display: flex; flex-direction: row; align-items: center; gap: .6rem; font-weight: 600; font-size: 14px; } .switch input { accent-color: var(--forest); width: 18px; height: 18px; }\ntable { width: 100%; border-collapse: collapse; }\nth { text-align: left; font-size: 12px; font-weight: 600; color: var(--ink-3); padding: .4rem .5rem; border-bottom: 2px solid var(--ink); white-space: nowrap; }\ntd { padding: .5rem .5rem; vertical-align: middle; border-bottom: 1px solid var(--line); }\ntbody tr:nth-child(even) td { background: rgba(255,255,255,.55); }\nth.r, td.r { text-align: right; white-space: nowrap; } td.mono, td.dim { white-space: nowrap; } .events td.c:empty::before { content: \"(no content)\"; color: var(--ink-3); }\ntd .ib { width: 26px; height: 26px; box-shadow: 1px 1px 0 var(--ink); } td .ib svg { width: 13px; height: 13px; }\ntd input.txt { padding: .25rem .5rem; font-size: 14px; } td select.role { width: auto; min-width: 7rem; padding: .25rem .4rem; font-size: 13px; } .people-table input.name { width: 7rem; } .people-table input.note { width: 9.5rem; }\n.kind { font-family: var(--mono); font-size: 12px; background: var(--sun); border: 1.5px solid var(--ink); padding: 0 .5rem; border-radius: 999px; font-weight: 500; white-space: nowrap; }\n.events td.c { max-width: 0; width: 100%; overflow-x: auto; white-space: nowrap; scrollbar-width: thin; -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 2rem), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 2rem), transparent); }\n.people-table td.name { width: 12rem; }\n.ev { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 4px; vertical-align: -5px; margin-right: .45rem; font: 500 9px var(--mono); background: var(--line); color: var(--ink-2); font-style: normal; }\n.plain { list-style: none; margin: 0; padding: 0; } .plain li { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: .5rem; padding: .5rem 0; border-bottom: 2px dotted var(--line-2); } .plain li > span { min-width: 0; overflow-wrap: anywhere; } .plain li:last-child { border-bottom: 0; } .plain li.empty { grid-template-columns: 1fr; color: var(--ink-3); }\n.kindline { margin: .4rem 0; color: var(--ink-2); display: flex; flex-wrap: wrap; align-items: center; gap: .35rem; }\n.tag { display: inline-flex; align-items: center; gap: .2rem; font-family: var(--mono); font-size: 13px; padding: .05rem .2rem .05rem .55rem; border: 2px solid var(--ink); border-radius: 999px; background: var(--paper); box-shadow: 1px 1px 0 var(--ink); }\n.tag.plain { padding-right: .55rem; box-shadow: none; border-color: var(--line-2); color: var(--ink-3); } .tag.blk { color: var(--red); }\n.tag .ib { width: 18px; height: 18px; border-width: 1.5px; box-shadow: none; margin-left: .2rem; } .tag .ib svg { width: 9px; height: 9px; }\n.counters { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1.2rem; margin-bottom: 1.6rem; }\n.counter { display: grid; justify-items: center; align-content: center; gap: .15rem; min-height: 7.2rem; text-align: center; padding: 1rem .8rem; border: 2px solid var(--ink); border-radius: 16px; box-shadow: 4px 4px 0 var(--ink); background: var(--paper); }\n.counter:nth-child(1) { background: var(--sun); } .counter:nth-child(2) { background: var(--peach); } .counter:nth-child(3) { background: var(--sky); } .counter:nth-child(4) { background: var(--mint); }\n.counter .big { font: 400 2.6rem/1 var(--display); white-space: nowrap; } .counter small { display: block; margin-top: .35rem; font: 700 12px var(--sans); text-transform: uppercase; letter-spacing: .08em; } .counter .sub { font-size: 12px; color: var(--ink-2); }\n.kbar { display: flex; width: 100%; height: 22px; border: 2px solid var(--ink); border-radius: 999px; overflow: hidden; background: var(--paper); margin-bottom: .35rem; } .kbar i { display: block; height: 100%; border-right: 2px solid var(--ink); } .kbar i:last-child { border-right: 0; }\n.k1 { background: var(--ink); } .k2 { background: var(--forest); } .k3 { background: var(--id-3); } .k4 { background: var(--id-2); } .k5 { background: var(--line-2); }\n.legend { display: flex; flex-wrap: wrap; justify-content: center; gap: .1rem .6rem; font-family: var(--mono); font-size: 10.5px; text-transform: uppercase; } .legend i { display: inline-block; width: 9px; height: 9px; border: 1.5px solid var(--ink); border-radius: 2px; margin-right: .3rem; vertical-align: -1px; }\n.usage { display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: .8rem; } .usage div { padding: .7rem .9rem; border: 2px solid var(--ink); border-radius: 12px; background: var(--paper); } .usage small { display: block; color: var(--ink-3); font-size: 12px; } .usage b { font: 400 1.5rem/1.2 var(--display); }\n@media (max-width: 52rem) { .two, .gauges, .limits, .form, .counters { grid-template-columns: 1fr !important; } .addrow { flex-wrap: wrap; } .mast { grid-template-columns: 1fr; } .mast .right { justify-items: start; text-align: left; } }\n@media (max-width: 40rem) { .events:not(#kinds):not(#jobs) th:first-child, .events:not(#kinds):not(#jobs) td:first-child { display: none; } }\n\n.slegend { justify-content: flex-start; margin: 0 0 1.2rem; }\n#s-totals { margin-bottom: .4rem; }\ntd.keep { white-space: nowrap; } td.keep input { width: 5.5rem; display: inline-block; margin-right: .3rem; } td.keep input::placeholder { color: var(--ink-3); }\n#kinds tr.any td:first-child { font-weight: 600; }\n#kinds td:first-child .kind { display: inline-block; min-width: 5.2em; text-align: center; margin-right: .35rem; }\n/* Tables keep their columns and scroll inside the card on narrow screens instead of pushing the page sideways. */\n.scroll { overflow-x: auto; max-width: 100%; scrollbar-width: thin; } .scroll table { min-width: 100%; }\nsection, .card { overflow-x: clip; }\n.kind.sys { background: var(--mint); border-color: var(--forest); color: var(--forest); }\ntd.keep.sys { color: var(--ink-3); font-size: 13px; white-space: normal; }\n\n#console.mod .tabs a:not([data-tab=people]):not([data-tab=moderation]) { display: none; } #console.mod #thresholdform { display: none; }\n#members select.role { width: auto; padding: .1rem .3rem; font-size: 12px; margin-left: .3rem; }\n.wire-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .5rem 1.2rem; padding: .8rem 0; border-bottom: 1px solid var(--line); align-items: start; } .wire-row:first-child { border-top: 2px solid var(--ink); }\n.wire-main { min-width: 0; } .wire-side { display: grid; justify-items: end; gap: .45rem; max-width: 26rem; }\n.wire-acts { display: flex; gap: .4rem; white-space: nowrap; } .wire-acts .btn { padding: .35rem .75rem; font-size: 13px; }\n.wire-meta { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .35rem .5rem; align-items: center; font-size: 13px; color: var(--ink-2); text-align: right; } .wire-meta:empty { display: none; }\n.pill { display: inline-block; padding: .1rem .6rem; border: 1.5px solid var(--ink); border-radius: 999px; font: 600 12px var(--sans); color: var(--ink); background: var(--paper); } .pill.on { background: var(--mint); } .pill.off { color: var(--ink-3); } .pill.bad { color: var(--red); }\n.chip { display: inline-block; padding: .05rem .45rem; border: 1px solid var(--forest); border-radius: 6px; font: 12px var(--mono); color: var(--forest); } .chip.bad { border-color: var(--red); color: var(--red); }\n.share { display: grid; grid-template-columns: minmax(0, 3fr) minmax(14rem, 2fr); gap: 1.4rem; align-items: start; } .share #cardimg { width: 100%; height: auto; display: block; }\n.share-side { display: grid; gap: .8rem; justify-items: start; } .naddr { display: block; font-size: 12px; line-height: 1.5; word-break: break-all; color: var(--ink-2); }\n@media (max-width: 52rem) { .share { grid-template-columns: 1fr; } .wire-side { display: contents; } .wire-acts { justify-self: end; } .wire-meta { grid-column: 1 / -1; justify-content: flex-start; text-align: left; } }\n#presets .btn { margin: 0 .4rem .4rem 0; }\n.panel form h3.gap { margin-top: 1.4rem; }\n.key { position: relative; display: inline-block; font-family: var(--mono); } .key::before { content: attr(data-short); } .key .full { position: absolute; left: 0; top: 0; width: 1px; height: 1px; overflow: hidden; opacity: 0; white-space: nowrap; }\n.mast .banner { grid-column: 1 / -1; width: 100%; aspect-ratio: 4 / 1; overflow: hidden; border: 2px solid var(--ink); border-radius: 18px; box-shadow: 4px 4px 0 var(--ink); background: var(--paper); margin-bottom: .5rem; } .mast .banner img { width: 100%; height: 100%; object-fit: cover; display: block; }\n.mast .wordmark { display: flex; align-items: center; gap: 1rem; } .mast .icon { width: 64px; height: 64px; border-radius: 50%; border: 2px solid var(--ink); box-shadow: 3px 3px 0 var(--ink); background: var(--paper); object-fit: cover; }\n.metarow { display: flex; flex-wrap: wrap; gap: .4rem .6rem; align-items: center; margin: -.4rem 0 1rem; font-size: 13px; color: var(--ink-2); }\n.metarow .tag { display: inline-block; padding: .1rem .6rem; border: 1.5px solid var(--ink); border-radius: 999px; font: 600 12px var(--sans); color: var(--ink); background: var(--sun); }\n.metarow .sep { color: var(--ink-3); } .metarow a { color: var(--forest); text-decoration: none; border-bottom: 1.5px solid var(--forest); } .metarow a:hover { border-bottom-width: 2px; }\n.about a { color: var(--forest); text-decoration: none; border-bottom: 1.5px solid var(--forest); }\n.wire-acts label { display: inline-flex; align-items: center; gap: .35rem; font-size: 14px; color: var(--ink-2); cursor: pointer; } .wire-acts label input { margin: 0; accent-color: var(--forest); } .wire-row.dim .wire-main { color: var(--ink-3); }\n"; -export const CONSOLE_JS = "(async () => {\n const $ = (s) => document.querySelector(s);\n const $$ = (s) => document.querySelectorAll(s);\n const host = location.host;\n const wsURL = (location.protocol === \"https:\" ? \"wss://\" : \"ws://\") + host;\n const rpcURL = location.origin + \"/\";\n let info = null, me = null, owner = \"\", policy = null, fuel = null, people = null, myRole = \"\";\n\n const toast = (msg) => { const t = $(\"#toast\"); t.textContent = msg; t.classList.add(\"show\"); clearTimeout(t._t); t._t = setTimeout(() => t.classList.remove(\"show\"), 2600); };\n // guard wraps a handler: disables the button, shows errors as toasts. Defined first, since handlers below use it.\n const guard = (fn) => async (ev) => { ev.preventDefault(); const b = ev.submitter || ev.target; if (b) b.disabled = true; try { await fn(ev); } catch (e) { toast(e.message); } finally { if (b) b.disabled = false; } };\n const short = (hex) => hex ? hex.slice(0, 8) + \"…\" + hex.slice(-4) : \"\";\n // key shows a key short but keeps the whole of it in the DOM: selecting, double-clicking or clicking it copies the full hex.\n const key = (hex) => hex ? '' + hex + '' : '';\n const hue = (hex) => (parseInt(hex.slice(0, 2), 16) * 360 / 256).toFixed(0) + \"deg\";\n const av = (hex) => '';\n const fmtBytes = (n) => n < 1e6 ? (n / 1e3).toFixed(0) + \" KB\" : n < 1e9 ? (n / 1e6).toFixed(1) + \" MB\" : (n / 1e9).toFixed(2) + \" GB\";\n const fmtHours = (ms) => ms < 3600e3 ? Math.round(ms / 60e3) + \" min\" : (ms / 3600e3).toFixed(ms < 36e6 ? 1 : 0) + \" h\";\n const fuelOver = () => !!fuel && (fuel.eventBytes > fuel.freeEventBytes || fuel.mediaBytes > fuel.freeMediaBytes || fuel.activeMs > fuel.freeActiveMs || fuel.rowsWritten > fuel.freeRowsWritten);\n const fmtTime = (t) => t ? new Date(t * 1000).toLocaleString(undefined, { month: \"short\", day: \"numeric\", hour: \"2-digit\", minute: \"2-digit\" }) : \"–\";\n const fmtDay = (t) => t ? new Date(t * 1000).toLocaleDateString(undefined, { month: \"short\", day: \"numeric\" }) : \"–\";\n const ago = (t) => { const s = Math.max(0, Math.floor(Date.now() / 1000) - t); return s < 60 ? \"just now\" : s < 3600 ? Math.floor(s / 60) + \" min\" : s < 86400 ? Math.floor(s / 3600) + \" h\" : Math.floor(s / 86400) + \" d\"; };\n const esc = (s) => String(s ?? \"\").replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c]);\n\n const IC = {\n x: '',\n undo: '',\n check: '',\n trash: '',\n ban: '',\n banuser: '',\n eye: '',\n lock: '',\n pen: '',\n people: '',\n person: '',\n gauge: '',\n bolt: '',\n copy: '',\n pin: '',\n };\n const ib = (icon, label, act, id, extra) => '\";\n\n const CH = \"qpzry9x8gf2tvdw0s3jn54khce6mua7l\";\n function npubToHex(s) {\n s = s.trim().toLowerCase();\n if (/^[0-9a-f]{64}$/.test(s)) return s;\n if (!s.startsWith(\"npub1\")) return null;\n const data = s.slice(5, -6).split(\"\").map((c) => CH.indexOf(c));\n if (data.some((d) => d < 0)) return null;\n let bits = 0, acc = 0, out = [];\n for (const d of data) { acc = (acc << 5) | d; bits += 5; if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 255); } }\n return out.length === 32 ? out.map((b) => b.toString(16).padStart(2, \"0\")).join(\"\") : null;\n }\n\n async function sha256hex(s) {\n const b = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(s));\n return [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, \"0\")).join(\"\");\n }\n // ---- signing: a NIP-07 extension, or a NIP-46 remote signer ----\n // The remote path loads the bundled library on first use; its session\n // (client key, signer pubkey, relays) lives in localStorage until sign-out.\n let remote = null, lib = null, pendingNote = null;\n const NO_SIGNER = \"Install a nostr extension (Alby, nos2x, …) or connect a remote signer.\";\n const withTimeout = (p, ms, what) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(what + \" did not answer; is the signer app open?\")), ms))]);\n const signer = {\n ready: () => !!remote || !!window.nostr,\n async getPublicKey() { if (remote) return withTimeout(remote.getPublicKey(), 20000, \"The remote signer\"); if (window.nostr) return window.nostr.getPublicKey(); throw new Error(NO_SIGNER); },\n async signEvent(ev) { if (remote) return withTimeout(remote.signEvent(ev), 60000, \"The remote signer\"); if (window.nostr) return window.nostr.signEvent(ev); throw new Error(NO_SIGNER); },\n };\n async function signerLib() {\n if (lib) return lib;\n if (!window.NostrSigner) await new Promise((res, rej) => { const s = document.createElement(\"script\"); s.src = window.SIGNER_URL || \"/signer.js\"; s.onload = res; s.onerror = () => rej(new Error(\"Could not load the signer library.\")); document.head.appendChild(s); });\n return (lib = window.NostrSigner);\n }\n const onauth = (u) => window.open(u, \"_blank\");\n // The signer conversation rides this relay, and a relay may ask the socket to\n // authenticate before it delivers kind 24133 to its parties; answer with the\n // session's own key.\n const authPool = (L, sk) => new L.SimplePool({ automaticallyAuth: () => (evt) => Promise.resolve(L.finalizeEvent(evt, sk)) });\n const saveSession = (sk, s, secret) => localStorage.setItem(\"nip46\", JSON.stringify({ sk: lib.bytesToHex(sk), pubkey: s.bp.pubkey, relays: s.bp.relays, secret: secret || null }));\n async function resumeRemote() {\n const raw = localStorage.getItem(\"nip46\");\n if (!raw) return false;\n try {\n const s = JSON.parse(raw); const L = await signerLib();\n remote = L.BunkerSigner.fromBunker(L.hexToBytes(s.sk), { pubkey: s.pubkey, relays: s.relays, secret: s.secret }, { onauth, pool: authPool(L, L.hexToBytes(s.sk)) });\n return true;\n } catch { localStorage.removeItem(\"nip46\"); return false; }\n }\n async function connectBunker(input) {\n const L = await signerLib();\n const bp = await L.parseBunkerInput(input.trim());\n if (!bp) throw new Error(\"That is not a bunker:// URL.\");\n if (!bp.relays.length) throw new Error(\"The bunker URL names no relay.\");\n const sk = L.generateSecretKey();\n const s = L.BunkerSigner.fromBunker(sk, bp, { onauth, pool: authPool(L, sk) });\n await withTimeout(s.connect({ name: host, url: location.origin }), 60000, \"The signer\");\n await Promise.race([s.switchRelays(), new Promise((r) => setTimeout(r, 3000))]);\n remote = s; saveSession(sk, s, bp.secret);\n }\n // The nostrconnect:// flow: this relay carries the traffic, so no third relay is involved.\n let ncPending = null;\n async function offerNostrConnect() {\n if (ncPending) return ncPending;\n const L = await signerLib();\n const sk = L.generateSecretKey();\n const secret = L.bytesToHex(crypto.getRandomValues(new Uint8Array(8)));\n const uri = L.createNostrConnectURI({ clientPubkey: L.getPublicKey(sk), relays: [wsURL], secret, name: host, url: location.origin, perms: [\"sign_event:27235\", \"sign_event:9734\"] });\n $(\"#nclink\").href = uri; $(\"#ncnote\").textContent = \"\";\n $(\"#ncqr\").src = \"/qr.svg?text=\" + encodeURIComponent(uri); $(\"#ncqr\").classList.remove(\"hidden\");\n ncPending = L.BunkerSigner.fromURI(sk, uri, { onauth, pool: authPool(L, sk) }, 600000).then((s) => { remote = s; saveSession(sk, s, secret); ncPending = null; return s; }, (e) => { ncPending = null; throw e; });\n return ncPending;\n }\n async function remoteDone() {\n me = await signer.getPublicKey();\n localStorage.setItem(\"me\", me);\n $(\"#remotesec\").classList.add(\"hidden\");\n toast(\"Remote signer connected\");\n if (pendingNote && !owner) { const n = pendingNote; pendingNote = null; await claimNow(n); return; }\n pendingNote = null;\n renderHeader(); await loadAdmin(); await loadPeople();\n }\n function showRemote(note) {\n pendingNote = note || null;\n $(\"#remotesec\").classList.remove(\"hidden\");\n $(\"#remotesec\").scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n offerNostrConnect().then(remoteDone).catch((e) => { $(\"#ncnote\").textContent = e.message; });\n }\n\n async function rpc(method, ...params) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const body = JSON.stringify({ method, params });\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", rpcURL], [\"method\", \"POST\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(rpcURL, { method: \"POST\", headers: { \"content-type\": \"application/nostr+json+rpc\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body });\n const json = await resp.json();\n if (json.error) throw new Error(json.error);\n return json.result;\n }\n\n // ---- front of house ----\n // urlish shows a URL as a short link: the host and the start of the path, the whole thing on hover and as the target.\n class Html { constructor(s) { this.s = s; } }\n const urlish = (u) => {\n if (!u) return \"\";\n let label = u; try { const x = new URL(u); label = x.host + (x.pathname.length > 1 ? x.pathname.slice(0, 18) + (x.pathname.length > 18 ? \"\\u2026\" : \"\") : \"\"); } catch { /* not a URL */ }\n if (!/^https?:\\/\\//.test(u)) return u;\n return new Html('' + esc(label) + \"\");\n };\n function renderHeader() {\n const name = (info && info.name) || host.split(\".\")[0];\n $(\"#title\").textContent = name;\n document.title = name + \" - relay\";\n $(\"#url\").textContent = wsURL;\n $(\"#d-ws\").textContent = wsURL; $(\"#d-blossom\").textContent = location.origin; $(\"#d-nip05\").textContent = \"you@\" + host;\n $(\"#nip05-example\").textContent = \"alice@\" + host;\n const desc = (info && info.description) || \"\";\n $(\"#desc\").textContent = desc; $(\"#desc\").classList.toggle(\"hidden\", !desc);\n // What the owner declared about the relay, on the page, not only in the document clients read.\n const img = (id, url) => { const el = $(id); if (url) el.src = url; el.classList.toggle(\"hidden\", !url); };\n img(\"#bannerimg\", info?.banner); $(\"#banner\").classList.toggle(\"hidden\", !info?.banner);\n img(\"#iconimg\", info?.icon);\n const meta = [];\n for (const t of info?.tags || []) meta.push('' + esc(t) + \"\");\n const where = [...(info?.language_tags || []), ...(info?.relay_countries || [])];\n if (where.length) meta.push(\"\" + esc(where.join(\", \")) + \"\");\n const link = (label, href) => href ? '' + label + \"\" : \"\";\n const contact = info?.contact ? (/^(mailto:|https?:\\/\\/)/.test(info.contact) ? link(\"Contact\", info.contact) : \"\" + esc(info.contact) + \"\") : \"\";\n for (const x of [contact, link(\"Terms\", info?.terms_of_service), link(\"Posting policy\", info?.posting_policy), link(\"Privacy\", info?.privacy_policy)]) if (x) meta.push(x);\n $(\"#metarow\").innerHTML = meta.join('|'); $(\"#metarow\").classList.toggle(\"hidden\", meta.length === 0);\n $(\"#frontsec\").classList.toggle(\"hidden\", !desc && meta.length === 0);\n owner = (info && info.pubkey) || \"\";\n const isOwner = !!me && me === owner;\n const lease = !owner && info && info.lease ? info.lease : null;\n $(\"#ownerline\").innerHTML = owner ? 'run by ' + key(owner) + \"\" : lease ? \"temporary until \" + fmtDay(lease.expires_at) : \"unclaimed\";\n $(\"#owner-av\").classList.toggle(\"hidden\", !owner);\n if (owner) $(\"#owner-av\").style.setProperty(\"--h\", hue(owner));\n $(\"#who\").textContent = me ? (isOwner ? \"(that's you)\" : \"\") : \"\";\n $(\"#unclaimed\").classList.toggle(\"hidden\", !!owner || !!lease);\n $(\"#leased\").classList.toggle(\"hidden\", !lease);\n if (lease) {\n $(\"#lease-until\").textContent = fmtTime(lease.expires_at);\n $(\"#leasenote\").textContent = lease.holder ? \"Reserved for the key that asked for it; sign with that key.\" : \"\";\n }\n $(\"#signin\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signin46\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signout\").classList.toggle(\"hidden\", !me);\n $(\"#about\").innerHTML = [\n [\"Name\", info?.name], [\"Description\", info?.description], [\"Contact\", info?.contact], [\"Owner\", owner ? short(owner) : \"\"], [\"Relay key\", info?.self ? short(info.self) : \"\"], [\"Software\", urlish(info?.software)], [\"Version\", info?.version],\n [\"Max query\", info?.limitation?.max_limit], [\"Auth required\", String(!!info?.limitation?.auth_required)], [\"Restricted writes\", String(!!info?.limitation?.restricted_writes)], [\"Min PoW\", info?.limitation?.min_pow_difficulty || 0],\n [\"Tags\", info?.tags?.join(\", \")], [\"Languages\", info?.language_tags?.join(\", \")], [\"Countries\", info?.relay_countries?.join(\", \")],\n [\"Terms\", urlish(info?.terms_of_service)], [\"Posting policy\", urlish(info?.posting_policy)], [\"Privacy policy\", urlish(info?.privacy_policy)], [\"Icon\", urlish(info?.icon)], [\"Banner\", urlish(info?.banner)],\n [\"NIPs\", info?.supported_nips?.join(\" \")],\n ].map(([k, v]) => \"
                                                                                                                  \" + k + \"\" + (v === undefined || v === \"\" ? '' : v instanceof Html ? v.s : esc(v)) + \"
                                                                                                                  \").join(\"\");\n renderCare();\n }\n\n function renderCare() {\n const g = (icon, label, off) => '' + IC[icon] + \"\" + label + \"\";\n const writes = policy ? policy.writes : (info?.limitation?.restricted_writes ? \"allowlist\" : \"open\");\n const reads = policy ? policy.reads : (info?.limitation?.auth_required ? \"auth\" : \"open\");\n let out = writes === \"owner\" ? g(\"person\", \"only owner writes\") : writes === \"allowlist\" ? g(\"people\", \"members write\") : writes === \"wot\" ? g(\"people\", \"members and follows write\") : g(\"pen\", \"anyone writes\");\n out += reads === \"members\" ? g(\"people\", \"members read\") : reads === \"auth\" ? g(\"lock\", \"sign in to read\") : g(\"eye\", \"anyone reads\");\n if (fuel) {\n const over = fuelOver();\n out += fuel.outOfFuel ? g(\"gauge\", \"out of fuel\", true) : over ? g(\"bolt\", \"burning sats\") : g(\"gauge\", \"on free allowance\");\n }\n $(\"#care\").innerHTML = out;\n }\n\n async function loadPeople() {\n try { people = await (await fetch(\"/people\")).json(); } catch { return; }\n const isOwner = !!me && me === owner;\n let list = people.people || [];\n if (!people.public && isOwner && policy) list = (window.__members || []);\n $(\"#peoplesec\").classList.toggle(\"hidden\", !owner || list.length === 0);\n $(\"#people-note\").classList.toggle(\"hidden\", people.public);\n $(\"#people-count\").textContent = list.length || \"\";\n $(\"#dir\").innerHTML = list.map((m) => '' + av(m.pubkey) + '' + (m.name ? esc(m.name) + \"@\" + host : key(m.pubkey)) + \"\" + (m.role === \"owner\" ? 'owner' : \"\") + \"\").join(\"\");\n }\n\n async function loadInfo() {\n info = await (await fetch(\"/\", { headers: { accept: \"application/nostr+json\" } })).json();\n renderHeader();\n const git = info.supported_grasps?.includes(\"GRASP-01\");\n $(\"#git-connect\").classList.toggle(\"hidden\", !git);\n const clone = \"git clone '\" + location.origin + \"//.git'\";\n $(\"#git-clone\").textContent = clone;\n $(\"#git-copy\").dataset.copytext = clone;\n renderApps();\n }\n\n async function loadFuel() {\n try { fuel = await (await fetch(\"/fuel\")).json(); } catch { return; }\n $(\"#fuelsec\").classList.toggle(\"hidden\", !owner);\n const pct = (used, free) => Math.min(100, Math.round((used / Math.max(free, 1)) * 100));\n const gauge = (id, used, free, text) => { const g = $(\"#g-\" + id), share = free ? used / free : 0; g.style.width = pct(used, free) + \"%\"; g.classList.toggle(\"over\", share > 1); g.classList.toggle(\"warm\", share > 0.75 && share <= 1); $(\"#p-\" + id).textContent = share > 1 ? \"over\" : Math.round(share * 100) + \"%\"; $(\"#t-\" + id).textContent = text; };\n gauge(\"events\", fuel.eventBytes, fuel.freeEventBytes, fmtBytes(fuel.eventBytes) + \" of \" + fmtBytes(fuel.freeEventBytes) + \" free\");\n gauge(\"media\", fuel.mediaBytes, fuel.freeMediaBytes, fmtBytes(fuel.mediaBytes) + \" of \" + fmtBytes(fuel.freeMediaBytes) + \" free\");\n gauge(\"active\", fuel.activeMs, fuel.freeActiveMs, fmtHours(fuel.activeMs) + \" of \" + fmtHours(fuel.freeActiveMs) + \" free\");\n gauge(\"rows\", fuel.rowsWritten, fuel.freeRowsWritten, fuel.rowsWritten.toLocaleString() + \" of \" + fuel.freeRowsWritten.toLocaleString() + \" free\");\n const sats = Math.floor(fuel.balanceMsats / 1000);\n const r = fuel.rates;\n $(\"#fuel-balance\").innerHTML = fuel.outOfFuel\n ? 'out of fuel Writes are paused until someone tops up.'\n : \"Balance \" + sats.toLocaleString() + \" sats. Past the allowances, prices track what the hosting costs: \" + r.satsPerGBMonthEvents.toLocaleString() + \" sats per GB-month of events, \" + r.satsPerGBMonthMedia.toLocaleString() + \" per GB-month of files, \" + r.satsPerActiveHour.toLocaleString() + \" per hour awake, \" + r.satsPerMillionRows.toLocaleString() + \" per million rows written. Traffic is free.\";\n $(\"#topup\").classList.toggle(\"hidden\", !fuel.enabled);\n // Who paid is the owner's to see: it comes with the signed stats call.\n let credits = [];\n if (owner && signer.ready()) { try { credits = (await rpc(\"stats\")).credits || []; } catch { credits = []; } }\n $(\"#credits tbody\").innerHTML = credits.length ? credits.map((c) => '' + esc(fmtTime(c.at)) + '' + av(c.payer) + key(c.payer) + '' + Math.floor(c.msats / 1000).toLocaleString() + \"\").join(\"\") : 'no zaps yet';\n if (!fuel.enabled) $(\"#topup-note\").textContent = \"Top-ups are not enabled on this service yet.\";\n renderCare();\n renderFuelTile();\n }\n\n async function topUp(sats) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const msats = Math.round(sats * 1000);\n const zapRequest = await signer.signEvent({ kind: 9734, created_at: Math.floor(Date.now() / 1000), content: \"fuel for \" + host, tags: [[\"p\", fuel.servicePubkey], [\"amount\", String(msats)], [\"relays\", wsURL]] });\n const r = await (await fetch(\"/fuel/invoice\", { method: \"POST\", body: JSON.stringify({ zapRequest }) })).json();\n if (r.error) throw new Error(r.error);\n const inv = r.invoice;\n $(\"#invoice\").classList.remove(\"hidden\");\n $(\"#inv-text\").value = inv; $(\"#inv-link\").href = \"lightning:\" + inv; $(\"#inv-state\").textContent = \"waiting for payment…\";\n if (window.webln) { try { await window.webln.enable(); await window.webln.sendPayment(inv); } catch (e) { toast(e.message || \"wallet declined\"); } }\n const before = fuel.creditedMsats;\n for (let i = 0; i < 120; i++) {\n await new Promise((r) => setTimeout(r, 2500));\n await loadFuel();\n if (fuel.creditedMsats > before) { $(\"#inv-state\").textContent = \"paid, thank you\"; toast(\"Fuel credited\"); return; }\n }\n $(\"#inv-state\").textContent = \"no receipt yet; it can take a minute after paying\";\n }\n\n // ---- console ----\n function showTab(name) {\n if (name === \"content\" || name === \"storage\") name = \"data\";\n const known = myRole === \"moderator\" ? [\"people\", \"moderation\"] : [\"people\", \"moderation\", \"rules\", \"identity\", \"data\", \"sync\", \"views\", \"health\", \"owner\"];\n if (!known.includes(name)) name = \"people\";\n $$(\".tabs a\").forEach((a) => a.classList.toggle(\"on\", a.dataset.tab === name));\n $$(\".panel\").forEach((p) => p.classList.toggle(\"on\", p.dataset.panel === name));\n }\n // Tabs switch in place. A fragment still opens a tab when someone arrives with one,\n // but clicking never writes one, and any fragment already there is dropped.\n $$(\".tabs a\").forEach((a) => a.addEventListener(\"click\", (ev) => { ev.preventDefault(); showTab(a.dataset.tab); if (location.hash) history.replaceState(null, \"\", location.pathname + location.search); }));\n window.addEventListener(\"hashchange\", () => showTab(location.hash.slice(1)));\n // The strip fades on whichever side has more tabs off screen.\n const tabsFade = () => { const t = $(\"#tabs\"); t.classList.toggle(\"fade-l\", t.scrollLeft > 4); t.classList.toggle(\"fade-r\", t.scrollLeft + t.clientWidth < t.scrollWidth - 4); };\n $(\"#tabs\").addEventListener(\"scroll\", tabsFade); window.addEventListener(\"resize\", tabsFade); new ResizeObserver(tabsFade).observe($(\"#tabs\"));\n\n function renderFuelTile() {\n if (!fuel) return;\n const over = fuelOver();\n const sats = Math.max(0, Math.floor(fuel.balanceMsats / 1000));\n const d = new Date(), end = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1), days = Math.max(1, Math.ceil((end - Date.now()) / 86400000));\n if (fuel.outOfFuel) { $(\"#h-fuel\").textContent = \"0 sats\"; $(\"#h-fuel-label\").textContent = \"out of fuel\"; $(\"#h-fuel-sub\").textContent = \"writes are paused\"; }\n else if (over) { $(\"#h-fuel\").textContent = sats.toLocaleString() + \" sats\"; $(\"#h-fuel-label\").textContent = \"fuel left\"; $(\"#h-fuel-sub\").textContent = \"past the free allowance\"; }\n else { $(\"#h-fuel\").textContent = days + (days === 1 ? \" day\" : \" days\"); $(\"#h-fuel-label\").textContent = \"free allowance left\"; $(\"#h-fuel-sub\").textContent = sats ? \"then \" + sats.toLocaleString() + \" sats\" : \"then a top-up is needed\"; }\n $(\"#usage\").innerHTML = [[\"events stored\", fmtBytes(fuel.eventBytes)], [\"files stored\", fmtBytes(fuel.mediaBytes)], [\"awake this month\", fmtHours(fuel.activeMs)], [\"rows written\", fuel.rowsWritten.toLocaleString()], [\"rows read\", fuel.rowsRead.toLocaleString()], [\"received this month\", fmtBytes(fuel.bytesIn)], [\"served this month\", fmtBytes(fuel.bytesOut)], [\"charged\", Math.floor(fuel.chargedMsats / 1000).toLocaleString() + \" sats\"]]\n .map(([k, v]) => \"
                                                                                                                  \" + k + \"\" + v + \"
                                                                                                                  \").join(\"\");\n }\n\n function renderHealth(stats) {\n $(\"#h-last\").textContent = stats.newest ? ago(stats.newest) : \"none\";\n $(\"#h-last-sub\").textContent = stats.newest ? fmtTime(stats.newest) : \"no events yet\";\n $(\"#h-conns\").textContent = stats.connections;\n const buckets = [[\"notes\", \"k1\", [1]], [\"reactions\", \"k2\", [7]], [\"DMs\", \"k3\", [4, 14, 1059]], [\"long-form\", \"k4\", [30023]], [\"other\", \"k5\", null]];\n const sums = buckets.map(() => 0); let total = 0;\n for (const { kind, n } of stats.kinds || []) { total += n; const i = buckets.findIndex((b) => b[2] && b[2].includes(kind)); sums[i < 0 ? 4 : i] += n; }\n const parts = buckets.map((b, i) => [b[0], b[1], sums[i]]).filter((p) => p[2] > 0);\n $(\"#h-kinds\").innerHTML = total ? parts.map(([name, cls, n]) => '').join(\"\") : '';\n $(\"#h-kinds-legend\").innerHTML = total ? parts.map(([name, cls, n]) => '' + name + \" \" + Math.round(n * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n renderFuelTile();\n }\n\n async function loadAdmin() {\n const isOwner = !!me && me === owner;\n myRole = isOwner ? \"owner\" : \"\";\n let stats = null, p = null;\n // A signed-in member may be a moderator; the relay says by answering stats.\n if (me && owner && !isOwner) { try { [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]); myRole = \"moderator\"; } catch { myRole = \"\"; } }\n $(\"#console\").classList.toggle(\"hidden\", !myRole);\n loadMine();\n $(\"#console\").classList.toggle(\"mod\", myRole === \"moderator\");\n if (!myRole) { policy = null; renderCare(); return; }\n if (isOwner) [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]);\n policy = p;\n renderHealth(stats);\n renderCare();\n const fa = $(\"#access\"), fi = $(\"#identity\");\n fa.writes.value = p.writes; fa.reads.value = p.reads; fa.openKinds.value = (p.openKinds || []).join(\", \"); fa.guestReplies.checked = !!p.guestReplies;\n $(\"#wordsform\").words.value = (p.blockedWords || []).join(\"\\n\"); $(\"#wordsform\").inTags.checked = !!p.blockedWordsInTags; $(\"#thresholdform\").reportThreshold.value = p.reportThreshold || 0; fa.minPow.value = p.minPow; fa.maxFuture.value = p.maxFuture; fa.maxLimit.value = p.maxLimit; fa.maxSubs.value = p.maxSubs; fa.maxMessageKB.value = p.maxMessageKB;\n fa.eventsPerMinute.value = p.eventsPerMinute; fa.reqsPerMinute.value = p.reqsPerMinute; fa.maxBlobMB.value = p.maxBlobMB;\n renderFeatures(p.features || {});\n $(\"#push-policy-form\").elements.origins.value = (p.pushCallbacks || []).join(\"\\n\");\n $(\"#push-policy-form\").elements.lettered.checked = !!p.letteredNips;\n $(\"#push-policy-form\").elements.delivery.checked = !!p.delivery?.enabled;\n $(\"#push-policy-form\").elements.deliveryMax.value = p.delivery?.maxTargets || 8;\n fi.name.value = p.name; fi.contact.value = p.contact; fi.description.value = p.description; fi.icon.value = p.icon;\n const fj = $(\"#joinform\"); fj.joinTerms.value = p.joinTerms; fj.directoryPublic.checked = !!p.directoryPublic;\n loadCard();\n fi.banner.value = p.banner || \"\"; fi.postingPolicy.value = p.postingPolicy || \"\"; fi.privacyPolicy.value = p.privacyPolicy || \"\";\n fi.tags.value = (p.tags || []).join(\", \"); fi.languageTags.value = (p.languageTags || []).join(\", \"); fi.relayCountries.value = (p.relayCountries || []).join(\", \");\n const fn = $(\"#notify\"), nt = p.notify || {};\n fn.reports.checked = !!nt.reports; fn.fuel.checked = !!nt.fuel; fn.jobs.checked = !!nt.jobs; fn.succession.checked = !!nt.succession; fn.digest.checked = !!nt.digest;\n showTab(location.hash.slice(1));\n if (isOwner) { await renderPresets(); renderWire(); renderSuccession(); loadDomains(); }\n await Promise.all([loadLists(), loadEvents(true), loadPins(), ...(isOwner ? [loadStorage()] : [])]);\n }\n\n async function loadAudit(before) {\n const rows = await rpc(\"listaudit\", before || 0);\n const html = rows.map((r) => '' + esc(fmtTime(r.at)) + '' + av(r.actor) + key(r.actor) + '' + esc(r.action) + '' + (/^[0-9a-f]{64}$/.test(r.target) ? key(r.target) : esc(r.target)) + '' + esc(r.detail) + '').join(\"\");\n const tb = $(\"#audit tbody\");\n if (before) tb.insertAdjacentHTML(\"beforeend\", html); else tb.innerHTML = html || 'Nothing yet';\n $(\"#audit-more\").style.display = rows.length < 100 ? \"none\" : \"\";\n }\n $(\"#audit-more\").onclick = guard(async () => { const last = $(\"#audit tbody tr:last-child\"); await loadAudit(last ? +last.dataset.seq : 0); });\n async function loadLists() {\n loadAudit(0);\n const [mem, bans, bannedEvents, allow, block, invites, reports, blobs, blocks, sites] = await Promise.all([rpc(\"listmembers\"), rpc(\"listbannedpubkeys\"), rpc(\"listbannedevents\"), rpc(\"listallowedkinds\"), rpc(\"listblockedkinds\"), rpc(\"listinvites\"), rpc(\"listreports\"), rpc(\"listblobs\", 100), rpc(\"listblockedips\"), rpc(\"listsites\")]);\n const members = mem.members;\n window.__members = members;\n const roleCell = (m) => m.role === \"owner\" ? ' owner' : myRole === \"owner\" ? ' ' : m.role === \"moderator\" ? ' moderator' : \"\";\n const untouchable = (m) => m.role === \"owner\" || (myRole !== \"owner\" && m.role === \"moderator\");\n // The tree: everyone under whoever invited them, the owner and the owner's own additions at the root.\n const known = new Set(members.map((m) => m.pubkey));\n const byInviter = new Map();\n for (const m of members) { const k = m.role !== \"owner\" && known.has(m.invited_by) ? m.invited_by : \"\"; if (!byInviter.has(k)) byInviter.set(k, []); byInviter.get(k).push(m); }\n const ordered = [], placed = new Set();\n const walk = (k, depth) => { for (const m of byInviter.get(k) || []) { if (placed.has(m.pubkey)) continue; placed.add(m.pubkey); m.depth = depth; ordered.push(m); walk(m.pubkey, depth + 1); } };\n walk(\"\", 0);\n for (const m of members) if (!placed.has(m.pubkey)) { m.depth = 0; ordered.push(m); }\n const nameOf = (pk) => { const x = members.find((m) => m.pubkey === pk); return x && x.name ? x.name : short(pk); };\n const limits = (m) => m.role === \"owner\" ? \"\" : myRole === \"owner\"\n ? ''\n : '' + (m.keep_days ? m.keep_days + \" d\" : \"\") + (m.max_bytes ? \" \" + fmtBytes(m.max_bytes) : \"\") + \"\";\n $(\"#members tbody\").innerHTML = ordered.map((m) => '' + av(m.pubkey) + key(m.pubkey) + roleCell(m) + (m.depth ? ' via ' + esc(nameOf(m.invited_by)) + \"\" : \"\") + (m.invites ? ' ' + m.invites + \" inv\" : \"\") + '' + limits(m) + '' + esc(fmtDay(m.joined_at)) + \", \" + esc(m.via) + '' + ib(\"check\", \"Save\", \"savemember\", m.pubkey) + (untouchable(m) ? \"\" : ib(\"x\", \"Remove\", \"removemember\", m.pubkey) + ib(\"banuser\", \"Ban\", \"banpubkey\", m.pubkey, \"danger\")) + \"\").join(\"\");\n const tf = $(\"#treeform\");\n tf.classList.toggle(\"hidden\", myRole !== \"owner\");\n if (policy && policy.memberInvites) { tf.depth.value = policy.memberInvites.depth; tf.quota.value = policy.memberInvites.quota; }\n $(\"#transfer [name=pubkey]\").innerHTML = members.filter((m) => m.role !== \"owner\").map((m) => '\").join(\"\");\n $(\"#succession [name=heir]\").innerHTML = $(\"#transfer [name=pubkey]\").innerHTML;\n $(\"#tc-people\").textContent = members.length;\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#invites\").innerHTML = invites.length ? invites.map((i) => '
                                                                                                                • inv…' + i.code.slice(-8) + \" \" + esc(i.note || \"\") + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
                                                                                                                • \").join(\"\") : '
                                                                                                                • no invites
                                                                                                                • ';\n const person = (r, icon, label, act) => \"
                                                                                                                • \" + av(r.pubkey) + '' + short(r.pubkey) + \" \" + esc(r.reason || \"\") + \"\" + ib(icon, label, act, r.pubkey) + \"
                                                                                                                • \";\n $(\"#bans\").innerHTML = bans.length ? bans.map((r) => person(r, \"undo\", \"Unban\", \"unrulepubkey\")).join(\"\") : '
                                                                                                                • nobody banned
                                                                                                                • ';\n $(\"#blocks\").innerHTML = blocks.length ? blocks.map((r) => '
                                                                                                                • ip' + esc(r.ip) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"undo\", \"Unblock\", \"unblockip\", r.ip) + \"
                                                                                                                • \").join(\"\") : '
                                                                                                                • no addresses blocked
                                                                                                                • ';\n $(\"#banned-events\").innerHTML = bannedEvents.map((r) => '
                                                                                                                • ev' + key(r.id) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"check\", \"Allow again\", \"allowevent\", r.id) + \"
                                                                                                                • \").join(\"\");\n $(\"#reports tbody\").innerHTML = reports.length ? reports.map((r) => '' + esc(fmtTime(r.at)) + '' + esc(r.type || \"report\") + '' + av(r.target_pubkey) + key(r.target_pubkey) + (r.target_event ? (r.blob ? ' file ' + key(r.target_event) + \"\" : ' ev ' + key(r.target_event) + (r.hidden ? ' hidden' : \"\") + \"\") : \"\") + '' + esc(r.content) + '' + ib(\"check\", \"Dismiss\", \"resolve:dismiss\", r.id) + ib(\"trash\", r.blob ? \"Delete the file\" : \"Delete the event\", \"resolve:delete\", r.id) + ib(\"banuser\", \"Ban the author\", \"resolve:ban\", r.id, \"danger\") + \"\").join(\"\") : 'nothing reported';\n $(\"#reports-count\").textContent = reports.length || \"\"; $(\"#tc-reports\").textContent = reports.length || \"\";\n $(\"#blobs tbody\").innerHTML = blobs.length ? blobs.map((b) => '' + esc(fmtTime(b.uploaded)) + '' + key(b.sha256) + ' ' + esc(b.type) + '' + fmtBytes(b.size) + '' + av(b.uploader) + key(b.uploader) + '' + ib(\"trash\", \"Delete file\", \"deleteblob\", b.sha256, \"danger\") + \"\").join(\"\") : 'no uploads';\n $(\"#blobs-count\").textContent = blobs.length || \"\";\n $(\"#sites tbody\").innerHTML = sites.length ? sites.map((s) => '' + av(s.author) + key(s.author) + '' + esc(s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + '' + esc(s.url) + '' + esc(s.paths) + (s.missing ? ' ' + esc(s.missing) + ' missing' : '') + '' + fmtBytes(s.size) + '' + esc(s.expires_at ? fmtTime(s.expires_at) : \"never\") + '' + ib(\"trash\", \"Delete site\", \"deleteevent\", s.id, \"danger\") + '').join(\"\") : 'no sites yet';\n $(\"#sites-count\").textContent = sites.length || \"\";\n const tag = (k, cls) => '' + k + ib(\"x\", \"Remove rule\", \"unrulekind\", String(k)) + \"\";\n $(\"#kinds-allow\").innerHTML = allow.length ? allow.map((k) => tag(k, \"ok\")).join(\"\") : 'all';\n $(\"#kinds-block\").innerHTML = block.length ? block.map((k) => tag(k, \"blk\")).join(\"\") : 'none';\n await loadPeople();\n }\n\n const KIND_NAMES = { 0: \"profiles\", 1: \"notes\", 3: \"contacts\", 4: \"DMs\", 5: \"deletions\", 6: \"reposts\", 7: \"reactions\", 16: \"reposts\", 1059: \"gift wraps\", 1063: \"file headers\", 1111: \"comments\", 1984: \"reports\", 9734: \"zap requests\", 9735: \"zap receipts\", 9802: \"highlights\", 10002: \"relay lists\", 13534: \"roster\", 9000: \"group adds\", 9001: \"group removals\", 9021: \"join requests\", 9022: \"leave requests\", 39000: \"group info\", 39001: \"group admins\", 39002: \"group members\", 39003: \"group roles\", 30023: \"articles\", 30024: \"drafts\", 30078: \"app data\", 30311: \"live events\", 30818: \"wiki\" };\n const kindName = (k) => KIND_NAMES[k] || (k >= 20000 && k < 30000 ? \"ephemeral\" : k >= 30000 && k < 40000 ? \"addressable\" : k >= 10000 && k < 20000 ? \"replaceable\" : \"kind \" + k);\n const SYS_KINDS = new Set([0, 3, 10002, 9735, 13534, 8000, 8001, 9000, 9001, 39000, 39001, 39002, 39003]);\n let storage = null;\n\n // Features (settings.ts): a select each; search has three modes, the rest on or off.\n const FEATURES = [\n [\"search\", \"Search\", \"NIP-50. Prose indexes notes, threads, comments, highlights, articles and wiki pages; full indexes every public kind with content. A change applies to events from then on.\", [\"prose:prose\", \"full:full\", \"off:off\"]],\n [\"sync\", \"Sync\", \"NIP-77 reconciliation, which reads the whole matching set per sync.\"],\n [\"count\", \"Counts\", \"NIP-45 COUNT, with HLL sketches.\"],\n [\"discovery\", \"Discovery record\", \"NIP-66: the record the relay signs about itself, for crawlers.\"],\n [\"names\", \"Names\", \"NIP-05 addresses under this relay's domain.\"],\n [\"files\", \"Files\", \"Blossom and NIP-96: uploads, downloads and listings.\"],\n [\"pages\", \"Pages and feed\", \"Notes and articles as pages, and the Atom feed.\"],\n [\"sites\", \"Static websites\", \"NIP-5A sites on their own hostnames. Mirroring copies missing files into this relay and costs fuel.\", [\"mirror:on, mirror files\", \"proxy:on, fetch as needed\", \"off:off\"]],\n [\"marmot\", \"Marmot transport\", \"Signed KeyPackages and encrypted group messages, with account admission for ephemeral authors.\"],\n [\"grasp\", \"Git repositories\", \"GRASP Git hosting with admitted repository state. The prototype backend has bounded storage and compute limits.\"],\n [\"push\", \"Relay push\", \"NIP-9a callback delivery for members and the owner. Requires approved callback origins; advertises lettered NIP identifiers.\"],\n [\"signer\", \"Signer traffic\", \"NIP-46 remote signing carried for anyone, never stored.\"],\n ];\n function renderFeatures(f) {\n $(\"#features\").innerHTML = FEATURES.map(([k, title, about, modes]) => {\n const cur = k === \"sites\" ? (f.sites?.enabled === false ? \"off\" : f.sites?.mirror === false ? \"proxy\" : \"mirror\") : modes ? String(f[k] || \"prose\") : String(f[k] !== false);\n const opts = (modes || [\"true:on\", \"false:off\"]).map((m) => { const [v, l] = m.split(\":\"); return '\"; }).join(\"\");\n return \"\";\n }).join(\"\");\n }\n $(\"#features\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-feature]\"); if (!sel) return;\n const k = sel.dataset.feature, v = k === \"search\" || k === \"sites\" ? sel.value : sel.value === \"true\";\n policy = await rpc(\"setpolicy\", { features: { [k]: k === \"sites\" ? { enabled: v !== \"off\", mirror: v === \"mirror\" } : v } });\n toast(k === \"search\" ? \"Search: \" + v : (v ? \"Switched on \" : \"Switched off \") + k); await loadInfo();\n }));\n\n $(\"#push-policy-form\").addEventListener(\"submit\", guard(async (ev) => {\n ev.preventDefault();\n const form = ev.target;\n const origins = form.elements.origins.value.split(/\\s+/).filter(Boolean);\n const updated = await rpc(\"setpolicy\", { pushCallbacks: origins, letteredNips: form.elements.lettered.checked, delivery: { enabled: form.elements.delivery.checked, maxTargets: Math.max(1, Math.min(16, Math.floor(+form.elements.deliveryMax.value || 8))) } });\n if (JSON.stringify(updated.pushCallbacks) !== JSON.stringify([...new Set(origins.map((s) => s.replace(/\\/$/, \"\")))])) throw new Error(\"Use up to sixteen exact HTTPS origins, with no path or credentials.\");\n policy = updated;\n toast(\"Saved delivery policy\"); await loadInfo();\n }));\n\n async function loadViews() {\n let views;\n try { views = await rpc(\"listviews\"); } catch { return; }\n $(\"#views\").innerHTML = views.map((v) => {\n const runs = v.trigger === \"off\" ? \"off\" : v.trigger === \"live\" ? \"live, from memory\" : v.trigger === \"write\" ? \"on write and daily\" : v.trigger;\n const label = (c) => (c === \"off\" ? \"off\" : c === \"write\" ? \"on write\" : c === \"live\" ? \"on\" : c);\n const choices = [...v.choices, v.default].filter((c, i, a) => a.indexOf(c) === i);\n const pick = '\";\n const who = v.audience === \"members\" ? \"members\" + (v.stored ? \"\" : \", on request\") : \"anyone\";\n const last = v.trigger === \"live\" ? \"\" : v.last ? \"last run \" + fmtTime(v.last.at) : \"not run yet\";\n const rows = v.trigger === \"live\" ? \"no rows\" : v.last ? v.last.rows.toLocaleString() + \" rows\" : \"\";\n const meta = [runs, who, last, rows].filter(Boolean).map((x) => \"\" + esc(x) + \"\").join('|');\n return '
                                                                                                                  ' + esc(v.name) + '
                                                                                                                  ' + esc(v.about) + '
                                                                                                                  ' + pick + (v.on ? 'Open' : \"\") + '
                                                                                                                  ' + meta + \"
                                                                                                                  \";\n }).join(\"\");\n }\n $(\"#views\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-view]\"); if (!sel) return;\n const value = sel.value === \"live\" ? true : sel.value;\n await rpc(\"setpolicy\", { views: { [sel.dataset.view]: value } });\n toast(sel.value === \"off\" ? \"Took down \" + sel.dataset.view : sel.dataset.view + \": \" + (sel.value === \"live\" ? \"on\" : sel.value === \"write\" ? \"on write\" : sel.value)); await loadViews(); await loadInfo();\n }));\n async function loadDumps() {\n const list = await rpc(\"listdumps\");\n const f = $(\"#dumpform\");\n if (policy) { f.dumps.value = policy.dumps || \"off\"; f.keep.value = policy.dumpsKeep || 7; }\n $(\"#dumps-count\").textContent = list.length || \"\";\n $(\"#dumps\").innerHTML = list.length ? list.map((d) => '
                                                                                                                • jsonl' + esc(d.name) + ' ' + d.events.toLocaleString() + \" events, \" + fmtBytes(d.bytes) + \"\" + ib(\"copy\", \"Download\", \"downloaddump\", d.name) + ib(\"trash\", \"Delete\", \"deletedump\", d.name, \"danger\") + \"
                                                                                                                • \").join(\"\") : '
                                                                                                                • no dumps yet
                                                                                                                • ';\n }\n // A dump is fetched with a signed request and handed to the browser as a file.\n async function downloadDump(name) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const url = location.origin + \"/dumps/\" + name;\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"GET\"]] });\n const resp = await fetch(url, { headers: { authorization: \"Nostr \" + btoa(JSON.stringify(ev)) } });\n if (!resp.ok) throw new Error((await resp.json()).error || \"download failed\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(\".\")[0] + \"-\" + name; a.click(); URL.revokeObjectURL(a.href);\n }\n // A plain member's own invites, when the owner lets members invite.\n async function loadMine() {\n const sec = $(\"#myinvites\");\n if (!me || !owner || myRole || !signer.ready()) { sec.classList.add(\"hidden\"); return; }\n let mine;\n try { mine = await rpc(\"listinvites\"); } catch { sec.classList.add(\"hidden\"); return; }\n sec.classList.remove(\"hidden\");\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#mine\").innerHTML = mine.length ? mine.map((i) => '
                                                                                                                • inv…' + i.code.slice(-8) + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
                                                                                                                • \").join(\"\") : '
                                                                                                                • no invites yet
                                                                                                                • ';\n }\n async function loadStorage() {\n storage = await rpc(\"storagestats\");\n pollJobs();\n loadViews();\n loadDumps().catch(() => {});\n const st = storage;\n const top = st.kinds.slice(0, 4), rest = st.kinds.slice(4).reduce((a, k) => a + k.bytes, 0);\n const parts = [...top.map((k, i) => [k.kind + \" \" + kindName(k.kind), \"k\" + (i + 1), k.bytes]), ...(rest ? [[\"other\", \"k5\", rest]] : [])];\n const total = st.eventBytes || 1;\n $(\"#s-bar\").innerHTML = parts.length ? parts.map(([n, c, b]) => '').join(\"\") : '';\n $(\"#s-legend\").innerHTML = parts.length ? parts.map(([n, c, b]) => '' + esc(n) + \" \" + Math.round(b * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n const overhead = Math.max(0, st.databaseBytes - st.eventBytes);\n $(\"#s-totals\").innerHTML = [[\"events\", st.events.toLocaleString() + \" \" + fmtBytes(st.eventBytes) + \"\"], [\"index and overhead\", fmtBytes(overhead)], [\"files\", st.blobs.toLocaleString() + \" \" + fmtBytes(st.mediaBytes) + \"\"]]\n .map(([k, v]) => \"
                                                                                                                  \" + k + \"\" + v + \"
                                                                                                                  \").join(\"\");\n const any = st.retention.find((r) => r.kind === null);\n const own = (kind) => { const r = st.retention.find((x) => x.kind === kind); return r ? r.days : \"\"; };\n const row = (k) => {\n const key = k.kind === null ? \"\" : String(k.kind);\n const placeholder = k.kind === null ? \"forever\" : k.replaceable || !any ? \"forever\" : any.days + \" (everything else)\";\n const pill = k.kind === null ? \"everything else\" : '' + k.kind + \" \" + esc(kindName(k.kind));\n const cells = k.protected\n ? 'required'\n : ' days' + ib(\"check\", \"Save keep-for rule\", \"saveretention\", key) + ib(\"trash\", \"Purge\", \"purgekind\", key, \"danger\") + \"\";\n return '' + pill + '' + (k.n === undefined ? \"\" : k.n.toLocaleString()) + '' + (k.bytes === undefined ? \"\" : fmtBytes(k.bytes)) + '' + (k.oldest ? esc(fmtDay(k.oldest)) : \"\") + \"\" + cells + \"\";\n };\n $(\"#kinds tbody\").innerHTML = [...st.kinds, { kind: null, days: any ? any.days : 0 }].map(row).join(\"\");\n loadListHistory().catch(() => {});\n }\n\n async function loadListHistory() {\n const rows = await rpc(\"listlisthistory\");\n const labels = { 3: \"follows\", 10002: \"relay list\", 10003: \"bookmarks\", 30003: \"bookmark list\" };\n $(\"#listhistory tbody\").innerHTML = rows.length ? rows.map((r) => '' + esc((labels[r.kind] || (\"kind \" + r.kind)) + (r.d ? \" / \" + r.d : \"\")) + '' + esc(fmtTime(r.created_at)) + '' + esc(fmtTime(r.saved_at)) + '' + ib(\"undo\", \"Restore this version\", \"restorelist\", r.event_id) + '').join(\"\") : 'no older list versions yet';\n }\n\n let searchQuery = \"\";\n async function loadEvents(reset) {\n const list = searchQuery ? await rpc(\"searchevents\", searchQuery, 200) : await rpc(\"listrecentevents\", reset ? 50 : 200);\n $(\"#events tbody\").innerHTML = list.length ? list.map((e) => '' + esc(fmtTime(e.created_at)) + '' + e.kind + '' + av(e.pubkey) + key(e.pubkey) + '' + esc(e.content) + '' + ib(\"pin\", \"Pin\", \"pinevent\", e.id) + ib(\"trash\", \"Delete event\", \"deleteevent\", e.id) + ib(\"ban\", \"Ban event\", \"banevent\", e.id, \"danger\") + ib(\"banuser\", \"Ban author\", \"banpubkey\", e.pubkey, \"danger\") + \"\").join(\"\") : 'no events yet';\n $(\"#more\").classList.toggle(\"hidden\", searchQuery || list.length < 50);\n }\n $(\"#searchform\").onsubmit = guard(async (ev) => {\n searchQuery = ev.target.q.value.trim();\n $(\"#searchclear\").classList.toggle(\"hidden\", !searchQuery);\n await loadEvents(true);\n });\n $(\"#searchclear\").onclick = guard(async () => {\n searchQuery = \"\"; $(\"#searchform\").reset(); $(\"#searchclear\").classList.add(\"hidden\");\n await loadEvents(true);\n });\n async function loadPins() {\n const pins = await rpc(\"listpins\");\n $(\"#pins-count\").textContent = pins.length || \"\";\n $(\"#pins\").innerHTML = pins.length ? pins.map((t) => '
                                                                                                                • ' + (t[0] === \"e\" ? \"event\" : \"address\") + '' + esc(t[1].length > 40 ? t[1].slice(0, 16) + \"\\u2026\" + t[1].slice(-8) : t[1]) + \"\" + ib(\"copy\", \"Copy\", \"copy\", t[1]) + ib(\"x\", \"Unpin\", \"unpinevent\", t[1]) + \"
                                                                                                                • \").join(\"\") : '
                                                                                                                • nothing pinned
                                                                                                                • ';\n }\n $(\"#pinform\").onsubmit = guard(async (ev) => {\n await rpc(\"pinevent\", ev.target.id.value.trim()); ev.target.reset(); toast(\"Pinned\"); await loadPins();\n });\n\n // ---- actions ----\n const refresh = () => Promise.all([loadLists(), loadEvents(true), loadStorage(), loadPins()]);\n\n $(\"#copy\").onclick = async () => { await navigator.clipboard.writeText(wsURL); toast(\"copied \" + wsURL); };\n $(\"#signin\").onclick = guard(async () => {\n if (!window.nostr) throw new Error(\"No nostr extension found. Install one (Alby, nos2x, …) and reload, or use a remote signer.\");\n me = await window.nostr.getPublicKey();\n localStorage.setItem(\"me\", me);\n renderHeader(); await loadAdmin(); await loadPeople();\n });\n $(\"#signin46\").onclick = () => showRemote(null);\n $$(\".remote\").forEach((b) => { b.onclick = () => showRemote($(\"#\" + b.dataset.note)); });\n $(\"#nccopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#nclink\").href); toast(\"Link copied; paste it into your signer app\"); };\n $(\"#bunkerform\").onsubmit = guard(async (ev) => {\n $(\"#remotenote\").textContent = \"Connecting…\";\n try { await connectBunker(ev.target.url.value); } catch (e) { $(\"#remotenote\").textContent = e.message; throw e; }\n $(\"#remotenote\").textContent = \"\"; ev.target.reset();\n await remoteDone();\n });\n $(\"#signout\").onclick = async () => {\n if (remote) { const r = remote; remote = null; try { await withTimeout(r.logout(), 5000, \"The signer\"); } catch { /* the session is gone either way */ } }\n localStorage.removeItem(\"nip46\");\n me = null; localStorage.removeItem(\"me\"); renderHeader(); await loadAdmin(); await loadPeople();\n };\n const claimNow = async (note) => {\n if (!signer.ready()) { note.textContent = \"Sign the claim with a nostr extension or a remote signer.\"; showRemote(note); return; }\n me = await signer.getPublicKey();\n const r = await rpc(\"claim\");\n if (!r.claimed) throw new Error(\"Somebody else claimed it first.\");\n localStorage.setItem(\"me\", me);\n toast(\"It's yours.\");\n await loadInfo(); await loadFuel(); await loadAdmin(); await loadPeople();\n if (r.converted && confirm(\"This relay began as a temporary one: anyone can write, and everything is deleted after 14 days. Switch to the default rules and keep everything from now on? Each rule can be changed later on the Rules and Storage tabs.\")) {\n await rpc(\"resetrules\"); toast(\"Rules reset\"); await loadInfo(); await loadAdmin();\n }\n };\n $(\"#claim\").onclick = guard(() => claimNow($(\"#claimnote\")));\n $(\"#claimlease\").onclick = guard(() => claimNow($(\"#leasenote\")));\n // Jobs run in the background; the table follows them while the tab is open.\n let jobsTimer = 0;\n const fmtJob = (j) => {\n const what = j.kind === \"mirror\" ? \"mirror site\" : j.kind === \"import\" ? \"import\" : j.label === \"backfill\" ? \"fetch my history\" : j.kind === \"pull\" ? \"pull\" : \"rebroadcast\";\n const f = [];\n if (j.filter.authors) f.push(j.filter.authors.length === 1 && j.filter.authors[0] === me ? \"my events\" : j.filter.authors.length + \" authors\");\n if (j.filter.kinds) f.push(\"kinds \" + j.filter.kinds.join(\", \"));\n if (j.filter.since) f.push(\"since \" + fmtDay(j.filter.since));\n const when = j.every ? \"every \" + (j.every === 24 ? \"day\" : j.every + \" h\") + (j.nextRun ? \", next \" + fmtTime(j.nextRun) : \"\") : \"once\";\n const l = j.last;\n const count = (stored, blobs, sent, refused) => (j.kind === \"mirror\" ? blobs.toLocaleString() + \" files mirrored\" : j.kind === \"import\" ? stored.toLocaleString() + \" events\" + ((j.last ? j.last.duplicates : j.duplicates) ? \", \" + (j.last ? j.last.duplicates : j.duplicates) + \" already here\" : \"\") : j.kind === \"pull\" ? stored.toLocaleString() + \" events\" + (blobs ? \", \" + blobs + \" files\" : \"\") : sent.toLocaleString() + \" sent\" + (refused ? \", \" + refused + \" refused\" : \"\"));\n const res = j.running ? \"running: \" + count(j.stored, j.blobs, j.sent, j.refused) + \"...\" : !l ? \"waiting\" : l.error ? \"failed: \" + l.error : count(l.stored, l.blobs, l.sent, l.refused) + (l.skipped ? \", \" + l.skipped + \" skipped\" : \"\") + \", \" + fmtTime(l.finishedAt);\n const sources = j.running ? j.pullSources : l?.sources;\n const details = sources?.length ? '
                                                                                                                  Source results' + sources.map((s) => '

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

                                                                                                                  ').join('') + '
                                                                                                                  ' : '';\n return \"\" + what + \"\" + j.relays.map(esc).join(\"
                                                                                                                  \") + \"\" + (f.join(\", \") || \"everything\") + \"\" + when + \"\" + esc(res) + details + \"\" + (j.running ? \"\" : ib(\"undo\", \"Run now\", \"runjob\", j.id)) + ib(\"x\", \"Remove\", \"removejob\", j.id) + \"\";\n };\n async function pollJobs() {\n clearTimeout(jobsTimer);\n let jobs;\n try { jobs = await rpc(\"listjobs\"); } catch { return; }\n $(\"#jobs tbody\").innerHTML = jobs.length ? jobs.map(fmtJob).join(\"\") : 'no jobs yet';\n if (jobs.some((j) => j.running || (j.nextRun && j.nextRun <= Math.floor(Date.now() / 1000) + 1))) jobsTimer = setTimeout(pollJobs, 3000);\n }\n const urls = (s) => s.split(/[\\s,]+/).map((u) => u.trim()).filter(Boolean);\n const kindsOf = (s) => s.split(/[\\s,]+/).map((k) => parseInt(k, 10)).filter((k) => Number.isInteger(k) && k >= 0);\n $(\"#pullform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"addjob\", { kind: \"pull\", relays: [f.url.value.trim()], every: +f.every.value });\n toast(+f.every.value ? \"Mirror scheduled\" : \"Pull started\"); f.reset(); await pollJobs();\n });\n $(\"#backfillform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"backfill\", urls(f.relays.value));\n toast(\"Fetching your history\"); f.reset(); await pollJobs();\n });\n $(\"#pushform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const filter = {};\n const kinds = kindsOf(f.kinds.value); if (kinds.length) filter.kinds = kinds;\n const days = +f.days.value; if (days > 0) filter.since = Math.floor(Date.now() / 1000) - days * 86400;\n await rpc(\"addjob\", { kind: \"push\", relays: urls(f.targets.value), filter, every: +f.every.value });\n toast(\"Rebroadcast started\"); f.reset(); await pollJobs();\n });\n // ---- presets: writes, reads, kinds and keep-for rules in one click ----\n let presets = null;\n async function renderPresets() {\n if (!presets) { try { presets = await rpc(\"listpresets\"); } catch { presets = []; } }\n $(\"#presets\").innerHTML = presets.map((p) => '\").join(\"\");\n $(\"#presetsourcerow\").classList.toggle(\"hidden\", !presets.some((p) => p.source));\n }\n $(\"#presets\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-preset]\"); if (!b) return;\n const p = (presets || []).find((x) => x.name === b.dataset.preset); if (!p) return;\n if (!confirm(p.title + \": \" + p.about + \"\\n\\nThis replaces the writes and reads rules, the directory setting, the kind rules and the keep-for rules. Limits, identity, people and bans stay.\")) return;\n const source = $(\"#presetsource\").value.trim();\n if (p.source === \"required\" && !source) { $(\"#presetnote\").textContent = p.title + \" needs a source relay to mirror; enter its wss:// URL first.\"; return; }\n b.disabled = true;\n try { policy = await rpc(\"applypreset\", p.name, p.source && source ? { source } : undefined); $(\"#presetnote\").textContent = \"Now: \" + p.about + (policy.job ? \" Mirroring \" + source + \" every \" + policy.job.every + \" h.\" : \"\"); toast(p.title + \" applied\"); await loadInfo(); await loadAdmin(); }\n catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // ---- wire me in: this relay in the owner's own lists ----\n // The lists are replaceable, so a fresh list with only this relay would\n // clobber the real one once it spread. Every publish starts from the\n // newest copy found here or on the indexers, verified when the signer\n // library is around, and adds or removes this relay in it.\n const INDEXERS = [\"wss://purplepag.es\", \"wss://relay.nostr.band\", \"wss://relay.damus.io\", \"wss://nos.lol\"];\n const LISTS = [\n { kind: 10002, tag: \"r\", title: \"Relay list\", nip: \"NIP-65\", about: \"where clients read your notes and send you mentions\" },\n { kind: 10050, tag: \"relay\", title: \"DM inbox\", nip: \"NIP-17\", about: \"where people send you private messages\" },\n { kind: 10007, tag: \"relay\", title: \"Search relays\", nip: \"NIP-51\", about: \"where clients run your searches\" },\n { kind: 10063, tag: \"server\", title: \"Blossom servers\", nip: \"BUD-03\", about: \"where clients upload and look for your files\" },\n ];\n const mineFor = (l) => (l.tag === \"server\" ? location.origin : wsURL);\n const normURL = (u) => { try { const x = new URL(String(u).trim()); return (x.host + x.pathname).replace(/\\/+$/, \"\").toLowerCase(); } catch { return String(u).trim().toLowerCase(); } };\n const isMine = (l, t) => t[0] === l.tag && normURL(t[1] || \"\") === normURL(mineFor(l));\n const listHas = (list, l) => !!list && list.tags.some((t) => isMine(l, t));\n // mergeList is pure: the newest list with this relay put first or taken out, every other tag kept.\n function mergeList(list, l, include) {\n const tags = (list ? list.tags : []).filter((t) => !isMine(l, t));\n if (include) tags.unshift([l.tag, mineFor(l)]);\n return { kind: l.kind, created_at: Math.floor(Date.now() / 1000), content: list ? list.content : \"\", tags };\n }\n const relaysIn = (list) => list.tags.filter((t) => (t[0] === \"r\" || t[0] === \"relay\") && /^wss?:\\/\\//i.test(t[1] || \"\")).map((t) => t[1].trim());\n // overWS opens one socket, sends one message, feeds answers to onMessage until it says done or time runs out.\n function overWS(url, ms, first, onMessage) {\n return new Promise((res) => {\n let ws = null, done = false, out = null;\n const finish = () => { if (done) return; done = true; clearTimeout(timer); try { if (ws) ws.close(); } catch { /* closed */ } res(out); };\n const timer = setTimeout(finish, ms);\n try { ws = new WebSocket(url); } catch { return finish(); }\n ws.onopen = () => ws.send(JSON.stringify(first));\n ws.onmessage = (m) => { let d; try { d = JSON.parse(m.data); } catch { return; } if (!Array.isArray(d)) return; if (onMessage(d, (v) => { out = v; })) finish(); };\n ws.onerror = finish; ws.onclose = finish;\n });\n }\n function fetchNewest(url, filter, ms) {\n let best = null;\n return overWS(url, ms, [\"REQ\", \"w\", filter], (d, set) => {\n if (d[0] === \"EVENT\" && d[1] === \"w\" && d[2] && (!best || d[2].created_at > best.created_at)) { best = d[2]; set(best); }\n return (d[0] === \"EOSE\" || d[0] === \"CLOSED\") && d[1] === \"w\";\n });\n }\n function publishTo(url, event, ms) {\n return overWS(url, ms, [\"EVENT\", event], (d, set) => { if (d[0] === \"OK\" && d[1] === event.id) { set({ url, ok: !!d[2], msg: d[3] || \"\" }); return true; } return false; })\n .then((r) => r || { url, ok: false, msg: \"no answer\" });\n }\n // bridge signs a NIP-98 request to this relay's HTTP door, so the owner's own reads and writes pass any read rule.\n async function bridge(path, body) {\n const url = location.origin + path, raw = JSON.stringify(body);\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"POST\"], [\"payload\", await sha256hex(raw)]] });\n const resp = await fetch(url, { method: \"POST\", headers: { \"content-type\": \"application/json\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body: raw });\n const json = await resp.json();\n if (!resp.ok) throw new Error(json.error || \"HTTP \" + resp.status);\n return json;\n }\n const wire = {};\n function renderWire() {\n const pill = (state) => {\n const cls = state === \"listed\" ? \"on\" : state === \"failed\" ? \"bad\" : state === \"not listed\" ? \"off\" : \"\";\n const label = state === \"checking\" || state === \"publishing\" ? state + \"\\u2026\" : state;\n return '' + esc(label) + \"\";\n };\n $(\"#wire\").innerHTML = LISTS.map((l) => {\n const w = wire[l.kind] || { state: \"\", results: [], list: null };\n const busy = w.state === \"checking\" || w.state === \"publishing\";\n const btn = (act, cls, label) => '\";\n const buttons = w.state === \"listed\" ? btn(\"remove\", \"\", \"Remove this relay\") : btn(\"add\", \"pri\", \"Add this relay\") + btn(\"check\", \"\", w.state ? \"Check again\" : \"Check\");\n const n = w.list ? w.list.tags.filter((t) => t[0] === l.tag).length : 0;\n const from = w.list ? \"\" + (n === 1 ? \"1 entry\" : n + \" entries\") + \", \" + fmtTime(w.list.created_at) + \"\" : w.state && !busy ? \"no list found\" : \"\";\n const sent = (w.results || []).map((r) => '' + esc(r.url.replace(/^wss?:\\/\\//, \"\")) + (r.ok ? \"\" : \" failed\") + \"\").join(\"\");\n const meta = (w.state ? pill(w.state) : \"\") + from + sent;\n return '
                                                                                                                  ' + l.title + ' ' + l.nip + \"
                                                                                                                  \" + l.about + '
                                                                                                                  ' + buttons + '
                                                                                                                  ' + meta + \"
                                                                                                                  \";\n }).join(\"\");\n }\n async function wireCheck(l) {\n const w = (wire[l.kind] = { state: \"checking\", list: null, results: [] }); renderWire();\n const filter = { kinds: [l.kind], authors: [me], limit: 1 };\n const found = [];\n try { found.push(...(await bridge(\"/query\", [filter]))); } catch { /* the relay may hold none */ }\n const remote = await Promise.all(INDEXERS.map((u) => fetchNewest(u, filter, 4000)));\n let lib = null; try { lib = await signerLib(); } catch { /* unverified lists are still the owner's own, by pubkey */ }\n for (const e of remote) if (e && e.pubkey === me && e.kind === l.kind && Array.isArray(e.tags) && (!lib || lib.verifyEvent(e))) found.push(e);\n w.list = found.sort((a, b) => b.created_at - a.created_at)[0] || null;\n w.state = listHas(w.list, l) ? \"listed\" : \"not listed\"; renderWire();\n }\n async function wirePublish(l, include) {\n if (!wire[l.kind] || !wire[l.kind].state || wire[l.kind].state === \"failed\") await wireCheck(l);\n const w = wire[l.kind]; w.state = \"publishing\"; w.results = []; renderWire();\n try {\n const signed = await signer.signEvent(mergeList(w.list, l, include));\n const here = await bridge(\"/events\", signed).then((r) => ({ url: wsURL, ok: !!r.accepted, msg: r.message || \"\" })).catch((e) => ({ url: wsURL, ok: false, msg: e.message }));\n const targets = [...new Set([...relaysIn(signed), ...INDEXERS])].filter((u) => normURL(u) !== normURL(wsURL));\n const rest = await Promise.all(targets.map((u) => publishTo(u, signed, 6000)));\n w.results = [here, ...rest]; w.list = signed; w.state = include ? \"listed\" : \"not listed\";\n toast((include ? \"Added to your \" : \"Removed from your \") + l.title.toLowerCase());\n } catch (e) { w.state = \"failed\"; w.results = [{ url: wsURL, ok: false, msg: e.message }]; toast(e.message); }\n renderWire();\n }\n $(\"#wire\").addEventListener(\"click\", (ev) => {\n const b = ev.target.closest(\"button[data-wire]\"); if (!b) return;\n const l = LISTS.find((x) => String(x.kind) === b.dataset.wire); if (!l) return;\n if (b.dataset.do === \"check\") wireCheck(l); else wirePublish(l, b.dataset.do === \"add\");\n });\n\n // ---- custom domains: this relay under a hostname the owner controls ----\n let domainSites = [];\n const domainOptions = (current = \"\") => [{ label: \"\", title: \"This relay\" }, ...domainSites.map((s) => ({ label: s.label, title: (s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + \" / \" + key(s.author) })), ...(current && !domainSites.some((s) => s.label === current) ? [{ label: current, title: \"Site \" + current }] : [])].map((s) => '\").join(\"\");\n let domains = null; // null: not enabled on this host\n function renderDomains() {\n $(\"#domains\").innerHTML = (domains || []).map((d) => {\n const state = d.ready ? \"active\" : \"hostname \" + d.status.replace(/_/g, \" \") + \", certificate \" + d.sslStatus.replace(/_/g, \" \");\n const btn = (act, cls, label) => '\";\n const rows = d.ready ? \"\" : '
                                                                                                                  ' + d.records.map((r) => \"\").join(\"\") + \"
                                                                                                                  TypeNameValue
                                                                                                                  \" + esc(r.type) + '' + esc(r.name) + '' + esc(r.value) + \"\" + esc(r.note) + \"
                                                                                                                  \";\n return '
                                                                                                                  ' + esc(d.host) + '' + esc(state) + \"\" + btn(\"check\", \"\", \"Check\") + btn(\"remove\", \"danger\", \"Remove\") + '
                                                                                                                  \" + rows;\n }).join(\"\");\n }\n async function loadDomains() {\n try { [domains, domainSites] = await Promise.all([rpc(\"listdomains\"), rpc(\"listsites\")]); $(\"#adddomain select[name=site]\").innerHTML = domainOptions(); $(\"#domainnote\").textContent = domains.length ? \"\" : \"No custom domain yet.\"; $(\"#adddomain\").classList.remove(\"hidden\"); }\n catch (e) { domains = null; $(\"#domainnote\").textContent = /^unsupported/.test(e.message) ? \"Custom domains are not enabled on this host.\" : e.message; $(\"#adddomain\").classList.add(\"hidden\"); }\n renderDomains();\n }\n $(\"#adddomain\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"adddomain\", f.host.value.trim(), f.site.value); f.reset(); toast(\"Domain added; now create the CNAME\"); await loadDomains();\n });\n $(\"#domains\").addEventListener(\"change\", guard(async (ev) => {\n const select = ev.target.closest(\"select[data-domain-site]\"); if (!select) return;\n await rpc(\"setdomainsite\", select.dataset.domainSite, select.value);\n toast(\"Domain destination saved\"); await loadDomains();\n }));\n $(\"#domains\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-domain]\"); if (!b) return;\n const host = b.dataset.domain;\n if (b.dataset.do === \"remove\" && !confirm(\"Remove \" + host + \"? Its certificate goes with it and the name stops answering.\")) return;\n b.disabled = true;\n try {\n if (b.dataset.do === \"remove\") { await rpc(\"removedomain\", host); toast(\"Removed\"); }\n else { const d = await rpc(\"checkdomain\", host); toast(d.ready ? host + \" is live\" : \"Not yet: \" + (d.status === \"active\" ? \"certificate pending\" : \"waiting for the CNAME\")); }\n await loadDomains();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n $(\"#access\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const openKinds = f.openKinds.value.split(/[\\s,]+/).filter(Boolean).map(Number);\n if (openKinds.some((k) => !Number.isInteger(k) || k < 0 || k > 65535)) throw new Error(\"Open kinds must be whole numbers.\");\n policy = await rpc(\"setpolicy\", { writes: f.writes.value, reads: f.reads.value, openKinds, guestReplies: f.guestReplies.checked, minPow: +f.minPow.value, maxFuture: +f.maxFuture.value, maxLimit: +f.maxLimit.value, maxSubs: +f.maxSubs.value, maxMessageKB: +f.maxMessageKB.value, eventsPerMinute: +f.eventsPerMinute.value, reqsPerMinute: +f.reqsPerMinute.value, maxBlobMB: +f.maxBlobMB.value });\n toast(\"Rules saved\"); await loadInfo();\n });\n $(\"#notify\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { notify: { reports: f.reports.checked, fuel: f.fuel.checked, jobs: f.jobs.checked, succession: f.succession.checked, digest: f.digest.checked } });\n toast(\"Notifications saved\");\n });\n $(\"#notifytest\").onclick = guard(async () => {\n const r = await rpc(\"notifytest\");\n toast(r.sent ? \"Sent. Look for a message from the relay in your DMs.\" : \"Could not send.\");\n });\n $(\"#identity\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const list = (v) => v.split(\",\").map((s) => s.trim()).filter(Boolean);\n policy = await rpc(\"setpolicy\", { name: f.name.value, contact: f.contact.value, description: f.description.value, icon: f.icon.value,\n banner: f.banner.value, postingPolicy: f.postingPolicy.value, privacyPolicy: f.privacyPolicy.value, tags: list(f.tags.value), languageTags: list(f.languageTags.value), relayCountries: list(f.relayCountries.value) });\n toast(\"Identity saved\"); await loadInfo(); await loadPeople(); loadCard();\n });\n $(\"#addmember\").onsubmit = guard(async (ev) => {\n const f = ev.target; const pk = npubToHex(f.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"setmember\", pk, { name: f.name.value.trim() || null, note: f.note.value }); f.reset(); toast(\"Member added\"); await loadLists();\n });\n $(\"#addblock\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"blockip\", f.ip.value.trim(), f.reason.value); f.reset(); toast(\"Blocked\"); await loadLists();\n });\n $(\"#wordsform\").onsubmit = guard(async (ev) => {\n const words = ev.target.words.value.split(\"\\n\").map((s) => s.trim()).filter(Boolean);\n const kept = await rpc(\"setblockedwords\", words);\n policy = await rpc(\"setpolicy\", { blockedWordsInTags: ev.target.inTags.checked });\n ev.target.words.value = kept.join(\"\\n\"); toast(kept.length ? kept.length + \" words blocked\" : \"No words blocked\");\n });\n $(\"#thresholdform\").onsubmit = guard(async (ev) => {\n policy = await rpc(\"setpolicy\", { reportThreshold: +ev.target.reportThreshold.value });\n toast(policy.reportThreshold ? \"Hidden after \" + policy.reportThreshold + \" reports\" : \"Never hidden by reports\"); await loadLists();\n });\n $(\"#addban\").onsubmit = guard(async (ev) => {\n const pk = npubToHex(ev.target.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"banpubkey\", pk, ev.target.reason.value, ev.target.erase.checked); ev.target.reset(); toast(ev.target.erase.checked ? \"Banned and erased\" : \"Banned\"); await loadLists();\n });\n $(\"#mintinvite\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const inv = await rpc(\"createinvite\", +f.ttl.value, +f.max.value, f.note.value);\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n f.note.value = \"\"; toast(\"Invite created and copied\"); await loadLists();\n });\n $(\"#kindform\").onsubmit = guard(async (ev) => {\n const rule = ev.submitter.value; const k = +ev.target.kind.value;\n await rpc(rule === \"allow\" ? \"allowkind\" : \"disallowkind\", k); ev.target.reset(); toast((rule === \"allow\" ? \"Allowed kind \" : \"Blocked kind \") + k); await loadLists();\n });\n $(\"#more\").onclick = guard(() => loadEvents(false));\n $(\"#dumpform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { dumps: f.dumps.value, dumpsKeep: Math.max(1, Math.min(60, Math.floor(+f.keep.value || 7))) });\n toast(policy.dumps === \"off\" ? \"Dumps off\" : \"Dumping \" + policy.dumps); await loadDumps();\n });\n $(\"#importform\").onsubmit = guard(async (ev) => {\n const file = ev.target.file.files[0]; if (!file) return;\n if (!signer.ready()) throw new Error(NO_SIGNER);\n if (file.size > 64 * 1024 * 1024) throw new Error(\"At most 64 MB per import.\");\n const body = await file.text();\n const url = location.origin + \"/import?name=\" + encodeURIComponent(file.name);\n const token = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"PUT\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(url, { method: \"PUT\", headers: { authorization: \"Nostr \" + btoa(JSON.stringify(token)), \"content-type\": \"application/x-ndjson\" }, body });\n const r = await resp.json();\n if (!resp.ok) throw new Error(r.error || \"import failed\");\n ev.target.reset(); toast(\"Importing \" + fmtBytes(r.bytes)); await pollJobs();\n });\n $(\"#dumpnow\").onclick = guard(async () => { const d = await rpc(\"dumpnow\"); toast(\"Dumped \" + d.events.toLocaleString() + \" events\"); await loadStorage(); });\n $(\"#treeform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { memberInvites: { depth: Math.max(0, Math.floor(+f.depth.value || 0)), quota: Math.max(0, Math.floor(+f.quota.value || 0)) } });\n toast(policy.memberInvites.depth ? \"Members may invite\" : \"Only you and moderators invite\");\n });\n $(\"#mintmine\").onclick = guard(async () => {\n const inv = await rpc(\"createinvite\", 259200, 1, \"\");\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n toast(\"Invite created and copied\"); await loadMine();\n });\n $(\"#exportcfg\").onclick = guard(async () => {\n const cfg = await rpc(\"exportconfig\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(new Blob([JSON.stringify(cfg, null, 2)], { type: \"application/json\" }));\n a.download = host.split(\".\")[0] + \".bind.ws.config.json\";\n a.click(); URL.revokeObjectURL(a.href);\n });\n $(\"#importcfg\").onclick = () => $(\"#cfgfile\").click();\n $(\"#cfgfile\").onchange = guard(async (ev) => {\n const file = ev.target.files[0]; if (!file) return;\n const cfg = JSON.parse(await file.text());\n const check = await rpc(\"importconfig\", cfg, { dryRun: true });\n const lines = check.changes.summary.length ? check.changes.summary.join(\"\\n\") : \"Nothing would change.\";\n const dropped = check.warnings.length ? \"\\n\\nNot taken:\\n\" + check.warnings.join(\"\\n\") : \"\";\n if (!confirm(\"Apply \" + file.name + \" to this relay?\\n\\n\" + lines + dropped)) { ev.target.value = \"\"; return; }\n await rpc(\"importconfig\", cfg); ev.target.value = \"\"; toast(\"Configuration imported\"); await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // Succession: the status line under the heir form, from successionstatus.\n async function renderSuccession() {\n const el = $(\"#successionnote\"), f = $(\"#succession\");\n let st;\n try { st = await rpc(\"successionstatus\"); } catch { el.textContent = \"\"; return; }\n const sc = st.succession;\n $(\"#clearsuccession\").classList.toggle(\"hidden\", !sc);\n f.querySelector(\"button.btn:not(#clearsuccession)\").textContent = sc ? \"Change heir\" : \"Set heir\";\n if (!sc) { el.textContent = \"No heir named. Last signed in \" + fmtTime(st.ownerSeenAt) + \".\"; return; }\n if (f.heir.querySelector('[value=\"' + sc.heir + '\"]')) f.heir.value = sc.heir;\n f.afterDays.value = String(sc.afterDays);\n const who = (window.__members || []).find((m) => m.pubkey === sc.heir);\n const heir = who && who.name ? who.name + \"@\" + host : short(sc.heir);\n el.textContent = \"Heir: \" + heir + \". Last signed in \" + fmtTime(st.ownerSeenAt) + (st.silentDays ? \" (\" + st.silentDays + \" days ago)\" : \"\") + \". \" +\n (st.warning ? \"The warning month is running: the relay goes to \" + heir + \" on \" + fmtDay(st.handoverAt) + \" unless you sign in.\" : \"If you stay away, it goes to \" + heir + \" around \" + fmtDay(st.handoverAt) + \".\") +\n (st.log && st.log.length ? \" Handed over before: \" + st.log.map((l) => fmtDay(l.at) + \" to \" + short(l.to)).join(\", \") + \".\" : \"\");\n }\n $(\"#succession\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"setsuccession\", { heir: f.heir.value, afterDays: +f.afterDays.value });\n toast(\"Heir set\"); await loadAdmin();\n });\n $(\"#clearsuccession\").onclick = guard(async () => {\n await rpc(\"clearsuccession\");\n toast(\"Heir cleared\"); await loadAdmin();\n });\n $(\"#transfer\").onsubmit = guard(async (ev) => {\n const pk = ev.target.pubkey.value; if (!pk) return;\n const name = host.split(\".\")[0];\n const typed = prompt(\"This hands \" + host + \" to \" + short(pk) + \" for good. You stay on as a moderator. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing changed.\"); return; }\n await rpc(\"transferowner\", pk);\n toast(\"Transferred\");\n await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // ---- fork: a new name with this relay's events, claim reserved for a key ----\n $(\"#forkform\").scope.onchange = (ev) => $(\"#forkkinds\").classList.toggle(\"hidden\", ev.target.value !== \"kinds\");\n $(\"#joinform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { joinTerms: f.joinTerms.value, directoryPublic: f.directoryPublic.checked });\n toast(\"Saved\"); await loadInfo(); await loadPeople();\n });\n $(\"#forkform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const opts = { people: f.people.checked };\n if (f.name.value.trim()) opts.name = f.name.value.trim().toLowerCase();\n if (f.holder.value.trim()) { const pk = npubToHex(f.holder.value); if (!pk) throw new Error(\"That is not a pubkey.\"); opts.holder = pk; }\n if (f.scope.value === \"mine\") opts.filter = { authors: [me] };\n if (f.scope.value === \"kinds\") { const kinds = f.kinds.value.split(/[\\s,]+/).filter(Boolean).map(Number); if (!kinds.length || kinds.some((k) => !Number.isInteger(k) || k < 0)) throw new Error(\"Give kinds as numbers.\"); opts.filter = { kinds }; }\n if (!confirm(\"Fork this relay into a new name\" + (opts.name ? \" (\" + opts.name + \")\" : \"\") + \"? It pulls \" + (f.scope.value === \"all\" ? \"everything\" : f.scope.value === \"mine\" ? \"your events\" : \"the chosen kinds\") + (opts.people ? \" and the people\" : \"\") + \", and only \" + (opts.holder ? \"that key\" : \"you\") + \" can claim it.\")) return;\n const r = await rpc(\"forkrelay\", opts);\n $(\"#forknote\").textContent = r.handover + \" Expires \" + fmtTime(r.expires_at) + \".\";\n $(\"#forkurl\").textContent = r.console;\n $(\"#forkresult\").classList.remove(\"hidden\");\n toast(\"Forked to \" + r.name);\n });\n $(\"#forkcopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#forkurl\").textContent); toast(\"copied\"); };\n $(\"#deleterelay\").onclick = guard(async () => {\n const name = host.split(\".\")[0];\n const typed = prompt(\"This deletes everything on \" + host + \" and gives the name up. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing was deleted.\"); return; }\n await rpc(\"deleterelay\", name);\n localStorage.removeItem(\"me\");\n location.href = \"/\";\n });\n $(\"#topup\").onsubmit = guard((ev) => topUp(+ev.target.sats.value));\n $(\"#inv-copy\").onclick = async () => { await navigator.clipboard.writeText($(\"#inv-text\").value); toast(\"invoice copied\"); };\n $$(\"button[data-copy]\").forEach((b) => { b.onclick = async () => { await navigator.clipboard.writeText(b.dataset.copy === \"ws\" ? wsURL : location.origin); toast(\"copied\"); }; });\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-act]\"); if (!b) return;\n const act = b.dataset.act, id = b.dataset.id;\n if (act === \"copy\") { await navigator.clipboard.writeText(id); toast(\"copied\"); return; }\n if ((act === \"banpubkey\" || act === \"banevent\" || act === \"resolve:ban\") && !confirm(act === \"banevent\" ? \"Delete this event and refuse it forever?\" : \"Ban this author and refuse everything they post?\")) return;\n const erase = (act === \"banpubkey\" || act === \"resolve:ban\") && confirm(\"Also erase everything they wrote and uploaded here?\");\n if (act === \"deleteblob\" && !confirm(\"Delete this file for good?\")) return;\n if (act === \"deletedump\" && !confirm(\"Delete this dump?\")) return;\n if (act === \"downloaddump\") { b.disabled = true; try { await downloadDump(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }\n if (act === \"restorelist\") {\n if (!signer.ready()) { toast(NO_SIGNER); return; }\n try {\n const preview = await rpc(\"restorelist\", id);\n const d = preview.diff || {};\n const added = (d.addedTags || []).map((t) => \"+ \" + JSON.stringify(t)).join(\"\\n\");\n const removed = (d.removedTags || []).map((t) => \"- \" + JSON.stringify(t)).join(\"\\n\");\n const changes = [added, removed, d.contentChanged ? \"content changed\" : \"content unchanged\"].filter(Boolean).join(\"\\n\");\n if (!confirm(\"Restore this list version?\\n\\n\" + (changes || \"No tag or content changes\") + \"\\n\\nIt will be signed and published as the newest version.\")) return;\n const signed = await signer.signEvent(preview.draft);\n const result = await bridge(\"/events\", signed);\n if (!result.accepted) throw new Error(result.message || \"The relay refused the restored list.\");\n toast(\"List restored\"); await loadListHistory(); await loadStorage();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if ((act === \"removemember\" || act === \"banpubkey\") && (window.__members || []).some((m) => m.invited_by === id) && confirm(\"Also remove everyone this member invited, and everyone they invited in turn?\")) {\n b.disabled = true;\n try { const r = await rpc(\"removesubtree\", id); if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase); toast(\"Removed \" + r.removed.length); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"removejob\" && !confirm(\"Remove this job?\")) return;\n if (act === \"purgekind\") {\n const kind = id === \"\" ? null : +id;\n const label = kind === null ? \"everything without its own rule\" : kindName(kind) + \" (kind \" + kind + \")\";\n const typed = prompt(\"Purge \" + label + \" older than how many days? 0 purges all of them. This cannot be undone.\", \"30\");\n if (typed === null) return;\n const days = Math.max(0, Math.floor(+typed || 0));\n b.disabled = true;\n try { const r = await rpc(\"purgekind\", kind, days); toast(\"Purged \" + r.deleted.toLocaleString() + \" events\"); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"saveretention\") {\n const kind = id === \"\" ? null : +id;\n const days = Math.max(0, Math.floor(+b.closest(\"tr\").querySelector(\".days\").value || 0));\n b.disabled = true;\n try { await rpc(\"setretention\", kind, days); toast(days ? \"Kept for \" + days + \" days\" : \"Kept forever\"); await loadStorage(); await loadInfo(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n b.disabled = true;\n try {\n if (act === \"savemember\") { const tr = b.closest(\"tr\"), role = tr.querySelector(\"select.role\"), keep = tr.querySelector(\".keep\"), cap = tr.querySelector(\".cap\"); await rpc(\"setmember\", id, { name: tr.querySelector(\".name\").value.trim() || null, note: tr.querySelector(\".note\").value, ...(role ? { role: role.value } : {}), ...(keep ? { keepDays: Math.max(0, Math.floor(+keep.value || 0)), maxBytes: Math.max(0, Math.floor(+cap.value || 0)) * 1024 } : {}) }); }\n else if (act.startsWith(\"resolve:\")) await rpc(\"resolvereport\", id, act.slice(8), erase);\n else if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase);\n else await rpc(act, act === \"unrulekind\" ? +id : id, \"\");\n toast(\"Done\"); await (myRole ? refresh() : loadMine());\n if (act === \"pinevent\" || act === \"unpinevent\") await loadPins();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // The share block: the card picture, the group naddr and its QR.\n let card = null;\n async function loadCard() {\n try {\n card = await (await fetch(\"/card.json\", { cache: \"no-store\" })).json();\n $(\"#cardimg\").src = \"/card.svg?t=\" + Date.now();\n $(\"#naddr\").textContent = card.naddr || \"\";\n const q = $(\"#naddrqr\");\n if (card.naddr) { q.src = \"/qr.svg?text=\" + encodeURIComponent(card.naddr); q.classList.remove(\"hidden\"); } else q.classList.add(\"hidden\");\n } catch { /* the card is decoration */ }\n renderApps();\n }\n // The app rows on the Connect section: one per client people actually use,\n // with the link that lands on this relay in it. Relay apps take the relay\n // or the group address; feed apps take the owner's profile with this relay\n // as the hint, since they have no notion of opening a relay.\n function renderApps() {\n const el = $(\"#apps\"); if (!el) return;\n const enc = encodeURIComponent;\n const nprofile = card && card.nprofile ? card.nprofile : \"\";\n const naddr = card && card.naddr ? card.naddr : \"\";\n const whose = me && owner && me === owner ? \"your\" : \"the owner's\";\n const link = (label, href) => '' + label + \"\";\n const app = (label, uri) => '' + label + \"\";\n const copy = (label, text) => '\";\n const row = (name, where, note, acts) => '
                                                                                                                  ' + name + \"\" + where + \"

                                                                                                                  \" + note + '

                                                                                                                  ' + acts.filter(Boolean).join(\"\") + \"
                                                                                                                  \";\n const profileNote = \"Opens \" + whose + \" profile with this relay attached.\";\n const groups = [\n [\"As a place\", \"These open the relay itself: its feed, its people, its group.\", [\n row(\"Jumble\", \"web\", \"A feed of everything on this relay.\", [link(\"Open\", \"https://jumble.social/?r=\" + enc(wsURL))]),\n row(\"Coracle\", \"web\", \"The relay's page: its feed and its people.\", [link(\"Open\", \"https://coracle.social/relays/\" + enc(host))]),\n row(\"Flotilla\", \"web, phone\", \"The relay as a space, with the group as a room.\", [link(\"Open\", \"https://app.flotilla.social/spaces/\" + enc(host)), naddr && app(\"Open group\", \"nostr:\" + naddr)]),\n row(\"0xchat\", \"phone\", \"The group, in a chat app.\", [naddr && app(\"Open group\", \"nostr:\" + naddr), naddr && copy(\"Copy naddr\", naddr)]),\n row(\"noStrudel\", \"web\", \"Relays, add this one, then open its page.\", [copy(\"Copy relay URL\", wsURL)]),\n ]],\n [\"Find me here\", \"Feed apps have no relay pages. They meet this relay through a profile link that names it, then keep it once it is in the relay settings.\", [\n row(\"Primal\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://primal.net/p/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"YakiHonne\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://yakihonne.com/profile/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"Damus\", \"iPhone\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Amethyst\", \"Android\", profileNote + \" Then Relays in the drawer.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Nostur\", \"iPhone, Mac\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n ]],\n ];\n if (info?.supported_grasps?.includes(\"GRASP-01\")) groups.push([\n \"Git repositories\", \"Use this relay with a Git client.\", [\n row(\"GitWorkshop\", \"web\", \"Browse this relay's Git repositories.\", [link(\"Open in app\", \"https://gitworkshop.dev/relay/\" + enc((wsURL.startsWith(\"ws://\") ? \"ws:\" : \"\") + host)), copy(\"Copy relay URL\", wsURL)]),\n ],\n ]);\n el.innerHTML = groups.map(([h, note, rows]) => '

                                                                                                                  ' + h + '

                                                                                                                  ' + note + '

                                                                                                                  ' + rows.join(\"\") + \"
                                                                                                                  \").join(\"\");\n $(\"#apps-ws\").textContent = wsURL;\n const tile = (label, text) => '
                                                                                                                  ' + label + '\"QR
                                                                                                                  ';\n $(\"#phones\").innerHTML = [nprofile && tile(\"Find me here, for a phone\", \"nostr:\" + nprofile), naddr && tile(\"The group, for a phone\", \"nostr:\" + naddr)].filter(Boolean).join(\"\");\n }\n // The folds are a group: opening one closes the rest. Browsers with the\n // details name attribute do this themselves; this covers the others.\n $$(\".folds details\").forEach((d) => d.addEventListener(\"toggle\", () => { if (d.open) $$(\".folds details\").forEach((o) => { if (o !== d && o.open) o.open = false; }); }));\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-copytext]\"); if (!b) return;\n try { await navigator.clipboard.writeText(b.dataset.copytext); toast(\"copied\"); } catch { /* no clipboard */ }\n });\n $(\"#copynaddr\").onclick = async () => { if (!card || !card.naddr) return; await navigator.clipboard.writeText(card.naddr); toast(\"copied naddr\"); };\n $(\"#copyembed\").onclick = async () => { await navigator.clipboard.writeText('\"''); toast(\"copied embed\"); };\n\n // A click on a key copies it; a double-click selects the whole key so the usual copy shortcut takes it too.\n document.addEventListener(\"dblclick\", (ev) => {\n const k = ev.target.closest(\".key\"); if (!k) return;\n const s = window.getSelection(), r = document.createRange(); r.selectNodeContents(k.querySelector(\".full\")); s.removeAllRanges(); s.addRange(r);\n });\n document.addEventListener(\"click\", async (ev) => {\n const k = ev.target.closest(\".key\"); if (!k || ev.detail > 1 || String(window.getSelection())) return;\n try { await navigator.clipboard.writeText(k.title); toast(\"copied \" + k.dataset.short); } catch { /* no clipboard */ }\n });\n // ---- boot ----\n try { await loadInfo(); } catch { renderHeader(); }\n await loadFuel();\n await loadCard();\n me = localStorage.getItem(\"me\");\n if (me && await resumeRemote()) { /* a remote session answers for itself when first used */ }\n else if (me && window.nostr) { try { const pk = await window.nostr.getPublicKey(); if (pk !== me) me = null; } catch { me = null; } }\n else if (me) me = null;\n renderHeader();\n renderApps();\n await loadAdmin();\n await loadPeople();\n})();\n"; +export const CONSOLE_JS = "(async () => {\n const $ = (s) => document.querySelector(s);\n const $$ = (s) => document.querySelectorAll(s);\n const host = location.host;\n const wsURL = (location.protocol === \"https:\" ? \"wss://\" : \"ws://\") + host;\n const rpcURL = location.origin + \"/\";\n let info = null, me = null, owner = \"\", policy = null, fuel = null, people = null, myRole = \"\";\n\n const toast = (msg) => { const t = $(\"#toast\"); t.textContent = msg; t.classList.add(\"show\"); clearTimeout(t._t); t._t = setTimeout(() => t.classList.remove(\"show\"), 2600); };\n // guard wraps a handler: disables the button, shows errors as toasts. Defined first, since handlers below use it.\n const guard = (fn) => async (ev) => { ev.preventDefault(); const b = ev.submitter || ev.target; if (b) b.disabled = true; try { await fn(ev); } catch (e) { toast(e.message); } finally { if (b) b.disabled = false; } };\n const short = (hex) => hex ? hex.slice(0, 8) + \"…\" + hex.slice(-4) : \"\";\n // key shows a key short but keeps the whole of it in the DOM: selecting, double-clicking or clicking it copies the full hex.\n const key = (hex) => hex ? '' + hex + '' : '';\n const hue = (hex) => (parseInt(hex.slice(0, 2), 16) * 360 / 256).toFixed(0) + \"deg\";\n const av = (hex) => '';\n const fmtBytes = (n) => n < 1e6 ? (n / 1e3).toFixed(0) + \" KB\" : n < 1e9 ? (n / 1e6).toFixed(1) + \" MB\" : (n / 1e9).toFixed(2) + \" GB\";\n const fmtHours = (ms) => ms < 3600e3 ? Math.round(ms / 60e3) + \" min\" : (ms / 3600e3).toFixed(ms < 36e6 ? 1 : 0) + \" h\";\n const fuelOver = () => !!fuel && (fuel.eventBytes > fuel.freeEventBytes || fuel.mediaBytes > fuel.freeMediaBytes || fuel.activeMs > fuel.freeActiveMs || fuel.rowsWritten > fuel.freeRowsWritten);\n const fmtTime = (t) => t ? new Date(t * 1000).toLocaleString(undefined, { month: \"short\", day: \"numeric\", hour: \"2-digit\", minute: \"2-digit\" }) : \"–\";\n const fmtDay = (t) => t ? new Date(t * 1000).toLocaleDateString(undefined, { month: \"short\", day: \"numeric\" }) : \"–\";\n const ago = (t) => { const s = Math.max(0, Math.floor(Date.now() / 1000) - t); return s < 60 ? \"just now\" : s < 3600 ? Math.floor(s / 60) + \" min\" : s < 86400 ? Math.floor(s / 3600) + \" h\" : Math.floor(s / 86400) + \" d\"; };\n const esc = (s) => String(s ?? \"\").replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c]);\n\n const IC = {\n x: '',\n undo: '',\n check: '',\n trash: '',\n ban: '',\n banuser: '',\n eye: '',\n lock: '',\n pen: '',\n people: '',\n person: '',\n gauge: '',\n bolt: '',\n copy: '',\n pin: '',\n };\n const ib = (icon, label, act, id, extra) => '\";\n\n const CH = \"qpzry9x8gf2tvdw0s3jn54khce6mua7l\";\n function npubToHex(s) {\n s = s.trim().toLowerCase();\n if (/^[0-9a-f]{64}$/.test(s)) return s;\n if (!s.startsWith(\"npub1\")) return null;\n const data = s.slice(5, -6).split(\"\").map((c) => CH.indexOf(c));\n if (data.some((d) => d < 0)) return null;\n let bits = 0, acc = 0, out = [];\n for (const d of data) { acc = (acc << 5) | d; bits += 5; if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 255); } }\n return out.length === 32 ? out.map((b) => b.toString(16).padStart(2, \"0\")).join(\"\") : null;\n }\n\n async function sha256hex(s) {\n const b = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(s));\n return [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, \"0\")).join(\"\");\n }\n // ---- signing: a NIP-07 extension, or a NIP-46 remote signer ----\n // The remote path loads the bundled library on first use; its session\n // (client key, signer pubkey, relays) lives in localStorage until sign-out.\n let remote = null, lib = null, pendingNote = null;\n const NO_SIGNER = \"Install a nostr extension (Alby, nos2x, …) or connect a remote signer.\";\n const withTimeout = (p, ms, what) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(what + \" did not answer; is the signer app open?\")), ms))]);\n const signer = {\n ready: () => !!remote || !!window.nostr,\n async getPublicKey() { if (remote) return withTimeout(remote.getPublicKey(), 20000, \"The remote signer\"); if (window.nostr) return window.nostr.getPublicKey(); throw new Error(NO_SIGNER); },\n async signEvent(ev) { if (remote) return withTimeout(remote.signEvent(ev), 60000, \"The remote signer\"); if (window.nostr) return window.nostr.signEvent(ev); throw new Error(NO_SIGNER); },\n };\n async function signerLib() {\n if (lib) return lib;\n if (!window.NostrSigner) await new Promise((res, rej) => { const s = document.createElement(\"script\"); s.src = window.SIGNER_URL || \"/signer.js\"; s.onload = res; s.onerror = () => rej(new Error(\"Could not load the signer library.\")); document.head.appendChild(s); });\n return (lib = window.NostrSigner);\n }\n const onauth = (u) => window.open(u, \"_blank\");\n // The signer conversation rides this relay, and a relay may ask the socket to\n // authenticate before it delivers kind 24133 to its parties; answer with the\n // session's own key.\n const authPool = (L, sk) => new L.SimplePool({ automaticallyAuth: () => (evt) => Promise.resolve(L.finalizeEvent(evt, sk)) });\n const saveSession = (sk, s, secret) => localStorage.setItem(\"nip46\", JSON.stringify({ sk: lib.bytesToHex(sk), pubkey: s.bp.pubkey, relays: s.bp.relays, secret: secret || null }));\n async function resumeRemote() {\n const raw = localStorage.getItem(\"nip46\");\n if (!raw) return false;\n try {\n const s = JSON.parse(raw); const L = await signerLib();\n remote = L.BunkerSigner.fromBunker(L.hexToBytes(s.sk), { pubkey: s.pubkey, relays: s.relays, secret: s.secret }, { onauth, pool: authPool(L, L.hexToBytes(s.sk)) });\n return true;\n } catch { localStorage.removeItem(\"nip46\"); return false; }\n }\n async function connectBunker(input) {\n const L = await signerLib();\n const bp = await L.parseBunkerInput(input.trim());\n if (!bp) throw new Error(\"That is not a bunker:// URL.\");\n if (!bp.relays.length) throw new Error(\"The bunker URL names no relay.\");\n const sk = L.generateSecretKey();\n const s = L.BunkerSigner.fromBunker(sk, bp, { onauth, pool: authPool(L, sk) });\n await withTimeout(s.connect({ name: host, url: location.origin }), 60000, \"The signer\");\n await Promise.race([s.switchRelays(), new Promise((r) => setTimeout(r, 3000))]);\n remote = s; saveSession(sk, s, bp.secret);\n }\n // The nostrconnect:// flow: this relay carries the traffic, so no third relay is involved.\n let ncPending = null;\n async function offerNostrConnect() {\n if (ncPending) return ncPending;\n const L = await signerLib();\n const sk = L.generateSecretKey();\n const secret = L.bytesToHex(crypto.getRandomValues(new Uint8Array(8)));\n const uri = L.createNostrConnectURI({ clientPubkey: L.getPublicKey(sk), relays: [wsURL], secret, name: host, url: location.origin, perms: [\"sign_event:27235\", \"sign_event:9734\"] });\n $(\"#nclink\").href = uri; $(\"#ncnote\").textContent = \"\";\n $(\"#ncqr\").src = \"/qr.svg?text=\" + encodeURIComponent(uri); $(\"#ncqr\").classList.remove(\"hidden\");\n ncPending = L.BunkerSigner.fromURI(sk, uri, { onauth, pool: authPool(L, sk) }, 600000).then((s) => { remote = s; saveSession(sk, s, secret); ncPending = null; return s; }, (e) => { ncPending = null; throw e; });\n return ncPending;\n }\n async function remoteDone() {\n me = await signer.getPublicKey();\n localStorage.setItem(\"me\", me);\n $(\"#remotesec\").classList.add(\"hidden\");\n toast(\"Remote signer connected\");\n if (pendingNote && !owner) { const n = pendingNote; pendingNote = null; await claimNow(n); return; }\n pendingNote = null;\n renderHeader(); await loadAdmin(); await loadPeople();\n }\n function showRemote(note) {\n pendingNote = note || null;\n $(\"#remotesec\").classList.remove(\"hidden\");\n $(\"#remotesec\").scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n offerNostrConnect().then(remoteDone).catch((e) => { $(\"#ncnote\").textContent = e.message; });\n }\n\n async function rpc(method, ...params) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const body = JSON.stringify({ method, params });\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", rpcURL], [\"method\", \"POST\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(rpcURL, { method: \"POST\", headers: { \"content-type\": \"application/nostr+json+rpc\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body });\n const json = await resp.json();\n if (json.error) throw new Error(json.error);\n return json.result;\n }\n\n // ---- front of house ----\n // urlish shows a URL as a short link: the host and the start of the path, the whole thing on hover and as the target.\n class Html { constructor(s) { this.s = s; } }\n const urlish = (u) => {\n if (!u) return \"\";\n let label = u; try { const x = new URL(u); label = x.host + (x.pathname.length > 1 ? x.pathname.slice(0, 18) + (x.pathname.length > 18 ? \"\\u2026\" : \"\") : \"\"); } catch { /* not a URL */ }\n if (!/^https?:\\/\\//.test(u)) return u;\n return new Html('' + esc(label) + \"\");\n };\n function renderHeader() {\n const name = (info && info.name) || host.split(\".\")[0];\n $(\"#title\").textContent = name;\n document.title = name + \" - relay\";\n $(\"#url\").textContent = wsURL;\n $(\"#d-ws\").textContent = wsURL; $(\"#d-blossom\").textContent = location.origin; $(\"#d-nip05\").textContent = \"you@\" + host;\n $(\"#nip05-example\").textContent = \"alice@\" + host;\n const desc = (info && info.description) || \"\";\n $(\"#desc\").textContent = desc; $(\"#desc\").classList.toggle(\"hidden\", !desc);\n // What the owner declared about the relay, on the page, not only in the document clients read.\n const img = (id, url) => { const el = $(id); if (url) el.src = url; el.classList.toggle(\"hidden\", !url); };\n img(\"#bannerimg\", info?.banner); $(\"#banner\").classList.toggle(\"hidden\", !info?.banner);\n img(\"#iconimg\", info?.icon);\n const meta = [];\n for (const t of info?.tags || []) meta.push('' + esc(t) + \"\");\n const where = [...(info?.language_tags || []), ...(info?.relay_countries || [])];\n if (where.length) meta.push(\"\" + esc(where.join(\", \")) + \"\");\n const link = (label, href) => href ? '' + label + \"\" : \"\";\n const contact = info?.contact ? (/^(mailto:|https?:\\/\\/)/.test(info.contact) ? link(\"Contact\", info.contact) : \"\" + esc(info.contact) + \"\") : \"\";\n for (const x of [contact, link(\"Terms\", info?.terms_of_service), link(\"Posting policy\", info?.posting_policy), link(\"Privacy\", info?.privacy_policy)]) if (x) meta.push(x);\n $(\"#metarow\").innerHTML = meta.join('|'); $(\"#metarow\").classList.toggle(\"hidden\", meta.length === 0);\n $(\"#frontsec\").classList.toggle(\"hidden\", !desc && meta.length === 0);\n owner = (info && info.pubkey) || \"\";\n const isOwner = !!me && me === owner;\n const lease = !owner && info && info.lease ? info.lease : null;\n $(\"#ownerline\").innerHTML = owner ? 'run by ' + key(owner) + \"\" : lease ? \"temporary until \" + fmtDay(lease.expires_at) : \"unclaimed\";\n $(\"#owner-av\").classList.toggle(\"hidden\", !owner);\n if (owner) $(\"#owner-av\").style.setProperty(\"--h\", hue(owner));\n $(\"#who\").textContent = me ? (isOwner ? \"(that's you)\" : \"\") : \"\";\n $(\"#unclaimed\").classList.toggle(\"hidden\", !!owner || !!lease);\n $(\"#leased\").classList.toggle(\"hidden\", !lease);\n if (lease) {\n $(\"#lease-until\").textContent = fmtTime(lease.expires_at);\n $(\"#leasenote\").textContent = lease.holder ? \"Reserved for the key that asked for it; sign with that key.\" : \"\";\n }\n $(\"#signin\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signin46\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signout\").classList.toggle(\"hidden\", !me);\n $(\"#about\").innerHTML = [\n [\"Name\", info?.name], [\"Description\", info?.description], [\"Contact\", info?.contact], [\"Owner\", owner ? short(owner) : \"\"], [\"Relay key\", info?.self ? short(info.self) : \"\"], [\"Software\", urlish(info?.software)], [\"Version\", info?.version],\n [\"Max query\", info?.limitation?.max_limit], [\"Auth required\", String(!!info?.limitation?.auth_required)], [\"Restricted writes\", String(!!info?.limitation?.restricted_writes)], [\"Min PoW\", info?.limitation?.min_pow_difficulty || 0],\n [\"Tags\", info?.tags?.join(\", \")], [\"Languages\", info?.language_tags?.join(\", \")], [\"Countries\", info?.relay_countries?.join(\", \")],\n [\"Terms\", urlish(info?.terms_of_service)], [\"Posting policy\", urlish(info?.posting_policy)], [\"Privacy policy\", urlish(info?.privacy_policy)], [\"Icon\", urlish(info?.icon)], [\"Banner\", urlish(info?.banner)],\n [\"NIPs\", info?.supported_nips?.join(\" \")],\n ].map(([k, v]) => \"
                                                                                                                  \" + k + \"\" + (v === undefined || v === \"\" ? '' : v instanceof Html ? v.s : esc(v)) + \"
                                                                                                                  \").join(\"\");\n renderCare();\n }\n\n function renderCare() {\n const g = (icon, label, off) => '' + IC[icon] + \"\" + label + \"\";\n const writes = policy ? policy.writes : (info?.limitation?.restricted_writes ? \"allowlist\" : \"open\");\n const reads = policy ? policy.reads : (info?.limitation?.auth_required ? \"auth\" : \"open\");\n let out = writes === \"owner\" ? g(\"person\", \"only owner writes\") : writes === \"allowlist\" ? g(\"people\", \"members write\") : writes === \"wot\" ? g(\"people\", \"members and follows write\") : g(\"pen\", \"anyone writes\");\n out += reads === \"members\" ? g(\"people\", \"members read\") : reads === \"auth\" ? g(\"lock\", \"sign in to read\") : g(\"eye\", \"anyone reads\");\n if (fuel) {\n const over = fuelOver();\n out += fuel.outOfFuel ? g(\"gauge\", \"out of fuel\", true) : over ? g(\"bolt\", \"burning sats\") : g(\"gauge\", \"on free allowance\");\n }\n $(\"#care\").innerHTML = out;\n }\n\n async function loadPeople() {\n try { people = await (await fetch(\"/people\")).json(); } catch { return; }\n const isOwner = !!me && me === owner;\n let list = people.people || [];\n if (!people.public && isOwner && policy) list = (window.__members || []);\n $(\"#peoplesec\").classList.toggle(\"hidden\", !owner || list.length === 0);\n $(\"#people-note\").classList.toggle(\"hidden\", people.public);\n $(\"#people-count\").textContent = list.length || \"\";\n $(\"#dir\").innerHTML = list.map((m) => '' + av(m.pubkey) + '' + (m.name ? esc(m.name) + \"@\" + host : key(m.pubkey)) + \"\" + (m.role === \"owner\" ? 'owner' : \"\") + \"\").join(\"\");\n }\n\n async function loadInfo() {\n info = await (await fetch(\"/\", { headers: { accept: \"application/nostr+json\" } })).json();\n renderHeader();\n const git = info.supported_grasps?.includes(\"GRASP-01\");\n $(\"#git-connect\").classList.toggle(\"hidden\", !git);\n const clone = \"git clone '\" + location.origin + \"//.git'\";\n $(\"#git-clone\").textContent = clone;\n $(\"#git-copy\").dataset.copytext = clone;\n renderApps();\n }\n\n async function loadFuel() {\n try { fuel = await (await fetch(\"/fuel\")).json(); } catch { return; }\n $(\"#fuelsec\").classList.toggle(\"hidden\", !owner);\n const pct = (used, free) => Math.min(100, Math.round((used / Math.max(free, 1)) * 100));\n const gauge = (id, used, free, text) => { const g = $(\"#g-\" + id), share = free ? used / free : 0; g.style.width = pct(used, free) + \"%\"; g.classList.toggle(\"over\", share > 1); g.classList.toggle(\"warm\", share > 0.75 && share <= 1); $(\"#p-\" + id).textContent = share > 1 ? \"over\" : Math.round(share * 100) + \"%\"; $(\"#t-\" + id).textContent = text; };\n gauge(\"events\", fuel.eventBytes, fuel.freeEventBytes, fmtBytes(fuel.eventBytes) + \" of \" + fmtBytes(fuel.freeEventBytes) + \" free\");\n gauge(\"media\", fuel.mediaBytes, fuel.freeMediaBytes, fmtBytes(fuel.mediaBytes) + \" of \" + fmtBytes(fuel.freeMediaBytes) + \" free\");\n gauge(\"active\", fuel.activeMs, fuel.freeActiveMs, fmtHours(fuel.activeMs) + \" of \" + fmtHours(fuel.freeActiveMs) + \" free\");\n gauge(\"rows\", fuel.rowsWritten, fuel.freeRowsWritten, fuel.rowsWritten.toLocaleString() + \" of \" + fuel.freeRowsWritten.toLocaleString() + \" free\");\n const sats = Math.floor(fuel.balanceMsats / 1000);\n const r = fuel.rates;\n $(\"#fuel-balance\").innerHTML = fuel.outOfFuel\n ? 'out of fuel Writes are paused until someone tops up.'\n : \"Balance \" + sats.toLocaleString() + \" sats. Past the allowances, prices track what the hosting costs: \" + r.satsPerGBMonthEvents.toLocaleString() + \" sats per GB-month of events, \" + r.satsPerGBMonthMedia.toLocaleString() + \" per GB-month of files, \" + r.satsPerActiveHour.toLocaleString() + \" per hour awake, \" + r.satsPerMillionRows.toLocaleString() + \" per million rows written. Traffic is free.\";\n $(\"#topup\").classList.toggle(\"hidden\", !fuel.enabled);\n // Who paid is the owner's to see: it comes with the signed stats call.\n let credits = [];\n if (owner && signer.ready()) { try { credits = (await rpc(\"stats\")).credits || []; } catch { credits = []; } }\n $(\"#credits tbody\").innerHTML = credits.length ? credits.map((c) => '' + esc(fmtTime(c.at)) + '' + av(c.payer) + key(c.payer) + '' + Math.floor(c.msats / 1000).toLocaleString() + \"\").join(\"\") : 'no zaps yet';\n if (!fuel.enabled) $(\"#topup-note\").textContent = \"Top-ups are not enabled on this service yet.\";\n renderCare();\n renderFuelTile();\n }\n\n async function topUp(sats) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const msats = Math.round(sats * 1000);\n const zapRequest = await signer.signEvent({ kind: 9734, created_at: Math.floor(Date.now() / 1000), content: \"fuel for \" + host, tags: [[\"p\", fuel.servicePubkey], [\"amount\", String(msats)], [\"relays\", wsURL]] });\n const r = await (await fetch(\"/fuel/invoice\", { method: \"POST\", body: JSON.stringify({ zapRequest }) })).json();\n if (r.error) throw new Error(r.error);\n const inv = r.invoice;\n $(\"#invoice\").classList.remove(\"hidden\");\n $(\"#inv-text\").value = inv; $(\"#inv-link\").href = \"lightning:\" + inv; $(\"#inv-state\").textContent = \"waiting for payment…\";\n if (window.webln) { try { await window.webln.enable(); await window.webln.sendPayment(inv); } catch (e) { toast(e.message || \"wallet declined\"); } }\n const before = fuel.creditedMsats;\n for (let i = 0; i < 120; i++) {\n await new Promise((r) => setTimeout(r, 2500));\n await loadFuel();\n if (fuel.creditedMsats > before) { $(\"#inv-state\").textContent = \"paid, thank you\"; toast(\"Fuel credited\"); return; }\n }\n $(\"#inv-state\").textContent = \"no receipt yet; it can take a minute after paying\";\n }\n\n // ---- console ----\n function showTab(name) {\n if (name === \"content\" || name === \"storage\") name = \"data\";\n const known = myRole === \"moderator\" ? [\"people\", \"moderation\"] : [\"people\", \"moderation\", \"rules\", \"identity\", \"data\", \"sync\", \"views\", \"health\", \"owner\"];\n if (!known.includes(name)) name = \"people\";\n $$(\".tabs a\").forEach((a) => a.classList.toggle(\"on\", a.dataset.tab === name));\n $$(\".panel\").forEach((p) => p.classList.toggle(\"on\", p.dataset.panel === name));\n }\n // Tabs switch in place. A fragment still opens a tab when someone arrives with one,\n // but clicking never writes one, and any fragment already there is dropped.\n $$(\".tabs a\").forEach((a) => a.addEventListener(\"click\", (ev) => { ev.preventDefault(); showTab(a.dataset.tab); if (location.hash) history.replaceState(null, \"\", location.pathname + location.search); }));\n window.addEventListener(\"hashchange\", () => showTab(location.hash.slice(1)));\n // The strip fades on whichever side has more tabs off screen.\n const tabsFade = () => { const t = $(\"#tabs\"); t.classList.toggle(\"fade-l\", t.scrollLeft > 4); t.classList.toggle(\"fade-r\", t.scrollLeft + t.clientWidth < t.scrollWidth - 4); };\n $(\"#tabs\").addEventListener(\"scroll\", tabsFade); window.addEventListener(\"resize\", tabsFade); new ResizeObserver(tabsFade).observe($(\"#tabs\"));\n\n function renderFuelTile() {\n if (!fuel) return;\n const over = fuelOver();\n const sats = Math.max(0, Math.floor(fuel.balanceMsats / 1000));\n const d = new Date(), end = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1), days = Math.max(1, Math.ceil((end - Date.now()) / 86400000));\n if (fuel.outOfFuel) { $(\"#h-fuel\").textContent = \"0 sats\"; $(\"#h-fuel-label\").textContent = \"out of fuel\"; $(\"#h-fuel-sub\").textContent = \"writes are paused\"; }\n else if (over) { $(\"#h-fuel\").textContent = sats.toLocaleString() + \" sats\"; $(\"#h-fuel-label\").textContent = \"fuel left\"; $(\"#h-fuel-sub\").textContent = \"past the free allowance\"; }\n else { $(\"#h-fuel\").textContent = days + (days === 1 ? \" day\" : \" days\"); $(\"#h-fuel-label\").textContent = \"free allowance left\"; $(\"#h-fuel-sub\").textContent = sats ? \"then \" + sats.toLocaleString() + \" sats\" : \"then a top-up is needed\"; }\n $(\"#usage\").innerHTML = [[\"events stored\", fmtBytes(fuel.eventBytes)], [\"files stored\", fmtBytes(fuel.mediaBytes)], [\"awake this month\", fmtHours(fuel.activeMs)], [\"rows written\", fuel.rowsWritten.toLocaleString()], [\"rows read\", fuel.rowsRead.toLocaleString()], [\"received this month\", fmtBytes(fuel.bytesIn)], [\"served this month\", fmtBytes(fuel.bytesOut)], [\"charged\", Math.floor(fuel.chargedMsats / 1000).toLocaleString() + \" sats\"]]\n .map(([k, v]) => \"
                                                                                                                  \" + k + \"\" + v + \"
                                                                                                                  \").join(\"\");\n }\n\n function renderHealth(stats) {\n $(\"#h-last\").textContent = stats.newest ? ago(stats.newest) : \"none\";\n $(\"#h-last-sub\").textContent = stats.newest ? fmtTime(stats.newest) : \"no events yet\";\n $(\"#h-conns\").textContent = stats.connections;\n const buckets = [[\"notes\", \"k1\", [1]], [\"reactions\", \"k2\", [7]], [\"DMs\", \"k3\", [4, 14, 1059]], [\"long-form\", \"k4\", [30023]], [\"other\", \"k5\", null]];\n const sums = buckets.map(() => 0); let total = 0;\n for (const { kind, n } of stats.kinds || []) { total += n; const i = buckets.findIndex((b) => b[2] && b[2].includes(kind)); sums[i < 0 ? 4 : i] += n; }\n const parts = buckets.map((b, i) => [b[0], b[1], sums[i]]).filter((p) => p[2] > 0);\n $(\"#h-kinds\").innerHTML = total ? parts.map(([name, cls, n]) => '').join(\"\") : '';\n $(\"#h-kinds-legend\").innerHTML = total ? parts.map(([name, cls, n]) => '' + name + \" \" + Math.round(n * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n renderFuelTile();\n }\n\n async function loadAdmin() {\n const isOwner = !!me && me === owner;\n myRole = isOwner ? \"owner\" : \"\";\n let stats = null, p = null;\n // A signed-in member may be a moderator; the relay says by answering stats.\n if (me && owner && !isOwner) { try { [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]); myRole = \"moderator\"; } catch { myRole = \"\"; } }\n $(\"#console\").classList.toggle(\"hidden\", !myRole);\n loadMine();\n $(\"#console\").classList.toggle(\"mod\", myRole === \"moderator\");\n if (!myRole) { policy = null; renderCare(); return; }\n if (isOwner) [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]);\n policy = p;\n renderHealth(stats);\n renderCare();\n const fa = $(\"#access\"), fi = $(\"#identity\");\n fa.writes.value = p.writes; fa.reads.value = p.reads; fa.openKinds.value = (p.openKinds || []).join(\", \"); fa.guestReplies.checked = !!p.guestReplies;\n $(\"#wordsform\").words.value = (p.blockedWords || []).join(\"\\n\"); $(\"#wordsform\").inTags.checked = !!p.blockedWordsInTags; $(\"#thresholdform\").reportThreshold.value = p.reportThreshold || 0; fa.minPow.value = p.minPow; fa.maxFuture.value = p.maxFuture; fa.maxLimit.value = p.maxLimit; fa.maxSubs.value = p.maxSubs; fa.maxMessageKB.value = p.maxMessageKB;\n fa.eventsPerMinute.value = p.eventsPerMinute; fa.reqsPerMinute.value = p.reqsPerMinute; fa.maxBlobMB.value = p.maxBlobMB;\n renderFeatures(p.features || {});\n $(\"#push-policy-form\").elements.origins.value = (p.pushCallbacks || []).join(\"\\n\");\n $(\"#push-policy-form\").elements.lettered.checked = !!p.letteredNips;\n $(\"#push-policy-form\").elements.delivery.checked = !!p.delivery?.enabled;\n $(\"#push-policy-form\").elements.deliveryMax.value = p.delivery?.maxTargets || 8;\n fi.name.value = p.name; fi.contact.value = p.contact; fi.description.value = p.description; fi.icon.value = p.icon;\n const fj = $(\"#joinform\"); fj.joinTerms.value = p.joinTerms; fj.directoryPublic.checked = !!p.directoryPublic;\n loadCard();\n fi.banner.value = p.banner || \"\"; fi.postingPolicy.value = p.postingPolicy || \"\"; fi.privacyPolicy.value = p.privacyPolicy || \"\";\n fi.tags.value = (p.tags || []).join(\", \"); fi.languageTags.value = (p.languageTags || []).join(\", \"); fi.relayCountries.value = (p.relayCountries || []).join(\", \");\n const fn = $(\"#notify\"), nt = p.notify || {};\n fn.reports.checked = !!nt.reports; fn.fuel.checked = !!nt.fuel; fn.jobs.checked = !!nt.jobs; fn.succession.checked = !!nt.succession; fn.digest.checked = !!nt.digest;\n showTab(location.hash.slice(1));\n if (isOwner) { await renderPresets(); renderWire(); renderSuccession(); loadDomains(); }\n await Promise.all([loadLists(), loadEvents(true), loadPins(), ...(isOwner ? [loadStorage()] : [])]);\n }\n\n async function loadAudit(before) {\n const rows = await rpc(\"listaudit\", before || 0);\n const html = rows.map((r) => '' + esc(fmtTime(r.at)) + '' + av(r.actor) + key(r.actor) + '' + esc(r.action) + '' + (/^[0-9a-f]{64}$/.test(r.target) ? key(r.target) : esc(r.target)) + '' + esc(r.detail) + '').join(\"\");\n const tb = $(\"#audit tbody\");\n if (before) tb.insertAdjacentHTML(\"beforeend\", html); else tb.innerHTML = html || 'Nothing yet';\n $(\"#audit-more\").style.display = rows.length < 100 ? \"none\" : \"\";\n }\n $(\"#audit-more\").onclick = guard(async () => { const last = $(\"#audit tbody tr:last-child\"); await loadAudit(last ? +last.dataset.seq : 0); });\n async function loadLists() {\n loadAudit(0);\n const [mem, bans, bannedEvents, allow, block, invites, reports, blobs, blocks, sites] = await Promise.all([rpc(\"listmembers\"), rpc(\"listbannedpubkeys\"), rpc(\"listbannedevents\"), rpc(\"listallowedkinds\"), rpc(\"listblockedkinds\"), rpc(\"listinvites\"), rpc(\"listreports\"), rpc(\"listblobs\", 100), rpc(\"listblockedips\"), rpc(\"listsites\")]);\n const members = mem.members;\n window.__members = members;\n const roleCell = (m) => m.role === \"owner\" ? ' owner' : myRole === \"owner\" ? ' ' : m.role === \"moderator\" ? ' moderator' : \"\";\n const untouchable = (m) => m.role === \"owner\" || (myRole !== \"owner\" && m.role === \"moderator\");\n // The tree: everyone under whoever invited them, the owner and the owner's own additions at the root.\n const known = new Set(members.map((m) => m.pubkey));\n const byInviter = new Map();\n for (const m of members) { const k = m.role !== \"owner\" && known.has(m.invited_by) ? m.invited_by : \"\"; if (!byInviter.has(k)) byInviter.set(k, []); byInviter.get(k).push(m); }\n const ordered = [], placed = new Set();\n const walk = (k, depth) => { for (const m of byInviter.get(k) || []) { if (placed.has(m.pubkey)) continue; placed.add(m.pubkey); m.depth = depth; ordered.push(m); walk(m.pubkey, depth + 1); } };\n walk(\"\", 0);\n for (const m of members) if (!placed.has(m.pubkey)) { m.depth = 0; ordered.push(m); }\n const nameOf = (pk) => { const x = members.find((m) => m.pubkey === pk); return x && x.name ? x.name : short(pk); };\n const limits = (m) => m.role === \"owner\" ? \"\" : myRole === \"owner\"\n ? ''\n : '' + (m.keep_days ? m.keep_days + \" d\" : \"\") + (m.max_bytes ? \" \" + fmtBytes(m.max_bytes) : \"\") + \"\";\n $(\"#members tbody\").innerHTML = ordered.map((m) => '' + av(m.pubkey) + key(m.pubkey) + roleCell(m) + (m.depth ? ' via ' + esc(nameOf(m.invited_by)) + \"\" : \"\") + (m.invites ? ' ' + m.invites + \" inv\" : \"\") + '' + limits(m) + '' + esc(fmtDay(m.joined_at)) + \", \" + esc(m.via) + '' + ib(\"check\", \"Save\", \"savemember\", m.pubkey) + (untouchable(m) ? \"\" : ib(\"x\", \"Remove\", \"removemember\", m.pubkey) + ib(\"banuser\", \"Ban\", \"banpubkey\", m.pubkey, \"danger\")) + \"\").join(\"\");\n const tf = $(\"#treeform\");\n tf.classList.toggle(\"hidden\", myRole !== \"owner\");\n if (policy && policy.memberInvites) { tf.depth.value = policy.memberInvites.depth; tf.quota.value = policy.memberInvites.quota; }\n $(\"#transfer [name=pubkey]\").innerHTML = members.filter((m) => m.role !== \"owner\").map((m) => '\").join(\"\");\n $(\"#succession [name=heir]\").innerHTML = $(\"#transfer [name=pubkey]\").innerHTML;\n $(\"#tc-people\").textContent = members.length;\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#invites\").innerHTML = invites.length ? invites.map((i) => '
                                                                                                                • inv…' + i.code.slice(-8) + \" \" + esc(i.note || \"\") + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
                                                                                                                • \").join(\"\") : '
                                                                                                                • no invites
                                                                                                                • ';\n const person = (r, icon, label, act) => \"
                                                                                                                • \" + av(r.pubkey) + '' + short(r.pubkey) + \" \" + esc(r.reason || \"\") + \"\" + ib(icon, label, act, r.pubkey) + \"
                                                                                                                • \";\n $(\"#bans\").innerHTML = bans.length ? bans.map((r) => person(r, \"undo\", \"Unban\", \"unrulepubkey\")).join(\"\") : '
                                                                                                                • nobody banned
                                                                                                                • ';\n $(\"#blocks\").innerHTML = blocks.length ? blocks.map((r) => '
                                                                                                                • ip' + esc(r.ip) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"undo\", \"Unblock\", \"unblockip\", r.ip) + \"
                                                                                                                • \").join(\"\") : '
                                                                                                                • no addresses blocked
                                                                                                                • ';\n $(\"#banned-events\").innerHTML = bannedEvents.map((r) => '
                                                                                                                • ev' + key(r.id) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"check\", \"Allow again\", \"allowevent\", r.id) + \"
                                                                                                                • \").join(\"\");\n $(\"#reports tbody\").innerHTML = reports.length ? reports.map((r) => '' + esc(fmtTime(r.at)) + '' + esc(r.type || \"report\") + '' + av(r.target_pubkey) + key(r.target_pubkey) + (r.target_event ? (r.blob ? ' file ' + key(r.target_event) + \"\" : ' ev ' + key(r.target_event) + (r.hidden ? ' hidden' : \"\") + \"\") : \"\") + '' + esc(r.content) + '' + ib(\"check\", \"Dismiss\", \"resolve:dismiss\", r.id) + ib(\"trash\", r.blob ? \"Delete the file\" : \"Delete the event\", \"resolve:delete\", r.id) + ib(\"banuser\", \"Ban the author\", \"resolve:ban\", r.id, \"danger\") + \"\").join(\"\") : 'nothing reported';\n $(\"#reports-count\").textContent = reports.length || \"\"; $(\"#tc-reports\").textContent = reports.length || \"\";\n $(\"#blobs tbody\").innerHTML = blobs.length ? blobs.map((b) => '' + esc(fmtTime(b.uploaded)) + '' + key(b.sha256) + ' ' + esc(b.type) + '' + fmtBytes(b.size) + '' + av(b.uploader) + key(b.uploader) + '' + ib(\"trash\", \"Delete file\", \"deleteblob\", b.sha256, \"danger\") + \"\").join(\"\") : 'no uploads';\n $(\"#blobs-count\").textContent = blobs.length || \"\";\n $(\"#sites tbody\").innerHTML = sites.length ? sites.map((s) => '' + av(s.author) + key(s.author) + '' + esc(s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + '' + esc(s.url) + '' + esc(s.paths) + (s.missing ? ' ' + esc(s.missing) + ' missing' : '') + '' + fmtBytes(s.size) + '' + esc(s.expires_at ? fmtTime(s.expires_at) : \"never\") + '' + ib(\"trash\", \"Delete site\", \"deleteevent\", s.id, \"danger\") + '').join(\"\") : 'no sites yet';\n $(\"#sites-count\").textContent = sites.length || \"\";\n const tag = (k, cls) => '' + k + ib(\"x\", \"Remove rule\", \"unrulekind\", String(k)) + \"\";\n $(\"#kinds-allow\").innerHTML = allow.length ? allow.map((k) => tag(k, \"ok\")).join(\"\") : 'all';\n $(\"#kinds-block\").innerHTML = block.length ? block.map((k) => tag(k, \"blk\")).join(\"\") : 'none';\n await loadPeople();\n }\n\n const KIND_NAMES = { 0: \"profiles\", 1: \"notes\", 3: \"contacts\", 4: \"DMs\", 5: \"deletions\", 6: \"reposts\", 7: \"reactions\", 16: \"reposts\", 1059: \"gift wraps\", 1063: \"file headers\", 1111: \"comments\", 1984: \"reports\", 9734: \"zap requests\", 9735: \"zap receipts\", 9802: \"highlights\", 10002: \"relay lists\", 13534: \"roster\", 9000: \"group adds\", 9001: \"group removals\", 9021: \"join requests\", 9022: \"leave requests\", 39000: \"group info\", 39001: \"group admins\", 39002: \"group members\", 39003: \"group roles\", 30023: \"articles\", 30024: \"drafts\", 30078: \"app data\", 30311: \"live events\", 30818: \"wiki\" };\n const kindName = (k) => KIND_NAMES[k] || (k >= 20000 && k < 30000 ? \"ephemeral\" : k >= 30000 && k < 40000 ? \"addressable\" : k >= 10000 && k < 20000 ? \"replaceable\" : \"kind \" + k);\n const SYS_KINDS = new Set([0, 3, 10002, 9735, 13534, 8000, 8001, 9000, 9001, 39000, 39001, 39002, 39003]);\n let storage = null;\n\n // Features (settings.ts): a select each; search has three modes, the rest on or off.\n const FEATURES = [\n [\"search\", \"Search\", \"NIP-50. Prose indexes notes, threads, comments, highlights, articles and wiki pages; full indexes every public kind with content. A change applies to events from then on.\", [\"prose:prose\", \"full:full\", \"off:off\"]],\n [\"sync\", \"Sync\", \"NIP-77 reconciliation, which reads the whole matching set per sync.\"],\n [\"count\", \"Counts\", \"NIP-45 COUNT, with HLL sketches.\"],\n [\"discovery\", \"Discovery record\", \"NIP-66: the record the relay signs about itself, for crawlers.\"],\n [\"names\", \"Names\", \"NIP-05 addresses under this relay's domain.\"],\n [\"files\", \"Files\", \"Blossom and NIP-96: uploads, downloads and listings.\"],\n [\"pages\", \"Pages and feed\", \"Notes and articles as pages, and the Atom feed.\"],\n [\"sites\", \"Static websites\", \"NIP-5A sites on their own hostnames. Mirroring copies missing files into this relay and costs fuel.\", [\"mirror:on, mirror files\", \"proxy:on, fetch as needed\", \"off:off\"]],\n [\"marmot\", \"Marmot transport\", \"Signed KeyPackages and encrypted group messages, with account admission for ephemeral authors.\"],\n [\"grasp\", \"Git repositories\", \"GRASP Git hosting with admitted repository state. The prototype backend has bounded storage and compute limits.\"],\n [\"push\", \"Relay push\", \"NIP-9a callback delivery for members and the owner. Requires approved callback origins; advertises lettered NIP identifiers.\"],\n [\"signer\", \"Signer traffic\", \"NIP-46 remote signing carried for anyone, never stored.\"],\n ];\n function renderFeatures(f) {\n $(\"#features\").innerHTML = FEATURES.map(([k, title, about, modes]) => {\n const cur = k === \"sites\" ? (f.sites?.enabled === false ? \"off\" : f.sites?.mirror === false ? \"proxy\" : \"mirror\") : modes ? String(f[k] || \"prose\") : String(f[k] !== false);\n const opts = (modes || [\"true:on\", \"false:off\"]).map((m) => { const [v, l] = m.split(\":\"); return '\"; }).join(\"\");\n return \"\";\n }).join(\"\");\n }\n $(\"#features\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-feature]\"); if (!sel) return;\n const k = sel.dataset.feature, v = k === \"search\" || k === \"sites\" ? sel.value : sel.value === \"true\";\n policy = await rpc(\"setpolicy\", { features: { [k]: k === \"sites\" ? { enabled: v !== \"off\", mirror: v === \"mirror\" } : v } });\n toast(k === \"search\" ? \"Search: \" + v : (v ? \"Switched on \" : \"Switched off \") + k); await loadInfo();\n }));\n\n $(\"#push-policy-form\").addEventListener(\"submit\", guard(async (ev) => {\n ev.preventDefault();\n const form = ev.target;\n const origins = form.elements.origins.value.split(/\\s+/).filter(Boolean);\n const updated = await rpc(\"setpolicy\", { pushCallbacks: origins, letteredNips: form.elements.lettered.checked, delivery: { enabled: form.elements.delivery.checked, maxTargets: Math.max(1, Math.min(16, Math.floor(+form.elements.deliveryMax.value || 8))) } });\n if (JSON.stringify(updated.pushCallbacks) !== JSON.stringify([...new Set(origins.map((s) => s.replace(/\\/$/, \"\")))])) throw new Error(\"Use up to sixteen exact HTTPS origins, with no path or credentials.\");\n policy = updated;\n toast(\"Saved delivery policy\"); await loadInfo();\n }));\n\n async function loadViews() {\n let views;\n try { views = await rpc(\"listviews\"); } catch { return; }\n $(\"#views\").innerHTML = views.map((v) => {\n const runs = v.trigger === \"off\" ? \"off\" : v.trigger === \"live\" ? \"live, from memory\" : v.trigger === \"write\" ? \"on write and daily\" : v.trigger;\n const label = (c) => (c === \"off\" ? \"off\" : c === \"write\" ? \"on write\" : c === \"live\" ? \"on\" : c);\n const choices = [...v.choices, v.default].filter((c, i, a) => a.indexOf(c) === i);\n const pick = '\";\n const who = v.audience === \"members\" ? \"members\" + (v.stored ? \"\" : \", on request\") : \"anyone\";\n const last = v.trigger === \"live\" ? \"\" : v.last ? \"last run \" + fmtTime(v.last.at) : \"not run yet\";\n const rows = v.trigger === \"live\" ? \"no rows\" : v.last ? v.last.rows.toLocaleString() + \" rows\" : \"\";\n const meta = [runs, who, last, rows].filter(Boolean).map((x) => \"\" + esc(x) + \"\").join('|');\n return '
                                                                                                                  ' + esc(v.name) + '
                                                                                                                  ' + esc(v.about) + '
                                                                                                                  ' + pick + (v.on ? 'Open' : \"\") + '
                                                                                                                  ' + meta + \"
                                                                                                                  \";\n }).join(\"\");\n }\n $(\"#views\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-view]\"); if (!sel) return;\n const value = sel.value === \"live\" ? true : sel.value;\n await rpc(\"setpolicy\", { views: { [sel.dataset.view]: value } });\n toast(sel.value === \"off\" ? \"Took down \" + sel.dataset.view : sel.dataset.view + \": \" + (sel.value === \"live\" ? \"on\" : sel.value === \"write\" ? \"on write\" : sel.value)); await loadViews(); await loadInfo();\n }));\n async function loadDumps() {\n const list = await rpc(\"listdumps\");\n const f = $(\"#dumpform\");\n if (policy) { f.dumps.value = policy.dumps || \"off\"; f.keep.value = policy.dumpsKeep || 7; }\n $(\"#dumps-count\").textContent = list.length || \"\";\n $(\"#dumps\").innerHTML = list.length ? list.map((d) => '
                                                                                                                • jsonl' + esc(d.name) + ' ' + d.events.toLocaleString() + \" events, \" + fmtBytes(d.bytes) + \"\" + ib(\"copy\", \"Download\", \"downloaddump\", d.name) + ib(\"trash\", \"Delete\", \"deletedump\", d.name, \"danger\") + \"
                                                                                                                • \").join(\"\") : '
                                                                                                                • no dumps yet
                                                                                                                • ';\n }\n // A dump is fetched with a signed request and handed to the browser as a file.\n async function downloadDump(name) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const url = location.origin + \"/dumps/\" + name;\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"GET\"]] });\n const resp = await fetch(url, { headers: { authorization: \"Nostr \" + btoa(JSON.stringify(ev)) } });\n if (!resp.ok) throw new Error((await resp.json()).error || \"download failed\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(\".\")[0] + \"-\" + name; a.click(); URL.revokeObjectURL(a.href);\n }\n // A plain member's own invites, when the owner lets members invite.\n async function loadMine() {\n const sec = $(\"#myinvites\");\n if (!me || !owner || myRole || !signer.ready()) { sec.classList.add(\"hidden\"); return; }\n let mine;\n try { mine = await rpc(\"listinvites\"); } catch { sec.classList.add(\"hidden\"); return; }\n sec.classList.remove(\"hidden\");\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#mine\").innerHTML = mine.length ? mine.map((i) => '
                                                                                                                • inv…' + i.code.slice(-8) + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
                                                                                                                • \").join(\"\") : '
                                                                                                                • no invites yet
                                                                                                                • ';\n }\n async function loadStorage() {\n storage = await rpc(\"storagestats\");\n pollJobs();\n loadViews();\n loadDumps().catch(() => {});\n const st = storage;\n const top = st.kinds.slice(0, 4), rest = st.kinds.slice(4).reduce((a, k) => a + k.bytes, 0);\n const parts = [...top.map((k, i) => [k.kind + \" \" + kindName(k.kind), \"k\" + (i + 1), k.bytes]), ...(rest ? [[\"other\", \"k5\", rest]] : [])];\n const total = st.eventBytes || 1;\n $(\"#s-bar\").innerHTML = parts.length ? parts.map(([n, c, b]) => '').join(\"\") : '';\n $(\"#s-legend\").innerHTML = parts.length ? parts.map(([n, c, b]) => '' + esc(n) + \" \" + Math.round(b * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n const overhead = Math.max(0, st.databaseBytes - st.eventBytes);\n $(\"#s-totals\").innerHTML = [[\"events\", st.events.toLocaleString() + \" \" + fmtBytes(st.eventBytes) + \"\"], [\"index and overhead\", fmtBytes(overhead)], [\"files\", st.blobs.toLocaleString() + \" \" + fmtBytes(st.mediaBytes) + \"\"]]\n .map(([k, v]) => \"
                                                                                                                  \" + k + \"\" + v + \"
                                                                                                                  \").join(\"\");\n const any = st.retention.find((r) => r.kind === null);\n const own = (kind) => { const r = st.retention.find((x) => x.kind === kind); return r ? r.days : \"\"; };\n const row = (k) => {\n const key = k.kind === null ? \"\" : String(k.kind);\n const placeholder = k.kind === null ? \"forever\" : k.replaceable || !any ? \"forever\" : any.days + \" (everything else)\";\n const pill = k.kind === null ? \"everything else\" : '' + k.kind + \" \" + esc(kindName(k.kind));\n const cells = k.protected\n ? 'required'\n : ' days' + ib(\"check\", \"Save keep-for rule\", \"saveretention\", key) + ib(\"trash\", \"Purge\", \"purgekind\", key, \"danger\") + \"\";\n return '' + pill + '' + (k.n === undefined ? \"\" : k.n.toLocaleString()) + '' + (k.bytes === undefined ? \"\" : fmtBytes(k.bytes)) + '' + (k.oldest ? esc(fmtDay(k.oldest)) : \"\") + \"\" + cells + \"\";\n };\n $(\"#kinds tbody\").innerHTML = [...st.kinds, { kind: null, days: any ? any.days : 0 }].map(row).join(\"\");\n loadListHistory().catch(() => {});\n }\n\n async function loadListHistory() {\n const rows = await rpc(\"listlisthistory\");\n const labels = { 3: \"follows\", 10002: \"relay list\", 10003: \"bookmarks\", 30003: \"bookmark list\" };\n $(\"#listhistory tbody\").innerHTML = rows.length ? rows.map((r) => '' + esc((labels[r.kind] || (\"kind \" + r.kind)) + (r.d ? \" / \" + r.d : \"\")) + '' + esc(fmtTime(r.created_at)) + '' + esc(fmtTime(r.saved_at)) + '' + ib(\"undo\", \"Restore this version\", \"restorelist\", r.event_id) + '').join(\"\") : 'no older list versions yet';\n }\n\n let searchQuery = \"\";\n async function loadEvents(reset) {\n const list = searchQuery ? await rpc(\"searchevents\", searchQuery, 200) : await rpc(\"listrecentevents\", reset ? 50 : 200);\n $(\"#events tbody\").innerHTML = list.length ? list.map((e) => '' + esc(fmtTime(e.created_at)) + '' + e.kind + '' + av(e.pubkey) + key(e.pubkey) + '' + esc(e.content) + '' + ib(\"pin\", \"Pin\", \"pinevent\", e.id) + ib(\"trash\", \"Delete event\", \"deleteevent\", e.id) + ib(\"ban\", \"Ban event\", \"banevent\", e.id, \"danger\") + ib(\"banuser\", \"Ban author\", \"banpubkey\", e.pubkey, \"danger\") + \"\").join(\"\") : 'no events yet';\n $(\"#more\").classList.toggle(\"hidden\", searchQuery || list.length < 50);\n }\n $(\"#searchform\").onsubmit = guard(async (ev) => {\n searchQuery = ev.target.q.value.trim();\n $(\"#searchclear\").classList.toggle(\"hidden\", !searchQuery);\n await loadEvents(true);\n });\n $(\"#searchclear\").onclick = guard(async () => {\n searchQuery = \"\"; $(\"#searchform\").reset(); $(\"#searchclear\").classList.add(\"hidden\");\n await loadEvents(true);\n });\n async function loadPins() {\n const pins = await rpc(\"listpins\");\n $(\"#pins-count\").textContent = pins.length || \"\";\n $(\"#pins\").innerHTML = pins.length ? pins.map((t) => '
                                                                                                                • ' + (t[0] === \"e\" ? \"event\" : \"address\") + '' + esc(t[1].length > 40 ? t[1].slice(0, 16) + \"\\u2026\" + t[1].slice(-8) : t[1]) + \"\" + ib(\"copy\", \"Copy\", \"copy\", t[1]) + ib(\"x\", \"Unpin\", \"unpinevent\", t[1]) + \"
                                                                                                                • \").join(\"\") : '
                                                                                                                • nothing pinned
                                                                                                                • ';\n }\n $(\"#pinform\").onsubmit = guard(async (ev) => {\n await rpc(\"pinevent\", ev.target.id.value.trim()); ev.target.reset(); toast(\"Pinned\"); await loadPins();\n });\n\n // ---- actions ----\n const refresh = () => Promise.all([loadLists(), loadEvents(true), loadStorage(), loadPins()]);\n\n $(\"#copy\").onclick = async () => { await navigator.clipboard.writeText(wsURL); toast(\"copied \" + wsURL); };\n $(\"#signin\").onclick = guard(async () => {\n if (!window.nostr) throw new Error(\"No nostr extension found. Install one (Alby, nos2x, …) and reload, or use a remote signer.\");\n me = await window.nostr.getPublicKey();\n localStorage.setItem(\"me\", me);\n renderHeader(); await loadAdmin(); await loadPeople();\n });\n $(\"#signin46\").onclick = () => showRemote(null);\n $$(\".remote\").forEach((b) => { b.onclick = () => showRemote($(\"#\" + b.dataset.note)); });\n $(\"#nccopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#nclink\").href); toast(\"Link copied; paste it into your signer app\"); };\n $(\"#bunkerform\").onsubmit = guard(async (ev) => {\n $(\"#remotenote\").textContent = \"Connecting…\";\n try { await connectBunker(ev.target.url.value); } catch (e) { $(\"#remotenote\").textContent = e.message; throw e; }\n $(\"#remotenote\").textContent = \"\"; ev.target.reset();\n await remoteDone();\n });\n $(\"#signout\").onclick = async () => {\n if (remote) { const r = remote; remote = null; try { await withTimeout(r.logout(), 5000, \"The signer\"); } catch { /* the session is gone either way */ } }\n localStorage.removeItem(\"nip46\");\n me = null; localStorage.removeItem(\"me\"); renderHeader(); await loadAdmin(); await loadPeople();\n };\n const claimNow = async (note) => {\n if (!signer.ready()) { note.textContent = \"Sign the claim with a nostr extension or a remote signer.\"; showRemote(note); return; }\n me = await signer.getPublicKey();\n const r = await rpc(\"claim\");\n if (!r.claimed) throw new Error(\"Somebody else claimed it first.\");\n localStorage.setItem(\"me\", me);\n toast(\"It's yours.\");\n await loadInfo(); await loadFuel(); await loadAdmin(); await loadPeople();\n if (r.converted && confirm(\"This relay began as a temporary one: anyone can write, and everything is deleted after 14 days. Switch to the default rules and keep everything from now on? Each rule can be changed later on the Rules and Storage tabs.\")) {\n await rpc(\"resetrules\"); toast(\"Rules reset\"); await loadInfo(); await loadAdmin();\n }\n };\n $(\"#claim\").onclick = guard(() => claimNow($(\"#claimnote\")));\n $(\"#claimlease\").onclick = guard(() => claimNow($(\"#leasenote\")));\n // Jobs run in the background; the table follows them while the tab is open.\n let jobsTimer = 0;\n const fmtJob = (j) => {\n const what = j.kind === \"mirror\" ? \"mirror site\" : j.kind === \"import\" ? \"import\" : j.label === \"backfill\" ? \"fetch my history\" : j.kind === \"pull\" ? \"pull\" : \"rebroadcast\";\n const f = [];\n if (j.filter.authors) f.push(j.filter.authors.length === 1 && j.filter.authors[0] === me ? \"my events\" : j.filter.authors.length + \" authors\");\n if (j.filter.kinds) f.push(\"kinds \" + j.filter.kinds.join(\", \"));\n if (j.filter.since) f.push(\"since \" + fmtDay(j.filter.since));\n const when = j.every ? \"every \" + (j.every === 24 ? \"day\" : j.every + \" h\") + (j.nextRun ? \", next \" + fmtTime(j.nextRun) : \"\") : \"once\";\n const l = j.last;\n const count = (stored, blobs, sent, refused) => (j.kind === \"mirror\" ? blobs.toLocaleString() + \" files mirrored\" : j.kind === \"import\" ? stored.toLocaleString() + \" events\" + ((j.last ? j.last.duplicates : j.duplicates) ? \", \" + (j.last ? j.last.duplicates : j.duplicates) + \" already here\" : \"\") : j.kind === \"pull\" ? stored.toLocaleString() + \" events\" + (blobs ? \", \" + blobs + \" files\" : \"\") : sent.toLocaleString() + \" sent\" + (refused ? \", \" + refused + \" refused\" : \"\"));\n const res = j.running ? \"running: \" + count(j.stored, j.blobs, j.sent, j.refused) + \"...\" : !l ? \"waiting\" : l.error ? \"failed: \" + l.error : count(l.stored, l.blobs, l.sent, l.refused) + (l.skipped ? \", \" + l.skipped + \" skipped\" : \"\") + \", \" + fmtTime(l.finishedAt);\n const sources = j.running ? j.pullSources : l?.sources;\n const details = sources?.length ? '
                                                                                                                  Source results' + sources.map((s) => '

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

                                                                                                                  ').join('') + '
                                                                                                                  ' : '';\n const targets = j.kind === \"push\" && j.targetStatus ? \"
                                                                                                                  \" + Object.entries(j.targetStatus).map(([u, s]) => esc(u) + \": \" + esc(s.status)).join(\"
                                                                                                                  \") + \"
                                                                                                                  \" : \"\";\n return \"\" + what + \"\" + j.relays.map(esc).join(\"
                                                                                                                  \") + targets + \"\" + (f.join(\", \") || \"everything\") + \"\" + when + \"\" + esc(res) + details + \"\" + (j.running ? \"\" : ib(\"undo\", \"Run now\", \"runjob\", j.id)) + ib(\"x\", \"Remove\", \"removejob\", j.id) + \"\";\n };\n async function pollJobs() {\n clearTimeout(jobsTimer);\n let jobs;\n try { jobs = await rpc(\"listjobs\"); } catch { return; }\n $(\"#jobs tbody\").innerHTML = jobs.length ? jobs.map(fmtJob).join(\"\") : 'no jobs yet';\n if (jobs.some((j) => j.running || (j.nextRun && j.nextRun <= Math.floor(Date.now() / 1000) + 1))) jobsTimer = setTimeout(pollJobs, 3000);\n }\n const urls = (s) => s.split(/[\\s,]+/).map((u) => u.trim()).filter(Boolean);\n const kindsOf = (s) => s.split(/[\\s,]+/).map((k) => parseInt(k, 10)).filter((k) => Number.isInteger(k) && k >= 0);\n $(\"#pullform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"addjob\", { kind: \"pull\", relays: [f.url.value.trim()], every: +f.every.value });\n toast(+f.every.value ? \"Mirror scheduled\" : \"Pull started\"); f.reset(); await pollJobs();\n });\n $(\"#backfillform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"backfill\", urls(f.relays.value));\n toast(\"Fetching your history\"); f.reset(); await pollJobs();\n });\n $(\"#pushform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const filter = {};\n const kinds = kindsOf(f.kinds.value); if (kinds.length) filter.kinds = kinds;\n const days = +f.days.value; if (days > 0) filter.since = Math.floor(Date.now() / 1000) - days * 86400;\n await rpc(\"addjob\", { kind: \"push\", relays: urls(f.targets.value), filter, every: +f.every.value });\n toast(\"Rebroadcast started\"); f.reset(); await pollJobs();\n });\n // ---- presets: writes, reads, kinds and keep-for rules in one click ----\n let presets = null;\n async function renderPresets() {\n if (!presets) { try { presets = await rpc(\"listpresets\"); } catch { presets = []; } }\n $(\"#presets\").innerHTML = presets.map((p) => '\").join(\"\");\n $(\"#presetsourcerow\").classList.toggle(\"hidden\", !presets.some((p) => p.source));\n }\n $(\"#presets\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-preset]\"); if (!b) return;\n const p = (presets || []).find((x) => x.name === b.dataset.preset); if (!p) return;\n if (!confirm(p.title + \": \" + p.about + \"\\n\\nThis replaces the writes and reads rules, the directory setting, the kind rules and the keep-for rules. Limits, identity, people and bans stay.\")) return;\n const source = $(\"#presetsource\").value.trim();\n if (p.source === \"required\" && !source) { $(\"#presetnote\").textContent = p.title + \" needs a source relay to mirror; enter its wss:// URL first.\"; return; }\n b.disabled = true;\n try { policy = await rpc(\"applypreset\", p.name, p.source && source ? { source } : undefined); $(\"#presetnote\").textContent = \"Now: \" + p.about + (policy.job ? \" Mirroring \" + source + \" every \" + policy.job.every + \" h.\" : \"\"); toast(p.title + \" applied\"); await loadInfo(); await loadAdmin(); }\n catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // ---- wire me in: this relay in the owner's own lists ----\n // The lists are replaceable, so a fresh list with only this relay would\n // clobber the real one once it spread. Every publish starts from the\n // newest copy found here or on the indexers, verified when the signer\n // library is around, and adds or removes this relay in it.\n const INDEXERS = [\"wss://purplepag.es\", \"wss://relay.nostr.band\", \"wss://relay.damus.io\", \"wss://nos.lol\"];\n const LISTS = [\n { kind: 10002, tag: \"r\", title: \"Relay list\", nip: \"NIP-65\", about: \"where clients read your notes and send you mentions\" },\n { kind: 10050, tag: \"relay\", title: \"DM inbox\", nip: \"NIP-17\", about: \"where people send you private messages\" },\n { kind: 10007, tag: \"relay\", title: \"Search relays\", nip: \"NIP-51\", about: \"where clients run your searches\" },\n { kind: 10063, tag: \"server\", title: \"Blossom servers\", nip: \"BUD-03\", about: \"where clients upload and look for your files\" },\n ];\n const mineFor = (l) => (l.tag === \"server\" ? location.origin : wsURL);\n const normURL = (u) => { try { const x = new URL(String(u).trim()); return (x.host + x.pathname).replace(/\\/+$/, \"\").toLowerCase(); } catch { return String(u).trim().toLowerCase(); } };\n const isMine = (l, t) => t[0] === l.tag && normURL(t[1] || \"\") === normURL(mineFor(l));\n const listHas = (list, l) => !!list && list.tags.some((t) => isMine(l, t));\n // mergeList is pure: the newest list with this relay put first or taken out, every other tag kept.\n function mergeList(list, l, include) {\n const tags = (list ? list.tags : []).filter((t) => !isMine(l, t));\n if (include) tags.unshift([l.tag, mineFor(l)]);\n return { kind: l.kind, created_at: Math.floor(Date.now() / 1000), content: list ? list.content : \"\", tags };\n }\n const relaysIn = (list) => list.tags.filter((t) => (t[0] === \"r\" || t[0] === \"relay\") && /^wss?:\\/\\//i.test(t[1] || \"\")).map((t) => t[1].trim());\n // overWS opens one socket, sends one message, feeds answers to onMessage until it says done or time runs out.\n function overWS(url, ms, first, onMessage) {\n return new Promise((res) => {\n let ws = null, done = false, out = null;\n const finish = () => { if (done) return; done = true; clearTimeout(timer); try { if (ws) ws.close(); } catch { /* closed */ } res(out); };\n const timer = setTimeout(finish, ms);\n try { ws = new WebSocket(url); } catch { return finish(); }\n ws.onopen = () => ws.send(JSON.stringify(first));\n ws.onmessage = (m) => { let d; try { d = JSON.parse(m.data); } catch { return; } if (!Array.isArray(d)) return; if (onMessage(d, (v) => { out = v; })) finish(); };\n ws.onerror = finish; ws.onclose = finish;\n });\n }\n function fetchNewest(url, filter, ms) {\n let best = null;\n return overWS(url, ms, [\"REQ\", \"w\", filter], (d, set) => {\n if (d[0] === \"EVENT\" && d[1] === \"w\" && d[2] && (!best || d[2].created_at > best.created_at)) { best = d[2]; set(best); }\n return (d[0] === \"EOSE\" || d[0] === \"CLOSED\") && d[1] === \"w\";\n });\n }\n function publishTo(url, event, ms) {\n return overWS(url, ms, [\"EVENT\", event], (d, set) => { if (d[0] === \"OK\" && d[1] === event.id) { set({ url, ok: !!d[2], msg: d[3] || \"\" }); return true; } return false; })\n .then((r) => r || { url, ok: false, msg: \"no answer\" });\n }\n // bridge signs a NIP-98 request to this relay's HTTP door, so the owner's own reads and writes pass any read rule.\n async function bridge(path, body) {\n const url = location.origin + path, raw = JSON.stringify(body);\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"POST\"], [\"payload\", await sha256hex(raw)]] });\n const resp = await fetch(url, { method: \"POST\", headers: { \"content-type\": \"application/json\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body: raw });\n const json = await resp.json();\n if (!resp.ok) throw new Error(json.error || \"HTTP \" + resp.status);\n return json;\n }\n const wire = {};\n function renderWire() {\n const pill = (state) => {\n const cls = state === \"listed\" ? \"on\" : state === \"failed\" ? \"bad\" : state === \"not listed\" ? \"off\" : \"\";\n const label = state === \"checking\" || state === \"publishing\" ? state + \"\\u2026\" : state;\n return '' + esc(label) + \"\";\n };\n $(\"#wire\").innerHTML = LISTS.map((l) => {\n const w = wire[l.kind] || { state: \"\", results: [], list: null };\n const busy = w.state === \"checking\" || w.state === \"publishing\";\n const btn = (act, cls, label) => '\";\n const buttons = w.state === \"listed\" ? btn(\"remove\", \"\", \"Remove this relay\") : btn(\"add\", \"pri\", \"Add this relay\") + btn(\"check\", \"\", w.state ? \"Check again\" : \"Check\");\n const n = w.list ? w.list.tags.filter((t) => t[0] === l.tag).length : 0;\n const from = w.list ? \"\" + (n === 1 ? \"1 entry\" : n + \" entries\") + \", \" + fmtTime(w.list.created_at) + \"\" : w.state && !busy ? \"no list found\" : \"\";\n const sent = (w.results || []).map((r) => '' + esc(r.url.replace(/^wss?:\\/\\//, \"\")) + (r.ok ? \"\" : \" failed\") + \"\").join(\"\");\n const meta = (w.state ? pill(w.state) : \"\") + from + sent;\n return '
                                                                                                                  ' + l.title + ' ' + l.nip + \"
                                                                                                                  \" + l.about + '
                                                                                                                  ' + buttons + '
                                                                                                                  ' + meta + \"
                                                                                                                  \";\n }).join(\"\");\n }\n async function wireCheck(l) {\n const w = (wire[l.kind] = { state: \"checking\", list: null, results: [] }); renderWire();\n const filter = { kinds: [l.kind], authors: [me], limit: 1 };\n const found = [];\n try { found.push(...(await bridge(\"/query\", [filter]))); } catch { /* the relay may hold none */ }\n const remote = await Promise.all(INDEXERS.map((u) => fetchNewest(u, filter, 4000)));\n let lib = null; try { lib = await signerLib(); } catch { /* unverified lists are still the owner's own, by pubkey */ }\n for (const e of remote) if (e && e.pubkey === me && e.kind === l.kind && Array.isArray(e.tags) && (!lib || lib.verifyEvent(e))) found.push(e);\n w.list = found.sort((a, b) => b.created_at - a.created_at)[0] || null;\n w.state = listHas(w.list, l) ? \"listed\" : \"not listed\"; renderWire();\n }\n async function wirePublish(l, include) {\n if (!wire[l.kind] || !wire[l.kind].state || wire[l.kind].state === \"failed\") await wireCheck(l);\n const w = wire[l.kind]; w.state = \"publishing\"; w.results = []; renderWire();\n try {\n const signed = await signer.signEvent(mergeList(w.list, l, include));\n const here = await bridge(\"/events\", signed).then((r) => ({ url: wsURL, ok: !!r.accepted, msg: r.message || \"\" })).catch((e) => ({ url: wsURL, ok: false, msg: e.message }));\n const targets = [...new Set([...relaysIn(signed), ...INDEXERS])].filter((u) => normURL(u) !== normURL(wsURL));\n const rest = await Promise.all(targets.map((u) => publishTo(u, signed, 6000)));\n w.results = [here, ...rest]; w.list = signed; w.state = include ? \"listed\" : \"not listed\";\n toast((include ? \"Added to your \" : \"Removed from your \") + l.title.toLowerCase());\n } catch (e) { w.state = \"failed\"; w.results = [{ url: wsURL, ok: false, msg: e.message }]; toast(e.message); }\n renderWire();\n }\n $(\"#wire\").addEventListener(\"click\", (ev) => {\n const b = ev.target.closest(\"button[data-wire]\"); if (!b) return;\n const l = LISTS.find((x) => String(x.kind) === b.dataset.wire); if (!l) return;\n if (b.dataset.do === \"check\") wireCheck(l); else wirePublish(l, b.dataset.do === \"add\");\n });\n\n // ---- custom domains: this relay under a hostname the owner controls ----\n let domainSites = [];\n const domainOptions = (current = \"\") => [{ label: \"\", title: \"This relay\" }, ...domainSites.map((s) => ({ label: s.label, title: (s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + \" / \" + key(s.author) })), ...(current && !domainSites.some((s) => s.label === current) ? [{ label: current, title: \"Site \" + current }] : [])].map((s) => '\").join(\"\");\n let domains = null; // null: not enabled on this host\n function renderDomains() {\n $(\"#domains\").innerHTML = (domains || []).map((d) => {\n const state = d.ready ? \"active\" : \"hostname \" + d.status.replace(/_/g, \" \") + \", certificate \" + d.sslStatus.replace(/_/g, \" \");\n const btn = (act, cls, label) => '\";\n const rows = d.ready ? \"\" : '
                                                                                                                  ' + d.records.map((r) => \"\").join(\"\") + \"
                                                                                                                  TypeNameValue
                                                                                                                  \" + esc(r.type) + '' + esc(r.name) + '' + esc(r.value) + \"\" + esc(r.note) + \"
                                                                                                                  \";\n return '
                                                                                                                  ' + esc(d.host) + '' + esc(state) + \"\" + btn(\"check\", \"\", \"Check\") + btn(\"remove\", \"danger\", \"Remove\") + '
                                                                                                                  \" + rows;\n }).join(\"\");\n }\n async function loadDomains() {\n try { [domains, domainSites] = await Promise.all([rpc(\"listdomains\"), rpc(\"listsites\")]); $(\"#adddomain select[name=site]\").innerHTML = domainOptions(); $(\"#domainnote\").textContent = domains.length ? \"\" : \"No custom domain yet.\"; $(\"#adddomain\").classList.remove(\"hidden\"); }\n catch (e) { domains = null; $(\"#domainnote\").textContent = /^unsupported/.test(e.message) ? \"Custom domains are not enabled on this host.\" : e.message; $(\"#adddomain\").classList.add(\"hidden\"); }\n renderDomains();\n }\n $(\"#adddomain\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"adddomain\", f.host.value.trim(), f.site.value); f.reset(); toast(\"Domain added; now create the CNAME\"); await loadDomains();\n });\n $(\"#domains\").addEventListener(\"change\", guard(async (ev) => {\n const select = ev.target.closest(\"select[data-domain-site]\"); if (!select) return;\n await rpc(\"setdomainsite\", select.dataset.domainSite, select.value);\n toast(\"Domain destination saved\"); await loadDomains();\n }));\n $(\"#domains\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-domain]\"); if (!b) return;\n const host = b.dataset.domain;\n if (b.dataset.do === \"remove\" && !confirm(\"Remove \" + host + \"? Its certificate goes with it and the name stops answering.\")) return;\n b.disabled = true;\n try {\n if (b.dataset.do === \"remove\") { await rpc(\"removedomain\", host); toast(\"Removed\"); }\n else { const d = await rpc(\"checkdomain\", host); toast(d.ready ? host + \" is live\" : \"Not yet: \" + (d.status === \"active\" ? \"certificate pending\" : \"waiting for the CNAME\")); }\n await loadDomains();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n $(\"#access\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const openKinds = f.openKinds.value.split(/[\\s,]+/).filter(Boolean).map(Number);\n if (openKinds.some((k) => !Number.isInteger(k) || k < 0 || k > 65535)) throw new Error(\"Open kinds must be whole numbers.\");\n policy = await rpc(\"setpolicy\", { writes: f.writes.value, reads: f.reads.value, openKinds, guestReplies: f.guestReplies.checked, minPow: +f.minPow.value, maxFuture: +f.maxFuture.value, maxLimit: +f.maxLimit.value, maxSubs: +f.maxSubs.value, maxMessageKB: +f.maxMessageKB.value, eventsPerMinute: +f.eventsPerMinute.value, reqsPerMinute: +f.reqsPerMinute.value, maxBlobMB: +f.maxBlobMB.value });\n toast(\"Rules saved\"); await loadInfo();\n });\n $(\"#notify\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { notify: { reports: f.reports.checked, fuel: f.fuel.checked, jobs: f.jobs.checked, succession: f.succession.checked, digest: f.digest.checked } });\n toast(\"Notifications saved\");\n });\n $(\"#notifytest\").onclick = guard(async () => {\n const r = await rpc(\"notifytest\");\n toast(r.sent ? \"Sent. Look for a message from the relay in your DMs.\" : \"Could not send.\");\n });\n $(\"#identity\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const list = (v) => v.split(\",\").map((s) => s.trim()).filter(Boolean);\n policy = await rpc(\"setpolicy\", { name: f.name.value, contact: f.contact.value, description: f.description.value, icon: f.icon.value,\n banner: f.banner.value, postingPolicy: f.postingPolicy.value, privacyPolicy: f.privacyPolicy.value, tags: list(f.tags.value), languageTags: list(f.languageTags.value), relayCountries: list(f.relayCountries.value) });\n toast(\"Identity saved\"); await loadInfo(); await loadPeople(); loadCard();\n });\n $(\"#addmember\").onsubmit = guard(async (ev) => {\n const f = ev.target; const pk = npubToHex(f.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"setmember\", pk, { name: f.name.value.trim() || null, note: f.note.value }); f.reset(); toast(\"Member added\"); await loadLists();\n });\n $(\"#addblock\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"blockip\", f.ip.value.trim(), f.reason.value); f.reset(); toast(\"Blocked\"); await loadLists();\n });\n $(\"#wordsform\").onsubmit = guard(async (ev) => {\n const words = ev.target.words.value.split(\"\\n\").map((s) => s.trim()).filter(Boolean);\n const kept = await rpc(\"setblockedwords\", words);\n policy = await rpc(\"setpolicy\", { blockedWordsInTags: ev.target.inTags.checked });\n ev.target.words.value = kept.join(\"\\n\"); toast(kept.length ? kept.length + \" words blocked\" : \"No words blocked\");\n });\n $(\"#thresholdform\").onsubmit = guard(async (ev) => {\n policy = await rpc(\"setpolicy\", { reportThreshold: +ev.target.reportThreshold.value });\n toast(policy.reportThreshold ? \"Hidden after \" + policy.reportThreshold + \" reports\" : \"Never hidden by reports\"); await loadLists();\n });\n $(\"#addban\").onsubmit = guard(async (ev) => {\n const pk = npubToHex(ev.target.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"banpubkey\", pk, ev.target.reason.value, ev.target.erase.checked); ev.target.reset(); toast(ev.target.erase.checked ? \"Banned and erased\" : \"Banned\"); await loadLists();\n });\n $(\"#mintinvite\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const inv = await rpc(\"createinvite\", +f.ttl.value, +f.max.value, f.note.value);\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n f.note.value = \"\"; toast(\"Invite created and copied\"); await loadLists();\n });\n $(\"#kindform\").onsubmit = guard(async (ev) => {\n const rule = ev.submitter.value; const k = +ev.target.kind.value;\n await rpc(rule === \"allow\" ? \"allowkind\" : \"disallowkind\", k); ev.target.reset(); toast((rule === \"allow\" ? \"Allowed kind \" : \"Blocked kind \") + k); await loadLists();\n });\n $(\"#more\").onclick = guard(() => loadEvents(false));\n $(\"#dumpform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { dumps: f.dumps.value, dumpsKeep: Math.max(1, Math.min(60, Math.floor(+f.keep.value || 7))) });\n toast(policy.dumps === \"off\" ? \"Dumps off\" : \"Dumping \" + policy.dumps); await loadDumps();\n });\n $(\"#importform\").onsubmit = guard(async (ev) => {\n const file = ev.target.file.files[0]; if (!file) return;\n if (!signer.ready()) throw new Error(NO_SIGNER);\n if (file.size > 64 * 1024 * 1024) throw new Error(\"At most 64 MB per import.\");\n const body = await file.text();\n const url = location.origin + \"/import?name=\" + encodeURIComponent(file.name);\n const token = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"PUT\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(url, { method: \"PUT\", headers: { authorization: \"Nostr \" + btoa(JSON.stringify(token)), \"content-type\": \"application/x-ndjson\" }, body });\n const r = await resp.json();\n if (!resp.ok) throw new Error(r.error || \"import failed\");\n ev.target.reset(); toast(\"Importing \" + fmtBytes(r.bytes)); await pollJobs();\n });\n $(\"#dumpnow\").onclick = guard(async () => { const d = await rpc(\"dumpnow\"); toast(\"Dumped \" + d.events.toLocaleString() + \" events\"); await loadStorage(); });\n $(\"#treeform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { memberInvites: { depth: Math.max(0, Math.floor(+f.depth.value || 0)), quota: Math.max(0, Math.floor(+f.quota.value || 0)) } });\n toast(policy.memberInvites.depth ? \"Members may invite\" : \"Only you and moderators invite\");\n });\n $(\"#mintmine\").onclick = guard(async () => {\n const inv = await rpc(\"createinvite\", 259200, 1, \"\");\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n toast(\"Invite created and copied\"); await loadMine();\n });\n $(\"#exportcfg\").onclick = guard(async () => {\n const cfg = await rpc(\"exportconfig\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(new Blob([JSON.stringify(cfg, null, 2)], { type: \"application/json\" }));\n a.download = host.split(\".\")[0] + \".bind.ws.config.json\";\n a.click(); URL.revokeObjectURL(a.href);\n });\n $(\"#importcfg\").onclick = () => $(\"#cfgfile\").click();\n $(\"#cfgfile\").onchange = guard(async (ev) => {\n const file = ev.target.files[0]; if (!file) return;\n const cfg = JSON.parse(await file.text());\n const check = await rpc(\"importconfig\", cfg, { dryRun: true });\n const lines = check.changes.summary.length ? check.changes.summary.join(\"\\n\") : \"Nothing would change.\";\n const dropped = check.warnings.length ? \"\\n\\nNot taken:\\n\" + check.warnings.join(\"\\n\") : \"\";\n if (!confirm(\"Apply \" + file.name + \" to this relay?\\n\\n\" + lines + dropped)) { ev.target.value = \"\"; return; }\n await rpc(\"importconfig\", cfg); ev.target.value = \"\"; toast(\"Configuration imported\"); await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // Succession: the status line under the heir form, from successionstatus.\n async function renderSuccession() {\n const el = $(\"#successionnote\"), f = $(\"#succession\");\n let st;\n try { st = await rpc(\"successionstatus\"); } catch { el.textContent = \"\"; return; }\n const sc = st.succession;\n $(\"#clearsuccession\").classList.toggle(\"hidden\", !sc);\n f.querySelector(\"button.btn:not(#clearsuccession)\").textContent = sc ? \"Change heir\" : \"Set heir\";\n if (!sc) { el.textContent = \"No heir named. Last signed in \" + fmtTime(st.ownerSeenAt) + \".\"; return; }\n if (f.heir.querySelector('[value=\"' + sc.heir + '\"]')) f.heir.value = sc.heir;\n f.afterDays.value = String(sc.afterDays);\n const who = (window.__members || []).find((m) => m.pubkey === sc.heir);\n const heir = who && who.name ? who.name + \"@\" + host : short(sc.heir);\n el.textContent = \"Heir: \" + heir + \". Last signed in \" + fmtTime(st.ownerSeenAt) + (st.silentDays ? \" (\" + st.silentDays + \" days ago)\" : \"\") + \". \" +\n (st.warning ? \"The warning month is running: the relay goes to \" + heir + \" on \" + fmtDay(st.handoverAt) + \" unless you sign in.\" : \"If you stay away, it goes to \" + heir + \" around \" + fmtDay(st.handoverAt) + \".\") +\n (st.log && st.log.length ? \" Handed over before: \" + st.log.map((l) => fmtDay(l.at) + \" to \" + short(l.to)).join(\", \") + \".\" : \"\");\n }\n $(\"#succession\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"setsuccession\", { heir: f.heir.value, afterDays: +f.afterDays.value });\n toast(\"Heir set\"); await loadAdmin();\n });\n $(\"#clearsuccession\").onclick = guard(async () => {\n await rpc(\"clearsuccession\");\n toast(\"Heir cleared\"); await loadAdmin();\n });\n $(\"#transfer\").onsubmit = guard(async (ev) => {\n const pk = ev.target.pubkey.value; if (!pk) return;\n const name = host.split(\".\")[0];\n const typed = prompt(\"This hands \" + host + \" to \" + short(pk) + \" for good. You stay on as a moderator. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing changed.\"); return; }\n await rpc(\"transferowner\", pk);\n toast(\"Transferred\");\n await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // ---- fork: a new name with this relay's events, claim reserved for a key ----\n $(\"#forkform\").scope.onchange = (ev) => $(\"#forkkinds\").classList.toggle(\"hidden\", ev.target.value !== \"kinds\");\n $(\"#joinform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { joinTerms: f.joinTerms.value, directoryPublic: f.directoryPublic.checked });\n toast(\"Saved\"); await loadInfo(); await loadPeople();\n });\n $(\"#forkform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const opts = { people: f.people.checked };\n if (f.name.value.trim()) opts.name = f.name.value.trim().toLowerCase();\n if (f.holder.value.trim()) { const pk = npubToHex(f.holder.value); if (!pk) throw new Error(\"That is not a pubkey.\"); opts.holder = pk; }\n if (f.scope.value === \"mine\") opts.filter = { authors: [me] };\n if (f.scope.value === \"kinds\") { const kinds = f.kinds.value.split(/[\\s,]+/).filter(Boolean).map(Number); if (!kinds.length || kinds.some((k) => !Number.isInteger(k) || k < 0)) throw new Error(\"Give kinds as numbers.\"); opts.filter = { kinds }; }\n if (!confirm(\"Fork this relay into a new name\" + (opts.name ? \" (\" + opts.name + \")\" : \"\") + \"? It pulls \" + (f.scope.value === \"all\" ? \"everything\" : f.scope.value === \"mine\" ? \"your events\" : \"the chosen kinds\") + (opts.people ? \" and the people\" : \"\") + \", and only \" + (opts.holder ? \"that key\" : \"you\") + \" can claim it.\")) return;\n const r = await rpc(\"forkrelay\", opts);\n $(\"#forknote\").textContent = r.handover + \" Expires \" + fmtTime(r.expires_at) + \".\";\n $(\"#forkurl\").textContent = r.console;\n $(\"#forkresult\").classList.remove(\"hidden\");\n toast(\"Forked to \" + r.name);\n });\n $(\"#forkcopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#forkurl\").textContent); toast(\"copied\"); };\n $(\"#deleterelay\").onclick = guard(async () => {\n const name = host.split(\".\")[0];\n const typed = prompt(\"This deletes everything on \" + host + \" and gives the name up. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing was deleted.\"); return; }\n await rpc(\"deleterelay\", name);\n localStorage.removeItem(\"me\");\n location.href = \"/\";\n });\n $(\"#topup\").onsubmit = guard((ev) => topUp(+ev.target.sats.value));\n $(\"#inv-copy\").onclick = async () => { await navigator.clipboard.writeText($(\"#inv-text\").value); toast(\"invoice copied\"); };\n $$(\"button[data-copy]\").forEach((b) => { b.onclick = async () => { await navigator.clipboard.writeText(b.dataset.copy === \"ws\" ? wsURL : location.origin); toast(\"copied\"); }; });\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-act]\"); if (!b) return;\n const act = b.dataset.act, id = b.dataset.id;\n if (act === \"copy\") { await navigator.clipboard.writeText(id); toast(\"copied\"); return; }\n if ((act === \"banpubkey\" || act === \"banevent\" || act === \"resolve:ban\") && !confirm(act === \"banevent\" ? \"Delete this event and refuse it forever?\" : \"Ban this author and refuse everything they post?\")) return;\n const erase = (act === \"banpubkey\" || act === \"resolve:ban\") && confirm(\"Also erase everything they wrote and uploaded here?\");\n if (act === \"deleteblob\" && !confirm(\"Delete this file for good?\")) return;\n if (act === \"deletedump\" && !confirm(\"Delete this dump?\")) return;\n if (act === \"downloaddump\") { b.disabled = true; try { await downloadDump(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }\n if (act === \"restorelist\") {\n if (!signer.ready()) { toast(NO_SIGNER); return; }\n try {\n const preview = await rpc(\"restorelist\", id);\n const d = preview.diff || {};\n const added = (d.addedTags || []).map((t) => \"+ \" + JSON.stringify(t)).join(\"\\n\");\n const removed = (d.removedTags || []).map((t) => \"- \" + JSON.stringify(t)).join(\"\\n\");\n const changes = [added, removed, d.contentChanged ? \"content changed\" : \"content unchanged\"].filter(Boolean).join(\"\\n\");\n if (!confirm(\"Restore this list version?\\n\\n\" + (changes || \"No tag or content changes\") + \"\\n\\nIt will be signed and published as the newest version.\")) return;\n const signed = await signer.signEvent(preview.draft);\n const result = await bridge(\"/events\", signed);\n if (!result.accepted) throw new Error(result.message || \"The relay refused the restored list.\");\n toast(\"List restored\"); await loadListHistory(); await loadStorage();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if ((act === \"removemember\" || act === \"banpubkey\") && (window.__members || []).some((m) => m.invited_by === id) && confirm(\"Also remove everyone this member invited, and everyone they invited in turn?\")) {\n b.disabled = true;\n try { const r = await rpc(\"removesubtree\", id); if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase); toast(\"Removed \" + r.removed.length); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"removejob\" && !confirm(\"Remove this job?\")) return;\n if (act === \"purgekind\") {\n const kind = id === \"\" ? null : +id;\n const label = kind === null ? \"everything without its own rule\" : kindName(kind) + \" (kind \" + kind + \")\";\n const typed = prompt(\"Purge \" + label + \" older than how many days? 0 purges all of them. This cannot be undone.\", \"30\");\n if (typed === null) return;\n const days = Math.max(0, Math.floor(+typed || 0));\n b.disabled = true;\n try { const r = await rpc(\"purgekind\", kind, days); toast(\"Purged \" + r.deleted.toLocaleString() + \" events\"); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"saveretention\") {\n const kind = id === \"\" ? null : +id;\n const days = Math.max(0, Math.floor(+b.closest(\"tr\").querySelector(\".days\").value || 0));\n b.disabled = true;\n try { await rpc(\"setretention\", kind, days); toast(days ? \"Kept for \" + days + \" days\" : \"Kept forever\"); await loadStorage(); await loadInfo(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n b.disabled = true;\n try {\n if (act === \"savemember\") { const tr = b.closest(\"tr\"), role = tr.querySelector(\"select.role\"), keep = tr.querySelector(\".keep\"), cap = tr.querySelector(\".cap\"); await rpc(\"setmember\", id, { name: tr.querySelector(\".name\").value.trim() || null, note: tr.querySelector(\".note\").value, ...(role ? { role: role.value } : {}), ...(keep ? { keepDays: Math.max(0, Math.floor(+keep.value || 0)), maxBytes: Math.max(0, Math.floor(+cap.value || 0)) * 1024 } : {}) }); }\n else if (act.startsWith(\"resolve:\")) await rpc(\"resolvereport\", id, act.slice(8), erase);\n else if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase);\n else await rpc(act, act === \"unrulekind\" ? +id : id, \"\");\n toast(\"Done\"); await (myRole ? refresh() : loadMine());\n if (act === \"pinevent\" || act === \"unpinevent\") await loadPins();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // The share block: the card picture, the group naddr and its QR.\n let card = null;\n async function loadCard() {\n try {\n card = await (await fetch(\"/card.json\", { cache: \"no-store\" })).json();\n $(\"#cardimg\").src = \"/card.svg?t=\" + Date.now();\n $(\"#naddr\").textContent = card.naddr || \"\";\n const q = $(\"#naddrqr\");\n if (card.naddr) { q.src = \"/qr.svg?text=\" + encodeURIComponent(card.naddr); q.classList.remove(\"hidden\"); } else q.classList.add(\"hidden\");\n } catch { /* the card is decoration */ }\n renderApps();\n }\n // The app rows on the Connect section: one per client people actually use,\n // with the link that lands on this relay in it. Relay apps take the relay\n // or the group address; feed apps take the owner's profile with this relay\n // as the hint, since they have no notion of opening a relay.\n function renderApps() {\n const el = $(\"#apps\"); if (!el) return;\n const enc = encodeURIComponent;\n const nprofile = card && card.nprofile ? card.nprofile : \"\";\n const naddr = card && card.naddr ? card.naddr : \"\";\n const whose = me && owner && me === owner ? \"your\" : \"the owner's\";\n const link = (label, href) => '' + label + \"\";\n const app = (label, uri) => '' + label + \"\";\n const copy = (label, text) => '\";\n const row = (name, where, note, acts) => '
                                                                                                                  ' + name + \"\" + where + \"

                                                                                                                  \" + note + '

                                                                                                                  ' + acts.filter(Boolean).join(\"\") + \"
                                                                                                                  \";\n const profileNote = \"Opens \" + whose + \" profile with this relay attached.\";\n const groups = [\n [\"As a place\", \"These open the relay itself: its feed, its people, its group.\", [\n row(\"Jumble\", \"web\", \"A feed of everything on this relay.\", [link(\"Open\", \"https://jumble.social/?r=\" + enc(wsURL))]),\n row(\"Coracle\", \"web\", \"The relay's page: its feed and its people.\", [link(\"Open\", \"https://coracle.social/relays/\" + enc(host))]),\n row(\"Flotilla\", \"web, phone\", \"The relay as a space, with the group as a room.\", [link(\"Open\", \"https://app.flotilla.social/spaces/\" + enc(host)), naddr && app(\"Open group\", \"nostr:\" + naddr)]),\n row(\"0xchat\", \"phone\", \"The group, in a chat app.\", [naddr && app(\"Open group\", \"nostr:\" + naddr), naddr && copy(\"Copy naddr\", naddr)]),\n row(\"noStrudel\", \"web\", \"Relays, add this one, then open its page.\", [copy(\"Copy relay URL\", wsURL)]),\n ]],\n [\"Find me here\", \"Feed apps have no relay pages. They meet this relay through a profile link that names it, then keep it once it is in the relay settings.\", [\n row(\"Primal\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://primal.net/p/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"YakiHonne\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://yakihonne.com/profile/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"Damus\", \"iPhone\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Amethyst\", \"Android\", profileNote + \" Then Relays in the drawer.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Nostur\", \"iPhone, Mac\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n ]],\n ];\n if (info?.supported_grasps?.includes(\"GRASP-01\")) groups.push([\n \"Git repositories\", \"Use this relay with a Git client.\", [\n row(\"GitWorkshop\", \"web\", \"Browse this relay's Git repositories.\", [link(\"Open in app\", \"https://gitworkshop.dev/relay/\" + enc((wsURL.startsWith(\"ws://\") ? \"ws:\" : \"\") + host)), copy(\"Copy relay URL\", wsURL)]),\n ],\n ]);\n el.innerHTML = groups.map(([h, note, rows]) => '

                                                                                                                  ' + h + '

                                                                                                                  ' + note + '

                                                                                                                  ' + rows.join(\"\") + \"
                                                                                                                  \").join(\"\");\n $(\"#apps-ws\").textContent = wsURL;\n const tile = (label, text) => '
                                                                                                                  ' + label + '\"QR
                                                                                                                  ';\n $(\"#phones\").innerHTML = [nprofile && tile(\"Find me here, for a phone\", \"nostr:\" + nprofile), naddr && tile(\"The group, for a phone\", \"nostr:\" + naddr)].filter(Boolean).join(\"\");\n }\n // The folds are a group: opening one closes the rest. Browsers with the\n // details name attribute do this themselves; this covers the others.\n $$(\".folds details\").forEach((d) => d.addEventListener(\"toggle\", () => { if (d.open) $$(\".folds details\").forEach((o) => { if (o !== d && o.open) o.open = false; }); }));\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-copytext]\"); if (!b) return;\n try { await navigator.clipboard.writeText(b.dataset.copytext); toast(\"copied\"); } catch { /* no clipboard */ }\n });\n $(\"#copynaddr\").onclick = async () => { if (!card || !card.naddr) return; await navigator.clipboard.writeText(card.naddr); toast(\"copied naddr\"); };\n $(\"#copyembed\").onclick = async () => { await navigator.clipboard.writeText('\"''); toast(\"copied embed\"); };\n\n // A click on a key copies it; a double-click selects the whole key so the usual copy shortcut takes it too.\n document.addEventListener(\"dblclick\", (ev) => {\n const k = ev.target.closest(\".key\"); if (!k) return;\n const s = window.getSelection(), r = document.createRange(); r.selectNodeContents(k.querySelector(\".full\")); s.removeAllRanges(); s.addRange(r);\n });\n document.addEventListener(\"click\", async (ev) => {\n const k = ev.target.closest(\".key\"); if (!k || ev.detail > 1 || String(window.getSelection())) return;\n try { await navigator.clipboard.writeText(k.title); toast(\"copied \" + k.dataset.short); } catch { /* no clipboard */ }\n });\n // ---- boot ----\n try { await loadInfo(); } catch { renderHeader(); }\n await loadFuel();\n await loadCard();\n me = localStorage.getItem(\"me\");\n if (me && await resumeRemote()) { /* a remote session answers for itself when first used */ }\n else if (me && window.nostr) { try { const pk = await window.nostr.getPublicKey(); if (pk !== me) me = null; } catch { me = null; } }\n else if (me) me = null;\n renderHeader();\n renderApps();\n await loadAdmin();\n await loadPeople();\n})();\n"; diff --git a/src/jobs.ts b/src/jobs.ts index 6cd9c1f..112cf17 100644 --- a/src/jobs.ts +++ b/src/jobs.ts @@ -44,6 +44,10 @@ export interface Job { failures: number; relayIndex: number; // pull: which source is being synced cursor: number; // push: last sequence number forwarded, kept across runs + // Newer push jobs keep independent cursors. `cursor` remains for old job + // readers and is the greatest cursor reached by any target. + targetCursors?: Record; + targetStatus?: Record; stored: number; skipped: number; blobs: number; @@ -104,7 +108,7 @@ export function checkJob(raw: unknown, relay: Relay): JobSpec | string { const every = r.every === undefined ? 0 : Number(r.every); if (!(EVERY as readonly number[]).includes(every)) return "invalid: every must be 0, 1, 6 or 24 hours"; if (kind === "push" && relay.settings.policy.reads === "members" && filter.kinds?.some(isPrivate)) return "restricted: a members-only relay does not rebroadcast private kinds"; - return { kind, label, relays, filter, every, running: false, startedAt: 0, rounds: 0, failures: 0, relayIndex: 0, cursor: 0, stored: 0, skipped: 0, blobs: 0, sent: 0, refused: 0, last: null }; + return { kind, label, relays, filter, every, running: false, startedAt: 0, rounds: 0, failures: 0, relayIndex: 0, cursor: 0, targetCursors: {}, targetStatus: {}, stored: 0, skipped: 0, blobs: 0, sent: 0, refused: 0, last: null }; } // relaysFromList reads the owner's kind 10002 stored on this relay and @@ -172,34 +176,38 @@ async function runPushRound(relay: Relay, job: Job): Promise<{ more: boolean; er if (job.filter.authors) f.authors = job.filter.authors; if (job.filter.kinds) f.kinds = job.filter.kinds; if (job.filter.since) f.since = job.filter.since; - const rows = relay.store.after(job.cursor, f, PUSH_BATCH, now()); - if (rows.length === 0) return { more: false, error: "" }; + job.targetCursors ??= {}; + job.targetStatus ??= {}; const membersOnly = relay.settings.policy.reads === "members"; - const events: Event[] = []; - for (const r of rows) { - const e = JSON.parse(r.raw) as Event; - if (hasTag(e, "-") || (membersOnly && isPrivate(e.kind))) { - job.skipped++; - continue; - } - events.push(e); - } - let failed = ""; - let reached = 0; + let more = false, failed = "", reached = 0; + const legacyCursor = job.cursor; for (const url of job.relays) { + const cursor = job.targetCursors[url] ?? legacyCursor ?? 0; + const rows = relay.store.after(cursor, f, PUSH_BATCH, now()); + if (rows.length === 0) { job.targetStatus[url] = { status: "accepted", error: "", at: now() }; continue; } + more = more || rows.length === PUSH_BATCH; + const events: Event[] = []; + for (const row of rows) { + const e = JSON.parse(row.raw) as Event; + if (hasTag(e, "-") || (membersOnly && isPrivate(e.kind))) { job.skipped++; continue; } + events.push(e); + } try { await pushTo(relay, url, events, job); reached++; + job.targetCursors[url] = rows[rows.length - 1].seq; + job.targetStatus[url] = { status: "accepted", error: "", at: now() }; + job.cursor = Math.max(job.cursor, rows[rows.length - 1].seq); } catch (err) { failed = url + ": " + (err instanceof Error ? err.message : String(err)); + job.targetStatus[url] = { status: "pending", error: failed, at: now() }; } } - if (reached === 0 && events.length) return { more: false, error: failed }; - job.cursor = rows[rows.length - 1].seq; - return { more: rows.length === PUSH_BATCH, error: "" }; + if (reached === 0 && failed) return { more: true, error: failed }; + return { more, error: "" }; } -async function pushTo(relay: Relay, url: string, events: Event[], job: Job) { +async function pushTo(relay: Relay, url: string, events: Event[], job: Job): Promise { if (events.length === 0) return; const sock = new Socket(await dial(relay, url)); try { diff --git a/test/object/delivery.test.ts b/test/object/delivery.test.ts new file mode 100644 index 0000000..9191210 --- /dev/null +++ b/test/object/delivery.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { generateSecretKey } from "nostr-tools/pure"; +import { ev, pk, rpc, alarm } from "../helpers/relay.ts"; +import { WS } from "../helpers/ws.ts"; + +describe("NIP-65 automatic delivery", () => { + it("routes public events to the author's write relay and exposes target status", async () => { + const owner = generateSecretKey(); + const source = "auto-source.bind.ws", target = "auto-target.bind.ws"; + await rpc(target, generateSecretKey(), "claim"); + await rpc(source, owner, "claim"); + await rpc(source, owner, "setpolicy", { delivery: { enabled: true, maxTargets: 4 } }); + const targetSocket = await WS.connect(target); + const sourceSocket = await WS.connect(source); + const list = ev(owner, 10002, "", [["r", "wss://" + target, "write"]]); + expect((await sourceSocket.ok(list)).ok).toBe(true); + const note = ev(owner, 1, "routed"); + expect((await sourceSocket.ok(note)).ok).toBe(true); + await alarm("auto-source"); + expect((await targetSocket.req({ ids: [note.id] })).map((e) => e.id)).toEqual([note.id]); + const status = await rpc(source, owner, "deliverystatus"); + expect(status.result).toEqual(expect.arrayContaining([expect.objectContaining({ event_id: note.id, target: "wss://" + target, status: "accepted" })])); + }); + + it("does not queue private or protected events", async () => { + const owner = generateSecretKey(), friend = generateSecretKey(); + const source = "auto-filter.bind.ws", target = "auto-filter-target.bind.ws"; + await rpc(target, generateSecretKey(), "claim"); + await rpc(source, owner, "claim"); + await rpc(source, owner, "setpolicy", { delivery: { enabled: true, maxTargets: 4 } }); + const socket = await WS.connect(source); + expect((await socket.ok(ev(owner, 10002, "", [["r", "wss://" + target, "write"]]))).ok).toBe(true); + const dm = ev(owner, 4, "secret", [["p", pk(friend)]]); + const protectedEvent = ev(owner, 1, "protected", [["-", ""]]); + expect((await socket.ok(dm)).ok).toBe(true); + expect((await socket.ok(protectedEvent)).ok).toBe(false); + const status = await rpc(source, owner, "deliverystatus"); + expect(status.result.filter((x: any) => [dm.id, protectedEvent.id].includes(x.event_id))).toEqual([]); + }); +}); From 57d9fcf7c7a3baa3db09b429164a842cee1b0269 Mon Sep 17 00:00:00 2001 From: Dami Date: Fri, 4 Sep 2026 18:11:59 -0600 Subject: [PATCH 07/14] fix: retain failed relay delivery progress --- src/jobs.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/jobs.ts b/src/jobs.ts index 112cf17..1496b32 100644 --- a/src/jobs.ts +++ b/src/jobs.ts @@ -181,8 +181,11 @@ async function runPushRound(relay: Relay, job: Job): Promise<{ more: boolean; er const membersOnly = relay.settings.policy.reads === "members"; let more = false, failed = "", reached = 0; const legacyCursor = job.cursor; + // Materialize every target's starting point before any target can advance + // the legacy cursor. This is the compatibility bridge for old jobs. + for (const url of job.relays) if (job.targetCursors[url] === undefined) job.targetCursors[url] = legacyCursor; for (const url of job.relays) { - const cursor = job.targetCursors[url] ?? legacyCursor ?? 0; + const cursor = job.targetCursors[url] ?? 0; const rows = relay.store.after(cursor, f, PUSH_BATCH, now()); if (rows.length === 0) { job.targetStatus[url] = { status: "accepted", error: "", at: now() }; continue; } more = more || rows.length === PUSH_BATCH; @@ -201,6 +204,7 @@ async function runPushRound(relay: Relay, job: Job): Promise<{ more: boolean; er } catch (err) { failed = url + ": " + (err instanceof Error ? err.message : String(err)); job.targetStatus[url] = { status: "pending", error: failed, at: now() }; + more = true; } } if (reached === 0 && failed) return { more: true, error: failed }; @@ -234,7 +238,7 @@ async function pushTo(relay: Relay, url: string, events: Event[], job: Job): Pro job.refused += pending.size; throw new Error("the relay stopped answering"); } - if (refusedInARow >= REFUSALS_PER_TARGET) return; + if (refusedInARow >= REFUSALS_PER_TARGET) throw new Error("target refused too many events"); } } finally { sock.close(); From 2a847b2ed8cc0f4252d6fe9e396d9e9cf3473b9d Mon Sep 17 00:00:00 2001 From: Dami Date: Fri, 4 Sep 2026 18:12:12 -0600 Subject: [PATCH 08/14] lists: expire saved versions and support colon address deletion --- src/list-history.ts | 17 +++++++++++------ src/manage.ts | 4 ++-- src/store.ts | 30 ++++++++++++++++++------------ 3 files changed, 31 insertions(+), 20 deletions(-) diff --git a/src/list-history.ts b/src/list-history.ts index a9d38b7..a5f3dfb 100644 --- a/src/list-history.ts +++ b/src/list-history.ts @@ -1,4 +1,4 @@ -import type { Event } from "./event.ts"; +import { expiration, type Event } from "./event.ts"; export type UnsignedEvent = { kind: number; created_at: number; tags: string[][]; content: string }; type HistorySQL = >(q: string, ...args: unknown[]) => SqlStorageCursor; @@ -18,6 +18,7 @@ CREATE TABLE IF NOT EXISTS list_history ( event_id TEXT NOT NULL, created_at INTEGER NOT NULL, saved_at INTEGER NOT NULL, + expires INTEGER NOT NULL DEFAULT 0, raw TEXT NOT NULL, PRIMARY KEY (owner, kind, d, event_id) ); @@ -26,6 +27,7 @@ CREATE INDEX IF NOT EXISTS list_history_list ON list_history(owner, kind, d, cre `; const listKind = (kind: number) => (LIST_KINDS as readonly number[]).includes(kind); +export const isListKind = listKind; const listD = (e: Pick) => e.kind >= 30000 ? e.tags.find((t: Event["tags"][number]) => t[0] === "d")?.[1] ?? "" : ""; // archiveCurrent records the version about to be replaced, then trims the @@ -34,7 +36,7 @@ const listD = (e: Pick) => e.kind >= 30000 ? e.tags.find export const archiveCurrent = (x: HistorySQL, e: Event, savedAt: number) => { if (!listKind(e.kind)) return; const d = listD(e); - x(`INSERT OR IGNORE INTO list_history(owner,kind,d,event_id,created_at,saved_at,raw) VALUES(?,?,?,?,?,?,?)`, e.pubkey, e.kind, d, e.id, e.created_at, savedAt, JSON.stringify(e)); + x(`INSERT OR IGNORE INTO list_history(owner,kind,d,event_id,created_at,saved_at,expires,raw) VALUES(?,?,?,?,?,?,?,?)`, e.pubkey, e.kind, d, e.id, e.created_at, savedAt, expiration(e), JSON.stringify(e)); x(`DELETE FROM list_history WHERE owner=? AND kind=? AND d=? AND event_id NOT IN (SELECT event_id FROM list_history WHERE owner=? AND kind=? AND d=? ORDER BY created_at DESC, event_id ASC LIMIT ?)`, e.pubkey, e.kind, d, e.pubkey, e.kind, d, LIST_HISTORY_LIMIT); x(`DELETE FROM list_history WHERE owner=? AND event_id NOT IN (SELECT event_id FROM list_history WHERE owner=? ORDER BY saved_at DESC, event_id ASC LIMIT ?)`, e.pubkey, e.pubkey, LIST_HISTORY_OWNER_LIMIT); x(`DELETE FROM list_history WHERE event_id NOT IN (SELECT event_id FROM list_history ORDER BY saved_at DESC, event_id ASC LIMIT ?)`, LIST_HISTORY_GLOBAL_LIMIT); @@ -53,11 +55,14 @@ export const clearList = (x: HistorySQL, owner: string, kind: number, d: string, export interface ListHistoryRow { owner: string; kind: number; d: string; event_id: string; created_at: number; saved_at: number; raw: string; } -export const listHistory = (x: HistorySQL, owner: string): Omit[] => - x>(`SELECT kind,d,event_id,created_at,saved_at FROM list_history WHERE owner=? ORDER BY saved_at DESC LIMIT ?`, owner, LIST_HISTORY_OWNER_LIMIT).toArray(); +export const listHistory = (x: HistorySQL, owner: string, now: number): Omit[] => { + x(`DELETE FROM list_history WHERE owner=? AND expires>0 AND expires<=?`, owner, now); + return x>(`SELECT kind,d,event_id,created_at,saved_at FROM list_history WHERE owner=? AND (expires=0 OR expires>?) ORDER BY saved_at DESC LIMIT ?`, owner, now, LIST_HISTORY_OWNER_LIMIT).toArray(); +}; -export const restoreHistory = (x: HistorySQL, owner: string, eventID: string): { draft: UnsignedEvent; diff: { addedTags: string[][]; removedTags: string[][]; contentChanged: boolean } } | string => { - const row = x<{ raw: string }>(`SELECT raw FROM list_history WHERE owner=? AND event_id=?`, owner, eventID).toArray()[0]; +export const restoreHistory = (x: HistorySQL, owner: string, eventID: string, now: number): { draft: UnsignedEvent; diff: { addedTags: string[][]; removedTags: string[][]; contentChanged: boolean } } | string => { + x(`DELETE FROM list_history WHERE owner=? AND expires>0 AND expires<=?`, owner, now); + const row = x<{ raw: string }>(`SELECT raw FROM list_history WHERE owner=? AND event_id=? AND (expires=0 OR expires>?)`, owner, eventID, now).toArray()[0]; if (!row) return "not found"; try { const event = JSON.parse(row.raw) as Event; diff --git a/src/manage.ts b/src/manage.ts index 904f8e9..b690d82 100644 --- a/src/manage.ts +++ b/src/manage.ts @@ -260,13 +260,13 @@ export const METHODS: Record = { }, listlisthistory: { action: "read", reads: true, ownListHistory: true, - run: ({ relay, caller, reply }) => reply({ result: relay.store.listHistory(caller) }), + run: ({ relay, caller, t, reply }) => reply({ result: relay.store.listHistory(caller, t) }), }, restorelist: { action: "read", reads: true, ownListHistory: true, run: ({ relay, caller, params, str, hex64, reply }) => { if (params.length !== 1 || !hex64(str(0))) return reply({ error: "invalid: give one saved list event id" }, 400); - const restored = relay.store.restoreHistory(caller, str(0)); + const restored = relay.store.restoreHistory(caller, str(0), now()); return typeof restored === "string" ? reply({ error: restored }, 404) : reply({ result: restored }); }, }, diff --git a/src/store.ts b/src/store.ts index 66e0492..28c02b9 100644 --- a/src/store.ts +++ b/src/store.ts @@ -7,7 +7,7 @@ import { ftsQuery, searchTerms, type Filter } from "./filter.ts"; import { SITE_SCHEMA, SITE_KINDS } from "./sites.ts"; import { HLL } from "./hll.ts"; import { hexToBytes, type SyncItem } from "./negentropy.ts"; -import { archiveCurrent, clearForDelete, clearList, listHistory, restoreHistory, LIST_HISTORY_SCHEMA, type ListHistoryRow } from "./list-history.ts"; +import { archiveCurrent, clearForDelete, clearList, isListKind, listHistory, restoreHistory, LIST_HISTORY_SCHEMA, type ListHistoryRow } from "./list-history.ts"; export const SCHEMA = ` CREATE TABLE IF NOT EXISTS events ( @@ -90,6 +90,7 @@ export class Store { init() { this.sql.exec(SCHEMA); this.sql.exec(LIST_HISTORY_SCHEMA); + try { this.sql.exec(`ALTER TABLE list_history ADD COLUMN expires INTEGER NOT NULL DEFAULT 0`); } catch { /* already migrated */ } this.sql.exec(SITE_SCHEMA); this.sql.exec(GRASP_SCHEMA); } @@ -140,15 +141,19 @@ export class Store { if (isReplaceable(e.kind)) { if (has(`SELECT 1 FROM events WHERE pubkey=? AND kind=? AND (created_at>? OR (created_at=? AND id(`SELECT raw FROM events WHERE pubkey=? AND kind=?`, e.pubkey, e.kind).toArray()[0]; - if (current) archiveCurrent(this.historySQL, JSON.parse(current.raw) as Event, now); + if (isListKind(e.kind)) { + const current = this.x<{ raw: string }>(`SELECT raw FROM events WHERE pubkey=? AND kind=?`, e.pubkey, e.kind).toArray()[0]; + if (current) archiveCurrent(this.historySQL, JSON.parse(current.raw) as Event, now); + } this.x(`DELETE FROM events WHERE pubkey=? AND kind=?`, e.pubkey, e.kind); } else if (isAddressable(e.kind)) { d = tag(e, "d"); if (has(`SELECT 1 FROM events WHERE pubkey=? AND kind=? AND d=? AND (created_at>? OR (created_at=? AND id(`SELECT raw FROM events WHERE pubkey=? AND kind=? AND d=?`, e.pubkey, e.kind, d).toArray()[0]; - if (current) archiveCurrent(this.historySQL, JSON.parse(current.raw) as Event, now); + if (isListKind(e.kind)) { + const current = this.x<{ raw: string }>(`SELECT raw FROM events WHERE pubkey=? AND kind=? AND d=?`, e.pubkey, e.kind, d).toArray()[0]; + if (current) archiveCurrent(this.historySQL, JSON.parse(current.raw) as Event, now); + } this.x(`DELETE FROM events WHERE pubkey=? AND kind=? AND d=?`, e.pubkey, e.kind, d); } else if (e.kind === 5) { for (const t of e.tags) { @@ -161,9 +166,10 @@ export class Store { clearForDelete(this.historySQL, e.pubkey, t[1]); } else if (t[0] === "a") { const parts = t[1].split(":"); - if (parts.length === 3 && parts[1] === e.pubkey) { - this.x(`DELETE FROM events WHERE kind=? AND pubkey=? AND d=? AND created_at<=?`, parseInt(parts[0], 10) || 0, e.pubkey, parts[2], e.created_at); - clearList(this.historySQL, e.pubkey, parseInt(parts[0], 10) || 0, parts[2], e.created_at); + if (parts.length >= 3 && parts[1] === e.pubkey) { + const addressD = parts.slice(2).join(":"); + this.x(`DELETE FROM events WHERE kind=? AND pubkey=? AND d=? AND created_at<=?`, parseInt(parts[0], 10) || 0, e.pubkey, addressD, e.created_at); + clearList(this.historySQL, e.pubkey, parseInt(parts[0], 10) || 0, addressD, e.created_at); } } } @@ -394,13 +400,13 @@ export class Store { // listHistory returns only metadata for versions signed by owner; raw event // content stays behind restoreHistory and the same owner check. - listHistory(owner: string): Omit[] { - return listHistory(this.historySQL, owner); + listHistory(owner: string, now: number): Omit[] { + return listHistory(this.historySQL, owner, now); } // restoreHistory returns a fresh unsigned draft and its current-list diff. - restoreHistory(owner: string, eventID: string): ReturnType { - return restoreHistory(this.historySQL, owner, eventID); + restoreHistory(owner: string, eventID: string, now: number): ReturnType { + return restoreHistory(this.historySQL, owner, eventID, now); } // dumpPage reads a page of raw events by sequence for the JSONL dump. From 858fbc77f7b4daf039098e696b42a9084fc413a7 Mon Sep 17 00:00:00 2001 From: Dami Date: Fri, 4 Sep 2026 18:12:12 -0600 Subject: [PATCH 09/14] test: cover owner-wide list history privacy and deletion --- test/object/list-history.test.ts | 55 ++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/test/object/list-history.test.ts b/test/object/list-history.test.ts index c6efa2d..2b07ac2 100644 --- a/test/object/list-history.test.ts +++ b/test/object/list-history.test.ts @@ -44,4 +44,59 @@ describe("private list recovery", () => { }); expect((await rpc(host, owner, "listlisthistory")).result).toEqual([]); }); + + it("caps an owner's history across distinct bookmark sets", async () => { + const host = "list-history-owner-cap.bind.ws"; + const owner = generateSecretKey(); + await rpc(host, owner, "claim"); + await runInDurableObject(env.RELAY.getByName("list-history-owner-cap"), (relay: Relay) => { + const base = now(); + for (let i = 0; i < 100; i++) { + expect(relay.store.save(ev(owner, 30003, "old-" + i, [["d", "set-" + i]], base + i * 2), base + i * 2)).toBe(""); + expect(relay.store.save(ev(owner, 30003, "new-" + i, [["d", "set-" + i]], base + i * 2 + 1), base + i * 2 + 1)).toBe(""); + } + expect(relay.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM list_history WHERE owner=?`, pk(owner)).one().n).toBe(96); + }); + }); + + it("keeps member history private and clears colon-containing address history on deletion", async () => { + const host = "list-history-privacy.bind.ws"; + const owner = generateSecretKey(); + const member = generateSecretKey(); + await rpc(host, owner, "claim"); + await rpc(host, owner, "setmember", pk(member)); + let ownerHistoryID = ""; + await runInDurableObject(env.RELAY.getByName("list-history-privacy"), (relay: Relay) => { + const base = now(); + relay.store.save(ev(owner, 10002, "owner-old", [], base), base); + const ownerCurrent = ev(owner, 10002, "owner-new", [], base + 1); + relay.store.save(ownerCurrent, base + 1); + ownerHistoryID = ownerCurrent.id; + relay.store.save(ev(member, 10002, "member-old", [], base + 2), base + 2); + relay.store.save(ev(member, 10002, "member-new", [], base + 3), base + 3); + relay.store.save(ev(owner, 30003, "set-old", [["d", "set:with:colon"]], base + 4), base + 4); + relay.store.save(ev(owner, 30003, "set-new", [["d", "set:with:colon"]], base + 5), base + 5); + }); + const memberHistory = (await rpc(host, member, "listlisthistory")).result as any[]; + expect(memberHistory).toHaveLength(1); + expect(memberHistory[0].kind).toBe(10002); + expect((await rpc(host, member, "restorelist", ownerHistoryID)).status).toBe(404); + await runInDurableObject(env.RELAY.getByName("list-history-privacy"), (relay: Relay) => { + const deletion = ev(owner, 5, "", [["a", `30003:${pk(owner)}:set:with:colon`]], now() + 10); + expect(relay.store.save(deletion, deletion.created_at)).toBe(""); + expect(relay.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM list_history WHERE owner=? AND kind=30003`, pk(owner)).one().n).toBe(0); + }); + }); + + it("does not restore an expired saved list", async () => { + const host = "list-history-expiry.bind.ws"; + const owner = generateSecretKey(); + await rpc(host, owner, "claim"); + await runInDurableObject(env.RELAY.getByName("list-history-expiry"), (relay: Relay) => { + const base = now(); + relay.store.save(ev(owner, 10003, "expired", [["expiration", String(base - 1)]], base), base); + relay.store.save(ev(owner, 10003, "current", [], base + 1), base + 1); + }); + expect((await rpc(host, owner, "listlisthistory")).result).toEqual([]); + }); }); From ff0825cab1ff833fdfdea0766702f0827a08325e Mon Sep 17 00:00:00 2001 From: Dami Date: Fri, 4 Sep 2026 18:13:45 -0600 Subject: [PATCH 10/14] delivery: automatic sends respect current visibility and expose target results Runs delivery under relay admission, bounds socket waits and errors, accounts for query usage, and excludes expired or banned events and members-only content. --- docs/04-data-and-names.md | 8 +++++++- docs/13-scripts-and-agents.md | 1 + docs/27-nip65-delivery.md | 23 ----------------------- src/console/console.html | 6 ++++++ src/console/console.js | 6 ++++++ src/delivery.ts | 16 ++++++++-------- src/gen/console.ts | 4 ++-- src/relay.ts | 2 +- 8 files changed, 31 insertions(+), 35 deletions(-) delete mode 100644 docs/27-nip65-delivery.md diff --git a/docs/04-data-and-names.md b/docs/04-data-and-names.md index e012735..d79f2e6 100644 --- a/docs/04-data-and-names.md +++ b/docs/04-data-and-names.md @@ -29,7 +29,13 @@ The Jobs table exposes **Source results** with mode, status, counts and any refu **Fetch my history** pulls your own events from every relay in your relay list, if a client has published that list here. Or give it relays to fetch from, separated by commas. -**Rebroadcast** sends what your relay holds to other relays. Choose kinds and a window in days, or leave both blank for everything. As a standing job it forwards only what arrived since the last run. A target that refuses five events in a row is left alone for that round. Events that only their author may publish are never sent, and a members-only relay never sends private messages. +**Rebroadcast** sends what your relay holds to other relays. Choose kinds and a window in days, or leave both blank for everything. As a standing job it forwards only what arrived since the last run. Each target has its own saved cursor and status, so a failing destination cannot skip history because another succeeded. A target that refuses five events in a row is left alone for that round; unacknowledged history remains available for a later retry. Events that only their author may publish are never sent, and a members-only relay never sends private messages. + +## Automatic delivery + +Enable **Automatic NIP-65 delivery** on the Rules tab to send newly accepted public events by you and your members to the author's write relays and tagged people's read relays. Routing uses the kind 10002 relay lists already stored here. It is off by default, with eight targets per event and a configurable maximum of one through sixteen. + +The Sync tab shows recent event/target results: pending, accepted or rejected, attempts and the last error. Each target gets up to four attempts. The queue holds at most 512 pending deliveries and 1,024 terminal results, kept for at most seven days. A full queue can omit new deliveries. This is best effort and costs fuel. Private kinds, protected events, imports and relay-generated events are excluded. Members-only relays never route automatically. Pending deliveries recheck the current event, membership and routing before sending. ## Import a file diff --git a/docs/13-scripts-and-agents.md b/docs/13-scripts-and-agents.md index 18e45a2..91c4797 100644 --- a/docs/13-scripts-and-agents.md +++ b/docs/13-scripts-and-agents.md @@ -156,6 +156,7 @@ The bridge takes the same header. `POST /events` answers `{ event_id, accepted, - `pullfrom url`, `pullstatus`: copy one relay and follow it. - `addjob {kind, relays, filter?, every?, label?}`: a `pull` or `push`, once or every 1, 6 or 24 hours. Up to 10 relays; filters take up to 50 authors and 50 kinds and a `since`. - `removejob id`, `runjob id`, `listjobs`. Pull jobs expose `pullSources` while running and `last.sources` after finishing: each source carries its URL, mode, status, stored/skipped/blob counts, retry count, error and coverage warning. Query window progress is persisted with the job. +- `deliverystatus`: recent automatic NIP-65 per-event target status, attempts and last error. Enable with `setpolicy {delivery: {enabled: true, maxTargets: 8}}`. - `backfill [relays?]`: your own events from your kind 10002 here, or from the list. **Transfer** diff --git a/docs/27-nip65-delivery.md b/docs/27-nip65-delivery.md deleted file mode 100644 index 74ee856..0000000 --- a/docs/27-nip65-delivery.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -title: NIP-65 delivery -audience: owners and developers ---- - -## Automatic delivery - -An owner may enable automatic NIP-65 delivery in the console or with -`setpolicy`. For each locally accepted public event, bind.ws reads the -author's current kind `10002` write relays and the read relays of people named -by `p` tags. It queues each relay independently and sends a normal Nostr -`EVENT` message. - -Delivery is off by default and is bounded to eight targets per event (the -owner may choose one through sixteen). Private, protected, imported and -relay-generated events are not routed automatically. The relay keeps only a -small durable queue; each target has its own accepted, rejected or pending -status, retry count and last error. `deliverystatus` exposes that status to the -owner. A target that fails does not advance another target's progress. - -The relay applies current bans, visibility and policy checks before delivery. -Delivery is best effort and costs fuel. Network work runs from the alarm after -event admission, so a slow destination cannot hold up publishing here. diff --git a/src/console/console.html b/src/console/console.html index 1e37ed2..8ef96bb 100644 --- a/src/console/console.html +++ b/src/console/console.html @@ -337,6 +337,7 @@

                                                                                                                  Sync

                                                                                                                  Jobs

                                                                                                                  Work the relay does on its own, one round at a time. A pull copies what another relay has and this one lacks. Fetch my history pulls your own events from the relays in your relay list. Rebroadcast sends what is here to other relays. Bans and kind rules apply to what arrives.

                                                                                                                  JobRelaysFilterScheduleResult
                                                                                                                  +
                                                                                                                  @@ -349,6 +350,11 @@

                                                                                                                  Jobs

                                                                                                                  +
                                                                                                                  +

                                                                                                                  Automatic delivery

                                                                                                                  +

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

                                                                                                                  +
                                                                                                                  EventTargetStatusAttemptsLast error
                                                                                                                  +

                                                                                                                  Fork this relay

                                                                                                                  A new name, temporary until claimed, filled from this relay. Yours to split a name by job, or somebody else's to hand them a community with its history. One fork an hour.

                                                                                                                  diff --git a/src/console/console.js b/src/console/console.js index 3263baf..bc5c4ba 100644 --- a/src/console/console.js +++ b/src/console/console.js @@ -612,6 +612,12 @@ let jobs; try { jobs = await rpc("listjobs"); } catch { return; } $("#jobs tbody").innerHTML = jobs.length ? jobs.map(fmtJob).join("") : 'no jobs yet'; + if (isOwner) { + try { + const deliveries = await rpc("deliverystatus"); + $("#deliveries tbody").innerHTML = deliveries.length ? deliveries.map((d) => '' + esc(d.event_id.slice(0, 12)) + '' + esc(d.target) + '' + esc(d.status) + '' + d.attempts + '' + esc(d.error || "") + '').join("") : 'no automatic deliveries yet'; + } catch { /* unavailable to non-owners */ } + } if (jobs.some((j) => j.running || (j.nextRun && j.nextRun <= Math.floor(Date.now() / 1000) + 1))) jobsTimer = setTimeout(pollJobs, 3000); } const urls = (s) => s.split(/[\s,]+/).map((u) => u.trim()).filter(Boolean); diff --git a/src/delivery.ts b/src/delivery.ts index a6c9ad3..9bc517c 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -1,6 +1,6 @@ // NIP-65 delivery: a small, durable, opt-in fanout queue. This is deliberately // separate from NIP-9a callbacks: targets are Nostr relays and receive EVENT. -import { isPrivate, now, tagValues, type Event } from "./event.ts"; +import { expiration, isPrivate, now, tagValues, type Event } from "./event.ts"; import { dial, Socket, checkPullURL } from "./pull.ts"; import type { Relay } from "./relay.ts"; @@ -14,7 +14,7 @@ CREATE TABLE IF NOT EXISTS delivery_queue ( CREATE INDEX IF NOT EXISTS delivery_due ON delivery_queue(status,due); `; const MAX_QUEUE = 512, MAX_ATTEMPTS = 4, TIMEOUT = 5000, BATCH = 4; -const rows = >(r: Relay, q: string, ...a: any[]): T[] => r.sql.exec(q, ...a).toArray(); +const rows = >(r: Relay, q: string, ...a: any[]): T[] => { const c = r.sql.exec(q, ...a); const out = c.toArray(); r.meterPush(c.rowsRead, c.rowsWritten); return out; }; const exec = (r: Relay, q: string, ...a: any[]) => { const c = r.sql.exec(q, ...a); r.meterPush(c.rowsRead, c.rowsWritten); return c; }; function list(r: Relay, pk: string): { read: string[]; write: string[] } { @@ -25,7 +25,7 @@ function list(r: Relay, pk: string): { read: string[]; write: string[] } { for (const t of (JSON.parse(row) as Event).tags) { if (t[0] !== "r" || !t[1]) continue; let u: URL; try { u = new URL(t[1]); } catch { continue; } - if (u.protocol !== "wss:" && u.protocol !== "ws:") continue; + if ((u.protocol !== "wss:" && u.protocol !== "ws:") || u.username || u.password || u.hash) continue; // NIP-65 is user supplied egress. Refuse obvious loopback/private // destinations; public DNS names remain the interoperable path. const h = u.hostname.toLowerCase(); @@ -55,7 +55,7 @@ function targets(r: Relay, e: Event): string[] { export function queueDelivery(r: Relay, e: Event): boolean { const p = r.settings.policy.delivery; - if (!p?.enabled || e.pubkey === r.identity.pubkey || !r.settings.isAllowed(e.pubkey) || isPrivate(e.kind) || e.tags.some((t: string[]) => t[0] === "-")) return false; + if (!p?.enabled || r.settings.policy.reads === "members" || r.settings.leaseExpired(now()) || e.pubkey === r.identity.pubkey || r.settings.isBanned(e.pubkey) || !r.settings.isAllowed(e.pubkey) || isPrivate(e.kind) || e.tags.some((t: string[]) => t[0] === "-")) return false; const ts = targets(r, e); if (!ts.length) return false; let n = rows<{ n: number }>(r, `SELECT count(*) n FROM delivery_queue WHERE status='pending'`)[0]?.n ?? 0; @@ -75,13 +75,13 @@ async function send(r: Relay, target: string, e: Event): Promise<{ ok: boolean; s.send("EVENT", e); const end = Date.now() + TIMEOUT; while (Date.now() < end) { - const m = await Promise.race([s.recv(), new Promise((_, rej) => setTimeout(() => rej(new Error("timeout")), Math.max(1, end - Date.now()))) ]); + const m = await s.recv(end - Date.now()); if (m[0] !== "OK" || m[1] !== e.id) continue; if (m[2] === true || String(m[3] ?? "").startsWith("duplicate:")) return { ok: true, error: "" }; - return { ok: false, error: String(m[3] ?? "rejected") }; + return { ok: false, error: String(m[3] ?? "rejected").slice(0, 300) }; } return { ok: false, error: "timeout" }; - } catch (e) { return { ok: false, error: e instanceof Error ? e.message : String(e) }; } + } catch (e) { return { ok: false, error: (e instanceof Error ? e.message : String(e)).slice(0, 300) }; } finally { s?.close(); } } @@ -96,7 +96,7 @@ export async function deliveryTick(r: Relay): Promise { const ev = rows<{ raw: string }>(r, `SELECT raw FROM events WHERE id=? AND pubkey=? AND kind NOT IN (4,1059,21059,24133)`, j.event_id, author)[0]; if (!ev) { exec(r, `UPDATE delivery_queue SET status='rejected',error='event unavailable',updated_at=? WHERE event_id=? AND target=?`, t, j.event_id, j.target); continue; } const event = JSON.parse(ev.raw) as Event; - if (!r.settings.isAllowed(author) || !targets(r, event).includes(j.target)) { exec(r, `UPDATE delivery_queue SET status='rejected',error='routing no longer permitted',updated_at=? WHERE event_id=? AND target=?`, t, j.event_id, j.target); continue; } + if (r.settings.policy.reads === "members" || r.settings.isBanned(author) || !r.settings.isAllowed(author) || isPrivate(event.kind) || event.tags.some((t) => t[0] === "-") || (expiration(event) > 0 && expiration(event) <= now()) || !targets(r, event).includes(j.target)) { exec(r, `UPDATE delivery_queue SET status='rejected',error='routing no longer permitted',updated_at=? WHERE event_id=? AND target=?`, t, j.event_id, j.target); continue; } exec(r, `UPDATE delivery_queue SET attempts=attempts+1,due=?,updated_at=? WHERE event_id=? AND target=?`, t + 60, t, j.event_id, j.target); const result = await send(r, j.target, event); const attempts = j.attempts + 1; diff --git a/src/gen/console.ts b/src/gen/console.ts index 4ea1ec3..bd828a7 100644 --- a/src/gen/console.ts +++ b/src/gen/console.ts @@ -1,4 +1,4 @@ // Generated by scripts/build/build-console.mjs from src/console. Do not edit; run npm run build:console. -export const CONSOLE_HTML = "
                                                                                                                  \n
                                                                                                                  \n
                                                                                                                  \"\"
                                                                                                                  \n
                                                                                                                  \"\"

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

                                                                                                                  Nobody owns this relay yet.

                                                                                                                  \n

                                                                                                                  Claim it and it's yours: you decide who can post, who can read, and what stays. One signature with a nostr browser extension; no account, no email, no card.

                                                                                                                  \n
                                                                                                                  \n
                                                                                                                  \n\n
                                                                                                                  \n

                                                                                                                  A temporary relay, for now.

                                                                                                                  \n

                                                                                                                  Anyone can read and write here until . Then everything on it is deleted and the name is freed. Claim it and it stays, events and files included: one signature with a nostr browser extension. Or claim a new name and pull this one into it from its Storage tab.

                                                                                                                  \n
                                                                                                                  \n
                                                                                                                  \n\n
                                                                                                                  \n

                                                                                                                  Connect a remote signer.

                                                                                                                  \n

                                                                                                                  Your key stays in a signer app such as Amber or nsec.app; this page asks it to sign. On a phone, open the link and approve there. On a computer, paste the bunker:// URL the app gives you.

                                                                                                                  \n \n
                                                                                                                  \n \"QR\n

                                                                                                                  \n
                                                                                                                  \n\n
                                                                                                                  \n
                                                                                                                  \n

                                                                                                                  \n

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

                                                                                                                  Git repositories, ntig

                                                                                                                  \n
                                                                                                                  \n

                                                                                                                  Use ordinary Git to clone a repository hosted here.

                                                                                                                  \n
                                                                                                                  Clone a repository
                                                                                                                  \n

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

                                                                                                                  \n

                                                                                                                  To host your own, use a Nostr Git client to publish your repository and signed branch state to this relay before pushing to the same remote. Git hosting guide.

                                                                                                                  \n
                                                                                                                  \n
                                                                                                                  \n
                                                                                                                  \n

                                                                                                                  Open it in an app

                                                                                                                  \n

                                                                                                                  Relay apps open this relay as a place. Feed apps do not: they find the owner here through a profile link that carries this relay as the hint, and learn the relay from that. Either way, add under the app's relay settings to post here.

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

                                                                                                                  People

                                                                                                                  \n

                                                                                                                  Hidden from visitors. Only you see this list.

                                                                                                                  \n
                                                                                                                  \n
                                                                                                                  \n\n
                                                                                                                  \n

                                                                                                                  Fuel

                                                                                                                  \n

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

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

                                                                                                                  \n
                                                                                                                  sats
                                                                                                                  \n
                                                                                                                  \n

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

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

                                                                                                                  Your invites

                                                                                                                  \n

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

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

                                                                                                                    People

                                                                                                                    \n

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

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

                                                                                                                    Invites

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

                                                                                                                      Joining

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

                                                                                                                      Moderation

                                                                                                                      \n

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

                                                                                                                      \n
                                                                                                                      \n

                                                                                                                      Reports

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

                                                                                                                      Log

                                                                                                                      \n

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

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

                                                                                                                      Bans

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

                                                                                                                          Blocked addresses

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

                                                                                                                            Recent events

                                                                                                                            \n

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

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

                                                                                                                            Pinned

                                                                                                                            \n

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

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

                                                                                                                              Rules

                                                                                                                              \n

                                                                                                                              Bans apply regardless of these.

                                                                                                                              \n
                                                                                                                              \n

                                                                                                                              Presets

                                                                                                                              \n

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

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

                                                                                                                              \n
                                                                                                                              \n
                                                                                                                              \n
                                                                                                                              \n

                                                                                                                              Writes

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

                                                                                                                              Reads

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

                                                                                                                              Kinds

                                                                                                                              \n

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

                                                                                                                              \n
                                                                                                                              \n

                                                                                                                              Allowed:

                                                                                                                              \n

                                                                                                                              Blocked:

                                                                                                                              \n
                                                                                                                              \n
                                                                                                                              \n

                                                                                                                              Features

                                                                                                                              \n

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

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

                                                                                                                              Blocked words

                                                                                                                              \n

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

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

                                                                                                                              Identity

                                                                                                                              \n
                                                                                                                              \n

                                                                                                                              Profile

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

                                                                                                                              For directories

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

                                                                                                                              Your own domain

                                                                                                                              \n

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

                                                                                                                              \n
                                                                                                                              \n
                                                                                                                              \n

                                                                                                                              \n
                                                                                                                              \n
                                                                                                                              \n

                                                                                                                              Your relay lists

                                                                                                                              \n

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

                                                                                                                              \n
                                                                                                                              \n
                                                                                                                              \n
                                                                                                                              \n

                                                                                                                              Share

                                                                                                                              \n

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

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

                                                                                                                              Data

                                                                                                                              \n

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

                                                                                                                              \n
                                                                                                                              \n

                                                                                                                              Recover your lists

                                                                                                                              \n

                                                                                                                              Older signed versions of your follows, relay lists and bookmarks stay private here. Restore a version to review it, then sign and publish it from this relay.

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

                                                                                                                              By kind

                                                                                                                              \n
                                                                                                                              KindCountSizeOldestKeep for
                                                                                                                              \n
                                                                                                                              \n
                                                                                                                              \n

                                                                                                                              Files

                                                                                                                              \n
                                                                                                                              TimeFileSizeUploader
                                                                                                                              \n
                                                                                                                              \n
                                                                                                                              \n

                                                                                                                              Sites

                                                                                                                              \n

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

                                                                                                                              \n
                                                                                                                              AuthorNameURLFilesSizeExpiry
                                                                                                                              \n
                                                                                                                              \n
                                                                                                                              \n

                                                                                                                              Dumps

                                                                                                                              \n

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

                                                                                                                              \n
                                                                                                                              \n
                                                                                                                                \n
                                                                                                                                \n
                                                                                                                                \n

                                                                                                                                Import a file

                                                                                                                                \n

                                                                                                                                A JSONL of events, one per line, such as a dump or a strfry export, or a JSON array. Up to 64 MB. Signatures are checked; bans and kind rules apply; the write rule does not. Progress shows under Jobs.

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

                                                                                                                                Sync

                                                                                                                                \n

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

                                                                                                                                \n
                                                                                                                                \n

                                                                                                                                Jobs

                                                                                                                                \n

                                                                                                                                Work the relay does on its own, one round at a time. A pull copies what another relay has and this one lacks. Fetch my history pulls your own events from the relays in your relay list. Rebroadcast sends what is here to other relays. Bans and kind rules apply to what arrives.

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

                                                                                                                                Fork this relay

                                                                                                                                \n

                                                                                                                                A new name, temporary until claimed, filled from this relay. Yours to split a name by job, or somebody else's to hand them a community with its history. One fork an hour.

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

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

                                                                                                                                Views

                                                                                                                                \n

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

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

                                                                                                                                Health

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

                                                                                                                                Zaps received

                                                                                                                                \n
                                                                                                                                WhenFromSats
                                                                                                                                \n
                                                                                                                                \n
                                                                                                                                \n

                                                                                                                                Notifications

                                                                                                                                \n

                                                                                                                                The relay writes you a private message (NIP-17) with its own key. It lands here, in your inbox on your own relay, and on your DM relays when this relay holds your kind 10050.

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

                                                                                                                                Owner

                                                                                                                                \n

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

                                                                                                                                \n
                                                                                                                                \n

                                                                                                                                Configuration

                                                                                                                                \n

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

                                                                                                                                \n
                                                                                                                                \n
                                                                                                                                \n
                                                                                                                                \n

                                                                                                                                Transfer ownership

                                                                                                                                \n

                                                                                                                                Hands the relay to a member. You stay on as a moderator. The relay's key, events, files and fuel do not change. There is no undo.

                                                                                                                                \n
                                                                                                                                \n
                                                                                                                                \n
                                                                                                                                \n

                                                                                                                                If I lose my key

                                                                                                                                \n

                                                                                                                                Name a member as your heir. If you do not sign in here for the time you pick, the relay writes to you once a week for a month, then hands itself to the heir and keeps you on as a moderator. Any signed action on the relay resets the clock.

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

                                                                                                                                \n
                                                                                                                                \n
                                                                                                                                \n

                                                                                                                                Delete this relay

                                                                                                                                \n

                                                                                                                                Deletes every event, file, member, invite and setting, closes every connection, and returns the name to unclaimed for anyone to take. There is no undo.

                                                                                                                                \n \n
                                                                                                                                \n
                                                                                                                                \n
                                                                                                                                \n\n \n
                                                                                                                                \n"; +export const CONSOLE_HTML = "
                                                                                                                                \n
                                                                                                                                \n
                                                                                                                                \"\"
                                                                                                                                \n
                                                                                                                                \"\"

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

                                                                                                                                Nobody owns this relay yet.

                                                                                                                                \n

                                                                                                                                Claim it and it's yours: you decide who can post, who can read, and what stays. One signature with a nostr browser extension; no account, no email, no card.

                                                                                                                                \n
                                                                                                                                \n
                                                                                                                                \n\n
                                                                                                                                \n

                                                                                                                                A temporary relay, for now.

                                                                                                                                \n

                                                                                                                                Anyone can read and write here until . Then everything on it is deleted and the name is freed. Claim it and it stays, events and files included: one signature with a nostr browser extension. Or claim a new name and pull this one into it from its Storage tab.

                                                                                                                                \n
                                                                                                                                \n
                                                                                                                                \n\n
                                                                                                                                \n

                                                                                                                                Connect a remote signer.

                                                                                                                                \n

                                                                                                                                Your key stays in a signer app such as Amber or nsec.app; this page asks it to sign. On a phone, open the link and approve there. On a computer, paste the bunker:// URL the app gives you.

                                                                                                                                \n \n
                                                                                                                                \n \"QR\n

                                                                                                                                \n
                                                                                                                                \n\n
                                                                                                                                \n
                                                                                                                                \n

                                                                                                                                \n

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

                                                                                                                                Git repositories, ntig

                                                                                                                                \n
                                                                                                                                \n

                                                                                                                                Use ordinary Git to clone a repository hosted here.

                                                                                                                                \n
                                                                                                                                Clone a repository
                                                                                                                                \n

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

                                                                                                                                \n

                                                                                                                                To host your own, use a Nostr Git client to publish your repository and signed branch state to this relay before pushing to the same remote. Git hosting guide.

                                                                                                                                \n
                                                                                                                                \n
                                                                                                                                \n
                                                                                                                                \n

                                                                                                                                Open it in an app

                                                                                                                                \n

                                                                                                                                Relay apps open this relay as a place. Feed apps do not: they find the owner here through a profile link that carries this relay as the hint, and learn the relay from that. Either way, add under the app's relay settings to post here.

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

                                                                                                                                People

                                                                                                                                \n

                                                                                                                                Hidden from visitors. Only you see this list.

                                                                                                                                \n
                                                                                                                                \n
                                                                                                                                \n\n
                                                                                                                                \n

                                                                                                                                Fuel

                                                                                                                                \n

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

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

                                                                                                                                \n
                                                                                                                                sats
                                                                                                                                \n
                                                                                                                                \n

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

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

                                                                                                                                Your invites

                                                                                                                                \n

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

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

                                                                                                                                  People

                                                                                                                                  \n

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

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

                                                                                                                                  Invites

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

                                                                                                                                    Joining

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

                                                                                                                                    Moderation

                                                                                                                                    \n

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

                                                                                                                                    \n
                                                                                                                                    \n

                                                                                                                                    Reports

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

                                                                                                                                    Log

                                                                                                                                    \n

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

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

                                                                                                                                    Bans

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

                                                                                                                                        Blocked addresses

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

                                                                                                                                          Recent events

                                                                                                                                          \n

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

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

                                                                                                                                          Pinned

                                                                                                                                          \n

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

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

                                                                                                                                            Rules

                                                                                                                                            \n

                                                                                                                                            Bans apply regardless of these.

                                                                                                                                            \n
                                                                                                                                            \n

                                                                                                                                            Presets

                                                                                                                                            \n

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

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

                                                                                                                                            \n
                                                                                                                                            \n
                                                                                                                                            \n
                                                                                                                                            \n

                                                                                                                                            Writes

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

                                                                                                                                            Reads

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

                                                                                                                                            Kinds

                                                                                                                                            \n

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

                                                                                                                                            \n
                                                                                                                                            \n

                                                                                                                                            Allowed:

                                                                                                                                            \n

                                                                                                                                            Blocked:

                                                                                                                                            \n
                                                                                                                                            \n
                                                                                                                                            \n

                                                                                                                                            Features

                                                                                                                                            \n

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

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

                                                                                                                                            Blocked words

                                                                                                                                            \n

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

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

                                                                                                                                            Identity

                                                                                                                                            \n
                                                                                                                                            \n

                                                                                                                                            Profile

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

                                                                                                                                            For directories

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

                                                                                                                                            Your own domain

                                                                                                                                            \n

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

                                                                                                                                            \n
                                                                                                                                            \n
                                                                                                                                            \n

                                                                                                                                            \n
                                                                                                                                            \n
                                                                                                                                            \n

                                                                                                                                            Your relay lists

                                                                                                                                            \n

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

                                                                                                                                            \n
                                                                                                                                            \n
                                                                                                                                            \n
                                                                                                                                            \n

                                                                                                                                            Share

                                                                                                                                            \n

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

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

                                                                                                                                            Data

                                                                                                                                            \n

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

                                                                                                                                            \n
                                                                                                                                            \n

                                                                                                                                            Recover your lists

                                                                                                                                            \n

                                                                                                                                            Older signed versions of your follows, relay lists and bookmarks stay private here. Restore a version to review it, then sign and publish it from this relay.

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

                                                                                                                                            By kind

                                                                                                                                            \n
                                                                                                                                            KindCountSizeOldestKeep for
                                                                                                                                            \n
                                                                                                                                            \n
                                                                                                                                            \n

                                                                                                                                            Files

                                                                                                                                            \n
                                                                                                                                            TimeFileSizeUploader
                                                                                                                                            \n
                                                                                                                                            \n
                                                                                                                                            \n

                                                                                                                                            Sites

                                                                                                                                            \n

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

                                                                                                                                            \n
                                                                                                                                            AuthorNameURLFilesSizeExpiry
                                                                                                                                            \n
                                                                                                                                            \n
                                                                                                                                            \n

                                                                                                                                            Dumps

                                                                                                                                            \n

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

                                                                                                                                            \n
                                                                                                                                            \n
                                                                                                                                              \n
                                                                                                                                              \n
                                                                                                                                              \n

                                                                                                                                              Import a file

                                                                                                                                              \n

                                                                                                                                              A JSONL of events, one per line, such as a dump or a strfry export, or a JSON array. Up to 64 MB. Signatures are checked; bans and kind rules apply; the write rule does not. Progress shows under Jobs.

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

                                                                                                                                              Sync

                                                                                                                                              \n

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

                                                                                                                                              \n
                                                                                                                                              \n

                                                                                                                                              Jobs

                                                                                                                                              \n

                                                                                                                                              Work the relay does on its own, one round at a time. A pull copies what another relay has and this one lacks. Fetch my history pulls your own events from the relays in your relay list. Rebroadcast sends what is here to other relays. Bans and kind rules apply to what arrives.

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

                                                                                                                                              Automatic delivery

                                                                                                                                              \n

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

                                                                                                                                              \n
                                                                                                                                              EventTargetStatusAttemptsLast error
                                                                                                                                              \n
                                                                                                                                              \n
                                                                                                                                              \n

                                                                                                                                              Fork this relay

                                                                                                                                              \n

                                                                                                                                              A new name, temporary until claimed, filled from this relay. Yours to split a name by job, or somebody else's to hand them a community with its history. One fork an hour.

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

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

                                                                                                                                              Views

                                                                                                                                              \n

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

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

                                                                                                                                              Health

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

                                                                                                                                              Zaps received

                                                                                                                                              \n
                                                                                                                                              WhenFromSats
                                                                                                                                              \n
                                                                                                                                              \n
                                                                                                                                              \n

                                                                                                                                              Notifications

                                                                                                                                              \n

                                                                                                                                              The relay writes you a private message (NIP-17) with its own key. It lands here, in your inbox on your own relay, and on your DM relays when this relay holds your kind 10050.

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

                                                                                                                                              Owner

                                                                                                                                              \n

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

                                                                                                                                              \n
                                                                                                                                              \n

                                                                                                                                              Configuration

                                                                                                                                              \n

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

                                                                                                                                              \n
                                                                                                                                              \n
                                                                                                                                              \n
                                                                                                                                              \n

                                                                                                                                              Transfer ownership

                                                                                                                                              \n

                                                                                                                                              Hands the relay to a member. You stay on as a moderator. The relay's key, events, files and fuel do not change. There is no undo.

                                                                                                                                              \n
                                                                                                                                              \n
                                                                                                                                              \n
                                                                                                                                              \n

                                                                                                                                              If I lose my key

                                                                                                                                              \n

                                                                                                                                              Name a member as your heir. If you do not sign in here for the time you pick, the relay writes to you once a week for a month, then hands itself to the heir and keeps you on as a moderator. Any signed action on the relay resets the clock.

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

                                                                                                                                              \n
                                                                                                                                              \n
                                                                                                                                              \n

                                                                                                                                              Delete this relay

                                                                                                                                              \n

                                                                                                                                              Deletes every event, file, member, invite and setting, closes every connection, and returns the name to unclaimed for anyone to take. There is no undo.

                                                                                                                                              \n \n
                                                                                                                                              \n
                                                                                                                                              \n
                                                                                                                                              \n\n \n
                                                                                                                                              \n"; export const CONSOLE_CSS = "main { max-width: 64rem; }\n.mast { display: grid; grid-template-columns: 1fr auto; gap: 1.5rem 2rem; align-items: end; margin-bottom: 2rem; }\n.mast .wordmark { grid-column: 1 / -1; justify-self: center; margin-bottom: .5rem; }\n.mast .right { display: grid; gap: .7rem; justify-items: end; text-align: right; }\n.urlrow, .owner { display: inline-flex; align-items: center; gap: .4rem; color: var(--ink-2); }\n.owner b { color: var(--ink); } .owner em { font-style: normal; color: var(--forest); }\n.urlrow .ib, .owner .ib { width: 26px; height: 26px; margin-left: .2rem; box-shadow: 1px 1px 0 var(--ink); } .urlrow .ib svg, .owner .ib svg { width: 13px; height: 13px; }\n.care { display: flex; gap: .9rem; flex-wrap: wrap; }\n.care .g { display: grid; justify-items: center; gap: .3rem; width: 5.4rem; }\n.care .g i { display: grid; place-items: center; width: 44px; height: 44px; border: 2px solid var(--ink); border-radius: 10px; background: var(--paper); box-shadow: 2px 2px 0 var(--ink); position: relative; }\n.care .g i svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }\n.care .g.off i::after { content: \"\"; position: absolute; width: 3px; height: 40px; background: var(--ink); transform: rotate(45deg); border-radius: 2px; box-shadow: 0 0 0 2px var(--paper); }\n.care .g small { font: 500 10px/1.2 var(--mono); text-transform: uppercase; letter-spacing: .04em; color: var(--ink-2); text-align: center; }\n#unclaimed { background: var(--butter); }\n.doors { display: grid; grid-template-columns: repeat(4, 1fr); gap: .8rem; } @media (max-width: 52rem) { .doors { grid-template-columns: 1fr 1fr; } } @media (max-width: 30rem) { .doors { grid-template-columns: 1fr; } }\n.door { display: grid; grid-template-rows: auto 1fr; align-content: start; gap: .35rem; padding: .8rem .9rem; border: 2px solid var(--ink); border-radius: 12px; background: var(--paper); box-shadow: 3px 3px 0 var(--ink); min-width: 0; }\n.apps { display: grid; gap: 1.4rem; margin-top: 1rem; } .appgroup h4 { margin: 0 0 .2rem; font-size: 1rem; } .appgroup > .note { margin-top: 0; }\n.appgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: .8rem; } @media (max-width: 52rem) { .appgrid { grid-template-columns: 1fr 1fr; } } @media (max-width: 30rem) { .appgrid { grid-template-columns: 1fr; } }\n.app { display: grid; align-content: start; gap: .4rem; padding: .8rem .9rem; border: 2px solid var(--ink); border-radius: 12px; background: var(--paper); box-shadow: 3px 3px 0 var(--ink); min-width: 0; }\n.app-head { display: flex; align-items: baseline; gap: .5rem; flex-wrap: wrap; } .app-head b { font-size: 15px; } .app-head small { color: var(--ink-3); } .app p { margin: 0; font-size: 13px; color: var(--ink-2); }\n.app-acts { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .2rem; } .app-acts .btn { padding: .3rem .7rem; font-size: 13px; }\n.phones { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, max-content)); gap: .8rem; margin-top: 1rem; } .phones img { display: block; width: 150px; height: 150px; } .phones .door { justify-items: start; }\n.door small { font: 700 11px var(--sans); text-transform: uppercase; letter-spacing: .06em; color: var(--ink-2); }\n.door .v { display: grid; grid-template-columns: 1fr auto; align-items: start; gap: .5rem; font: 500 13px/1.45 var(--mono); } .door .v span { min-width: 0; overflow-wrap: anywhere; }\n.door .ib { width: 24px; height: 24px; box-shadow: 1px 1px 0 var(--ink); flex: 0 0 auto; } .door .ib svg { width: 12px; height: 12px; }\n#peoplesec { background: var(--peach); }\n.dir { display: flex; flex-wrap: wrap; gap: .5rem; }\n.who { display: inline-flex; align-items: center; gap: .1rem; padding: .3rem .7rem .3rem .4rem; border: 2px solid var(--ink); border-radius: 999px; background: var(--paper); box-shadow: 2px 2px 0 var(--ink); font-size: 14px; }\n.who .role { font: 700 10px var(--mono); text-transform: uppercase; letter-spacing: .05em; color: var(--forest); margin-left: .4rem; }\n.who.me { background: var(--sun); }\n#fuelsec { background: var(--mint); }\n.gauges { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }\n.gauge small { display: block; font-weight: 600; font-size: 13px; }\n.gauge .bar { position: relative; height: 22px; border: 2px solid var(--ink); border-radius: 999px; margin: .5rem 0 .35rem; overflow: hidden; padding: 3px; background: var(--paper) linear-gradient(90deg, transparent calc(25% - 1px), var(--line) calc(25% - 1px) 25%, transparent 25% calc(50% - 1px), var(--line) calc(50% - 1px) 50%, transparent 50% calc(75% - 1px), var(--line) calc(75% - 1px) 75%, transparent 75%); }\n.gauge .bar b { position: absolute; right: .6rem; top: 0; bottom: 0; display: flex; align-items: center; font: 600 11px var(--mono); color: var(--ink-3); pointer-events: none; }\n.gauge .bar i { display: block; height: 100%; width: 0; min-width: 14px; border-radius: 999px; background: repeating-linear-gradient(-45deg, var(--forest) 0 8px, var(--forest-2) 8px 16px); transition: width .4s; }\n.gauge .bar i.warm { background: repeating-linear-gradient(-45deg, #d9a52a 0 8px, var(--sun) 8px 16px); }\n.gauge .bar i.over { background: repeating-linear-gradient(-45deg, var(--red) 0 8px, #d4614c 8px 16px); }\n.gauge span { font-size: 13px; color: var(--ink-3); }\n.balance { margin: 1.2rem 0 0; color: var(--ink-2); } .balance b { color: var(--ink); font-weight: 700; }\n.sats { display: inline-flex; align-items: center; width: auto; } .sats input { font: 15px var(--sans); width: 6rem; text-align: right; border: 0; outline: none; padding: 0; background: transparent; color: var(--ink); } .sats span { color: var(--ink-3); padding-left: .4rem; }\n.topup { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; margin-top: .8rem; }\n.invoice { margin-top: 1rem; border: 2px dashed var(--ink); border-radius: 12px; padding: .9rem 1rem; background: var(--paper); }\n.invoice p { margin: 0 0 .6rem; color: var(--ink-2); font-size: 14px; } .invoice .state { font-size: 13px; color: var(--forest); }\n.invoice textarea { margin-top: .7rem; min-height: 3.4rem; font-family: var(--mono); font-size: 11.5px; color: var(--ink-3); }\ndetails.disclosure summary { cursor: pointer; font: 400 1.6rem/1 var(--display); list-style: none; display: flex; align-items: center; gap: .5rem; }\ndetails.disclosure summary::-webkit-details-marker { display: none; }\n.folds details + details, .folds #frontsec:not(.hidden) + details { border-top: 2px solid var(--line); margin-top: 1.2rem; padding-top: 1.2rem; } .folds #frontsec .metarow { margin-bottom: 0; } .folds .doors { margin-top: 1rem; } .folds > details > .block:first-of-type { margin-top: 1.4rem; }\ndetails.disclosure summary::before { content: \"+\"; font: 700 1.4rem var(--mono); width: 1.4rem; } details.disclosure[open] summary::before { content: \"–\"; }\n.about { display: grid; grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); gap: .6rem 1.2rem; margin-top: 1rem; font-size: 14px; }\n.about small { display: block; color: var(--ink-3); font-size: 12px; } .about div > div { overflow-wrap: anywhere; }\n.console { margin-top: 3rem; }\n.tabs { display: flex; gap: .45rem; flex-wrap: nowrap; align-items: flex-end; margin: 0 0 -2px 1rem; padding: 2px 3rem 2px 0; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; }\n.tabs.fade-r { -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 2.5rem), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 2.5rem), transparent); }\n.tabs.fade-l { -webkit-mask-image: linear-gradient(90deg, transparent, #000 2.5rem); mask-image: linear-gradient(90deg, transparent, #000 2.5rem); }\n.tabs.fade-l.fade-r { -webkit-mask-image: linear-gradient(90deg, transparent, #000 2.5rem, #000 calc(100% - 2.5rem), transparent); mask-image: linear-gradient(90deg, transparent, #000 2.5rem, #000 calc(100% - 2.5rem), transparent); } .tabs::-webkit-scrollbar { display: none; } .tabs a { flex: 0 0 auto; white-space: nowrap; }\n.tabs a { font: 600 14px var(--sans); padding: .55rem 1rem; border: 2px solid var(--ink); border-bottom: 0; border-radius: 12px 12px 0 0; background: var(--head); color: var(--ink-2); text-decoration: none; margin-bottom: -2px; position: relative; z-index: 1; }\n.tabs a.on { color: var(--ink); z-index: 3; padding-top: .7rem; } .tabs a.on:nth-child(4n+1) { background: var(--sun); } .tabs a.on:nth-child(4n+2) { background: var(--peach); } .tabs a.on:nth-child(4n+3) { background: var(--sky); } .tabs a.on:nth-child(4n+4) { background: var(--mint); }\n.count { display: inline-grid; place-items: center; min-width: 1.5em; height: 1.5em; padding: 0 .4em; margin-left: .45rem; border: 1.5px solid var(--ink); border-radius: 999px; background: var(--sun); color: var(--ink); font: 700 11px/1 var(--mono); vertical-align: middle; box-shadow: 1px 1px 0 var(--ink); } .count:empty { display: none; } h2 .count { font-size: 12px; vertical-align: .45em; }\n.panel { display: none; margin: 0; border-radius: 0 18px 18px 18px; position: relative; z-index: 2; }\n.panel.on { display: block; }\n.panel h2 { font-size: 2rem; }\n.block { margin-top: 1.8rem; }\n.two { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; } .two > * { min-width: 0; }\n.actions { display: flex; justify-content: flex-end; margin-top: 1.1rem; } .actions.left { justify-content: flex-start; }\n.addrow { display: flex; gap: .5rem; align-items: center; margin-bottom: .9rem; } .addrow input { flex: 1; min-width: 0; } .addrow label { display: inline-flex; align-items: center; gap: .35rem; flex: 0 0 auto; white-space: nowrap; font-size: 14px; color: var(--ink-2); cursor: pointer; } .addrow label input { flex: none; width: auto; margin: 0; accent-color: var(--forest); } .addrow .ib { width: 38px; height: 38px; flex: 0 0 auto; border-radius: 10px; }\n.choices { display: grid; gap: .5rem; }\n.choices label { display: grid; grid-template-columns: auto 1fr; gap: 0 .7rem; cursor: pointer; padding: .7rem .8rem; border: 2px solid var(--line-2); border-radius: 12px; background: var(--paper); }\n.choices label:has(:checked) { border-color: var(--ink); box-shadow: 3px 3px 0 var(--ink); }\n.choices input { grid-row: span 2; margin: .2rem 0 0; accent-color: var(--forest); }\n.choices b { font-weight: 600; } .choices small { color: var(--ink-2); font-size: 13px; }\n.limits { display: grid; grid-template-columns: 1fr 1fr; gap: .6rem 1.5rem; margin-top: 1.3rem; }\n.limits label { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: .6rem; } .limits label small { color: var(--ink-3); }\n.form { display: grid; grid-template-columns: 1fr 1fr; gap: .9rem 1.5rem; } .form label { display: grid; gap: .3rem; font-weight: 600; font-size: 14px; } .form .wide { grid-column: 1 / -1; }\n.danger-zone { border: 2px solid var(--red); border-radius: 12px; padding: 1rem 1.1rem; background: var(--red-soft); } .danger-zone h3 { color: var(--red); } .form label.switch { display: flex; flex-direction: row; align-items: center; gap: .6rem; font-weight: 600; font-size: 14px; } .switch input { accent-color: var(--forest); width: 18px; height: 18px; }\ntable { width: 100%; border-collapse: collapse; }\nth { text-align: left; font-size: 12px; font-weight: 600; color: var(--ink-3); padding: .4rem .5rem; border-bottom: 2px solid var(--ink); white-space: nowrap; }\ntd { padding: .5rem .5rem; vertical-align: middle; border-bottom: 1px solid var(--line); }\ntbody tr:nth-child(even) td { background: rgba(255,255,255,.55); }\nth.r, td.r { text-align: right; white-space: nowrap; } td.mono, td.dim { white-space: nowrap; } .events td.c:empty::before { content: \"(no content)\"; color: var(--ink-3); }\ntd .ib { width: 26px; height: 26px; box-shadow: 1px 1px 0 var(--ink); } td .ib svg { width: 13px; height: 13px; }\ntd input.txt { padding: .25rem .5rem; font-size: 14px; } td select.role { width: auto; min-width: 7rem; padding: .25rem .4rem; font-size: 13px; } .people-table input.name { width: 7rem; } .people-table input.note { width: 9.5rem; }\n.kind { font-family: var(--mono); font-size: 12px; background: var(--sun); border: 1.5px solid var(--ink); padding: 0 .5rem; border-radius: 999px; font-weight: 500; white-space: nowrap; }\n.events td.c { max-width: 0; width: 100%; overflow-x: auto; white-space: nowrap; scrollbar-width: thin; -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 2rem), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 2rem), transparent); }\n.people-table td.name { width: 12rem; }\n.ev { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 4px; vertical-align: -5px; margin-right: .45rem; font: 500 9px var(--mono); background: var(--line); color: var(--ink-2); font-style: normal; }\n.plain { list-style: none; margin: 0; padding: 0; } .plain li { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: .5rem; padding: .5rem 0; border-bottom: 2px dotted var(--line-2); } .plain li > span { min-width: 0; overflow-wrap: anywhere; } .plain li:last-child { border-bottom: 0; } .plain li.empty { grid-template-columns: 1fr; color: var(--ink-3); }\n.kindline { margin: .4rem 0; color: var(--ink-2); display: flex; flex-wrap: wrap; align-items: center; gap: .35rem; }\n.tag { display: inline-flex; align-items: center; gap: .2rem; font-family: var(--mono); font-size: 13px; padding: .05rem .2rem .05rem .55rem; border: 2px solid var(--ink); border-radius: 999px; background: var(--paper); box-shadow: 1px 1px 0 var(--ink); }\n.tag.plain { padding-right: .55rem; box-shadow: none; border-color: var(--line-2); color: var(--ink-3); } .tag.blk { color: var(--red); }\n.tag .ib { width: 18px; height: 18px; border-width: 1.5px; box-shadow: none; margin-left: .2rem; } .tag .ib svg { width: 9px; height: 9px; }\n.counters { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1.2rem; margin-bottom: 1.6rem; }\n.counter { display: grid; justify-items: center; align-content: center; gap: .15rem; min-height: 7.2rem; text-align: center; padding: 1rem .8rem; border: 2px solid var(--ink); border-radius: 16px; box-shadow: 4px 4px 0 var(--ink); background: var(--paper); }\n.counter:nth-child(1) { background: var(--sun); } .counter:nth-child(2) { background: var(--peach); } .counter:nth-child(3) { background: var(--sky); } .counter:nth-child(4) { background: var(--mint); }\n.counter .big { font: 400 2.6rem/1 var(--display); white-space: nowrap; } .counter small { display: block; margin-top: .35rem; font: 700 12px var(--sans); text-transform: uppercase; letter-spacing: .08em; } .counter .sub { font-size: 12px; color: var(--ink-2); }\n.kbar { display: flex; width: 100%; height: 22px; border: 2px solid var(--ink); border-radius: 999px; overflow: hidden; background: var(--paper); margin-bottom: .35rem; } .kbar i { display: block; height: 100%; border-right: 2px solid var(--ink); } .kbar i:last-child { border-right: 0; }\n.k1 { background: var(--ink); } .k2 { background: var(--forest); } .k3 { background: var(--id-3); } .k4 { background: var(--id-2); } .k5 { background: var(--line-2); }\n.legend { display: flex; flex-wrap: wrap; justify-content: center; gap: .1rem .6rem; font-family: var(--mono); font-size: 10.5px; text-transform: uppercase; } .legend i { display: inline-block; width: 9px; height: 9px; border: 1.5px solid var(--ink); border-radius: 2px; margin-right: .3rem; vertical-align: -1px; }\n.usage { display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: .8rem; } .usage div { padding: .7rem .9rem; border: 2px solid var(--ink); border-radius: 12px; background: var(--paper); } .usage small { display: block; color: var(--ink-3); font-size: 12px; } .usage b { font: 400 1.5rem/1.2 var(--display); }\n@media (max-width: 52rem) { .two, .gauges, .limits, .form, .counters { grid-template-columns: 1fr !important; } .addrow { flex-wrap: wrap; } .mast { grid-template-columns: 1fr; } .mast .right { justify-items: start; text-align: left; } }\n@media (max-width: 40rem) { .events:not(#kinds):not(#jobs) th:first-child, .events:not(#kinds):not(#jobs) td:first-child { display: none; } }\n\n.slegend { justify-content: flex-start; margin: 0 0 1.2rem; }\n#s-totals { margin-bottom: .4rem; }\ntd.keep { white-space: nowrap; } td.keep input { width: 5.5rem; display: inline-block; margin-right: .3rem; } td.keep input::placeholder { color: var(--ink-3); }\n#kinds tr.any td:first-child { font-weight: 600; }\n#kinds td:first-child .kind { display: inline-block; min-width: 5.2em; text-align: center; margin-right: .35rem; }\n/* Tables keep their columns and scroll inside the card on narrow screens instead of pushing the page sideways. */\n.scroll { overflow-x: auto; max-width: 100%; scrollbar-width: thin; } .scroll table { min-width: 100%; }\nsection, .card { overflow-x: clip; }\n.kind.sys { background: var(--mint); border-color: var(--forest); color: var(--forest); }\ntd.keep.sys { color: var(--ink-3); font-size: 13px; white-space: normal; }\n\n#console.mod .tabs a:not([data-tab=people]):not([data-tab=moderation]) { display: none; } #console.mod #thresholdform { display: none; }\n#members select.role { width: auto; padding: .1rem .3rem; font-size: 12px; margin-left: .3rem; }\n.wire-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .5rem 1.2rem; padding: .8rem 0; border-bottom: 1px solid var(--line); align-items: start; } .wire-row:first-child { border-top: 2px solid var(--ink); }\n.wire-main { min-width: 0; } .wire-side { display: grid; justify-items: end; gap: .45rem; max-width: 26rem; }\n.wire-acts { display: flex; gap: .4rem; white-space: nowrap; } .wire-acts .btn { padding: .35rem .75rem; font-size: 13px; }\n.wire-meta { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .35rem .5rem; align-items: center; font-size: 13px; color: var(--ink-2); text-align: right; } .wire-meta:empty { display: none; }\n.pill { display: inline-block; padding: .1rem .6rem; border: 1.5px solid var(--ink); border-radius: 999px; font: 600 12px var(--sans); color: var(--ink); background: var(--paper); } .pill.on { background: var(--mint); } .pill.off { color: var(--ink-3); } .pill.bad { color: var(--red); }\n.chip { display: inline-block; padding: .05rem .45rem; border: 1px solid var(--forest); border-radius: 6px; font: 12px var(--mono); color: var(--forest); } .chip.bad { border-color: var(--red); color: var(--red); }\n.share { display: grid; grid-template-columns: minmax(0, 3fr) minmax(14rem, 2fr); gap: 1.4rem; align-items: start; } .share #cardimg { width: 100%; height: auto; display: block; }\n.share-side { display: grid; gap: .8rem; justify-items: start; } .naddr { display: block; font-size: 12px; line-height: 1.5; word-break: break-all; color: var(--ink-2); }\n@media (max-width: 52rem) { .share { grid-template-columns: 1fr; } .wire-side { display: contents; } .wire-acts { justify-self: end; } .wire-meta { grid-column: 1 / -1; justify-content: flex-start; text-align: left; } }\n#presets .btn { margin: 0 .4rem .4rem 0; }\n.panel form h3.gap { margin-top: 1.4rem; }\n.key { position: relative; display: inline-block; font-family: var(--mono); } .key::before { content: attr(data-short); } .key .full { position: absolute; left: 0; top: 0; width: 1px; height: 1px; overflow: hidden; opacity: 0; white-space: nowrap; }\n.mast .banner { grid-column: 1 / -1; width: 100%; aspect-ratio: 4 / 1; overflow: hidden; border: 2px solid var(--ink); border-radius: 18px; box-shadow: 4px 4px 0 var(--ink); background: var(--paper); margin-bottom: .5rem; } .mast .banner img { width: 100%; height: 100%; object-fit: cover; display: block; }\n.mast .wordmark { display: flex; align-items: center; gap: 1rem; } .mast .icon { width: 64px; height: 64px; border-radius: 50%; border: 2px solid var(--ink); box-shadow: 3px 3px 0 var(--ink); background: var(--paper); object-fit: cover; }\n.metarow { display: flex; flex-wrap: wrap; gap: .4rem .6rem; align-items: center; margin: -.4rem 0 1rem; font-size: 13px; color: var(--ink-2); }\n.metarow .tag { display: inline-block; padding: .1rem .6rem; border: 1.5px solid var(--ink); border-radius: 999px; font: 600 12px var(--sans); color: var(--ink); background: var(--sun); }\n.metarow .sep { color: var(--ink-3); } .metarow a { color: var(--forest); text-decoration: none; border-bottom: 1.5px solid var(--forest); } .metarow a:hover { border-bottom-width: 2px; }\n.about a { color: var(--forest); text-decoration: none; border-bottom: 1.5px solid var(--forest); }\n.wire-acts label { display: inline-flex; align-items: center; gap: .35rem; font-size: 14px; color: var(--ink-2); cursor: pointer; } .wire-acts label input { margin: 0; accent-color: var(--forest); } .wire-row.dim .wire-main { color: var(--ink-3); }\n"; -export const CONSOLE_JS = "(async () => {\n const $ = (s) => document.querySelector(s);\n const $$ = (s) => document.querySelectorAll(s);\n const host = location.host;\n const wsURL = (location.protocol === \"https:\" ? \"wss://\" : \"ws://\") + host;\n const rpcURL = location.origin + \"/\";\n let info = null, me = null, owner = \"\", policy = null, fuel = null, people = null, myRole = \"\";\n\n const toast = (msg) => { const t = $(\"#toast\"); t.textContent = msg; t.classList.add(\"show\"); clearTimeout(t._t); t._t = setTimeout(() => t.classList.remove(\"show\"), 2600); };\n // guard wraps a handler: disables the button, shows errors as toasts. Defined first, since handlers below use it.\n const guard = (fn) => async (ev) => { ev.preventDefault(); const b = ev.submitter || ev.target; if (b) b.disabled = true; try { await fn(ev); } catch (e) { toast(e.message); } finally { if (b) b.disabled = false; } };\n const short = (hex) => hex ? hex.slice(0, 8) + \"…\" + hex.slice(-4) : \"\";\n // key shows a key short but keeps the whole of it in the DOM: selecting, double-clicking or clicking it copies the full hex.\n const key = (hex) => hex ? '' + hex + '' : '';\n const hue = (hex) => (parseInt(hex.slice(0, 2), 16) * 360 / 256).toFixed(0) + \"deg\";\n const av = (hex) => '';\n const fmtBytes = (n) => n < 1e6 ? (n / 1e3).toFixed(0) + \" KB\" : n < 1e9 ? (n / 1e6).toFixed(1) + \" MB\" : (n / 1e9).toFixed(2) + \" GB\";\n const fmtHours = (ms) => ms < 3600e3 ? Math.round(ms / 60e3) + \" min\" : (ms / 3600e3).toFixed(ms < 36e6 ? 1 : 0) + \" h\";\n const fuelOver = () => !!fuel && (fuel.eventBytes > fuel.freeEventBytes || fuel.mediaBytes > fuel.freeMediaBytes || fuel.activeMs > fuel.freeActiveMs || fuel.rowsWritten > fuel.freeRowsWritten);\n const fmtTime = (t) => t ? new Date(t * 1000).toLocaleString(undefined, { month: \"short\", day: \"numeric\", hour: \"2-digit\", minute: \"2-digit\" }) : \"–\";\n const fmtDay = (t) => t ? new Date(t * 1000).toLocaleDateString(undefined, { month: \"short\", day: \"numeric\" }) : \"–\";\n const ago = (t) => { const s = Math.max(0, Math.floor(Date.now() / 1000) - t); return s < 60 ? \"just now\" : s < 3600 ? Math.floor(s / 60) + \" min\" : s < 86400 ? Math.floor(s / 3600) + \" h\" : Math.floor(s / 86400) + \" d\"; };\n const esc = (s) => String(s ?? \"\").replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c]);\n\n const IC = {\n x: '',\n undo: '',\n check: '',\n trash: '',\n ban: '',\n banuser: '',\n eye: '',\n lock: '',\n pen: '',\n people: '',\n person: '',\n gauge: '',\n bolt: '',\n copy: '',\n pin: '',\n };\n const ib = (icon, label, act, id, extra) => '\";\n\n const CH = \"qpzry9x8gf2tvdw0s3jn54khce6mua7l\";\n function npubToHex(s) {\n s = s.trim().toLowerCase();\n if (/^[0-9a-f]{64}$/.test(s)) return s;\n if (!s.startsWith(\"npub1\")) return null;\n const data = s.slice(5, -6).split(\"\").map((c) => CH.indexOf(c));\n if (data.some((d) => d < 0)) return null;\n let bits = 0, acc = 0, out = [];\n for (const d of data) { acc = (acc << 5) | d; bits += 5; if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 255); } }\n return out.length === 32 ? out.map((b) => b.toString(16).padStart(2, \"0\")).join(\"\") : null;\n }\n\n async function sha256hex(s) {\n const b = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(s));\n return [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, \"0\")).join(\"\");\n }\n // ---- signing: a NIP-07 extension, or a NIP-46 remote signer ----\n // The remote path loads the bundled library on first use; its session\n // (client key, signer pubkey, relays) lives in localStorage until sign-out.\n let remote = null, lib = null, pendingNote = null;\n const NO_SIGNER = \"Install a nostr extension (Alby, nos2x, …) or connect a remote signer.\";\n const withTimeout = (p, ms, what) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(what + \" did not answer; is the signer app open?\")), ms))]);\n const signer = {\n ready: () => !!remote || !!window.nostr,\n async getPublicKey() { if (remote) return withTimeout(remote.getPublicKey(), 20000, \"The remote signer\"); if (window.nostr) return window.nostr.getPublicKey(); throw new Error(NO_SIGNER); },\n async signEvent(ev) { if (remote) return withTimeout(remote.signEvent(ev), 60000, \"The remote signer\"); if (window.nostr) return window.nostr.signEvent(ev); throw new Error(NO_SIGNER); },\n };\n async function signerLib() {\n if (lib) return lib;\n if (!window.NostrSigner) await new Promise((res, rej) => { const s = document.createElement(\"script\"); s.src = window.SIGNER_URL || \"/signer.js\"; s.onload = res; s.onerror = () => rej(new Error(\"Could not load the signer library.\")); document.head.appendChild(s); });\n return (lib = window.NostrSigner);\n }\n const onauth = (u) => window.open(u, \"_blank\");\n // The signer conversation rides this relay, and a relay may ask the socket to\n // authenticate before it delivers kind 24133 to its parties; answer with the\n // session's own key.\n const authPool = (L, sk) => new L.SimplePool({ automaticallyAuth: () => (evt) => Promise.resolve(L.finalizeEvent(evt, sk)) });\n const saveSession = (sk, s, secret) => localStorage.setItem(\"nip46\", JSON.stringify({ sk: lib.bytesToHex(sk), pubkey: s.bp.pubkey, relays: s.bp.relays, secret: secret || null }));\n async function resumeRemote() {\n const raw = localStorage.getItem(\"nip46\");\n if (!raw) return false;\n try {\n const s = JSON.parse(raw); const L = await signerLib();\n remote = L.BunkerSigner.fromBunker(L.hexToBytes(s.sk), { pubkey: s.pubkey, relays: s.relays, secret: s.secret }, { onauth, pool: authPool(L, L.hexToBytes(s.sk)) });\n return true;\n } catch { localStorage.removeItem(\"nip46\"); return false; }\n }\n async function connectBunker(input) {\n const L = await signerLib();\n const bp = await L.parseBunkerInput(input.trim());\n if (!bp) throw new Error(\"That is not a bunker:// URL.\");\n if (!bp.relays.length) throw new Error(\"The bunker URL names no relay.\");\n const sk = L.generateSecretKey();\n const s = L.BunkerSigner.fromBunker(sk, bp, { onauth, pool: authPool(L, sk) });\n await withTimeout(s.connect({ name: host, url: location.origin }), 60000, \"The signer\");\n await Promise.race([s.switchRelays(), new Promise((r) => setTimeout(r, 3000))]);\n remote = s; saveSession(sk, s, bp.secret);\n }\n // The nostrconnect:// flow: this relay carries the traffic, so no third relay is involved.\n let ncPending = null;\n async function offerNostrConnect() {\n if (ncPending) return ncPending;\n const L = await signerLib();\n const sk = L.generateSecretKey();\n const secret = L.bytesToHex(crypto.getRandomValues(new Uint8Array(8)));\n const uri = L.createNostrConnectURI({ clientPubkey: L.getPublicKey(sk), relays: [wsURL], secret, name: host, url: location.origin, perms: [\"sign_event:27235\", \"sign_event:9734\"] });\n $(\"#nclink\").href = uri; $(\"#ncnote\").textContent = \"\";\n $(\"#ncqr\").src = \"/qr.svg?text=\" + encodeURIComponent(uri); $(\"#ncqr\").classList.remove(\"hidden\");\n ncPending = L.BunkerSigner.fromURI(sk, uri, { onauth, pool: authPool(L, sk) }, 600000).then((s) => { remote = s; saveSession(sk, s, secret); ncPending = null; return s; }, (e) => { ncPending = null; throw e; });\n return ncPending;\n }\n async function remoteDone() {\n me = await signer.getPublicKey();\n localStorage.setItem(\"me\", me);\n $(\"#remotesec\").classList.add(\"hidden\");\n toast(\"Remote signer connected\");\n if (pendingNote && !owner) { const n = pendingNote; pendingNote = null; await claimNow(n); return; }\n pendingNote = null;\n renderHeader(); await loadAdmin(); await loadPeople();\n }\n function showRemote(note) {\n pendingNote = note || null;\n $(\"#remotesec\").classList.remove(\"hidden\");\n $(\"#remotesec\").scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n offerNostrConnect().then(remoteDone).catch((e) => { $(\"#ncnote\").textContent = e.message; });\n }\n\n async function rpc(method, ...params) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const body = JSON.stringify({ method, params });\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", rpcURL], [\"method\", \"POST\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(rpcURL, { method: \"POST\", headers: { \"content-type\": \"application/nostr+json+rpc\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body });\n const json = await resp.json();\n if (json.error) throw new Error(json.error);\n return json.result;\n }\n\n // ---- front of house ----\n // urlish shows a URL as a short link: the host and the start of the path, the whole thing on hover and as the target.\n class Html { constructor(s) { this.s = s; } }\n const urlish = (u) => {\n if (!u) return \"\";\n let label = u; try { const x = new URL(u); label = x.host + (x.pathname.length > 1 ? x.pathname.slice(0, 18) + (x.pathname.length > 18 ? \"\\u2026\" : \"\") : \"\"); } catch { /* not a URL */ }\n if (!/^https?:\\/\\//.test(u)) return u;\n return new Html('' + esc(label) + \"\");\n };\n function renderHeader() {\n const name = (info && info.name) || host.split(\".\")[0];\n $(\"#title\").textContent = name;\n document.title = name + \" - relay\";\n $(\"#url\").textContent = wsURL;\n $(\"#d-ws\").textContent = wsURL; $(\"#d-blossom\").textContent = location.origin; $(\"#d-nip05\").textContent = \"you@\" + host;\n $(\"#nip05-example\").textContent = \"alice@\" + host;\n const desc = (info && info.description) || \"\";\n $(\"#desc\").textContent = desc; $(\"#desc\").classList.toggle(\"hidden\", !desc);\n // What the owner declared about the relay, on the page, not only in the document clients read.\n const img = (id, url) => { const el = $(id); if (url) el.src = url; el.classList.toggle(\"hidden\", !url); };\n img(\"#bannerimg\", info?.banner); $(\"#banner\").classList.toggle(\"hidden\", !info?.banner);\n img(\"#iconimg\", info?.icon);\n const meta = [];\n for (const t of info?.tags || []) meta.push('' + esc(t) + \"\");\n const where = [...(info?.language_tags || []), ...(info?.relay_countries || [])];\n if (where.length) meta.push(\"\" + esc(where.join(\", \")) + \"\");\n const link = (label, href) => href ? '' + label + \"\" : \"\";\n const contact = info?.contact ? (/^(mailto:|https?:\\/\\/)/.test(info.contact) ? link(\"Contact\", info.contact) : \"\" + esc(info.contact) + \"\") : \"\";\n for (const x of [contact, link(\"Terms\", info?.terms_of_service), link(\"Posting policy\", info?.posting_policy), link(\"Privacy\", info?.privacy_policy)]) if (x) meta.push(x);\n $(\"#metarow\").innerHTML = meta.join('|'); $(\"#metarow\").classList.toggle(\"hidden\", meta.length === 0);\n $(\"#frontsec\").classList.toggle(\"hidden\", !desc && meta.length === 0);\n owner = (info && info.pubkey) || \"\";\n const isOwner = !!me && me === owner;\n const lease = !owner && info && info.lease ? info.lease : null;\n $(\"#ownerline\").innerHTML = owner ? 'run by ' + key(owner) + \"\" : lease ? \"temporary until \" + fmtDay(lease.expires_at) : \"unclaimed\";\n $(\"#owner-av\").classList.toggle(\"hidden\", !owner);\n if (owner) $(\"#owner-av\").style.setProperty(\"--h\", hue(owner));\n $(\"#who\").textContent = me ? (isOwner ? \"(that's you)\" : \"\") : \"\";\n $(\"#unclaimed\").classList.toggle(\"hidden\", !!owner || !!lease);\n $(\"#leased\").classList.toggle(\"hidden\", !lease);\n if (lease) {\n $(\"#lease-until\").textContent = fmtTime(lease.expires_at);\n $(\"#leasenote\").textContent = lease.holder ? \"Reserved for the key that asked for it; sign with that key.\" : \"\";\n }\n $(\"#signin\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signin46\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signout\").classList.toggle(\"hidden\", !me);\n $(\"#about\").innerHTML = [\n [\"Name\", info?.name], [\"Description\", info?.description], [\"Contact\", info?.contact], [\"Owner\", owner ? short(owner) : \"\"], [\"Relay key\", info?.self ? short(info.self) : \"\"], [\"Software\", urlish(info?.software)], [\"Version\", info?.version],\n [\"Max query\", info?.limitation?.max_limit], [\"Auth required\", String(!!info?.limitation?.auth_required)], [\"Restricted writes\", String(!!info?.limitation?.restricted_writes)], [\"Min PoW\", info?.limitation?.min_pow_difficulty || 0],\n [\"Tags\", info?.tags?.join(\", \")], [\"Languages\", info?.language_tags?.join(\", \")], [\"Countries\", info?.relay_countries?.join(\", \")],\n [\"Terms\", urlish(info?.terms_of_service)], [\"Posting policy\", urlish(info?.posting_policy)], [\"Privacy policy\", urlish(info?.privacy_policy)], [\"Icon\", urlish(info?.icon)], [\"Banner\", urlish(info?.banner)],\n [\"NIPs\", info?.supported_nips?.join(\" \")],\n ].map(([k, v]) => \"
                                                                                                                                              \" + k + \"\" + (v === undefined || v === \"\" ? '' : v instanceof Html ? v.s : esc(v)) + \"
                                                                                                                                              \").join(\"\");\n renderCare();\n }\n\n function renderCare() {\n const g = (icon, label, off) => '' + IC[icon] + \"\" + label + \"\";\n const writes = policy ? policy.writes : (info?.limitation?.restricted_writes ? \"allowlist\" : \"open\");\n const reads = policy ? policy.reads : (info?.limitation?.auth_required ? \"auth\" : \"open\");\n let out = writes === \"owner\" ? g(\"person\", \"only owner writes\") : writes === \"allowlist\" ? g(\"people\", \"members write\") : writes === \"wot\" ? g(\"people\", \"members and follows write\") : g(\"pen\", \"anyone writes\");\n out += reads === \"members\" ? g(\"people\", \"members read\") : reads === \"auth\" ? g(\"lock\", \"sign in to read\") : g(\"eye\", \"anyone reads\");\n if (fuel) {\n const over = fuelOver();\n out += fuel.outOfFuel ? g(\"gauge\", \"out of fuel\", true) : over ? g(\"bolt\", \"burning sats\") : g(\"gauge\", \"on free allowance\");\n }\n $(\"#care\").innerHTML = out;\n }\n\n async function loadPeople() {\n try { people = await (await fetch(\"/people\")).json(); } catch { return; }\n const isOwner = !!me && me === owner;\n let list = people.people || [];\n if (!people.public && isOwner && policy) list = (window.__members || []);\n $(\"#peoplesec\").classList.toggle(\"hidden\", !owner || list.length === 0);\n $(\"#people-note\").classList.toggle(\"hidden\", people.public);\n $(\"#people-count\").textContent = list.length || \"\";\n $(\"#dir\").innerHTML = list.map((m) => '' + av(m.pubkey) + '' + (m.name ? esc(m.name) + \"@\" + host : key(m.pubkey)) + \"\" + (m.role === \"owner\" ? 'owner' : \"\") + \"\").join(\"\");\n }\n\n async function loadInfo() {\n info = await (await fetch(\"/\", { headers: { accept: \"application/nostr+json\" } })).json();\n renderHeader();\n const git = info.supported_grasps?.includes(\"GRASP-01\");\n $(\"#git-connect\").classList.toggle(\"hidden\", !git);\n const clone = \"git clone '\" + location.origin + \"//.git'\";\n $(\"#git-clone\").textContent = clone;\n $(\"#git-copy\").dataset.copytext = clone;\n renderApps();\n }\n\n async function loadFuel() {\n try { fuel = await (await fetch(\"/fuel\")).json(); } catch { return; }\n $(\"#fuelsec\").classList.toggle(\"hidden\", !owner);\n const pct = (used, free) => Math.min(100, Math.round((used / Math.max(free, 1)) * 100));\n const gauge = (id, used, free, text) => { const g = $(\"#g-\" + id), share = free ? used / free : 0; g.style.width = pct(used, free) + \"%\"; g.classList.toggle(\"over\", share > 1); g.classList.toggle(\"warm\", share > 0.75 && share <= 1); $(\"#p-\" + id).textContent = share > 1 ? \"over\" : Math.round(share * 100) + \"%\"; $(\"#t-\" + id).textContent = text; };\n gauge(\"events\", fuel.eventBytes, fuel.freeEventBytes, fmtBytes(fuel.eventBytes) + \" of \" + fmtBytes(fuel.freeEventBytes) + \" free\");\n gauge(\"media\", fuel.mediaBytes, fuel.freeMediaBytes, fmtBytes(fuel.mediaBytes) + \" of \" + fmtBytes(fuel.freeMediaBytes) + \" free\");\n gauge(\"active\", fuel.activeMs, fuel.freeActiveMs, fmtHours(fuel.activeMs) + \" of \" + fmtHours(fuel.freeActiveMs) + \" free\");\n gauge(\"rows\", fuel.rowsWritten, fuel.freeRowsWritten, fuel.rowsWritten.toLocaleString() + \" of \" + fuel.freeRowsWritten.toLocaleString() + \" free\");\n const sats = Math.floor(fuel.balanceMsats / 1000);\n const r = fuel.rates;\n $(\"#fuel-balance\").innerHTML = fuel.outOfFuel\n ? 'out of fuel Writes are paused until someone tops up.'\n : \"Balance \" + sats.toLocaleString() + \" sats. Past the allowances, prices track what the hosting costs: \" + r.satsPerGBMonthEvents.toLocaleString() + \" sats per GB-month of events, \" + r.satsPerGBMonthMedia.toLocaleString() + \" per GB-month of files, \" + r.satsPerActiveHour.toLocaleString() + \" per hour awake, \" + r.satsPerMillionRows.toLocaleString() + \" per million rows written. Traffic is free.\";\n $(\"#topup\").classList.toggle(\"hidden\", !fuel.enabled);\n // Who paid is the owner's to see: it comes with the signed stats call.\n let credits = [];\n if (owner && signer.ready()) { try { credits = (await rpc(\"stats\")).credits || []; } catch { credits = []; } }\n $(\"#credits tbody\").innerHTML = credits.length ? credits.map((c) => '' + esc(fmtTime(c.at)) + '' + av(c.payer) + key(c.payer) + '' + Math.floor(c.msats / 1000).toLocaleString() + \"\").join(\"\") : 'no zaps yet';\n if (!fuel.enabled) $(\"#topup-note\").textContent = \"Top-ups are not enabled on this service yet.\";\n renderCare();\n renderFuelTile();\n }\n\n async function topUp(sats) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const msats = Math.round(sats * 1000);\n const zapRequest = await signer.signEvent({ kind: 9734, created_at: Math.floor(Date.now() / 1000), content: \"fuel for \" + host, tags: [[\"p\", fuel.servicePubkey], [\"amount\", String(msats)], [\"relays\", wsURL]] });\n const r = await (await fetch(\"/fuel/invoice\", { method: \"POST\", body: JSON.stringify({ zapRequest }) })).json();\n if (r.error) throw new Error(r.error);\n const inv = r.invoice;\n $(\"#invoice\").classList.remove(\"hidden\");\n $(\"#inv-text\").value = inv; $(\"#inv-link\").href = \"lightning:\" + inv; $(\"#inv-state\").textContent = \"waiting for payment…\";\n if (window.webln) { try { await window.webln.enable(); await window.webln.sendPayment(inv); } catch (e) { toast(e.message || \"wallet declined\"); } }\n const before = fuel.creditedMsats;\n for (let i = 0; i < 120; i++) {\n await new Promise((r) => setTimeout(r, 2500));\n await loadFuel();\n if (fuel.creditedMsats > before) { $(\"#inv-state\").textContent = \"paid, thank you\"; toast(\"Fuel credited\"); return; }\n }\n $(\"#inv-state\").textContent = \"no receipt yet; it can take a minute after paying\";\n }\n\n // ---- console ----\n function showTab(name) {\n if (name === \"content\" || name === \"storage\") name = \"data\";\n const known = myRole === \"moderator\" ? [\"people\", \"moderation\"] : [\"people\", \"moderation\", \"rules\", \"identity\", \"data\", \"sync\", \"views\", \"health\", \"owner\"];\n if (!known.includes(name)) name = \"people\";\n $$(\".tabs a\").forEach((a) => a.classList.toggle(\"on\", a.dataset.tab === name));\n $$(\".panel\").forEach((p) => p.classList.toggle(\"on\", p.dataset.panel === name));\n }\n // Tabs switch in place. A fragment still opens a tab when someone arrives with one,\n // but clicking never writes one, and any fragment already there is dropped.\n $$(\".tabs a\").forEach((a) => a.addEventListener(\"click\", (ev) => { ev.preventDefault(); showTab(a.dataset.tab); if (location.hash) history.replaceState(null, \"\", location.pathname + location.search); }));\n window.addEventListener(\"hashchange\", () => showTab(location.hash.slice(1)));\n // The strip fades on whichever side has more tabs off screen.\n const tabsFade = () => { const t = $(\"#tabs\"); t.classList.toggle(\"fade-l\", t.scrollLeft > 4); t.classList.toggle(\"fade-r\", t.scrollLeft + t.clientWidth < t.scrollWidth - 4); };\n $(\"#tabs\").addEventListener(\"scroll\", tabsFade); window.addEventListener(\"resize\", tabsFade); new ResizeObserver(tabsFade).observe($(\"#tabs\"));\n\n function renderFuelTile() {\n if (!fuel) return;\n const over = fuelOver();\n const sats = Math.max(0, Math.floor(fuel.balanceMsats / 1000));\n const d = new Date(), end = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1), days = Math.max(1, Math.ceil((end - Date.now()) / 86400000));\n if (fuel.outOfFuel) { $(\"#h-fuel\").textContent = \"0 sats\"; $(\"#h-fuel-label\").textContent = \"out of fuel\"; $(\"#h-fuel-sub\").textContent = \"writes are paused\"; }\n else if (over) { $(\"#h-fuel\").textContent = sats.toLocaleString() + \" sats\"; $(\"#h-fuel-label\").textContent = \"fuel left\"; $(\"#h-fuel-sub\").textContent = \"past the free allowance\"; }\n else { $(\"#h-fuel\").textContent = days + (days === 1 ? \" day\" : \" days\"); $(\"#h-fuel-label\").textContent = \"free allowance left\"; $(\"#h-fuel-sub\").textContent = sats ? \"then \" + sats.toLocaleString() + \" sats\" : \"then a top-up is needed\"; }\n $(\"#usage\").innerHTML = [[\"events stored\", fmtBytes(fuel.eventBytes)], [\"files stored\", fmtBytes(fuel.mediaBytes)], [\"awake this month\", fmtHours(fuel.activeMs)], [\"rows written\", fuel.rowsWritten.toLocaleString()], [\"rows read\", fuel.rowsRead.toLocaleString()], [\"received this month\", fmtBytes(fuel.bytesIn)], [\"served this month\", fmtBytes(fuel.bytesOut)], [\"charged\", Math.floor(fuel.chargedMsats / 1000).toLocaleString() + \" sats\"]]\n .map(([k, v]) => \"
                                                                                                                                              \" + k + \"\" + v + \"
                                                                                                                                              \").join(\"\");\n }\n\n function renderHealth(stats) {\n $(\"#h-last\").textContent = stats.newest ? ago(stats.newest) : \"none\";\n $(\"#h-last-sub\").textContent = stats.newest ? fmtTime(stats.newest) : \"no events yet\";\n $(\"#h-conns\").textContent = stats.connections;\n const buckets = [[\"notes\", \"k1\", [1]], [\"reactions\", \"k2\", [7]], [\"DMs\", \"k3\", [4, 14, 1059]], [\"long-form\", \"k4\", [30023]], [\"other\", \"k5\", null]];\n const sums = buckets.map(() => 0); let total = 0;\n for (const { kind, n } of stats.kinds || []) { total += n; const i = buckets.findIndex((b) => b[2] && b[2].includes(kind)); sums[i < 0 ? 4 : i] += n; }\n const parts = buckets.map((b, i) => [b[0], b[1], sums[i]]).filter((p) => p[2] > 0);\n $(\"#h-kinds\").innerHTML = total ? parts.map(([name, cls, n]) => '').join(\"\") : '';\n $(\"#h-kinds-legend\").innerHTML = total ? parts.map(([name, cls, n]) => '' + name + \" \" + Math.round(n * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n renderFuelTile();\n }\n\n async function loadAdmin() {\n const isOwner = !!me && me === owner;\n myRole = isOwner ? \"owner\" : \"\";\n let stats = null, p = null;\n // A signed-in member may be a moderator; the relay says by answering stats.\n if (me && owner && !isOwner) { try { [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]); myRole = \"moderator\"; } catch { myRole = \"\"; } }\n $(\"#console\").classList.toggle(\"hidden\", !myRole);\n loadMine();\n $(\"#console\").classList.toggle(\"mod\", myRole === \"moderator\");\n if (!myRole) { policy = null; renderCare(); return; }\n if (isOwner) [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]);\n policy = p;\n renderHealth(stats);\n renderCare();\n const fa = $(\"#access\"), fi = $(\"#identity\");\n fa.writes.value = p.writes; fa.reads.value = p.reads; fa.openKinds.value = (p.openKinds || []).join(\", \"); fa.guestReplies.checked = !!p.guestReplies;\n $(\"#wordsform\").words.value = (p.blockedWords || []).join(\"\\n\"); $(\"#wordsform\").inTags.checked = !!p.blockedWordsInTags; $(\"#thresholdform\").reportThreshold.value = p.reportThreshold || 0; fa.minPow.value = p.minPow; fa.maxFuture.value = p.maxFuture; fa.maxLimit.value = p.maxLimit; fa.maxSubs.value = p.maxSubs; fa.maxMessageKB.value = p.maxMessageKB;\n fa.eventsPerMinute.value = p.eventsPerMinute; fa.reqsPerMinute.value = p.reqsPerMinute; fa.maxBlobMB.value = p.maxBlobMB;\n renderFeatures(p.features || {});\n $(\"#push-policy-form\").elements.origins.value = (p.pushCallbacks || []).join(\"\\n\");\n $(\"#push-policy-form\").elements.lettered.checked = !!p.letteredNips;\n $(\"#push-policy-form\").elements.delivery.checked = !!p.delivery?.enabled;\n $(\"#push-policy-form\").elements.deliveryMax.value = p.delivery?.maxTargets || 8;\n fi.name.value = p.name; fi.contact.value = p.contact; fi.description.value = p.description; fi.icon.value = p.icon;\n const fj = $(\"#joinform\"); fj.joinTerms.value = p.joinTerms; fj.directoryPublic.checked = !!p.directoryPublic;\n loadCard();\n fi.banner.value = p.banner || \"\"; fi.postingPolicy.value = p.postingPolicy || \"\"; fi.privacyPolicy.value = p.privacyPolicy || \"\";\n fi.tags.value = (p.tags || []).join(\", \"); fi.languageTags.value = (p.languageTags || []).join(\", \"); fi.relayCountries.value = (p.relayCountries || []).join(\", \");\n const fn = $(\"#notify\"), nt = p.notify || {};\n fn.reports.checked = !!nt.reports; fn.fuel.checked = !!nt.fuel; fn.jobs.checked = !!nt.jobs; fn.succession.checked = !!nt.succession; fn.digest.checked = !!nt.digest;\n showTab(location.hash.slice(1));\n if (isOwner) { await renderPresets(); renderWire(); renderSuccession(); loadDomains(); }\n await Promise.all([loadLists(), loadEvents(true), loadPins(), ...(isOwner ? [loadStorage()] : [])]);\n }\n\n async function loadAudit(before) {\n const rows = await rpc(\"listaudit\", before || 0);\n const html = rows.map((r) => '' + esc(fmtTime(r.at)) + '' + av(r.actor) + key(r.actor) + '' + esc(r.action) + '' + (/^[0-9a-f]{64}$/.test(r.target) ? key(r.target) : esc(r.target)) + '' + esc(r.detail) + '').join(\"\");\n const tb = $(\"#audit tbody\");\n if (before) tb.insertAdjacentHTML(\"beforeend\", html); else tb.innerHTML = html || 'Nothing yet';\n $(\"#audit-more\").style.display = rows.length < 100 ? \"none\" : \"\";\n }\n $(\"#audit-more\").onclick = guard(async () => { const last = $(\"#audit tbody tr:last-child\"); await loadAudit(last ? +last.dataset.seq : 0); });\n async function loadLists() {\n loadAudit(0);\n const [mem, bans, bannedEvents, allow, block, invites, reports, blobs, blocks, sites] = await Promise.all([rpc(\"listmembers\"), rpc(\"listbannedpubkeys\"), rpc(\"listbannedevents\"), rpc(\"listallowedkinds\"), rpc(\"listblockedkinds\"), rpc(\"listinvites\"), rpc(\"listreports\"), rpc(\"listblobs\", 100), rpc(\"listblockedips\"), rpc(\"listsites\")]);\n const members = mem.members;\n window.__members = members;\n const roleCell = (m) => m.role === \"owner\" ? ' owner' : myRole === \"owner\" ? ' ' : m.role === \"moderator\" ? ' moderator' : \"\";\n const untouchable = (m) => m.role === \"owner\" || (myRole !== \"owner\" && m.role === \"moderator\");\n // The tree: everyone under whoever invited them, the owner and the owner's own additions at the root.\n const known = new Set(members.map((m) => m.pubkey));\n const byInviter = new Map();\n for (const m of members) { const k = m.role !== \"owner\" && known.has(m.invited_by) ? m.invited_by : \"\"; if (!byInviter.has(k)) byInviter.set(k, []); byInviter.get(k).push(m); }\n const ordered = [], placed = new Set();\n const walk = (k, depth) => { for (const m of byInviter.get(k) || []) { if (placed.has(m.pubkey)) continue; placed.add(m.pubkey); m.depth = depth; ordered.push(m); walk(m.pubkey, depth + 1); } };\n walk(\"\", 0);\n for (const m of members) if (!placed.has(m.pubkey)) { m.depth = 0; ordered.push(m); }\n const nameOf = (pk) => { const x = members.find((m) => m.pubkey === pk); return x && x.name ? x.name : short(pk); };\n const limits = (m) => m.role === \"owner\" ? \"\" : myRole === \"owner\"\n ? ''\n : '' + (m.keep_days ? m.keep_days + \" d\" : \"\") + (m.max_bytes ? \" \" + fmtBytes(m.max_bytes) : \"\") + \"\";\n $(\"#members tbody\").innerHTML = ordered.map((m) => '' + av(m.pubkey) + key(m.pubkey) + roleCell(m) + (m.depth ? ' via ' + esc(nameOf(m.invited_by)) + \"\" : \"\") + (m.invites ? ' ' + m.invites + \" inv\" : \"\") + '' + limits(m) + '' + esc(fmtDay(m.joined_at)) + \", \" + esc(m.via) + '' + ib(\"check\", \"Save\", \"savemember\", m.pubkey) + (untouchable(m) ? \"\" : ib(\"x\", \"Remove\", \"removemember\", m.pubkey) + ib(\"banuser\", \"Ban\", \"banpubkey\", m.pubkey, \"danger\")) + \"\").join(\"\");\n const tf = $(\"#treeform\");\n tf.classList.toggle(\"hidden\", myRole !== \"owner\");\n if (policy && policy.memberInvites) { tf.depth.value = policy.memberInvites.depth; tf.quota.value = policy.memberInvites.quota; }\n $(\"#transfer [name=pubkey]\").innerHTML = members.filter((m) => m.role !== \"owner\").map((m) => '\").join(\"\");\n $(\"#succession [name=heir]\").innerHTML = $(\"#transfer [name=pubkey]\").innerHTML;\n $(\"#tc-people\").textContent = members.length;\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#invites\").innerHTML = invites.length ? invites.map((i) => '
                                                                                                                                            • inv…' + i.code.slice(-8) + \" \" + esc(i.note || \"\") + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
                                                                                                                                            • \").join(\"\") : '
                                                                                                                                            • no invites
                                                                                                                                            • ';\n const person = (r, icon, label, act) => \"
                                                                                                                                            • \" + av(r.pubkey) + '' + short(r.pubkey) + \" \" + esc(r.reason || \"\") + \"\" + ib(icon, label, act, r.pubkey) + \"
                                                                                                                                            • \";\n $(\"#bans\").innerHTML = bans.length ? bans.map((r) => person(r, \"undo\", \"Unban\", \"unrulepubkey\")).join(\"\") : '
                                                                                                                                            • nobody banned
                                                                                                                                            • ';\n $(\"#blocks\").innerHTML = blocks.length ? blocks.map((r) => '
                                                                                                                                            • ip' + esc(r.ip) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"undo\", \"Unblock\", \"unblockip\", r.ip) + \"
                                                                                                                                            • \").join(\"\") : '
                                                                                                                                            • no addresses blocked
                                                                                                                                            • ';\n $(\"#banned-events\").innerHTML = bannedEvents.map((r) => '
                                                                                                                                            • ev' + key(r.id) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"check\", \"Allow again\", \"allowevent\", r.id) + \"
                                                                                                                                            • \").join(\"\");\n $(\"#reports tbody\").innerHTML = reports.length ? reports.map((r) => '' + esc(fmtTime(r.at)) + '' + esc(r.type || \"report\") + '' + av(r.target_pubkey) + key(r.target_pubkey) + (r.target_event ? (r.blob ? ' file ' + key(r.target_event) + \"\" : ' ev ' + key(r.target_event) + (r.hidden ? ' hidden' : \"\") + \"\") : \"\") + '' + esc(r.content) + '' + ib(\"check\", \"Dismiss\", \"resolve:dismiss\", r.id) + ib(\"trash\", r.blob ? \"Delete the file\" : \"Delete the event\", \"resolve:delete\", r.id) + ib(\"banuser\", \"Ban the author\", \"resolve:ban\", r.id, \"danger\") + \"\").join(\"\") : 'nothing reported';\n $(\"#reports-count\").textContent = reports.length || \"\"; $(\"#tc-reports\").textContent = reports.length || \"\";\n $(\"#blobs tbody\").innerHTML = blobs.length ? blobs.map((b) => '' + esc(fmtTime(b.uploaded)) + '' + key(b.sha256) + ' ' + esc(b.type) + '' + fmtBytes(b.size) + '' + av(b.uploader) + key(b.uploader) + '' + ib(\"trash\", \"Delete file\", \"deleteblob\", b.sha256, \"danger\") + \"\").join(\"\") : 'no uploads';\n $(\"#blobs-count\").textContent = blobs.length || \"\";\n $(\"#sites tbody\").innerHTML = sites.length ? sites.map((s) => '' + av(s.author) + key(s.author) + '' + esc(s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + '' + esc(s.url) + '' + esc(s.paths) + (s.missing ? ' ' + esc(s.missing) + ' missing' : '') + '' + fmtBytes(s.size) + '' + esc(s.expires_at ? fmtTime(s.expires_at) : \"never\") + '' + ib(\"trash\", \"Delete site\", \"deleteevent\", s.id, \"danger\") + '').join(\"\") : 'no sites yet';\n $(\"#sites-count\").textContent = sites.length || \"\";\n const tag = (k, cls) => '' + k + ib(\"x\", \"Remove rule\", \"unrulekind\", String(k)) + \"\";\n $(\"#kinds-allow\").innerHTML = allow.length ? allow.map((k) => tag(k, \"ok\")).join(\"\") : 'all';\n $(\"#kinds-block\").innerHTML = block.length ? block.map((k) => tag(k, \"blk\")).join(\"\") : 'none';\n await loadPeople();\n }\n\n const KIND_NAMES = { 0: \"profiles\", 1: \"notes\", 3: \"contacts\", 4: \"DMs\", 5: \"deletions\", 6: \"reposts\", 7: \"reactions\", 16: \"reposts\", 1059: \"gift wraps\", 1063: \"file headers\", 1111: \"comments\", 1984: \"reports\", 9734: \"zap requests\", 9735: \"zap receipts\", 9802: \"highlights\", 10002: \"relay lists\", 13534: \"roster\", 9000: \"group adds\", 9001: \"group removals\", 9021: \"join requests\", 9022: \"leave requests\", 39000: \"group info\", 39001: \"group admins\", 39002: \"group members\", 39003: \"group roles\", 30023: \"articles\", 30024: \"drafts\", 30078: \"app data\", 30311: \"live events\", 30818: \"wiki\" };\n const kindName = (k) => KIND_NAMES[k] || (k >= 20000 && k < 30000 ? \"ephemeral\" : k >= 30000 && k < 40000 ? \"addressable\" : k >= 10000 && k < 20000 ? \"replaceable\" : \"kind \" + k);\n const SYS_KINDS = new Set([0, 3, 10002, 9735, 13534, 8000, 8001, 9000, 9001, 39000, 39001, 39002, 39003]);\n let storage = null;\n\n // Features (settings.ts): a select each; search has three modes, the rest on or off.\n const FEATURES = [\n [\"search\", \"Search\", \"NIP-50. Prose indexes notes, threads, comments, highlights, articles and wiki pages; full indexes every public kind with content. A change applies to events from then on.\", [\"prose:prose\", \"full:full\", \"off:off\"]],\n [\"sync\", \"Sync\", \"NIP-77 reconciliation, which reads the whole matching set per sync.\"],\n [\"count\", \"Counts\", \"NIP-45 COUNT, with HLL sketches.\"],\n [\"discovery\", \"Discovery record\", \"NIP-66: the record the relay signs about itself, for crawlers.\"],\n [\"names\", \"Names\", \"NIP-05 addresses under this relay's domain.\"],\n [\"files\", \"Files\", \"Blossom and NIP-96: uploads, downloads and listings.\"],\n [\"pages\", \"Pages and feed\", \"Notes and articles as pages, and the Atom feed.\"],\n [\"sites\", \"Static websites\", \"NIP-5A sites on their own hostnames. Mirroring copies missing files into this relay and costs fuel.\", [\"mirror:on, mirror files\", \"proxy:on, fetch as needed\", \"off:off\"]],\n [\"marmot\", \"Marmot transport\", \"Signed KeyPackages and encrypted group messages, with account admission for ephemeral authors.\"],\n [\"grasp\", \"Git repositories\", \"GRASP Git hosting with admitted repository state. The prototype backend has bounded storage and compute limits.\"],\n [\"push\", \"Relay push\", \"NIP-9a callback delivery for members and the owner. Requires approved callback origins; advertises lettered NIP identifiers.\"],\n [\"signer\", \"Signer traffic\", \"NIP-46 remote signing carried for anyone, never stored.\"],\n ];\n function renderFeatures(f) {\n $(\"#features\").innerHTML = FEATURES.map(([k, title, about, modes]) => {\n const cur = k === \"sites\" ? (f.sites?.enabled === false ? \"off\" : f.sites?.mirror === false ? \"proxy\" : \"mirror\") : modes ? String(f[k] || \"prose\") : String(f[k] !== false);\n const opts = (modes || [\"true:on\", \"false:off\"]).map((m) => { const [v, l] = m.split(\":\"); return '\"; }).join(\"\");\n return \"\";\n }).join(\"\");\n }\n $(\"#features\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-feature]\"); if (!sel) return;\n const k = sel.dataset.feature, v = k === \"search\" || k === \"sites\" ? sel.value : sel.value === \"true\";\n policy = await rpc(\"setpolicy\", { features: { [k]: k === \"sites\" ? { enabled: v !== \"off\", mirror: v === \"mirror\" } : v } });\n toast(k === \"search\" ? \"Search: \" + v : (v ? \"Switched on \" : \"Switched off \") + k); await loadInfo();\n }));\n\n $(\"#push-policy-form\").addEventListener(\"submit\", guard(async (ev) => {\n ev.preventDefault();\n const form = ev.target;\n const origins = form.elements.origins.value.split(/\\s+/).filter(Boolean);\n const updated = await rpc(\"setpolicy\", { pushCallbacks: origins, letteredNips: form.elements.lettered.checked, delivery: { enabled: form.elements.delivery.checked, maxTargets: Math.max(1, Math.min(16, Math.floor(+form.elements.deliveryMax.value || 8))) } });\n if (JSON.stringify(updated.pushCallbacks) !== JSON.stringify([...new Set(origins.map((s) => s.replace(/\\/$/, \"\")))])) throw new Error(\"Use up to sixteen exact HTTPS origins, with no path or credentials.\");\n policy = updated;\n toast(\"Saved delivery policy\"); await loadInfo();\n }));\n\n async function loadViews() {\n let views;\n try { views = await rpc(\"listviews\"); } catch { return; }\n $(\"#views\").innerHTML = views.map((v) => {\n const runs = v.trigger === \"off\" ? \"off\" : v.trigger === \"live\" ? \"live, from memory\" : v.trigger === \"write\" ? \"on write and daily\" : v.trigger;\n const label = (c) => (c === \"off\" ? \"off\" : c === \"write\" ? \"on write\" : c === \"live\" ? \"on\" : c);\n const choices = [...v.choices, v.default].filter((c, i, a) => a.indexOf(c) === i);\n const pick = '\";\n const who = v.audience === \"members\" ? \"members\" + (v.stored ? \"\" : \", on request\") : \"anyone\";\n const last = v.trigger === \"live\" ? \"\" : v.last ? \"last run \" + fmtTime(v.last.at) : \"not run yet\";\n const rows = v.trigger === \"live\" ? \"no rows\" : v.last ? v.last.rows.toLocaleString() + \" rows\" : \"\";\n const meta = [runs, who, last, rows].filter(Boolean).map((x) => \"\" + esc(x) + \"\").join('|');\n return '
                                                                                                                                              ' + esc(v.name) + '
                                                                                                                                              ' + esc(v.about) + '
                                                                                                                                              ' + pick + (v.on ? 'Open' : \"\") + '
                                                                                                                                              ' + meta + \"
                                                                                                                                              \";\n }).join(\"\");\n }\n $(\"#views\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-view]\"); if (!sel) return;\n const value = sel.value === \"live\" ? true : sel.value;\n await rpc(\"setpolicy\", { views: { [sel.dataset.view]: value } });\n toast(sel.value === \"off\" ? \"Took down \" + sel.dataset.view : sel.dataset.view + \": \" + (sel.value === \"live\" ? \"on\" : sel.value === \"write\" ? \"on write\" : sel.value)); await loadViews(); await loadInfo();\n }));\n async function loadDumps() {\n const list = await rpc(\"listdumps\");\n const f = $(\"#dumpform\");\n if (policy) { f.dumps.value = policy.dumps || \"off\"; f.keep.value = policy.dumpsKeep || 7; }\n $(\"#dumps-count\").textContent = list.length || \"\";\n $(\"#dumps\").innerHTML = list.length ? list.map((d) => '
                                                                                                                                            • jsonl' + esc(d.name) + ' ' + d.events.toLocaleString() + \" events, \" + fmtBytes(d.bytes) + \"\" + ib(\"copy\", \"Download\", \"downloaddump\", d.name) + ib(\"trash\", \"Delete\", \"deletedump\", d.name, \"danger\") + \"
                                                                                                                                            • \").join(\"\") : '
                                                                                                                                            • no dumps yet
                                                                                                                                            • ';\n }\n // A dump is fetched with a signed request and handed to the browser as a file.\n async function downloadDump(name) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const url = location.origin + \"/dumps/\" + name;\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"GET\"]] });\n const resp = await fetch(url, { headers: { authorization: \"Nostr \" + btoa(JSON.stringify(ev)) } });\n if (!resp.ok) throw new Error((await resp.json()).error || \"download failed\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(\".\")[0] + \"-\" + name; a.click(); URL.revokeObjectURL(a.href);\n }\n // A plain member's own invites, when the owner lets members invite.\n async function loadMine() {\n const sec = $(\"#myinvites\");\n if (!me || !owner || myRole || !signer.ready()) { sec.classList.add(\"hidden\"); return; }\n let mine;\n try { mine = await rpc(\"listinvites\"); } catch { sec.classList.add(\"hidden\"); return; }\n sec.classList.remove(\"hidden\");\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#mine\").innerHTML = mine.length ? mine.map((i) => '
                                                                                                                                            • inv…' + i.code.slice(-8) + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
                                                                                                                                            • \").join(\"\") : '
                                                                                                                                            • no invites yet
                                                                                                                                            • ';\n }\n async function loadStorage() {\n storage = await rpc(\"storagestats\");\n pollJobs();\n loadViews();\n loadDumps().catch(() => {});\n const st = storage;\n const top = st.kinds.slice(0, 4), rest = st.kinds.slice(4).reduce((a, k) => a + k.bytes, 0);\n const parts = [...top.map((k, i) => [k.kind + \" \" + kindName(k.kind), \"k\" + (i + 1), k.bytes]), ...(rest ? [[\"other\", \"k5\", rest]] : [])];\n const total = st.eventBytes || 1;\n $(\"#s-bar\").innerHTML = parts.length ? parts.map(([n, c, b]) => '').join(\"\") : '';\n $(\"#s-legend\").innerHTML = parts.length ? parts.map(([n, c, b]) => '' + esc(n) + \" \" + Math.round(b * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n const overhead = Math.max(0, st.databaseBytes - st.eventBytes);\n $(\"#s-totals\").innerHTML = [[\"events\", st.events.toLocaleString() + \" \" + fmtBytes(st.eventBytes) + \"\"], [\"index and overhead\", fmtBytes(overhead)], [\"files\", st.blobs.toLocaleString() + \" \" + fmtBytes(st.mediaBytes) + \"\"]]\n .map(([k, v]) => \"
                                                                                                                                              \" + k + \"\" + v + \"
                                                                                                                                              \").join(\"\");\n const any = st.retention.find((r) => r.kind === null);\n const own = (kind) => { const r = st.retention.find((x) => x.kind === kind); return r ? r.days : \"\"; };\n const row = (k) => {\n const key = k.kind === null ? \"\" : String(k.kind);\n const placeholder = k.kind === null ? \"forever\" : k.replaceable || !any ? \"forever\" : any.days + \" (everything else)\";\n const pill = k.kind === null ? \"everything else\" : '' + k.kind + \" \" + esc(kindName(k.kind));\n const cells = k.protected\n ? 'required'\n : ' days' + ib(\"check\", \"Save keep-for rule\", \"saveretention\", key) + ib(\"trash\", \"Purge\", \"purgekind\", key, \"danger\") + \"\";\n return '' + pill + '' + (k.n === undefined ? \"\" : k.n.toLocaleString()) + '' + (k.bytes === undefined ? \"\" : fmtBytes(k.bytes)) + '' + (k.oldest ? esc(fmtDay(k.oldest)) : \"\") + \"\" + cells + \"\";\n };\n $(\"#kinds tbody\").innerHTML = [...st.kinds, { kind: null, days: any ? any.days : 0 }].map(row).join(\"\");\n loadListHistory().catch(() => {});\n }\n\n async function loadListHistory() {\n const rows = await rpc(\"listlisthistory\");\n const labels = { 3: \"follows\", 10002: \"relay list\", 10003: \"bookmarks\", 30003: \"bookmark list\" };\n $(\"#listhistory tbody\").innerHTML = rows.length ? rows.map((r) => '' + esc((labels[r.kind] || (\"kind \" + r.kind)) + (r.d ? \" / \" + r.d : \"\")) + '' + esc(fmtTime(r.created_at)) + '' + esc(fmtTime(r.saved_at)) + '' + ib(\"undo\", \"Restore this version\", \"restorelist\", r.event_id) + '').join(\"\") : 'no older list versions yet';\n }\n\n let searchQuery = \"\";\n async function loadEvents(reset) {\n const list = searchQuery ? await rpc(\"searchevents\", searchQuery, 200) : await rpc(\"listrecentevents\", reset ? 50 : 200);\n $(\"#events tbody\").innerHTML = list.length ? list.map((e) => '' + esc(fmtTime(e.created_at)) + '' + e.kind + '' + av(e.pubkey) + key(e.pubkey) + '' + esc(e.content) + '' + ib(\"pin\", \"Pin\", \"pinevent\", e.id) + ib(\"trash\", \"Delete event\", \"deleteevent\", e.id) + ib(\"ban\", \"Ban event\", \"banevent\", e.id, \"danger\") + ib(\"banuser\", \"Ban author\", \"banpubkey\", e.pubkey, \"danger\") + \"\").join(\"\") : 'no events yet';\n $(\"#more\").classList.toggle(\"hidden\", searchQuery || list.length < 50);\n }\n $(\"#searchform\").onsubmit = guard(async (ev) => {\n searchQuery = ev.target.q.value.trim();\n $(\"#searchclear\").classList.toggle(\"hidden\", !searchQuery);\n await loadEvents(true);\n });\n $(\"#searchclear\").onclick = guard(async () => {\n searchQuery = \"\"; $(\"#searchform\").reset(); $(\"#searchclear\").classList.add(\"hidden\");\n await loadEvents(true);\n });\n async function loadPins() {\n const pins = await rpc(\"listpins\");\n $(\"#pins-count\").textContent = pins.length || \"\";\n $(\"#pins\").innerHTML = pins.length ? pins.map((t) => '
                                                                                                                                            • ' + (t[0] === \"e\" ? \"event\" : \"address\") + '' + esc(t[1].length > 40 ? t[1].slice(0, 16) + \"\\u2026\" + t[1].slice(-8) : t[1]) + \"\" + ib(\"copy\", \"Copy\", \"copy\", t[1]) + ib(\"x\", \"Unpin\", \"unpinevent\", t[1]) + \"
                                                                                                                                            • \").join(\"\") : '
                                                                                                                                            • nothing pinned
                                                                                                                                            • ';\n }\n $(\"#pinform\").onsubmit = guard(async (ev) => {\n await rpc(\"pinevent\", ev.target.id.value.trim()); ev.target.reset(); toast(\"Pinned\"); await loadPins();\n });\n\n // ---- actions ----\n const refresh = () => Promise.all([loadLists(), loadEvents(true), loadStorage(), loadPins()]);\n\n $(\"#copy\").onclick = async () => { await navigator.clipboard.writeText(wsURL); toast(\"copied \" + wsURL); };\n $(\"#signin\").onclick = guard(async () => {\n if (!window.nostr) throw new Error(\"No nostr extension found. Install one (Alby, nos2x, …) and reload, or use a remote signer.\");\n me = await window.nostr.getPublicKey();\n localStorage.setItem(\"me\", me);\n renderHeader(); await loadAdmin(); await loadPeople();\n });\n $(\"#signin46\").onclick = () => showRemote(null);\n $$(\".remote\").forEach((b) => { b.onclick = () => showRemote($(\"#\" + b.dataset.note)); });\n $(\"#nccopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#nclink\").href); toast(\"Link copied; paste it into your signer app\"); };\n $(\"#bunkerform\").onsubmit = guard(async (ev) => {\n $(\"#remotenote\").textContent = \"Connecting…\";\n try { await connectBunker(ev.target.url.value); } catch (e) { $(\"#remotenote\").textContent = e.message; throw e; }\n $(\"#remotenote\").textContent = \"\"; ev.target.reset();\n await remoteDone();\n });\n $(\"#signout\").onclick = async () => {\n if (remote) { const r = remote; remote = null; try { await withTimeout(r.logout(), 5000, \"The signer\"); } catch { /* the session is gone either way */ } }\n localStorage.removeItem(\"nip46\");\n me = null; localStorage.removeItem(\"me\"); renderHeader(); await loadAdmin(); await loadPeople();\n };\n const claimNow = async (note) => {\n if (!signer.ready()) { note.textContent = \"Sign the claim with a nostr extension or a remote signer.\"; showRemote(note); return; }\n me = await signer.getPublicKey();\n const r = await rpc(\"claim\");\n if (!r.claimed) throw new Error(\"Somebody else claimed it first.\");\n localStorage.setItem(\"me\", me);\n toast(\"It's yours.\");\n await loadInfo(); await loadFuel(); await loadAdmin(); await loadPeople();\n if (r.converted && confirm(\"This relay began as a temporary one: anyone can write, and everything is deleted after 14 days. Switch to the default rules and keep everything from now on? Each rule can be changed later on the Rules and Storage tabs.\")) {\n await rpc(\"resetrules\"); toast(\"Rules reset\"); await loadInfo(); await loadAdmin();\n }\n };\n $(\"#claim\").onclick = guard(() => claimNow($(\"#claimnote\")));\n $(\"#claimlease\").onclick = guard(() => claimNow($(\"#leasenote\")));\n // Jobs run in the background; the table follows them while the tab is open.\n let jobsTimer = 0;\n const fmtJob = (j) => {\n const what = j.kind === \"mirror\" ? \"mirror site\" : j.kind === \"import\" ? \"import\" : j.label === \"backfill\" ? \"fetch my history\" : j.kind === \"pull\" ? \"pull\" : \"rebroadcast\";\n const f = [];\n if (j.filter.authors) f.push(j.filter.authors.length === 1 && j.filter.authors[0] === me ? \"my events\" : j.filter.authors.length + \" authors\");\n if (j.filter.kinds) f.push(\"kinds \" + j.filter.kinds.join(\", \"));\n if (j.filter.since) f.push(\"since \" + fmtDay(j.filter.since));\n const when = j.every ? \"every \" + (j.every === 24 ? \"day\" : j.every + \" h\") + (j.nextRun ? \", next \" + fmtTime(j.nextRun) : \"\") : \"once\";\n const l = j.last;\n const count = (stored, blobs, sent, refused) => (j.kind === \"mirror\" ? blobs.toLocaleString() + \" files mirrored\" : j.kind === \"import\" ? stored.toLocaleString() + \" events\" + ((j.last ? j.last.duplicates : j.duplicates) ? \", \" + (j.last ? j.last.duplicates : j.duplicates) + \" already here\" : \"\") : j.kind === \"pull\" ? stored.toLocaleString() + \" events\" + (blobs ? \", \" + blobs + \" files\" : \"\") : sent.toLocaleString() + \" sent\" + (refused ? \", \" + refused + \" refused\" : \"\"));\n const res = j.running ? \"running: \" + count(j.stored, j.blobs, j.sent, j.refused) + \"...\" : !l ? \"waiting\" : l.error ? \"failed: \" + l.error : count(l.stored, l.blobs, l.sent, l.refused) + (l.skipped ? \", \" + l.skipped + \" skipped\" : \"\") + \", \" + fmtTime(l.finishedAt);\n const sources = j.running ? j.pullSources : l?.sources;\n const details = sources?.length ? '
                                                                                                                                              Source results' + sources.map((s) => '

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

                                                                                                                                              ').join('') + '
                                                                                                                                              ' : '';\n const targets = j.kind === \"push\" && j.targetStatus ? \"
                                                                                                                                              \" + Object.entries(j.targetStatus).map(([u, s]) => esc(u) + \": \" + esc(s.status)).join(\"
                                                                                                                                              \") + \"
                                                                                                                                              \" : \"\";\n return \"\" + what + \"\" + j.relays.map(esc).join(\"
                                                                                                                                              \") + targets + \"\" + (f.join(\", \") || \"everything\") + \"\" + when + \"\" + esc(res) + details + \"\" + (j.running ? \"\" : ib(\"undo\", \"Run now\", \"runjob\", j.id)) + ib(\"x\", \"Remove\", \"removejob\", j.id) + \"\";\n };\n async function pollJobs() {\n clearTimeout(jobsTimer);\n let jobs;\n try { jobs = await rpc(\"listjobs\"); } catch { return; }\n $(\"#jobs tbody\").innerHTML = jobs.length ? jobs.map(fmtJob).join(\"\") : 'no jobs yet';\n if (jobs.some((j) => j.running || (j.nextRun && j.nextRun <= Math.floor(Date.now() / 1000) + 1))) jobsTimer = setTimeout(pollJobs, 3000);\n }\n const urls = (s) => s.split(/[\\s,]+/).map((u) => u.trim()).filter(Boolean);\n const kindsOf = (s) => s.split(/[\\s,]+/).map((k) => parseInt(k, 10)).filter((k) => Number.isInteger(k) && k >= 0);\n $(\"#pullform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"addjob\", { kind: \"pull\", relays: [f.url.value.trim()], every: +f.every.value });\n toast(+f.every.value ? \"Mirror scheduled\" : \"Pull started\"); f.reset(); await pollJobs();\n });\n $(\"#backfillform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"backfill\", urls(f.relays.value));\n toast(\"Fetching your history\"); f.reset(); await pollJobs();\n });\n $(\"#pushform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const filter = {};\n const kinds = kindsOf(f.kinds.value); if (kinds.length) filter.kinds = kinds;\n const days = +f.days.value; if (days > 0) filter.since = Math.floor(Date.now() / 1000) - days * 86400;\n await rpc(\"addjob\", { kind: \"push\", relays: urls(f.targets.value), filter, every: +f.every.value });\n toast(\"Rebroadcast started\"); f.reset(); await pollJobs();\n });\n // ---- presets: writes, reads, kinds and keep-for rules in one click ----\n let presets = null;\n async function renderPresets() {\n if (!presets) { try { presets = await rpc(\"listpresets\"); } catch { presets = []; } }\n $(\"#presets\").innerHTML = presets.map((p) => '\").join(\"\");\n $(\"#presetsourcerow\").classList.toggle(\"hidden\", !presets.some((p) => p.source));\n }\n $(\"#presets\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-preset]\"); if (!b) return;\n const p = (presets || []).find((x) => x.name === b.dataset.preset); if (!p) return;\n if (!confirm(p.title + \": \" + p.about + \"\\n\\nThis replaces the writes and reads rules, the directory setting, the kind rules and the keep-for rules. Limits, identity, people and bans stay.\")) return;\n const source = $(\"#presetsource\").value.trim();\n if (p.source === \"required\" && !source) { $(\"#presetnote\").textContent = p.title + \" needs a source relay to mirror; enter its wss:// URL first.\"; return; }\n b.disabled = true;\n try { policy = await rpc(\"applypreset\", p.name, p.source && source ? { source } : undefined); $(\"#presetnote\").textContent = \"Now: \" + p.about + (policy.job ? \" Mirroring \" + source + \" every \" + policy.job.every + \" h.\" : \"\"); toast(p.title + \" applied\"); await loadInfo(); await loadAdmin(); }\n catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // ---- wire me in: this relay in the owner's own lists ----\n // The lists are replaceable, so a fresh list with only this relay would\n // clobber the real one once it spread. Every publish starts from the\n // newest copy found here or on the indexers, verified when the signer\n // library is around, and adds or removes this relay in it.\n const INDEXERS = [\"wss://purplepag.es\", \"wss://relay.nostr.band\", \"wss://relay.damus.io\", \"wss://nos.lol\"];\n const LISTS = [\n { kind: 10002, tag: \"r\", title: \"Relay list\", nip: \"NIP-65\", about: \"where clients read your notes and send you mentions\" },\n { kind: 10050, tag: \"relay\", title: \"DM inbox\", nip: \"NIP-17\", about: \"where people send you private messages\" },\n { kind: 10007, tag: \"relay\", title: \"Search relays\", nip: \"NIP-51\", about: \"where clients run your searches\" },\n { kind: 10063, tag: \"server\", title: \"Blossom servers\", nip: \"BUD-03\", about: \"where clients upload and look for your files\" },\n ];\n const mineFor = (l) => (l.tag === \"server\" ? location.origin : wsURL);\n const normURL = (u) => { try { const x = new URL(String(u).trim()); return (x.host + x.pathname).replace(/\\/+$/, \"\").toLowerCase(); } catch { return String(u).trim().toLowerCase(); } };\n const isMine = (l, t) => t[0] === l.tag && normURL(t[1] || \"\") === normURL(mineFor(l));\n const listHas = (list, l) => !!list && list.tags.some((t) => isMine(l, t));\n // mergeList is pure: the newest list with this relay put first or taken out, every other tag kept.\n function mergeList(list, l, include) {\n const tags = (list ? list.tags : []).filter((t) => !isMine(l, t));\n if (include) tags.unshift([l.tag, mineFor(l)]);\n return { kind: l.kind, created_at: Math.floor(Date.now() / 1000), content: list ? list.content : \"\", tags };\n }\n const relaysIn = (list) => list.tags.filter((t) => (t[0] === \"r\" || t[0] === \"relay\") && /^wss?:\\/\\//i.test(t[1] || \"\")).map((t) => t[1].trim());\n // overWS opens one socket, sends one message, feeds answers to onMessage until it says done or time runs out.\n function overWS(url, ms, first, onMessage) {\n return new Promise((res) => {\n let ws = null, done = false, out = null;\n const finish = () => { if (done) return; done = true; clearTimeout(timer); try { if (ws) ws.close(); } catch { /* closed */ } res(out); };\n const timer = setTimeout(finish, ms);\n try { ws = new WebSocket(url); } catch { return finish(); }\n ws.onopen = () => ws.send(JSON.stringify(first));\n ws.onmessage = (m) => { let d; try { d = JSON.parse(m.data); } catch { return; } if (!Array.isArray(d)) return; if (onMessage(d, (v) => { out = v; })) finish(); };\n ws.onerror = finish; ws.onclose = finish;\n });\n }\n function fetchNewest(url, filter, ms) {\n let best = null;\n return overWS(url, ms, [\"REQ\", \"w\", filter], (d, set) => {\n if (d[0] === \"EVENT\" && d[1] === \"w\" && d[2] && (!best || d[2].created_at > best.created_at)) { best = d[2]; set(best); }\n return (d[0] === \"EOSE\" || d[0] === \"CLOSED\") && d[1] === \"w\";\n });\n }\n function publishTo(url, event, ms) {\n return overWS(url, ms, [\"EVENT\", event], (d, set) => { if (d[0] === \"OK\" && d[1] === event.id) { set({ url, ok: !!d[2], msg: d[3] || \"\" }); return true; } return false; })\n .then((r) => r || { url, ok: false, msg: \"no answer\" });\n }\n // bridge signs a NIP-98 request to this relay's HTTP door, so the owner's own reads and writes pass any read rule.\n async function bridge(path, body) {\n const url = location.origin + path, raw = JSON.stringify(body);\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"POST\"], [\"payload\", await sha256hex(raw)]] });\n const resp = await fetch(url, { method: \"POST\", headers: { \"content-type\": \"application/json\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body: raw });\n const json = await resp.json();\n if (!resp.ok) throw new Error(json.error || \"HTTP \" + resp.status);\n return json;\n }\n const wire = {};\n function renderWire() {\n const pill = (state) => {\n const cls = state === \"listed\" ? \"on\" : state === \"failed\" ? \"bad\" : state === \"not listed\" ? \"off\" : \"\";\n const label = state === \"checking\" || state === \"publishing\" ? state + \"\\u2026\" : state;\n return '' + esc(label) + \"\";\n };\n $(\"#wire\").innerHTML = LISTS.map((l) => {\n const w = wire[l.kind] || { state: \"\", results: [], list: null };\n const busy = w.state === \"checking\" || w.state === \"publishing\";\n const btn = (act, cls, label) => '\";\n const buttons = w.state === \"listed\" ? btn(\"remove\", \"\", \"Remove this relay\") : btn(\"add\", \"pri\", \"Add this relay\") + btn(\"check\", \"\", w.state ? \"Check again\" : \"Check\");\n const n = w.list ? w.list.tags.filter((t) => t[0] === l.tag).length : 0;\n const from = w.list ? \"\" + (n === 1 ? \"1 entry\" : n + \" entries\") + \", \" + fmtTime(w.list.created_at) + \"\" : w.state && !busy ? \"no list found\" : \"\";\n const sent = (w.results || []).map((r) => '' + esc(r.url.replace(/^wss?:\\/\\//, \"\")) + (r.ok ? \"\" : \" failed\") + \"\").join(\"\");\n const meta = (w.state ? pill(w.state) : \"\") + from + sent;\n return '
                                                                                                                                              ' + l.title + ' ' + l.nip + \"
                                                                                                                                              \" + l.about + '
                                                                                                                                              ' + buttons + '
                                                                                                                                              ' + meta + \"
                                                                                                                                              \";\n }).join(\"\");\n }\n async function wireCheck(l) {\n const w = (wire[l.kind] = { state: \"checking\", list: null, results: [] }); renderWire();\n const filter = { kinds: [l.kind], authors: [me], limit: 1 };\n const found = [];\n try { found.push(...(await bridge(\"/query\", [filter]))); } catch { /* the relay may hold none */ }\n const remote = await Promise.all(INDEXERS.map((u) => fetchNewest(u, filter, 4000)));\n let lib = null; try { lib = await signerLib(); } catch { /* unverified lists are still the owner's own, by pubkey */ }\n for (const e of remote) if (e && e.pubkey === me && e.kind === l.kind && Array.isArray(e.tags) && (!lib || lib.verifyEvent(e))) found.push(e);\n w.list = found.sort((a, b) => b.created_at - a.created_at)[0] || null;\n w.state = listHas(w.list, l) ? \"listed\" : \"not listed\"; renderWire();\n }\n async function wirePublish(l, include) {\n if (!wire[l.kind] || !wire[l.kind].state || wire[l.kind].state === \"failed\") await wireCheck(l);\n const w = wire[l.kind]; w.state = \"publishing\"; w.results = []; renderWire();\n try {\n const signed = await signer.signEvent(mergeList(w.list, l, include));\n const here = await bridge(\"/events\", signed).then((r) => ({ url: wsURL, ok: !!r.accepted, msg: r.message || \"\" })).catch((e) => ({ url: wsURL, ok: false, msg: e.message }));\n const targets = [...new Set([...relaysIn(signed), ...INDEXERS])].filter((u) => normURL(u) !== normURL(wsURL));\n const rest = await Promise.all(targets.map((u) => publishTo(u, signed, 6000)));\n w.results = [here, ...rest]; w.list = signed; w.state = include ? \"listed\" : \"not listed\";\n toast((include ? \"Added to your \" : \"Removed from your \") + l.title.toLowerCase());\n } catch (e) { w.state = \"failed\"; w.results = [{ url: wsURL, ok: false, msg: e.message }]; toast(e.message); }\n renderWire();\n }\n $(\"#wire\").addEventListener(\"click\", (ev) => {\n const b = ev.target.closest(\"button[data-wire]\"); if (!b) return;\n const l = LISTS.find((x) => String(x.kind) === b.dataset.wire); if (!l) return;\n if (b.dataset.do === \"check\") wireCheck(l); else wirePublish(l, b.dataset.do === \"add\");\n });\n\n // ---- custom domains: this relay under a hostname the owner controls ----\n let domainSites = [];\n const domainOptions = (current = \"\") => [{ label: \"\", title: \"This relay\" }, ...domainSites.map((s) => ({ label: s.label, title: (s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + \" / \" + key(s.author) })), ...(current && !domainSites.some((s) => s.label === current) ? [{ label: current, title: \"Site \" + current }] : [])].map((s) => '\").join(\"\");\n let domains = null; // null: not enabled on this host\n function renderDomains() {\n $(\"#domains\").innerHTML = (domains || []).map((d) => {\n const state = d.ready ? \"active\" : \"hostname \" + d.status.replace(/_/g, \" \") + \", certificate \" + d.sslStatus.replace(/_/g, \" \");\n const btn = (act, cls, label) => '\";\n const rows = d.ready ? \"\" : '
                                                                                                                                              ' + d.records.map((r) => \"\").join(\"\") + \"
                                                                                                                                              TypeNameValue
                                                                                                                                              \" + esc(r.type) + '' + esc(r.name) + '' + esc(r.value) + \"\" + esc(r.note) + \"
                                                                                                                                              \";\n return '
                                                                                                                                              ' + esc(d.host) + '' + esc(state) + \"\" + btn(\"check\", \"\", \"Check\") + btn(\"remove\", \"danger\", \"Remove\") + '
                                                                                                                                              \" + rows;\n }).join(\"\");\n }\n async function loadDomains() {\n try { [domains, domainSites] = await Promise.all([rpc(\"listdomains\"), rpc(\"listsites\")]); $(\"#adddomain select[name=site]\").innerHTML = domainOptions(); $(\"#domainnote\").textContent = domains.length ? \"\" : \"No custom domain yet.\"; $(\"#adddomain\").classList.remove(\"hidden\"); }\n catch (e) { domains = null; $(\"#domainnote\").textContent = /^unsupported/.test(e.message) ? \"Custom domains are not enabled on this host.\" : e.message; $(\"#adddomain\").classList.add(\"hidden\"); }\n renderDomains();\n }\n $(\"#adddomain\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"adddomain\", f.host.value.trim(), f.site.value); f.reset(); toast(\"Domain added; now create the CNAME\"); await loadDomains();\n });\n $(\"#domains\").addEventListener(\"change\", guard(async (ev) => {\n const select = ev.target.closest(\"select[data-domain-site]\"); if (!select) return;\n await rpc(\"setdomainsite\", select.dataset.domainSite, select.value);\n toast(\"Domain destination saved\"); await loadDomains();\n }));\n $(\"#domains\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-domain]\"); if (!b) return;\n const host = b.dataset.domain;\n if (b.dataset.do === \"remove\" && !confirm(\"Remove \" + host + \"? Its certificate goes with it and the name stops answering.\")) return;\n b.disabled = true;\n try {\n if (b.dataset.do === \"remove\") { await rpc(\"removedomain\", host); toast(\"Removed\"); }\n else { const d = await rpc(\"checkdomain\", host); toast(d.ready ? host + \" is live\" : \"Not yet: \" + (d.status === \"active\" ? \"certificate pending\" : \"waiting for the CNAME\")); }\n await loadDomains();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n $(\"#access\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const openKinds = f.openKinds.value.split(/[\\s,]+/).filter(Boolean).map(Number);\n if (openKinds.some((k) => !Number.isInteger(k) || k < 0 || k > 65535)) throw new Error(\"Open kinds must be whole numbers.\");\n policy = await rpc(\"setpolicy\", { writes: f.writes.value, reads: f.reads.value, openKinds, guestReplies: f.guestReplies.checked, minPow: +f.minPow.value, maxFuture: +f.maxFuture.value, maxLimit: +f.maxLimit.value, maxSubs: +f.maxSubs.value, maxMessageKB: +f.maxMessageKB.value, eventsPerMinute: +f.eventsPerMinute.value, reqsPerMinute: +f.reqsPerMinute.value, maxBlobMB: +f.maxBlobMB.value });\n toast(\"Rules saved\"); await loadInfo();\n });\n $(\"#notify\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { notify: { reports: f.reports.checked, fuel: f.fuel.checked, jobs: f.jobs.checked, succession: f.succession.checked, digest: f.digest.checked } });\n toast(\"Notifications saved\");\n });\n $(\"#notifytest\").onclick = guard(async () => {\n const r = await rpc(\"notifytest\");\n toast(r.sent ? \"Sent. Look for a message from the relay in your DMs.\" : \"Could not send.\");\n });\n $(\"#identity\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const list = (v) => v.split(\",\").map((s) => s.trim()).filter(Boolean);\n policy = await rpc(\"setpolicy\", { name: f.name.value, contact: f.contact.value, description: f.description.value, icon: f.icon.value,\n banner: f.banner.value, postingPolicy: f.postingPolicy.value, privacyPolicy: f.privacyPolicy.value, tags: list(f.tags.value), languageTags: list(f.languageTags.value), relayCountries: list(f.relayCountries.value) });\n toast(\"Identity saved\"); await loadInfo(); await loadPeople(); loadCard();\n });\n $(\"#addmember\").onsubmit = guard(async (ev) => {\n const f = ev.target; const pk = npubToHex(f.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"setmember\", pk, { name: f.name.value.trim() || null, note: f.note.value }); f.reset(); toast(\"Member added\"); await loadLists();\n });\n $(\"#addblock\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"blockip\", f.ip.value.trim(), f.reason.value); f.reset(); toast(\"Blocked\"); await loadLists();\n });\n $(\"#wordsform\").onsubmit = guard(async (ev) => {\n const words = ev.target.words.value.split(\"\\n\").map((s) => s.trim()).filter(Boolean);\n const kept = await rpc(\"setblockedwords\", words);\n policy = await rpc(\"setpolicy\", { blockedWordsInTags: ev.target.inTags.checked });\n ev.target.words.value = kept.join(\"\\n\"); toast(kept.length ? kept.length + \" words blocked\" : \"No words blocked\");\n });\n $(\"#thresholdform\").onsubmit = guard(async (ev) => {\n policy = await rpc(\"setpolicy\", { reportThreshold: +ev.target.reportThreshold.value });\n toast(policy.reportThreshold ? \"Hidden after \" + policy.reportThreshold + \" reports\" : \"Never hidden by reports\"); await loadLists();\n });\n $(\"#addban\").onsubmit = guard(async (ev) => {\n const pk = npubToHex(ev.target.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"banpubkey\", pk, ev.target.reason.value, ev.target.erase.checked); ev.target.reset(); toast(ev.target.erase.checked ? \"Banned and erased\" : \"Banned\"); await loadLists();\n });\n $(\"#mintinvite\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const inv = await rpc(\"createinvite\", +f.ttl.value, +f.max.value, f.note.value);\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n f.note.value = \"\"; toast(\"Invite created and copied\"); await loadLists();\n });\n $(\"#kindform\").onsubmit = guard(async (ev) => {\n const rule = ev.submitter.value; const k = +ev.target.kind.value;\n await rpc(rule === \"allow\" ? \"allowkind\" : \"disallowkind\", k); ev.target.reset(); toast((rule === \"allow\" ? \"Allowed kind \" : \"Blocked kind \") + k); await loadLists();\n });\n $(\"#more\").onclick = guard(() => loadEvents(false));\n $(\"#dumpform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { dumps: f.dumps.value, dumpsKeep: Math.max(1, Math.min(60, Math.floor(+f.keep.value || 7))) });\n toast(policy.dumps === \"off\" ? \"Dumps off\" : \"Dumping \" + policy.dumps); await loadDumps();\n });\n $(\"#importform\").onsubmit = guard(async (ev) => {\n const file = ev.target.file.files[0]; if (!file) return;\n if (!signer.ready()) throw new Error(NO_SIGNER);\n if (file.size > 64 * 1024 * 1024) throw new Error(\"At most 64 MB per import.\");\n const body = await file.text();\n const url = location.origin + \"/import?name=\" + encodeURIComponent(file.name);\n const token = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"PUT\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(url, { method: \"PUT\", headers: { authorization: \"Nostr \" + btoa(JSON.stringify(token)), \"content-type\": \"application/x-ndjson\" }, body });\n const r = await resp.json();\n if (!resp.ok) throw new Error(r.error || \"import failed\");\n ev.target.reset(); toast(\"Importing \" + fmtBytes(r.bytes)); await pollJobs();\n });\n $(\"#dumpnow\").onclick = guard(async () => { const d = await rpc(\"dumpnow\"); toast(\"Dumped \" + d.events.toLocaleString() + \" events\"); await loadStorage(); });\n $(\"#treeform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { memberInvites: { depth: Math.max(0, Math.floor(+f.depth.value || 0)), quota: Math.max(0, Math.floor(+f.quota.value || 0)) } });\n toast(policy.memberInvites.depth ? \"Members may invite\" : \"Only you and moderators invite\");\n });\n $(\"#mintmine\").onclick = guard(async () => {\n const inv = await rpc(\"createinvite\", 259200, 1, \"\");\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n toast(\"Invite created and copied\"); await loadMine();\n });\n $(\"#exportcfg\").onclick = guard(async () => {\n const cfg = await rpc(\"exportconfig\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(new Blob([JSON.stringify(cfg, null, 2)], { type: \"application/json\" }));\n a.download = host.split(\".\")[0] + \".bind.ws.config.json\";\n a.click(); URL.revokeObjectURL(a.href);\n });\n $(\"#importcfg\").onclick = () => $(\"#cfgfile\").click();\n $(\"#cfgfile\").onchange = guard(async (ev) => {\n const file = ev.target.files[0]; if (!file) return;\n const cfg = JSON.parse(await file.text());\n const check = await rpc(\"importconfig\", cfg, { dryRun: true });\n const lines = check.changes.summary.length ? check.changes.summary.join(\"\\n\") : \"Nothing would change.\";\n const dropped = check.warnings.length ? \"\\n\\nNot taken:\\n\" + check.warnings.join(\"\\n\") : \"\";\n if (!confirm(\"Apply \" + file.name + \" to this relay?\\n\\n\" + lines + dropped)) { ev.target.value = \"\"; return; }\n await rpc(\"importconfig\", cfg); ev.target.value = \"\"; toast(\"Configuration imported\"); await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // Succession: the status line under the heir form, from successionstatus.\n async function renderSuccession() {\n const el = $(\"#successionnote\"), f = $(\"#succession\");\n let st;\n try { st = await rpc(\"successionstatus\"); } catch { el.textContent = \"\"; return; }\n const sc = st.succession;\n $(\"#clearsuccession\").classList.toggle(\"hidden\", !sc);\n f.querySelector(\"button.btn:not(#clearsuccession)\").textContent = sc ? \"Change heir\" : \"Set heir\";\n if (!sc) { el.textContent = \"No heir named. Last signed in \" + fmtTime(st.ownerSeenAt) + \".\"; return; }\n if (f.heir.querySelector('[value=\"' + sc.heir + '\"]')) f.heir.value = sc.heir;\n f.afterDays.value = String(sc.afterDays);\n const who = (window.__members || []).find((m) => m.pubkey === sc.heir);\n const heir = who && who.name ? who.name + \"@\" + host : short(sc.heir);\n el.textContent = \"Heir: \" + heir + \". Last signed in \" + fmtTime(st.ownerSeenAt) + (st.silentDays ? \" (\" + st.silentDays + \" days ago)\" : \"\") + \". \" +\n (st.warning ? \"The warning month is running: the relay goes to \" + heir + \" on \" + fmtDay(st.handoverAt) + \" unless you sign in.\" : \"If you stay away, it goes to \" + heir + \" around \" + fmtDay(st.handoverAt) + \".\") +\n (st.log && st.log.length ? \" Handed over before: \" + st.log.map((l) => fmtDay(l.at) + \" to \" + short(l.to)).join(\", \") + \".\" : \"\");\n }\n $(\"#succession\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"setsuccession\", { heir: f.heir.value, afterDays: +f.afterDays.value });\n toast(\"Heir set\"); await loadAdmin();\n });\n $(\"#clearsuccession\").onclick = guard(async () => {\n await rpc(\"clearsuccession\");\n toast(\"Heir cleared\"); await loadAdmin();\n });\n $(\"#transfer\").onsubmit = guard(async (ev) => {\n const pk = ev.target.pubkey.value; if (!pk) return;\n const name = host.split(\".\")[0];\n const typed = prompt(\"This hands \" + host + \" to \" + short(pk) + \" for good. You stay on as a moderator. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing changed.\"); return; }\n await rpc(\"transferowner\", pk);\n toast(\"Transferred\");\n await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // ---- fork: a new name with this relay's events, claim reserved for a key ----\n $(\"#forkform\").scope.onchange = (ev) => $(\"#forkkinds\").classList.toggle(\"hidden\", ev.target.value !== \"kinds\");\n $(\"#joinform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { joinTerms: f.joinTerms.value, directoryPublic: f.directoryPublic.checked });\n toast(\"Saved\"); await loadInfo(); await loadPeople();\n });\n $(\"#forkform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const opts = { people: f.people.checked };\n if (f.name.value.trim()) opts.name = f.name.value.trim().toLowerCase();\n if (f.holder.value.trim()) { const pk = npubToHex(f.holder.value); if (!pk) throw new Error(\"That is not a pubkey.\"); opts.holder = pk; }\n if (f.scope.value === \"mine\") opts.filter = { authors: [me] };\n if (f.scope.value === \"kinds\") { const kinds = f.kinds.value.split(/[\\s,]+/).filter(Boolean).map(Number); if (!kinds.length || kinds.some((k) => !Number.isInteger(k) || k < 0)) throw new Error(\"Give kinds as numbers.\"); opts.filter = { kinds }; }\n if (!confirm(\"Fork this relay into a new name\" + (opts.name ? \" (\" + opts.name + \")\" : \"\") + \"? It pulls \" + (f.scope.value === \"all\" ? \"everything\" : f.scope.value === \"mine\" ? \"your events\" : \"the chosen kinds\") + (opts.people ? \" and the people\" : \"\") + \", and only \" + (opts.holder ? \"that key\" : \"you\") + \" can claim it.\")) return;\n const r = await rpc(\"forkrelay\", opts);\n $(\"#forknote\").textContent = r.handover + \" Expires \" + fmtTime(r.expires_at) + \".\";\n $(\"#forkurl\").textContent = r.console;\n $(\"#forkresult\").classList.remove(\"hidden\");\n toast(\"Forked to \" + r.name);\n });\n $(\"#forkcopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#forkurl\").textContent); toast(\"copied\"); };\n $(\"#deleterelay\").onclick = guard(async () => {\n const name = host.split(\".\")[0];\n const typed = prompt(\"This deletes everything on \" + host + \" and gives the name up. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing was deleted.\"); return; }\n await rpc(\"deleterelay\", name);\n localStorage.removeItem(\"me\");\n location.href = \"/\";\n });\n $(\"#topup\").onsubmit = guard((ev) => topUp(+ev.target.sats.value));\n $(\"#inv-copy\").onclick = async () => { await navigator.clipboard.writeText($(\"#inv-text\").value); toast(\"invoice copied\"); };\n $$(\"button[data-copy]\").forEach((b) => { b.onclick = async () => { await navigator.clipboard.writeText(b.dataset.copy === \"ws\" ? wsURL : location.origin); toast(\"copied\"); }; });\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-act]\"); if (!b) return;\n const act = b.dataset.act, id = b.dataset.id;\n if (act === \"copy\") { await navigator.clipboard.writeText(id); toast(\"copied\"); return; }\n if ((act === \"banpubkey\" || act === \"banevent\" || act === \"resolve:ban\") && !confirm(act === \"banevent\" ? \"Delete this event and refuse it forever?\" : \"Ban this author and refuse everything they post?\")) return;\n const erase = (act === \"banpubkey\" || act === \"resolve:ban\") && confirm(\"Also erase everything they wrote and uploaded here?\");\n if (act === \"deleteblob\" && !confirm(\"Delete this file for good?\")) return;\n if (act === \"deletedump\" && !confirm(\"Delete this dump?\")) return;\n if (act === \"downloaddump\") { b.disabled = true; try { await downloadDump(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }\n if (act === \"restorelist\") {\n if (!signer.ready()) { toast(NO_SIGNER); return; }\n try {\n const preview = await rpc(\"restorelist\", id);\n const d = preview.diff || {};\n const added = (d.addedTags || []).map((t) => \"+ \" + JSON.stringify(t)).join(\"\\n\");\n const removed = (d.removedTags || []).map((t) => \"- \" + JSON.stringify(t)).join(\"\\n\");\n const changes = [added, removed, d.contentChanged ? \"content changed\" : \"content unchanged\"].filter(Boolean).join(\"\\n\");\n if (!confirm(\"Restore this list version?\\n\\n\" + (changes || \"No tag or content changes\") + \"\\n\\nIt will be signed and published as the newest version.\")) return;\n const signed = await signer.signEvent(preview.draft);\n const result = await bridge(\"/events\", signed);\n if (!result.accepted) throw new Error(result.message || \"The relay refused the restored list.\");\n toast(\"List restored\"); await loadListHistory(); await loadStorage();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if ((act === \"removemember\" || act === \"banpubkey\") && (window.__members || []).some((m) => m.invited_by === id) && confirm(\"Also remove everyone this member invited, and everyone they invited in turn?\")) {\n b.disabled = true;\n try { const r = await rpc(\"removesubtree\", id); if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase); toast(\"Removed \" + r.removed.length); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"removejob\" && !confirm(\"Remove this job?\")) return;\n if (act === \"purgekind\") {\n const kind = id === \"\" ? null : +id;\n const label = kind === null ? \"everything without its own rule\" : kindName(kind) + \" (kind \" + kind + \")\";\n const typed = prompt(\"Purge \" + label + \" older than how many days? 0 purges all of them. This cannot be undone.\", \"30\");\n if (typed === null) return;\n const days = Math.max(0, Math.floor(+typed || 0));\n b.disabled = true;\n try { const r = await rpc(\"purgekind\", kind, days); toast(\"Purged \" + r.deleted.toLocaleString() + \" events\"); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"saveretention\") {\n const kind = id === \"\" ? null : +id;\n const days = Math.max(0, Math.floor(+b.closest(\"tr\").querySelector(\".days\").value || 0));\n b.disabled = true;\n try { await rpc(\"setretention\", kind, days); toast(days ? \"Kept for \" + days + \" days\" : \"Kept forever\"); await loadStorage(); await loadInfo(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n b.disabled = true;\n try {\n if (act === \"savemember\") { const tr = b.closest(\"tr\"), role = tr.querySelector(\"select.role\"), keep = tr.querySelector(\".keep\"), cap = tr.querySelector(\".cap\"); await rpc(\"setmember\", id, { name: tr.querySelector(\".name\").value.trim() || null, note: tr.querySelector(\".note\").value, ...(role ? { role: role.value } : {}), ...(keep ? { keepDays: Math.max(0, Math.floor(+keep.value || 0)), maxBytes: Math.max(0, Math.floor(+cap.value || 0)) * 1024 } : {}) }); }\n else if (act.startsWith(\"resolve:\")) await rpc(\"resolvereport\", id, act.slice(8), erase);\n else if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase);\n else await rpc(act, act === \"unrulekind\" ? +id : id, \"\");\n toast(\"Done\"); await (myRole ? refresh() : loadMine());\n if (act === \"pinevent\" || act === \"unpinevent\") await loadPins();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // The share block: the card picture, the group naddr and its QR.\n let card = null;\n async function loadCard() {\n try {\n card = await (await fetch(\"/card.json\", { cache: \"no-store\" })).json();\n $(\"#cardimg\").src = \"/card.svg?t=\" + Date.now();\n $(\"#naddr\").textContent = card.naddr || \"\";\n const q = $(\"#naddrqr\");\n if (card.naddr) { q.src = \"/qr.svg?text=\" + encodeURIComponent(card.naddr); q.classList.remove(\"hidden\"); } else q.classList.add(\"hidden\");\n } catch { /* the card is decoration */ }\n renderApps();\n }\n // The app rows on the Connect section: one per client people actually use,\n // with the link that lands on this relay in it. Relay apps take the relay\n // or the group address; feed apps take the owner's profile with this relay\n // as the hint, since they have no notion of opening a relay.\n function renderApps() {\n const el = $(\"#apps\"); if (!el) return;\n const enc = encodeURIComponent;\n const nprofile = card && card.nprofile ? card.nprofile : \"\";\n const naddr = card && card.naddr ? card.naddr : \"\";\n const whose = me && owner && me === owner ? \"your\" : \"the owner's\";\n const link = (label, href) => '' + label + \"\";\n const app = (label, uri) => '' + label + \"\";\n const copy = (label, text) => '\";\n const row = (name, where, note, acts) => '
                                                                                                                                              ' + name + \"\" + where + \"

                                                                                                                                              \" + note + '

                                                                                                                                              ' + acts.filter(Boolean).join(\"\") + \"
                                                                                                                                              \";\n const profileNote = \"Opens \" + whose + \" profile with this relay attached.\";\n const groups = [\n [\"As a place\", \"These open the relay itself: its feed, its people, its group.\", [\n row(\"Jumble\", \"web\", \"A feed of everything on this relay.\", [link(\"Open\", \"https://jumble.social/?r=\" + enc(wsURL))]),\n row(\"Coracle\", \"web\", \"The relay's page: its feed and its people.\", [link(\"Open\", \"https://coracle.social/relays/\" + enc(host))]),\n row(\"Flotilla\", \"web, phone\", \"The relay as a space, with the group as a room.\", [link(\"Open\", \"https://app.flotilla.social/spaces/\" + enc(host)), naddr && app(\"Open group\", \"nostr:\" + naddr)]),\n row(\"0xchat\", \"phone\", \"The group, in a chat app.\", [naddr && app(\"Open group\", \"nostr:\" + naddr), naddr && copy(\"Copy naddr\", naddr)]),\n row(\"noStrudel\", \"web\", \"Relays, add this one, then open its page.\", [copy(\"Copy relay URL\", wsURL)]),\n ]],\n [\"Find me here\", \"Feed apps have no relay pages. They meet this relay through a profile link that names it, then keep it once it is in the relay settings.\", [\n row(\"Primal\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://primal.net/p/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"YakiHonne\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://yakihonne.com/profile/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"Damus\", \"iPhone\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Amethyst\", \"Android\", profileNote + \" Then Relays in the drawer.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Nostur\", \"iPhone, Mac\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n ]],\n ];\n if (info?.supported_grasps?.includes(\"GRASP-01\")) groups.push([\n \"Git repositories\", \"Use this relay with a Git client.\", [\n row(\"GitWorkshop\", \"web\", \"Browse this relay's Git repositories.\", [link(\"Open in app\", \"https://gitworkshop.dev/relay/\" + enc((wsURL.startsWith(\"ws://\") ? \"ws:\" : \"\") + host)), copy(\"Copy relay URL\", wsURL)]),\n ],\n ]);\n el.innerHTML = groups.map(([h, note, rows]) => '

                                                                                                                                              ' + h + '

                                                                                                                                              ' + note + '

                                                                                                                                              ' + rows.join(\"\") + \"
                                                                                                                                              \").join(\"\");\n $(\"#apps-ws\").textContent = wsURL;\n const tile = (label, text) => '
                                                                                                                                              ' + label + '\"QR
                                                                                                                                              ';\n $(\"#phones\").innerHTML = [nprofile && tile(\"Find me here, for a phone\", \"nostr:\" + nprofile), naddr && tile(\"The group, for a phone\", \"nostr:\" + naddr)].filter(Boolean).join(\"\");\n }\n // The folds are a group: opening one closes the rest. Browsers with the\n // details name attribute do this themselves; this covers the others.\n $$(\".folds details\").forEach((d) => d.addEventListener(\"toggle\", () => { if (d.open) $$(\".folds details\").forEach((o) => { if (o !== d && o.open) o.open = false; }); }));\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-copytext]\"); if (!b) return;\n try { await navigator.clipboard.writeText(b.dataset.copytext); toast(\"copied\"); } catch { /* no clipboard */ }\n });\n $(\"#copynaddr\").onclick = async () => { if (!card || !card.naddr) return; await navigator.clipboard.writeText(card.naddr); toast(\"copied naddr\"); };\n $(\"#copyembed\").onclick = async () => { await navigator.clipboard.writeText('\"''); toast(\"copied embed\"); };\n\n // A click on a key copies it; a double-click selects the whole key so the usual copy shortcut takes it too.\n document.addEventListener(\"dblclick\", (ev) => {\n const k = ev.target.closest(\".key\"); if (!k) return;\n const s = window.getSelection(), r = document.createRange(); r.selectNodeContents(k.querySelector(\".full\")); s.removeAllRanges(); s.addRange(r);\n });\n document.addEventListener(\"click\", async (ev) => {\n const k = ev.target.closest(\".key\"); if (!k || ev.detail > 1 || String(window.getSelection())) return;\n try { await navigator.clipboard.writeText(k.title); toast(\"copied \" + k.dataset.short); } catch { /* no clipboard */ }\n });\n // ---- boot ----\n try { await loadInfo(); } catch { renderHeader(); }\n await loadFuel();\n await loadCard();\n me = localStorage.getItem(\"me\");\n if (me && await resumeRemote()) { /* a remote session answers for itself when first used */ }\n else if (me && window.nostr) { try { const pk = await window.nostr.getPublicKey(); if (pk !== me) me = null; } catch { me = null; } }\n else if (me) me = null;\n renderHeader();\n renderApps();\n await loadAdmin();\n await loadPeople();\n})();\n"; +export const CONSOLE_JS = "(async () => {\n const $ = (s) => document.querySelector(s);\n const $$ = (s) => document.querySelectorAll(s);\n const host = location.host;\n const wsURL = (location.protocol === \"https:\" ? \"wss://\" : \"ws://\") + host;\n const rpcURL = location.origin + \"/\";\n let info = null, me = null, owner = \"\", policy = null, fuel = null, people = null, myRole = \"\";\n\n const toast = (msg) => { const t = $(\"#toast\"); t.textContent = msg; t.classList.add(\"show\"); clearTimeout(t._t); t._t = setTimeout(() => t.classList.remove(\"show\"), 2600); };\n // guard wraps a handler: disables the button, shows errors as toasts. Defined first, since handlers below use it.\n const guard = (fn) => async (ev) => { ev.preventDefault(); const b = ev.submitter || ev.target; if (b) b.disabled = true; try { await fn(ev); } catch (e) { toast(e.message); } finally { if (b) b.disabled = false; } };\n const short = (hex) => hex ? hex.slice(0, 8) + \"…\" + hex.slice(-4) : \"\";\n // key shows a key short but keeps the whole of it in the DOM: selecting, double-clicking or clicking it copies the full hex.\n const key = (hex) => hex ? '' + hex + '' : '';\n const hue = (hex) => (parseInt(hex.slice(0, 2), 16) * 360 / 256).toFixed(0) + \"deg\";\n const av = (hex) => '';\n const fmtBytes = (n) => n < 1e6 ? (n / 1e3).toFixed(0) + \" KB\" : n < 1e9 ? (n / 1e6).toFixed(1) + \" MB\" : (n / 1e9).toFixed(2) + \" GB\";\n const fmtHours = (ms) => ms < 3600e3 ? Math.round(ms / 60e3) + \" min\" : (ms / 3600e3).toFixed(ms < 36e6 ? 1 : 0) + \" h\";\n const fuelOver = () => !!fuel && (fuel.eventBytes > fuel.freeEventBytes || fuel.mediaBytes > fuel.freeMediaBytes || fuel.activeMs > fuel.freeActiveMs || fuel.rowsWritten > fuel.freeRowsWritten);\n const fmtTime = (t) => t ? new Date(t * 1000).toLocaleString(undefined, { month: \"short\", day: \"numeric\", hour: \"2-digit\", minute: \"2-digit\" }) : \"–\";\n const fmtDay = (t) => t ? new Date(t * 1000).toLocaleDateString(undefined, { month: \"short\", day: \"numeric\" }) : \"–\";\n const ago = (t) => { const s = Math.max(0, Math.floor(Date.now() / 1000) - t); return s < 60 ? \"just now\" : s < 3600 ? Math.floor(s / 60) + \" min\" : s < 86400 ? Math.floor(s / 3600) + \" h\" : Math.floor(s / 86400) + \" d\"; };\n const esc = (s) => String(s ?? \"\").replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c]);\n\n const IC = {\n x: '',\n undo: '',\n check: '',\n trash: '',\n ban: '',\n banuser: '',\n eye: '',\n lock: '',\n pen: '',\n people: '',\n person: '',\n gauge: '',\n bolt: '',\n copy: '',\n pin: '',\n };\n const ib = (icon, label, act, id, extra) => '\";\n\n const CH = \"qpzry9x8gf2tvdw0s3jn54khce6mua7l\";\n function npubToHex(s) {\n s = s.trim().toLowerCase();\n if (/^[0-9a-f]{64}$/.test(s)) return s;\n if (!s.startsWith(\"npub1\")) return null;\n const data = s.slice(5, -6).split(\"\").map((c) => CH.indexOf(c));\n if (data.some((d) => d < 0)) return null;\n let bits = 0, acc = 0, out = [];\n for (const d of data) { acc = (acc << 5) | d; bits += 5; if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 255); } }\n return out.length === 32 ? out.map((b) => b.toString(16).padStart(2, \"0\")).join(\"\") : null;\n }\n\n async function sha256hex(s) {\n const b = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(s));\n return [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, \"0\")).join(\"\");\n }\n // ---- signing: a NIP-07 extension, or a NIP-46 remote signer ----\n // The remote path loads the bundled library on first use; its session\n // (client key, signer pubkey, relays) lives in localStorage until sign-out.\n let remote = null, lib = null, pendingNote = null;\n const NO_SIGNER = \"Install a nostr extension (Alby, nos2x, …) or connect a remote signer.\";\n const withTimeout = (p, ms, what) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(what + \" did not answer; is the signer app open?\")), ms))]);\n const signer = {\n ready: () => !!remote || !!window.nostr,\n async getPublicKey() { if (remote) return withTimeout(remote.getPublicKey(), 20000, \"The remote signer\"); if (window.nostr) return window.nostr.getPublicKey(); throw new Error(NO_SIGNER); },\n async signEvent(ev) { if (remote) return withTimeout(remote.signEvent(ev), 60000, \"The remote signer\"); if (window.nostr) return window.nostr.signEvent(ev); throw new Error(NO_SIGNER); },\n };\n async function signerLib() {\n if (lib) return lib;\n if (!window.NostrSigner) await new Promise((res, rej) => { const s = document.createElement(\"script\"); s.src = window.SIGNER_URL || \"/signer.js\"; s.onload = res; s.onerror = () => rej(new Error(\"Could not load the signer library.\")); document.head.appendChild(s); });\n return (lib = window.NostrSigner);\n }\n const onauth = (u) => window.open(u, \"_blank\");\n // The signer conversation rides this relay, and a relay may ask the socket to\n // authenticate before it delivers kind 24133 to its parties; answer with the\n // session's own key.\n const authPool = (L, sk) => new L.SimplePool({ automaticallyAuth: () => (evt) => Promise.resolve(L.finalizeEvent(evt, sk)) });\n const saveSession = (sk, s, secret) => localStorage.setItem(\"nip46\", JSON.stringify({ sk: lib.bytesToHex(sk), pubkey: s.bp.pubkey, relays: s.bp.relays, secret: secret || null }));\n async function resumeRemote() {\n const raw = localStorage.getItem(\"nip46\");\n if (!raw) return false;\n try {\n const s = JSON.parse(raw); const L = await signerLib();\n remote = L.BunkerSigner.fromBunker(L.hexToBytes(s.sk), { pubkey: s.pubkey, relays: s.relays, secret: s.secret }, { onauth, pool: authPool(L, L.hexToBytes(s.sk)) });\n return true;\n } catch { localStorage.removeItem(\"nip46\"); return false; }\n }\n async function connectBunker(input) {\n const L = await signerLib();\n const bp = await L.parseBunkerInput(input.trim());\n if (!bp) throw new Error(\"That is not a bunker:// URL.\");\n if (!bp.relays.length) throw new Error(\"The bunker URL names no relay.\");\n const sk = L.generateSecretKey();\n const s = L.BunkerSigner.fromBunker(sk, bp, { onauth, pool: authPool(L, sk) });\n await withTimeout(s.connect({ name: host, url: location.origin }), 60000, \"The signer\");\n await Promise.race([s.switchRelays(), new Promise((r) => setTimeout(r, 3000))]);\n remote = s; saveSession(sk, s, bp.secret);\n }\n // The nostrconnect:// flow: this relay carries the traffic, so no third relay is involved.\n let ncPending = null;\n async function offerNostrConnect() {\n if (ncPending) return ncPending;\n const L = await signerLib();\n const sk = L.generateSecretKey();\n const secret = L.bytesToHex(crypto.getRandomValues(new Uint8Array(8)));\n const uri = L.createNostrConnectURI({ clientPubkey: L.getPublicKey(sk), relays: [wsURL], secret, name: host, url: location.origin, perms: [\"sign_event:27235\", \"sign_event:9734\"] });\n $(\"#nclink\").href = uri; $(\"#ncnote\").textContent = \"\";\n $(\"#ncqr\").src = \"/qr.svg?text=\" + encodeURIComponent(uri); $(\"#ncqr\").classList.remove(\"hidden\");\n ncPending = L.BunkerSigner.fromURI(sk, uri, { onauth, pool: authPool(L, sk) }, 600000).then((s) => { remote = s; saveSession(sk, s, secret); ncPending = null; return s; }, (e) => { ncPending = null; throw e; });\n return ncPending;\n }\n async function remoteDone() {\n me = await signer.getPublicKey();\n localStorage.setItem(\"me\", me);\n $(\"#remotesec\").classList.add(\"hidden\");\n toast(\"Remote signer connected\");\n if (pendingNote && !owner) { const n = pendingNote; pendingNote = null; await claimNow(n); return; }\n pendingNote = null;\n renderHeader(); await loadAdmin(); await loadPeople();\n }\n function showRemote(note) {\n pendingNote = note || null;\n $(\"#remotesec\").classList.remove(\"hidden\");\n $(\"#remotesec\").scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n offerNostrConnect().then(remoteDone).catch((e) => { $(\"#ncnote\").textContent = e.message; });\n }\n\n async function rpc(method, ...params) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const body = JSON.stringify({ method, params });\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", rpcURL], [\"method\", \"POST\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(rpcURL, { method: \"POST\", headers: { \"content-type\": \"application/nostr+json+rpc\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body });\n const json = await resp.json();\n if (json.error) throw new Error(json.error);\n return json.result;\n }\n\n // ---- front of house ----\n // urlish shows a URL as a short link: the host and the start of the path, the whole thing on hover and as the target.\n class Html { constructor(s) { this.s = s; } }\n const urlish = (u) => {\n if (!u) return \"\";\n let label = u; try { const x = new URL(u); label = x.host + (x.pathname.length > 1 ? x.pathname.slice(0, 18) + (x.pathname.length > 18 ? \"\\u2026\" : \"\") : \"\"); } catch { /* not a URL */ }\n if (!/^https?:\\/\\//.test(u)) return u;\n return new Html('' + esc(label) + \"\");\n };\n function renderHeader() {\n const name = (info && info.name) || host.split(\".\")[0];\n $(\"#title\").textContent = name;\n document.title = name + \" - relay\";\n $(\"#url\").textContent = wsURL;\n $(\"#d-ws\").textContent = wsURL; $(\"#d-blossom\").textContent = location.origin; $(\"#d-nip05\").textContent = \"you@\" + host;\n $(\"#nip05-example\").textContent = \"alice@\" + host;\n const desc = (info && info.description) || \"\";\n $(\"#desc\").textContent = desc; $(\"#desc\").classList.toggle(\"hidden\", !desc);\n // What the owner declared about the relay, on the page, not only in the document clients read.\n const img = (id, url) => { const el = $(id); if (url) el.src = url; el.classList.toggle(\"hidden\", !url); };\n img(\"#bannerimg\", info?.banner); $(\"#banner\").classList.toggle(\"hidden\", !info?.banner);\n img(\"#iconimg\", info?.icon);\n const meta = [];\n for (const t of info?.tags || []) meta.push('' + esc(t) + \"\");\n const where = [...(info?.language_tags || []), ...(info?.relay_countries || [])];\n if (where.length) meta.push(\"\" + esc(where.join(\", \")) + \"\");\n const link = (label, href) => href ? '' + label + \"\" : \"\";\n const contact = info?.contact ? (/^(mailto:|https?:\\/\\/)/.test(info.contact) ? link(\"Contact\", info.contact) : \"\" + esc(info.contact) + \"\") : \"\";\n for (const x of [contact, link(\"Terms\", info?.terms_of_service), link(\"Posting policy\", info?.posting_policy), link(\"Privacy\", info?.privacy_policy)]) if (x) meta.push(x);\n $(\"#metarow\").innerHTML = meta.join('|'); $(\"#metarow\").classList.toggle(\"hidden\", meta.length === 0);\n $(\"#frontsec\").classList.toggle(\"hidden\", !desc && meta.length === 0);\n owner = (info && info.pubkey) || \"\";\n const isOwner = !!me && me === owner;\n const lease = !owner && info && info.lease ? info.lease : null;\n $(\"#ownerline\").innerHTML = owner ? 'run by ' + key(owner) + \"\" : lease ? \"temporary until \" + fmtDay(lease.expires_at) : \"unclaimed\";\n $(\"#owner-av\").classList.toggle(\"hidden\", !owner);\n if (owner) $(\"#owner-av\").style.setProperty(\"--h\", hue(owner));\n $(\"#who\").textContent = me ? (isOwner ? \"(that's you)\" : \"\") : \"\";\n $(\"#unclaimed\").classList.toggle(\"hidden\", !!owner || !!lease);\n $(\"#leased\").classList.toggle(\"hidden\", !lease);\n if (lease) {\n $(\"#lease-until\").textContent = fmtTime(lease.expires_at);\n $(\"#leasenote\").textContent = lease.holder ? \"Reserved for the key that asked for it; sign with that key.\" : \"\";\n }\n $(\"#signin\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signin46\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signout\").classList.toggle(\"hidden\", !me);\n $(\"#about\").innerHTML = [\n [\"Name\", info?.name], [\"Description\", info?.description], [\"Contact\", info?.contact], [\"Owner\", owner ? short(owner) : \"\"], [\"Relay key\", info?.self ? short(info.self) : \"\"], [\"Software\", urlish(info?.software)], [\"Version\", info?.version],\n [\"Max query\", info?.limitation?.max_limit], [\"Auth required\", String(!!info?.limitation?.auth_required)], [\"Restricted writes\", String(!!info?.limitation?.restricted_writes)], [\"Min PoW\", info?.limitation?.min_pow_difficulty || 0],\n [\"Tags\", info?.tags?.join(\", \")], [\"Languages\", info?.language_tags?.join(\", \")], [\"Countries\", info?.relay_countries?.join(\", \")],\n [\"Terms\", urlish(info?.terms_of_service)], [\"Posting policy\", urlish(info?.posting_policy)], [\"Privacy policy\", urlish(info?.privacy_policy)], [\"Icon\", urlish(info?.icon)], [\"Banner\", urlish(info?.banner)],\n [\"NIPs\", info?.supported_nips?.join(\" \")],\n ].map(([k, v]) => \"
                                                                                                                                              \" + k + \"\" + (v === undefined || v === \"\" ? '' : v instanceof Html ? v.s : esc(v)) + \"
                                                                                                                                              \").join(\"\");\n renderCare();\n }\n\n function renderCare() {\n const g = (icon, label, off) => '' + IC[icon] + \"\" + label + \"\";\n const writes = policy ? policy.writes : (info?.limitation?.restricted_writes ? \"allowlist\" : \"open\");\n const reads = policy ? policy.reads : (info?.limitation?.auth_required ? \"auth\" : \"open\");\n let out = writes === \"owner\" ? g(\"person\", \"only owner writes\") : writes === \"allowlist\" ? g(\"people\", \"members write\") : writes === \"wot\" ? g(\"people\", \"members and follows write\") : g(\"pen\", \"anyone writes\");\n out += reads === \"members\" ? g(\"people\", \"members read\") : reads === \"auth\" ? g(\"lock\", \"sign in to read\") : g(\"eye\", \"anyone reads\");\n if (fuel) {\n const over = fuelOver();\n out += fuel.outOfFuel ? g(\"gauge\", \"out of fuel\", true) : over ? g(\"bolt\", \"burning sats\") : g(\"gauge\", \"on free allowance\");\n }\n $(\"#care\").innerHTML = out;\n }\n\n async function loadPeople() {\n try { people = await (await fetch(\"/people\")).json(); } catch { return; }\n const isOwner = !!me && me === owner;\n let list = people.people || [];\n if (!people.public && isOwner && policy) list = (window.__members || []);\n $(\"#peoplesec\").classList.toggle(\"hidden\", !owner || list.length === 0);\n $(\"#people-note\").classList.toggle(\"hidden\", people.public);\n $(\"#people-count\").textContent = list.length || \"\";\n $(\"#dir\").innerHTML = list.map((m) => '' + av(m.pubkey) + '' + (m.name ? esc(m.name) + \"@\" + host : key(m.pubkey)) + \"\" + (m.role === \"owner\" ? 'owner' : \"\") + \"\").join(\"\");\n }\n\n async function loadInfo() {\n info = await (await fetch(\"/\", { headers: { accept: \"application/nostr+json\" } })).json();\n renderHeader();\n const git = info.supported_grasps?.includes(\"GRASP-01\");\n $(\"#git-connect\").classList.toggle(\"hidden\", !git);\n const clone = \"git clone '\" + location.origin + \"//.git'\";\n $(\"#git-clone\").textContent = clone;\n $(\"#git-copy\").dataset.copytext = clone;\n renderApps();\n }\n\n async function loadFuel() {\n try { fuel = await (await fetch(\"/fuel\")).json(); } catch { return; }\n $(\"#fuelsec\").classList.toggle(\"hidden\", !owner);\n const pct = (used, free) => Math.min(100, Math.round((used / Math.max(free, 1)) * 100));\n const gauge = (id, used, free, text) => { const g = $(\"#g-\" + id), share = free ? used / free : 0; g.style.width = pct(used, free) + \"%\"; g.classList.toggle(\"over\", share > 1); g.classList.toggle(\"warm\", share > 0.75 && share <= 1); $(\"#p-\" + id).textContent = share > 1 ? \"over\" : Math.round(share * 100) + \"%\"; $(\"#t-\" + id).textContent = text; };\n gauge(\"events\", fuel.eventBytes, fuel.freeEventBytes, fmtBytes(fuel.eventBytes) + \" of \" + fmtBytes(fuel.freeEventBytes) + \" free\");\n gauge(\"media\", fuel.mediaBytes, fuel.freeMediaBytes, fmtBytes(fuel.mediaBytes) + \" of \" + fmtBytes(fuel.freeMediaBytes) + \" free\");\n gauge(\"active\", fuel.activeMs, fuel.freeActiveMs, fmtHours(fuel.activeMs) + \" of \" + fmtHours(fuel.freeActiveMs) + \" free\");\n gauge(\"rows\", fuel.rowsWritten, fuel.freeRowsWritten, fuel.rowsWritten.toLocaleString() + \" of \" + fuel.freeRowsWritten.toLocaleString() + \" free\");\n const sats = Math.floor(fuel.balanceMsats / 1000);\n const r = fuel.rates;\n $(\"#fuel-balance\").innerHTML = fuel.outOfFuel\n ? 'out of fuel Writes are paused until someone tops up.'\n : \"Balance \" + sats.toLocaleString() + \" sats. Past the allowances, prices track what the hosting costs: \" + r.satsPerGBMonthEvents.toLocaleString() + \" sats per GB-month of events, \" + r.satsPerGBMonthMedia.toLocaleString() + \" per GB-month of files, \" + r.satsPerActiveHour.toLocaleString() + \" per hour awake, \" + r.satsPerMillionRows.toLocaleString() + \" per million rows written. Traffic is free.\";\n $(\"#topup\").classList.toggle(\"hidden\", !fuel.enabled);\n // Who paid is the owner's to see: it comes with the signed stats call.\n let credits = [];\n if (owner && signer.ready()) { try { credits = (await rpc(\"stats\")).credits || []; } catch { credits = []; } }\n $(\"#credits tbody\").innerHTML = credits.length ? credits.map((c) => '' + esc(fmtTime(c.at)) + '' + av(c.payer) + key(c.payer) + '' + Math.floor(c.msats / 1000).toLocaleString() + \"\").join(\"\") : 'no zaps yet';\n if (!fuel.enabled) $(\"#topup-note\").textContent = \"Top-ups are not enabled on this service yet.\";\n renderCare();\n renderFuelTile();\n }\n\n async function topUp(sats) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const msats = Math.round(sats * 1000);\n const zapRequest = await signer.signEvent({ kind: 9734, created_at: Math.floor(Date.now() / 1000), content: \"fuel for \" + host, tags: [[\"p\", fuel.servicePubkey], [\"amount\", String(msats)], [\"relays\", wsURL]] });\n const r = await (await fetch(\"/fuel/invoice\", { method: \"POST\", body: JSON.stringify({ zapRequest }) })).json();\n if (r.error) throw new Error(r.error);\n const inv = r.invoice;\n $(\"#invoice\").classList.remove(\"hidden\");\n $(\"#inv-text\").value = inv; $(\"#inv-link\").href = \"lightning:\" + inv; $(\"#inv-state\").textContent = \"waiting for payment…\";\n if (window.webln) { try { await window.webln.enable(); await window.webln.sendPayment(inv); } catch (e) { toast(e.message || \"wallet declined\"); } }\n const before = fuel.creditedMsats;\n for (let i = 0; i < 120; i++) {\n await new Promise((r) => setTimeout(r, 2500));\n await loadFuel();\n if (fuel.creditedMsats > before) { $(\"#inv-state\").textContent = \"paid, thank you\"; toast(\"Fuel credited\"); return; }\n }\n $(\"#inv-state\").textContent = \"no receipt yet; it can take a minute after paying\";\n }\n\n // ---- console ----\n function showTab(name) {\n if (name === \"content\" || name === \"storage\") name = \"data\";\n const known = myRole === \"moderator\" ? [\"people\", \"moderation\"] : [\"people\", \"moderation\", \"rules\", \"identity\", \"data\", \"sync\", \"views\", \"health\", \"owner\"];\n if (!known.includes(name)) name = \"people\";\n $$(\".tabs a\").forEach((a) => a.classList.toggle(\"on\", a.dataset.tab === name));\n $$(\".panel\").forEach((p) => p.classList.toggle(\"on\", p.dataset.panel === name));\n }\n // Tabs switch in place. A fragment still opens a tab when someone arrives with one,\n // but clicking never writes one, and any fragment already there is dropped.\n $$(\".tabs a\").forEach((a) => a.addEventListener(\"click\", (ev) => { ev.preventDefault(); showTab(a.dataset.tab); if (location.hash) history.replaceState(null, \"\", location.pathname + location.search); }));\n window.addEventListener(\"hashchange\", () => showTab(location.hash.slice(1)));\n // The strip fades on whichever side has more tabs off screen.\n const tabsFade = () => { const t = $(\"#tabs\"); t.classList.toggle(\"fade-l\", t.scrollLeft > 4); t.classList.toggle(\"fade-r\", t.scrollLeft + t.clientWidth < t.scrollWidth - 4); };\n $(\"#tabs\").addEventListener(\"scroll\", tabsFade); window.addEventListener(\"resize\", tabsFade); new ResizeObserver(tabsFade).observe($(\"#tabs\"));\n\n function renderFuelTile() {\n if (!fuel) return;\n const over = fuelOver();\n const sats = Math.max(0, Math.floor(fuel.balanceMsats / 1000));\n const d = new Date(), end = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1), days = Math.max(1, Math.ceil((end - Date.now()) / 86400000));\n if (fuel.outOfFuel) { $(\"#h-fuel\").textContent = \"0 sats\"; $(\"#h-fuel-label\").textContent = \"out of fuel\"; $(\"#h-fuel-sub\").textContent = \"writes are paused\"; }\n else if (over) { $(\"#h-fuel\").textContent = sats.toLocaleString() + \" sats\"; $(\"#h-fuel-label\").textContent = \"fuel left\"; $(\"#h-fuel-sub\").textContent = \"past the free allowance\"; }\n else { $(\"#h-fuel\").textContent = days + (days === 1 ? \" day\" : \" days\"); $(\"#h-fuel-label\").textContent = \"free allowance left\"; $(\"#h-fuel-sub\").textContent = sats ? \"then \" + sats.toLocaleString() + \" sats\" : \"then a top-up is needed\"; }\n $(\"#usage\").innerHTML = [[\"events stored\", fmtBytes(fuel.eventBytes)], [\"files stored\", fmtBytes(fuel.mediaBytes)], [\"awake this month\", fmtHours(fuel.activeMs)], [\"rows written\", fuel.rowsWritten.toLocaleString()], [\"rows read\", fuel.rowsRead.toLocaleString()], [\"received this month\", fmtBytes(fuel.bytesIn)], [\"served this month\", fmtBytes(fuel.bytesOut)], [\"charged\", Math.floor(fuel.chargedMsats / 1000).toLocaleString() + \" sats\"]]\n .map(([k, v]) => \"
                                                                                                                                              \" + k + \"\" + v + \"
                                                                                                                                              \").join(\"\");\n }\n\n function renderHealth(stats) {\n $(\"#h-last\").textContent = stats.newest ? ago(stats.newest) : \"none\";\n $(\"#h-last-sub\").textContent = stats.newest ? fmtTime(stats.newest) : \"no events yet\";\n $(\"#h-conns\").textContent = stats.connections;\n const buckets = [[\"notes\", \"k1\", [1]], [\"reactions\", \"k2\", [7]], [\"DMs\", \"k3\", [4, 14, 1059]], [\"long-form\", \"k4\", [30023]], [\"other\", \"k5\", null]];\n const sums = buckets.map(() => 0); let total = 0;\n for (const { kind, n } of stats.kinds || []) { total += n; const i = buckets.findIndex((b) => b[2] && b[2].includes(kind)); sums[i < 0 ? 4 : i] += n; }\n const parts = buckets.map((b, i) => [b[0], b[1], sums[i]]).filter((p) => p[2] > 0);\n $(\"#h-kinds\").innerHTML = total ? parts.map(([name, cls, n]) => '').join(\"\") : '';\n $(\"#h-kinds-legend\").innerHTML = total ? parts.map(([name, cls, n]) => '' + name + \" \" + Math.round(n * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n renderFuelTile();\n }\n\n async function loadAdmin() {\n const isOwner = !!me && me === owner;\n myRole = isOwner ? \"owner\" : \"\";\n let stats = null, p = null;\n // A signed-in member may be a moderator; the relay says by answering stats.\n if (me && owner && !isOwner) { try { [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]); myRole = \"moderator\"; } catch { myRole = \"\"; } }\n $(\"#console\").classList.toggle(\"hidden\", !myRole);\n loadMine();\n $(\"#console\").classList.toggle(\"mod\", myRole === \"moderator\");\n if (!myRole) { policy = null; renderCare(); return; }\n if (isOwner) [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]);\n policy = p;\n renderHealth(stats);\n renderCare();\n const fa = $(\"#access\"), fi = $(\"#identity\");\n fa.writes.value = p.writes; fa.reads.value = p.reads; fa.openKinds.value = (p.openKinds || []).join(\", \"); fa.guestReplies.checked = !!p.guestReplies;\n $(\"#wordsform\").words.value = (p.blockedWords || []).join(\"\\n\"); $(\"#wordsform\").inTags.checked = !!p.blockedWordsInTags; $(\"#thresholdform\").reportThreshold.value = p.reportThreshold || 0; fa.minPow.value = p.minPow; fa.maxFuture.value = p.maxFuture; fa.maxLimit.value = p.maxLimit; fa.maxSubs.value = p.maxSubs; fa.maxMessageKB.value = p.maxMessageKB;\n fa.eventsPerMinute.value = p.eventsPerMinute; fa.reqsPerMinute.value = p.reqsPerMinute; fa.maxBlobMB.value = p.maxBlobMB;\n renderFeatures(p.features || {});\n $(\"#push-policy-form\").elements.origins.value = (p.pushCallbacks || []).join(\"\\n\");\n $(\"#push-policy-form\").elements.lettered.checked = !!p.letteredNips;\n $(\"#push-policy-form\").elements.delivery.checked = !!p.delivery?.enabled;\n $(\"#push-policy-form\").elements.deliveryMax.value = p.delivery?.maxTargets || 8;\n fi.name.value = p.name; fi.contact.value = p.contact; fi.description.value = p.description; fi.icon.value = p.icon;\n const fj = $(\"#joinform\"); fj.joinTerms.value = p.joinTerms; fj.directoryPublic.checked = !!p.directoryPublic;\n loadCard();\n fi.banner.value = p.banner || \"\"; fi.postingPolicy.value = p.postingPolicy || \"\"; fi.privacyPolicy.value = p.privacyPolicy || \"\";\n fi.tags.value = (p.tags || []).join(\", \"); fi.languageTags.value = (p.languageTags || []).join(\", \"); fi.relayCountries.value = (p.relayCountries || []).join(\", \");\n const fn = $(\"#notify\"), nt = p.notify || {};\n fn.reports.checked = !!nt.reports; fn.fuel.checked = !!nt.fuel; fn.jobs.checked = !!nt.jobs; fn.succession.checked = !!nt.succession; fn.digest.checked = !!nt.digest;\n showTab(location.hash.slice(1));\n if (isOwner) { await renderPresets(); renderWire(); renderSuccession(); loadDomains(); }\n await Promise.all([loadLists(), loadEvents(true), loadPins(), ...(isOwner ? [loadStorage()] : [])]);\n }\n\n async function loadAudit(before) {\n const rows = await rpc(\"listaudit\", before || 0);\n const html = rows.map((r) => '' + esc(fmtTime(r.at)) + '' + av(r.actor) + key(r.actor) + '' + esc(r.action) + '' + (/^[0-9a-f]{64}$/.test(r.target) ? key(r.target) : esc(r.target)) + '' + esc(r.detail) + '').join(\"\");\n const tb = $(\"#audit tbody\");\n if (before) tb.insertAdjacentHTML(\"beforeend\", html); else tb.innerHTML = html || 'Nothing yet';\n $(\"#audit-more\").style.display = rows.length < 100 ? \"none\" : \"\";\n }\n $(\"#audit-more\").onclick = guard(async () => { const last = $(\"#audit tbody tr:last-child\"); await loadAudit(last ? +last.dataset.seq : 0); });\n async function loadLists() {\n loadAudit(0);\n const [mem, bans, bannedEvents, allow, block, invites, reports, blobs, blocks, sites] = await Promise.all([rpc(\"listmembers\"), rpc(\"listbannedpubkeys\"), rpc(\"listbannedevents\"), rpc(\"listallowedkinds\"), rpc(\"listblockedkinds\"), rpc(\"listinvites\"), rpc(\"listreports\"), rpc(\"listblobs\", 100), rpc(\"listblockedips\"), rpc(\"listsites\")]);\n const members = mem.members;\n window.__members = members;\n const roleCell = (m) => m.role === \"owner\" ? ' owner' : myRole === \"owner\" ? ' ' : m.role === \"moderator\" ? ' moderator' : \"\";\n const untouchable = (m) => m.role === \"owner\" || (myRole !== \"owner\" && m.role === \"moderator\");\n // The tree: everyone under whoever invited them, the owner and the owner's own additions at the root.\n const known = new Set(members.map((m) => m.pubkey));\n const byInviter = new Map();\n for (const m of members) { const k = m.role !== \"owner\" && known.has(m.invited_by) ? m.invited_by : \"\"; if (!byInviter.has(k)) byInviter.set(k, []); byInviter.get(k).push(m); }\n const ordered = [], placed = new Set();\n const walk = (k, depth) => { for (const m of byInviter.get(k) || []) { if (placed.has(m.pubkey)) continue; placed.add(m.pubkey); m.depth = depth; ordered.push(m); walk(m.pubkey, depth + 1); } };\n walk(\"\", 0);\n for (const m of members) if (!placed.has(m.pubkey)) { m.depth = 0; ordered.push(m); }\n const nameOf = (pk) => { const x = members.find((m) => m.pubkey === pk); return x && x.name ? x.name : short(pk); };\n const limits = (m) => m.role === \"owner\" ? \"\" : myRole === \"owner\"\n ? ''\n : '' + (m.keep_days ? m.keep_days + \" d\" : \"\") + (m.max_bytes ? \" \" + fmtBytes(m.max_bytes) : \"\") + \"\";\n $(\"#members tbody\").innerHTML = ordered.map((m) => '' + av(m.pubkey) + key(m.pubkey) + roleCell(m) + (m.depth ? ' via ' + esc(nameOf(m.invited_by)) + \"\" : \"\") + (m.invites ? ' ' + m.invites + \" inv\" : \"\") + '' + limits(m) + '' + esc(fmtDay(m.joined_at)) + \", \" + esc(m.via) + '' + ib(\"check\", \"Save\", \"savemember\", m.pubkey) + (untouchable(m) ? \"\" : ib(\"x\", \"Remove\", \"removemember\", m.pubkey) + ib(\"banuser\", \"Ban\", \"banpubkey\", m.pubkey, \"danger\")) + \"\").join(\"\");\n const tf = $(\"#treeform\");\n tf.classList.toggle(\"hidden\", myRole !== \"owner\");\n if (policy && policy.memberInvites) { tf.depth.value = policy.memberInvites.depth; tf.quota.value = policy.memberInvites.quota; }\n $(\"#transfer [name=pubkey]\").innerHTML = members.filter((m) => m.role !== \"owner\").map((m) => '\").join(\"\");\n $(\"#succession [name=heir]\").innerHTML = $(\"#transfer [name=pubkey]\").innerHTML;\n $(\"#tc-people\").textContent = members.length;\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#invites\").innerHTML = invites.length ? invites.map((i) => '
                                                                                                                                            • inv…' + i.code.slice(-8) + \" \" + esc(i.note || \"\") + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
                                                                                                                                            • \").join(\"\") : '
                                                                                                                                            • no invites
                                                                                                                                            • ';\n const person = (r, icon, label, act) => \"
                                                                                                                                            • \" + av(r.pubkey) + '' + short(r.pubkey) + \" \" + esc(r.reason || \"\") + \"\" + ib(icon, label, act, r.pubkey) + \"
                                                                                                                                            • \";\n $(\"#bans\").innerHTML = bans.length ? bans.map((r) => person(r, \"undo\", \"Unban\", \"unrulepubkey\")).join(\"\") : '
                                                                                                                                            • nobody banned
                                                                                                                                            • ';\n $(\"#blocks\").innerHTML = blocks.length ? blocks.map((r) => '
                                                                                                                                            • ip' + esc(r.ip) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"undo\", \"Unblock\", \"unblockip\", r.ip) + \"
                                                                                                                                            • \").join(\"\") : '
                                                                                                                                            • no addresses blocked
                                                                                                                                            • ';\n $(\"#banned-events\").innerHTML = bannedEvents.map((r) => '
                                                                                                                                            • ev' + key(r.id) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"check\", \"Allow again\", \"allowevent\", r.id) + \"
                                                                                                                                            • \").join(\"\");\n $(\"#reports tbody\").innerHTML = reports.length ? reports.map((r) => '' + esc(fmtTime(r.at)) + '' + esc(r.type || \"report\") + '' + av(r.target_pubkey) + key(r.target_pubkey) + (r.target_event ? (r.blob ? ' file ' + key(r.target_event) + \"\" : ' ev ' + key(r.target_event) + (r.hidden ? ' hidden' : \"\") + \"\") : \"\") + '' + esc(r.content) + '' + ib(\"check\", \"Dismiss\", \"resolve:dismiss\", r.id) + ib(\"trash\", r.blob ? \"Delete the file\" : \"Delete the event\", \"resolve:delete\", r.id) + ib(\"banuser\", \"Ban the author\", \"resolve:ban\", r.id, \"danger\") + \"\").join(\"\") : 'nothing reported';\n $(\"#reports-count\").textContent = reports.length || \"\"; $(\"#tc-reports\").textContent = reports.length || \"\";\n $(\"#blobs tbody\").innerHTML = blobs.length ? blobs.map((b) => '' + esc(fmtTime(b.uploaded)) + '' + key(b.sha256) + ' ' + esc(b.type) + '' + fmtBytes(b.size) + '' + av(b.uploader) + key(b.uploader) + '' + ib(\"trash\", \"Delete file\", \"deleteblob\", b.sha256, \"danger\") + \"\").join(\"\") : 'no uploads';\n $(\"#blobs-count\").textContent = blobs.length || \"\";\n $(\"#sites tbody\").innerHTML = sites.length ? sites.map((s) => '' + av(s.author) + key(s.author) + '' + esc(s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + '' + esc(s.url) + '' + esc(s.paths) + (s.missing ? ' ' + esc(s.missing) + ' missing' : '') + '' + fmtBytes(s.size) + '' + esc(s.expires_at ? fmtTime(s.expires_at) : \"never\") + '' + ib(\"trash\", \"Delete site\", \"deleteevent\", s.id, \"danger\") + '').join(\"\") : 'no sites yet';\n $(\"#sites-count\").textContent = sites.length || \"\";\n const tag = (k, cls) => '' + k + ib(\"x\", \"Remove rule\", \"unrulekind\", String(k)) + \"\";\n $(\"#kinds-allow\").innerHTML = allow.length ? allow.map((k) => tag(k, \"ok\")).join(\"\") : 'all';\n $(\"#kinds-block\").innerHTML = block.length ? block.map((k) => tag(k, \"blk\")).join(\"\") : 'none';\n await loadPeople();\n }\n\n const KIND_NAMES = { 0: \"profiles\", 1: \"notes\", 3: \"contacts\", 4: \"DMs\", 5: \"deletions\", 6: \"reposts\", 7: \"reactions\", 16: \"reposts\", 1059: \"gift wraps\", 1063: \"file headers\", 1111: \"comments\", 1984: \"reports\", 9734: \"zap requests\", 9735: \"zap receipts\", 9802: \"highlights\", 10002: \"relay lists\", 13534: \"roster\", 9000: \"group adds\", 9001: \"group removals\", 9021: \"join requests\", 9022: \"leave requests\", 39000: \"group info\", 39001: \"group admins\", 39002: \"group members\", 39003: \"group roles\", 30023: \"articles\", 30024: \"drafts\", 30078: \"app data\", 30311: \"live events\", 30818: \"wiki\" };\n const kindName = (k) => KIND_NAMES[k] || (k >= 20000 && k < 30000 ? \"ephemeral\" : k >= 30000 && k < 40000 ? \"addressable\" : k >= 10000 && k < 20000 ? \"replaceable\" : \"kind \" + k);\n const SYS_KINDS = new Set([0, 3, 10002, 9735, 13534, 8000, 8001, 9000, 9001, 39000, 39001, 39002, 39003]);\n let storage = null;\n\n // Features (settings.ts): a select each; search has three modes, the rest on or off.\n const FEATURES = [\n [\"search\", \"Search\", \"NIP-50. Prose indexes notes, threads, comments, highlights, articles and wiki pages; full indexes every public kind with content. A change applies to events from then on.\", [\"prose:prose\", \"full:full\", \"off:off\"]],\n [\"sync\", \"Sync\", \"NIP-77 reconciliation, which reads the whole matching set per sync.\"],\n [\"count\", \"Counts\", \"NIP-45 COUNT, with HLL sketches.\"],\n [\"discovery\", \"Discovery record\", \"NIP-66: the record the relay signs about itself, for crawlers.\"],\n [\"names\", \"Names\", \"NIP-05 addresses under this relay's domain.\"],\n [\"files\", \"Files\", \"Blossom and NIP-96: uploads, downloads and listings.\"],\n [\"pages\", \"Pages and feed\", \"Notes and articles as pages, and the Atom feed.\"],\n [\"sites\", \"Static websites\", \"NIP-5A sites on their own hostnames. Mirroring copies missing files into this relay and costs fuel.\", [\"mirror:on, mirror files\", \"proxy:on, fetch as needed\", \"off:off\"]],\n [\"marmot\", \"Marmot transport\", \"Signed KeyPackages and encrypted group messages, with account admission for ephemeral authors.\"],\n [\"grasp\", \"Git repositories\", \"GRASP Git hosting with admitted repository state. The prototype backend has bounded storage and compute limits.\"],\n [\"push\", \"Relay push\", \"NIP-9a callback delivery for members and the owner. Requires approved callback origins; advertises lettered NIP identifiers.\"],\n [\"signer\", \"Signer traffic\", \"NIP-46 remote signing carried for anyone, never stored.\"],\n ];\n function renderFeatures(f) {\n $(\"#features\").innerHTML = FEATURES.map(([k, title, about, modes]) => {\n const cur = k === \"sites\" ? (f.sites?.enabled === false ? \"off\" : f.sites?.mirror === false ? \"proxy\" : \"mirror\") : modes ? String(f[k] || \"prose\") : String(f[k] !== false);\n const opts = (modes || [\"true:on\", \"false:off\"]).map((m) => { const [v, l] = m.split(\":\"); return '\"; }).join(\"\");\n return \"\";\n }).join(\"\");\n }\n $(\"#features\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-feature]\"); if (!sel) return;\n const k = sel.dataset.feature, v = k === \"search\" || k === \"sites\" ? sel.value : sel.value === \"true\";\n policy = await rpc(\"setpolicy\", { features: { [k]: k === \"sites\" ? { enabled: v !== \"off\", mirror: v === \"mirror\" } : v } });\n toast(k === \"search\" ? \"Search: \" + v : (v ? \"Switched on \" : \"Switched off \") + k); await loadInfo();\n }));\n\n $(\"#push-policy-form\").addEventListener(\"submit\", guard(async (ev) => {\n ev.preventDefault();\n const form = ev.target;\n const origins = form.elements.origins.value.split(/\\s+/).filter(Boolean);\n const updated = await rpc(\"setpolicy\", { pushCallbacks: origins, letteredNips: form.elements.lettered.checked, delivery: { enabled: form.elements.delivery.checked, maxTargets: Math.max(1, Math.min(16, Math.floor(+form.elements.deliveryMax.value || 8))) } });\n if (JSON.stringify(updated.pushCallbacks) !== JSON.stringify([...new Set(origins.map((s) => s.replace(/\\/$/, \"\")))])) throw new Error(\"Use up to sixteen exact HTTPS origins, with no path or credentials.\");\n policy = updated;\n toast(\"Saved delivery policy\"); await loadInfo();\n }));\n\n async function loadViews() {\n let views;\n try { views = await rpc(\"listviews\"); } catch { return; }\n $(\"#views\").innerHTML = views.map((v) => {\n const runs = v.trigger === \"off\" ? \"off\" : v.trigger === \"live\" ? \"live, from memory\" : v.trigger === \"write\" ? \"on write and daily\" : v.trigger;\n const label = (c) => (c === \"off\" ? \"off\" : c === \"write\" ? \"on write\" : c === \"live\" ? \"on\" : c);\n const choices = [...v.choices, v.default].filter((c, i, a) => a.indexOf(c) === i);\n const pick = '\";\n const who = v.audience === \"members\" ? \"members\" + (v.stored ? \"\" : \", on request\") : \"anyone\";\n const last = v.trigger === \"live\" ? \"\" : v.last ? \"last run \" + fmtTime(v.last.at) : \"not run yet\";\n const rows = v.trigger === \"live\" ? \"no rows\" : v.last ? v.last.rows.toLocaleString() + \" rows\" : \"\";\n const meta = [runs, who, last, rows].filter(Boolean).map((x) => \"\" + esc(x) + \"\").join('|');\n return '
                                                                                                                                              ' + esc(v.name) + '
                                                                                                                                              ' + esc(v.about) + '
                                                                                                                                              ' + pick + (v.on ? 'Open' : \"\") + '
                                                                                                                                              ' + meta + \"
                                                                                                                                              \";\n }).join(\"\");\n }\n $(\"#views\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-view]\"); if (!sel) return;\n const value = sel.value === \"live\" ? true : sel.value;\n await rpc(\"setpolicy\", { views: { [sel.dataset.view]: value } });\n toast(sel.value === \"off\" ? \"Took down \" + sel.dataset.view : sel.dataset.view + \": \" + (sel.value === \"live\" ? \"on\" : sel.value === \"write\" ? \"on write\" : sel.value)); await loadViews(); await loadInfo();\n }));\n async function loadDumps() {\n const list = await rpc(\"listdumps\");\n const f = $(\"#dumpform\");\n if (policy) { f.dumps.value = policy.dumps || \"off\"; f.keep.value = policy.dumpsKeep || 7; }\n $(\"#dumps-count\").textContent = list.length || \"\";\n $(\"#dumps\").innerHTML = list.length ? list.map((d) => '
                                                                                                                                            • jsonl' + esc(d.name) + ' ' + d.events.toLocaleString() + \" events, \" + fmtBytes(d.bytes) + \"\" + ib(\"copy\", \"Download\", \"downloaddump\", d.name) + ib(\"trash\", \"Delete\", \"deletedump\", d.name, \"danger\") + \"
                                                                                                                                            • \").join(\"\") : '
                                                                                                                                            • no dumps yet
                                                                                                                                            • ';\n }\n // A dump is fetched with a signed request and handed to the browser as a file.\n async function downloadDump(name) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const url = location.origin + \"/dumps/\" + name;\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"GET\"]] });\n const resp = await fetch(url, { headers: { authorization: \"Nostr \" + btoa(JSON.stringify(ev)) } });\n if (!resp.ok) throw new Error((await resp.json()).error || \"download failed\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(\".\")[0] + \"-\" + name; a.click(); URL.revokeObjectURL(a.href);\n }\n // A plain member's own invites, when the owner lets members invite.\n async function loadMine() {\n const sec = $(\"#myinvites\");\n if (!me || !owner || myRole || !signer.ready()) { sec.classList.add(\"hidden\"); return; }\n let mine;\n try { mine = await rpc(\"listinvites\"); } catch { sec.classList.add(\"hidden\"); return; }\n sec.classList.remove(\"hidden\");\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#mine\").innerHTML = mine.length ? mine.map((i) => '
                                                                                                                                            • inv…' + i.code.slice(-8) + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
                                                                                                                                            • \").join(\"\") : '
                                                                                                                                            • no invites yet
                                                                                                                                            • ';\n }\n async function loadStorage() {\n storage = await rpc(\"storagestats\");\n pollJobs();\n loadViews();\n loadDumps().catch(() => {});\n const st = storage;\n const top = st.kinds.slice(0, 4), rest = st.kinds.slice(4).reduce((a, k) => a + k.bytes, 0);\n const parts = [...top.map((k, i) => [k.kind + \" \" + kindName(k.kind), \"k\" + (i + 1), k.bytes]), ...(rest ? [[\"other\", \"k5\", rest]] : [])];\n const total = st.eventBytes || 1;\n $(\"#s-bar\").innerHTML = parts.length ? parts.map(([n, c, b]) => '').join(\"\") : '';\n $(\"#s-legend\").innerHTML = parts.length ? parts.map(([n, c, b]) => '' + esc(n) + \" \" + Math.round(b * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n const overhead = Math.max(0, st.databaseBytes - st.eventBytes);\n $(\"#s-totals\").innerHTML = [[\"events\", st.events.toLocaleString() + \" \" + fmtBytes(st.eventBytes) + \"\"], [\"index and overhead\", fmtBytes(overhead)], [\"files\", st.blobs.toLocaleString() + \" \" + fmtBytes(st.mediaBytes) + \"\"]]\n .map(([k, v]) => \"
                                                                                                                                              \" + k + \"\" + v + \"
                                                                                                                                              \").join(\"\");\n const any = st.retention.find((r) => r.kind === null);\n const own = (kind) => { const r = st.retention.find((x) => x.kind === kind); return r ? r.days : \"\"; };\n const row = (k) => {\n const key = k.kind === null ? \"\" : String(k.kind);\n const placeholder = k.kind === null ? \"forever\" : k.replaceable || !any ? \"forever\" : any.days + \" (everything else)\";\n const pill = k.kind === null ? \"everything else\" : '' + k.kind + \" \" + esc(kindName(k.kind));\n const cells = k.protected\n ? 'required'\n : ' days' + ib(\"check\", \"Save keep-for rule\", \"saveretention\", key) + ib(\"trash\", \"Purge\", \"purgekind\", key, \"danger\") + \"\";\n return '' + pill + '' + (k.n === undefined ? \"\" : k.n.toLocaleString()) + '' + (k.bytes === undefined ? \"\" : fmtBytes(k.bytes)) + '' + (k.oldest ? esc(fmtDay(k.oldest)) : \"\") + \"\" + cells + \"\";\n };\n $(\"#kinds tbody\").innerHTML = [...st.kinds, { kind: null, days: any ? any.days : 0 }].map(row).join(\"\");\n loadListHistory().catch(() => {});\n }\n\n async function loadListHistory() {\n const rows = await rpc(\"listlisthistory\");\n const labels = { 3: \"follows\", 10002: \"relay list\", 10003: \"bookmarks\", 30003: \"bookmark list\" };\n $(\"#listhistory tbody\").innerHTML = rows.length ? rows.map((r) => '' + esc((labels[r.kind] || (\"kind \" + r.kind)) + (r.d ? \" / \" + r.d : \"\")) + '' + esc(fmtTime(r.created_at)) + '' + esc(fmtTime(r.saved_at)) + '' + ib(\"undo\", \"Restore this version\", \"restorelist\", r.event_id) + '').join(\"\") : 'no older list versions yet';\n }\n\n let searchQuery = \"\";\n async function loadEvents(reset) {\n const list = searchQuery ? await rpc(\"searchevents\", searchQuery, 200) : await rpc(\"listrecentevents\", reset ? 50 : 200);\n $(\"#events tbody\").innerHTML = list.length ? list.map((e) => '' + esc(fmtTime(e.created_at)) + '' + e.kind + '' + av(e.pubkey) + key(e.pubkey) + '' + esc(e.content) + '' + ib(\"pin\", \"Pin\", \"pinevent\", e.id) + ib(\"trash\", \"Delete event\", \"deleteevent\", e.id) + ib(\"ban\", \"Ban event\", \"banevent\", e.id, \"danger\") + ib(\"banuser\", \"Ban author\", \"banpubkey\", e.pubkey, \"danger\") + \"\").join(\"\") : 'no events yet';\n $(\"#more\").classList.toggle(\"hidden\", searchQuery || list.length < 50);\n }\n $(\"#searchform\").onsubmit = guard(async (ev) => {\n searchQuery = ev.target.q.value.trim();\n $(\"#searchclear\").classList.toggle(\"hidden\", !searchQuery);\n await loadEvents(true);\n });\n $(\"#searchclear\").onclick = guard(async () => {\n searchQuery = \"\"; $(\"#searchform\").reset(); $(\"#searchclear\").classList.add(\"hidden\");\n await loadEvents(true);\n });\n async function loadPins() {\n const pins = await rpc(\"listpins\");\n $(\"#pins-count\").textContent = pins.length || \"\";\n $(\"#pins\").innerHTML = pins.length ? pins.map((t) => '
                                                                                                                                            • ' + (t[0] === \"e\" ? \"event\" : \"address\") + '' + esc(t[1].length > 40 ? t[1].slice(0, 16) + \"\\u2026\" + t[1].slice(-8) : t[1]) + \"\" + ib(\"copy\", \"Copy\", \"copy\", t[1]) + ib(\"x\", \"Unpin\", \"unpinevent\", t[1]) + \"
                                                                                                                                            • \").join(\"\") : '
                                                                                                                                            • nothing pinned
                                                                                                                                            • ';\n }\n $(\"#pinform\").onsubmit = guard(async (ev) => {\n await rpc(\"pinevent\", ev.target.id.value.trim()); ev.target.reset(); toast(\"Pinned\"); await loadPins();\n });\n\n // ---- actions ----\n const refresh = () => Promise.all([loadLists(), loadEvents(true), loadStorage(), loadPins()]);\n\n $(\"#copy\").onclick = async () => { await navigator.clipboard.writeText(wsURL); toast(\"copied \" + wsURL); };\n $(\"#signin\").onclick = guard(async () => {\n if (!window.nostr) throw new Error(\"No nostr extension found. Install one (Alby, nos2x, …) and reload, or use a remote signer.\");\n me = await window.nostr.getPublicKey();\n localStorage.setItem(\"me\", me);\n renderHeader(); await loadAdmin(); await loadPeople();\n });\n $(\"#signin46\").onclick = () => showRemote(null);\n $$(\".remote\").forEach((b) => { b.onclick = () => showRemote($(\"#\" + b.dataset.note)); });\n $(\"#nccopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#nclink\").href); toast(\"Link copied; paste it into your signer app\"); };\n $(\"#bunkerform\").onsubmit = guard(async (ev) => {\n $(\"#remotenote\").textContent = \"Connecting…\";\n try { await connectBunker(ev.target.url.value); } catch (e) { $(\"#remotenote\").textContent = e.message; throw e; }\n $(\"#remotenote\").textContent = \"\"; ev.target.reset();\n await remoteDone();\n });\n $(\"#signout\").onclick = async () => {\n if (remote) { const r = remote; remote = null; try { await withTimeout(r.logout(), 5000, \"The signer\"); } catch { /* the session is gone either way */ } }\n localStorage.removeItem(\"nip46\");\n me = null; localStorage.removeItem(\"me\"); renderHeader(); await loadAdmin(); await loadPeople();\n };\n const claimNow = async (note) => {\n if (!signer.ready()) { note.textContent = \"Sign the claim with a nostr extension or a remote signer.\"; showRemote(note); return; }\n me = await signer.getPublicKey();\n const r = await rpc(\"claim\");\n if (!r.claimed) throw new Error(\"Somebody else claimed it first.\");\n localStorage.setItem(\"me\", me);\n toast(\"It's yours.\");\n await loadInfo(); await loadFuel(); await loadAdmin(); await loadPeople();\n if (r.converted && confirm(\"This relay began as a temporary one: anyone can write, and everything is deleted after 14 days. Switch to the default rules and keep everything from now on? Each rule can be changed later on the Rules and Storage tabs.\")) {\n await rpc(\"resetrules\"); toast(\"Rules reset\"); await loadInfo(); await loadAdmin();\n }\n };\n $(\"#claim\").onclick = guard(() => claimNow($(\"#claimnote\")));\n $(\"#claimlease\").onclick = guard(() => claimNow($(\"#leasenote\")));\n // Jobs run in the background; the table follows them while the tab is open.\n let jobsTimer = 0;\n const fmtJob = (j) => {\n const what = j.kind === \"mirror\" ? \"mirror site\" : j.kind === \"import\" ? \"import\" : j.label === \"backfill\" ? \"fetch my history\" : j.kind === \"pull\" ? \"pull\" : \"rebroadcast\";\n const f = [];\n if (j.filter.authors) f.push(j.filter.authors.length === 1 && j.filter.authors[0] === me ? \"my events\" : j.filter.authors.length + \" authors\");\n if (j.filter.kinds) f.push(\"kinds \" + j.filter.kinds.join(\", \"));\n if (j.filter.since) f.push(\"since \" + fmtDay(j.filter.since));\n const when = j.every ? \"every \" + (j.every === 24 ? \"day\" : j.every + \" h\") + (j.nextRun ? \", next \" + fmtTime(j.nextRun) : \"\") : \"once\";\n const l = j.last;\n const count = (stored, blobs, sent, refused) => (j.kind === \"mirror\" ? blobs.toLocaleString() + \" files mirrored\" : j.kind === \"import\" ? stored.toLocaleString() + \" events\" + ((j.last ? j.last.duplicates : j.duplicates) ? \", \" + (j.last ? j.last.duplicates : j.duplicates) + \" already here\" : \"\") : j.kind === \"pull\" ? stored.toLocaleString() + \" events\" + (blobs ? \", \" + blobs + \" files\" : \"\") : sent.toLocaleString() + \" sent\" + (refused ? \", \" + refused + \" refused\" : \"\"));\n const res = j.running ? \"running: \" + count(j.stored, j.blobs, j.sent, j.refused) + \"...\" : !l ? \"waiting\" : l.error ? \"failed: \" + l.error : count(l.stored, l.blobs, l.sent, l.refused) + (l.skipped ? \", \" + l.skipped + \" skipped\" : \"\") + \", \" + fmtTime(l.finishedAt);\n const sources = j.running ? j.pullSources : l?.sources;\n const details = sources?.length ? '
                                                                                                                                              Source results' + sources.map((s) => '

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

                                                                                                                                              ').join('') + '
                                                                                                                                              ' : '';\n const targets = j.kind === \"push\" && j.targetStatus ? \"
                                                                                                                                              \" + Object.entries(j.targetStatus).map(([u, s]) => esc(u) + \": \" + esc(s.status)).join(\"
                                                                                                                                              \") + \"
                                                                                                                                              \" : \"\";\n return \"\" + what + \"\" + j.relays.map(esc).join(\"
                                                                                                                                              \") + targets + \"\" + (f.join(\", \") || \"everything\") + \"\" + when + \"\" + esc(res) + details + \"\" + (j.running ? \"\" : ib(\"undo\", \"Run now\", \"runjob\", j.id)) + ib(\"x\", \"Remove\", \"removejob\", j.id) + \"\";\n };\n async function pollJobs() {\n clearTimeout(jobsTimer);\n let jobs;\n try { jobs = await rpc(\"listjobs\"); } catch { return; }\n $(\"#jobs tbody\").innerHTML = jobs.length ? jobs.map(fmtJob).join(\"\") : 'no jobs yet';\n if (isOwner) {\n try {\n const deliveries = await rpc(\"deliverystatus\");\n $(\"#deliveries tbody\").innerHTML = deliveries.length ? deliveries.map((d) => '' + esc(d.event_id.slice(0, 12)) + '' + esc(d.target) + '' + esc(d.status) + '' + d.attempts + '' + esc(d.error || \"\") + '').join(\"\") : 'no automatic deliveries yet';\n } catch { /* unavailable to non-owners */ }\n }\n if (jobs.some((j) => j.running || (j.nextRun && j.nextRun <= Math.floor(Date.now() / 1000) + 1))) jobsTimer = setTimeout(pollJobs, 3000);\n }\n const urls = (s) => s.split(/[\\s,]+/).map((u) => u.trim()).filter(Boolean);\n const kindsOf = (s) => s.split(/[\\s,]+/).map((k) => parseInt(k, 10)).filter((k) => Number.isInteger(k) && k >= 0);\n $(\"#pullform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"addjob\", { kind: \"pull\", relays: [f.url.value.trim()], every: +f.every.value });\n toast(+f.every.value ? \"Mirror scheduled\" : \"Pull started\"); f.reset(); await pollJobs();\n });\n $(\"#backfillform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"backfill\", urls(f.relays.value));\n toast(\"Fetching your history\"); f.reset(); await pollJobs();\n });\n $(\"#pushform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const filter = {};\n const kinds = kindsOf(f.kinds.value); if (kinds.length) filter.kinds = kinds;\n const days = +f.days.value; if (days > 0) filter.since = Math.floor(Date.now() / 1000) - days * 86400;\n await rpc(\"addjob\", { kind: \"push\", relays: urls(f.targets.value), filter, every: +f.every.value });\n toast(\"Rebroadcast started\"); f.reset(); await pollJobs();\n });\n // ---- presets: writes, reads, kinds and keep-for rules in one click ----\n let presets = null;\n async function renderPresets() {\n if (!presets) { try { presets = await rpc(\"listpresets\"); } catch { presets = []; } }\n $(\"#presets\").innerHTML = presets.map((p) => '\").join(\"\");\n $(\"#presetsourcerow\").classList.toggle(\"hidden\", !presets.some((p) => p.source));\n }\n $(\"#presets\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-preset]\"); if (!b) return;\n const p = (presets || []).find((x) => x.name === b.dataset.preset); if (!p) return;\n if (!confirm(p.title + \": \" + p.about + \"\\n\\nThis replaces the writes and reads rules, the directory setting, the kind rules and the keep-for rules. Limits, identity, people and bans stay.\")) return;\n const source = $(\"#presetsource\").value.trim();\n if (p.source === \"required\" && !source) { $(\"#presetnote\").textContent = p.title + \" needs a source relay to mirror; enter its wss:// URL first.\"; return; }\n b.disabled = true;\n try { policy = await rpc(\"applypreset\", p.name, p.source && source ? { source } : undefined); $(\"#presetnote\").textContent = \"Now: \" + p.about + (policy.job ? \" Mirroring \" + source + \" every \" + policy.job.every + \" h.\" : \"\"); toast(p.title + \" applied\"); await loadInfo(); await loadAdmin(); }\n catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // ---- wire me in: this relay in the owner's own lists ----\n // The lists are replaceable, so a fresh list with only this relay would\n // clobber the real one once it spread. Every publish starts from the\n // newest copy found here or on the indexers, verified when the signer\n // library is around, and adds or removes this relay in it.\n const INDEXERS = [\"wss://purplepag.es\", \"wss://relay.nostr.band\", \"wss://relay.damus.io\", \"wss://nos.lol\"];\n const LISTS = [\n { kind: 10002, tag: \"r\", title: \"Relay list\", nip: \"NIP-65\", about: \"where clients read your notes and send you mentions\" },\n { kind: 10050, tag: \"relay\", title: \"DM inbox\", nip: \"NIP-17\", about: \"where people send you private messages\" },\n { kind: 10007, tag: \"relay\", title: \"Search relays\", nip: \"NIP-51\", about: \"where clients run your searches\" },\n { kind: 10063, tag: \"server\", title: \"Blossom servers\", nip: \"BUD-03\", about: \"where clients upload and look for your files\" },\n ];\n const mineFor = (l) => (l.tag === \"server\" ? location.origin : wsURL);\n const normURL = (u) => { try { const x = new URL(String(u).trim()); return (x.host + x.pathname).replace(/\\/+$/, \"\").toLowerCase(); } catch { return String(u).trim().toLowerCase(); } };\n const isMine = (l, t) => t[0] === l.tag && normURL(t[1] || \"\") === normURL(mineFor(l));\n const listHas = (list, l) => !!list && list.tags.some((t) => isMine(l, t));\n // mergeList is pure: the newest list with this relay put first or taken out, every other tag kept.\n function mergeList(list, l, include) {\n const tags = (list ? list.tags : []).filter((t) => !isMine(l, t));\n if (include) tags.unshift([l.tag, mineFor(l)]);\n return { kind: l.kind, created_at: Math.floor(Date.now() / 1000), content: list ? list.content : \"\", tags };\n }\n const relaysIn = (list) => list.tags.filter((t) => (t[0] === \"r\" || t[0] === \"relay\") && /^wss?:\\/\\//i.test(t[1] || \"\")).map((t) => t[1].trim());\n // overWS opens one socket, sends one message, feeds answers to onMessage until it says done or time runs out.\n function overWS(url, ms, first, onMessage) {\n return new Promise((res) => {\n let ws = null, done = false, out = null;\n const finish = () => { if (done) return; done = true; clearTimeout(timer); try { if (ws) ws.close(); } catch { /* closed */ } res(out); };\n const timer = setTimeout(finish, ms);\n try { ws = new WebSocket(url); } catch { return finish(); }\n ws.onopen = () => ws.send(JSON.stringify(first));\n ws.onmessage = (m) => { let d; try { d = JSON.parse(m.data); } catch { return; } if (!Array.isArray(d)) return; if (onMessage(d, (v) => { out = v; })) finish(); };\n ws.onerror = finish; ws.onclose = finish;\n });\n }\n function fetchNewest(url, filter, ms) {\n let best = null;\n return overWS(url, ms, [\"REQ\", \"w\", filter], (d, set) => {\n if (d[0] === \"EVENT\" && d[1] === \"w\" && d[2] && (!best || d[2].created_at > best.created_at)) { best = d[2]; set(best); }\n return (d[0] === \"EOSE\" || d[0] === \"CLOSED\") && d[1] === \"w\";\n });\n }\n function publishTo(url, event, ms) {\n return overWS(url, ms, [\"EVENT\", event], (d, set) => { if (d[0] === \"OK\" && d[1] === event.id) { set({ url, ok: !!d[2], msg: d[3] || \"\" }); return true; } return false; })\n .then((r) => r || { url, ok: false, msg: \"no answer\" });\n }\n // bridge signs a NIP-98 request to this relay's HTTP door, so the owner's own reads and writes pass any read rule.\n async function bridge(path, body) {\n const url = location.origin + path, raw = JSON.stringify(body);\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"POST\"], [\"payload\", await sha256hex(raw)]] });\n const resp = await fetch(url, { method: \"POST\", headers: { \"content-type\": \"application/json\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body: raw });\n const json = await resp.json();\n if (!resp.ok) throw new Error(json.error || \"HTTP \" + resp.status);\n return json;\n }\n const wire = {};\n function renderWire() {\n const pill = (state) => {\n const cls = state === \"listed\" ? \"on\" : state === \"failed\" ? \"bad\" : state === \"not listed\" ? \"off\" : \"\";\n const label = state === \"checking\" || state === \"publishing\" ? state + \"\\u2026\" : state;\n return '' + esc(label) + \"\";\n };\n $(\"#wire\").innerHTML = LISTS.map((l) => {\n const w = wire[l.kind] || { state: \"\", results: [], list: null };\n const busy = w.state === \"checking\" || w.state === \"publishing\";\n const btn = (act, cls, label) => '\";\n const buttons = w.state === \"listed\" ? btn(\"remove\", \"\", \"Remove this relay\") : btn(\"add\", \"pri\", \"Add this relay\") + btn(\"check\", \"\", w.state ? \"Check again\" : \"Check\");\n const n = w.list ? w.list.tags.filter((t) => t[0] === l.tag).length : 0;\n const from = w.list ? \"\" + (n === 1 ? \"1 entry\" : n + \" entries\") + \", \" + fmtTime(w.list.created_at) + \"\" : w.state && !busy ? \"no list found\" : \"\";\n const sent = (w.results || []).map((r) => '' + esc(r.url.replace(/^wss?:\\/\\//, \"\")) + (r.ok ? \"\" : \" failed\") + \"\").join(\"\");\n const meta = (w.state ? pill(w.state) : \"\") + from + sent;\n return '
                                                                                                                                              ' + l.title + ' ' + l.nip + \"
                                                                                                                                              \" + l.about + '
                                                                                                                                              ' + buttons + '
                                                                                                                                              ' + meta + \"
                                                                                                                                              \";\n }).join(\"\");\n }\n async function wireCheck(l) {\n const w = (wire[l.kind] = { state: \"checking\", list: null, results: [] }); renderWire();\n const filter = { kinds: [l.kind], authors: [me], limit: 1 };\n const found = [];\n try { found.push(...(await bridge(\"/query\", [filter]))); } catch { /* the relay may hold none */ }\n const remote = await Promise.all(INDEXERS.map((u) => fetchNewest(u, filter, 4000)));\n let lib = null; try { lib = await signerLib(); } catch { /* unverified lists are still the owner's own, by pubkey */ }\n for (const e of remote) if (e && e.pubkey === me && e.kind === l.kind && Array.isArray(e.tags) && (!lib || lib.verifyEvent(e))) found.push(e);\n w.list = found.sort((a, b) => b.created_at - a.created_at)[0] || null;\n w.state = listHas(w.list, l) ? \"listed\" : \"not listed\"; renderWire();\n }\n async function wirePublish(l, include) {\n if (!wire[l.kind] || !wire[l.kind].state || wire[l.kind].state === \"failed\") await wireCheck(l);\n const w = wire[l.kind]; w.state = \"publishing\"; w.results = []; renderWire();\n try {\n const signed = await signer.signEvent(mergeList(w.list, l, include));\n const here = await bridge(\"/events\", signed).then((r) => ({ url: wsURL, ok: !!r.accepted, msg: r.message || \"\" })).catch((e) => ({ url: wsURL, ok: false, msg: e.message }));\n const targets = [...new Set([...relaysIn(signed), ...INDEXERS])].filter((u) => normURL(u) !== normURL(wsURL));\n const rest = await Promise.all(targets.map((u) => publishTo(u, signed, 6000)));\n w.results = [here, ...rest]; w.list = signed; w.state = include ? \"listed\" : \"not listed\";\n toast((include ? \"Added to your \" : \"Removed from your \") + l.title.toLowerCase());\n } catch (e) { w.state = \"failed\"; w.results = [{ url: wsURL, ok: false, msg: e.message }]; toast(e.message); }\n renderWire();\n }\n $(\"#wire\").addEventListener(\"click\", (ev) => {\n const b = ev.target.closest(\"button[data-wire]\"); if (!b) return;\n const l = LISTS.find((x) => String(x.kind) === b.dataset.wire); if (!l) return;\n if (b.dataset.do === \"check\") wireCheck(l); else wirePublish(l, b.dataset.do === \"add\");\n });\n\n // ---- custom domains: this relay under a hostname the owner controls ----\n let domainSites = [];\n const domainOptions = (current = \"\") => [{ label: \"\", title: \"This relay\" }, ...domainSites.map((s) => ({ label: s.label, title: (s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + \" / \" + key(s.author) })), ...(current && !domainSites.some((s) => s.label === current) ? [{ label: current, title: \"Site \" + current }] : [])].map((s) => '\").join(\"\");\n let domains = null; // null: not enabled on this host\n function renderDomains() {\n $(\"#domains\").innerHTML = (domains || []).map((d) => {\n const state = d.ready ? \"active\" : \"hostname \" + d.status.replace(/_/g, \" \") + \", certificate \" + d.sslStatus.replace(/_/g, \" \");\n const btn = (act, cls, label) => '\";\n const rows = d.ready ? \"\" : '
                                                                                                                                              ' + d.records.map((r) => \"\").join(\"\") + \"
                                                                                                                                              TypeNameValue
                                                                                                                                              \" + esc(r.type) + '' + esc(r.name) + '' + esc(r.value) + \"\" + esc(r.note) + \"
                                                                                                                                              \";\n return '
                                                                                                                                              ' + esc(d.host) + '' + esc(state) + \"\" + btn(\"check\", \"\", \"Check\") + btn(\"remove\", \"danger\", \"Remove\") + '
                                                                                                                                              \" + rows;\n }).join(\"\");\n }\n async function loadDomains() {\n try { [domains, domainSites] = await Promise.all([rpc(\"listdomains\"), rpc(\"listsites\")]); $(\"#adddomain select[name=site]\").innerHTML = domainOptions(); $(\"#domainnote\").textContent = domains.length ? \"\" : \"No custom domain yet.\"; $(\"#adddomain\").classList.remove(\"hidden\"); }\n catch (e) { domains = null; $(\"#domainnote\").textContent = /^unsupported/.test(e.message) ? \"Custom domains are not enabled on this host.\" : e.message; $(\"#adddomain\").classList.add(\"hidden\"); }\n renderDomains();\n }\n $(\"#adddomain\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"adddomain\", f.host.value.trim(), f.site.value); f.reset(); toast(\"Domain added; now create the CNAME\"); await loadDomains();\n });\n $(\"#domains\").addEventListener(\"change\", guard(async (ev) => {\n const select = ev.target.closest(\"select[data-domain-site]\"); if (!select) return;\n await rpc(\"setdomainsite\", select.dataset.domainSite, select.value);\n toast(\"Domain destination saved\"); await loadDomains();\n }));\n $(\"#domains\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-domain]\"); if (!b) return;\n const host = b.dataset.domain;\n if (b.dataset.do === \"remove\" && !confirm(\"Remove \" + host + \"? Its certificate goes with it and the name stops answering.\")) return;\n b.disabled = true;\n try {\n if (b.dataset.do === \"remove\") { await rpc(\"removedomain\", host); toast(\"Removed\"); }\n else { const d = await rpc(\"checkdomain\", host); toast(d.ready ? host + \" is live\" : \"Not yet: \" + (d.status === \"active\" ? \"certificate pending\" : \"waiting for the CNAME\")); }\n await loadDomains();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n $(\"#access\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const openKinds = f.openKinds.value.split(/[\\s,]+/).filter(Boolean).map(Number);\n if (openKinds.some((k) => !Number.isInteger(k) || k < 0 || k > 65535)) throw new Error(\"Open kinds must be whole numbers.\");\n policy = await rpc(\"setpolicy\", { writes: f.writes.value, reads: f.reads.value, openKinds, guestReplies: f.guestReplies.checked, minPow: +f.minPow.value, maxFuture: +f.maxFuture.value, maxLimit: +f.maxLimit.value, maxSubs: +f.maxSubs.value, maxMessageKB: +f.maxMessageKB.value, eventsPerMinute: +f.eventsPerMinute.value, reqsPerMinute: +f.reqsPerMinute.value, maxBlobMB: +f.maxBlobMB.value });\n toast(\"Rules saved\"); await loadInfo();\n });\n $(\"#notify\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { notify: { reports: f.reports.checked, fuel: f.fuel.checked, jobs: f.jobs.checked, succession: f.succession.checked, digest: f.digest.checked } });\n toast(\"Notifications saved\");\n });\n $(\"#notifytest\").onclick = guard(async () => {\n const r = await rpc(\"notifytest\");\n toast(r.sent ? \"Sent. Look for a message from the relay in your DMs.\" : \"Could not send.\");\n });\n $(\"#identity\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const list = (v) => v.split(\",\").map((s) => s.trim()).filter(Boolean);\n policy = await rpc(\"setpolicy\", { name: f.name.value, contact: f.contact.value, description: f.description.value, icon: f.icon.value,\n banner: f.banner.value, postingPolicy: f.postingPolicy.value, privacyPolicy: f.privacyPolicy.value, tags: list(f.tags.value), languageTags: list(f.languageTags.value), relayCountries: list(f.relayCountries.value) });\n toast(\"Identity saved\"); await loadInfo(); await loadPeople(); loadCard();\n });\n $(\"#addmember\").onsubmit = guard(async (ev) => {\n const f = ev.target; const pk = npubToHex(f.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"setmember\", pk, { name: f.name.value.trim() || null, note: f.note.value }); f.reset(); toast(\"Member added\"); await loadLists();\n });\n $(\"#addblock\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"blockip\", f.ip.value.trim(), f.reason.value); f.reset(); toast(\"Blocked\"); await loadLists();\n });\n $(\"#wordsform\").onsubmit = guard(async (ev) => {\n const words = ev.target.words.value.split(\"\\n\").map((s) => s.trim()).filter(Boolean);\n const kept = await rpc(\"setblockedwords\", words);\n policy = await rpc(\"setpolicy\", { blockedWordsInTags: ev.target.inTags.checked });\n ev.target.words.value = kept.join(\"\\n\"); toast(kept.length ? kept.length + \" words blocked\" : \"No words blocked\");\n });\n $(\"#thresholdform\").onsubmit = guard(async (ev) => {\n policy = await rpc(\"setpolicy\", { reportThreshold: +ev.target.reportThreshold.value });\n toast(policy.reportThreshold ? \"Hidden after \" + policy.reportThreshold + \" reports\" : \"Never hidden by reports\"); await loadLists();\n });\n $(\"#addban\").onsubmit = guard(async (ev) => {\n const pk = npubToHex(ev.target.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"banpubkey\", pk, ev.target.reason.value, ev.target.erase.checked); ev.target.reset(); toast(ev.target.erase.checked ? \"Banned and erased\" : \"Banned\"); await loadLists();\n });\n $(\"#mintinvite\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const inv = await rpc(\"createinvite\", +f.ttl.value, +f.max.value, f.note.value);\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n f.note.value = \"\"; toast(\"Invite created and copied\"); await loadLists();\n });\n $(\"#kindform\").onsubmit = guard(async (ev) => {\n const rule = ev.submitter.value; const k = +ev.target.kind.value;\n await rpc(rule === \"allow\" ? \"allowkind\" : \"disallowkind\", k); ev.target.reset(); toast((rule === \"allow\" ? \"Allowed kind \" : \"Blocked kind \") + k); await loadLists();\n });\n $(\"#more\").onclick = guard(() => loadEvents(false));\n $(\"#dumpform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { dumps: f.dumps.value, dumpsKeep: Math.max(1, Math.min(60, Math.floor(+f.keep.value || 7))) });\n toast(policy.dumps === \"off\" ? \"Dumps off\" : \"Dumping \" + policy.dumps); await loadDumps();\n });\n $(\"#importform\").onsubmit = guard(async (ev) => {\n const file = ev.target.file.files[0]; if (!file) return;\n if (!signer.ready()) throw new Error(NO_SIGNER);\n if (file.size > 64 * 1024 * 1024) throw new Error(\"At most 64 MB per import.\");\n const body = await file.text();\n const url = location.origin + \"/import?name=\" + encodeURIComponent(file.name);\n const token = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"PUT\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(url, { method: \"PUT\", headers: { authorization: \"Nostr \" + btoa(JSON.stringify(token)), \"content-type\": \"application/x-ndjson\" }, body });\n const r = await resp.json();\n if (!resp.ok) throw new Error(r.error || \"import failed\");\n ev.target.reset(); toast(\"Importing \" + fmtBytes(r.bytes)); await pollJobs();\n });\n $(\"#dumpnow\").onclick = guard(async () => { const d = await rpc(\"dumpnow\"); toast(\"Dumped \" + d.events.toLocaleString() + \" events\"); await loadStorage(); });\n $(\"#treeform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { memberInvites: { depth: Math.max(0, Math.floor(+f.depth.value || 0)), quota: Math.max(0, Math.floor(+f.quota.value || 0)) } });\n toast(policy.memberInvites.depth ? \"Members may invite\" : \"Only you and moderators invite\");\n });\n $(\"#mintmine\").onclick = guard(async () => {\n const inv = await rpc(\"createinvite\", 259200, 1, \"\");\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n toast(\"Invite created and copied\"); await loadMine();\n });\n $(\"#exportcfg\").onclick = guard(async () => {\n const cfg = await rpc(\"exportconfig\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(new Blob([JSON.stringify(cfg, null, 2)], { type: \"application/json\" }));\n a.download = host.split(\".\")[0] + \".bind.ws.config.json\";\n a.click(); URL.revokeObjectURL(a.href);\n });\n $(\"#importcfg\").onclick = () => $(\"#cfgfile\").click();\n $(\"#cfgfile\").onchange = guard(async (ev) => {\n const file = ev.target.files[0]; if (!file) return;\n const cfg = JSON.parse(await file.text());\n const check = await rpc(\"importconfig\", cfg, { dryRun: true });\n const lines = check.changes.summary.length ? check.changes.summary.join(\"\\n\") : \"Nothing would change.\";\n const dropped = check.warnings.length ? \"\\n\\nNot taken:\\n\" + check.warnings.join(\"\\n\") : \"\";\n if (!confirm(\"Apply \" + file.name + \" to this relay?\\n\\n\" + lines + dropped)) { ev.target.value = \"\"; return; }\n await rpc(\"importconfig\", cfg); ev.target.value = \"\"; toast(\"Configuration imported\"); await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // Succession: the status line under the heir form, from successionstatus.\n async function renderSuccession() {\n const el = $(\"#successionnote\"), f = $(\"#succession\");\n let st;\n try { st = await rpc(\"successionstatus\"); } catch { el.textContent = \"\"; return; }\n const sc = st.succession;\n $(\"#clearsuccession\").classList.toggle(\"hidden\", !sc);\n f.querySelector(\"button.btn:not(#clearsuccession)\").textContent = sc ? \"Change heir\" : \"Set heir\";\n if (!sc) { el.textContent = \"No heir named. Last signed in \" + fmtTime(st.ownerSeenAt) + \".\"; return; }\n if (f.heir.querySelector('[value=\"' + sc.heir + '\"]')) f.heir.value = sc.heir;\n f.afterDays.value = String(sc.afterDays);\n const who = (window.__members || []).find((m) => m.pubkey === sc.heir);\n const heir = who && who.name ? who.name + \"@\" + host : short(sc.heir);\n el.textContent = \"Heir: \" + heir + \". Last signed in \" + fmtTime(st.ownerSeenAt) + (st.silentDays ? \" (\" + st.silentDays + \" days ago)\" : \"\") + \". \" +\n (st.warning ? \"The warning month is running: the relay goes to \" + heir + \" on \" + fmtDay(st.handoverAt) + \" unless you sign in.\" : \"If you stay away, it goes to \" + heir + \" around \" + fmtDay(st.handoverAt) + \".\") +\n (st.log && st.log.length ? \" Handed over before: \" + st.log.map((l) => fmtDay(l.at) + \" to \" + short(l.to)).join(\", \") + \".\" : \"\");\n }\n $(\"#succession\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"setsuccession\", { heir: f.heir.value, afterDays: +f.afterDays.value });\n toast(\"Heir set\"); await loadAdmin();\n });\n $(\"#clearsuccession\").onclick = guard(async () => {\n await rpc(\"clearsuccession\");\n toast(\"Heir cleared\"); await loadAdmin();\n });\n $(\"#transfer\").onsubmit = guard(async (ev) => {\n const pk = ev.target.pubkey.value; if (!pk) return;\n const name = host.split(\".\")[0];\n const typed = prompt(\"This hands \" + host + \" to \" + short(pk) + \" for good. You stay on as a moderator. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing changed.\"); return; }\n await rpc(\"transferowner\", pk);\n toast(\"Transferred\");\n await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // ---- fork: a new name with this relay's events, claim reserved for a key ----\n $(\"#forkform\").scope.onchange = (ev) => $(\"#forkkinds\").classList.toggle(\"hidden\", ev.target.value !== \"kinds\");\n $(\"#joinform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { joinTerms: f.joinTerms.value, directoryPublic: f.directoryPublic.checked });\n toast(\"Saved\"); await loadInfo(); await loadPeople();\n });\n $(\"#forkform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const opts = { people: f.people.checked };\n if (f.name.value.trim()) opts.name = f.name.value.trim().toLowerCase();\n if (f.holder.value.trim()) { const pk = npubToHex(f.holder.value); if (!pk) throw new Error(\"That is not a pubkey.\"); opts.holder = pk; }\n if (f.scope.value === \"mine\") opts.filter = { authors: [me] };\n if (f.scope.value === \"kinds\") { const kinds = f.kinds.value.split(/[\\s,]+/).filter(Boolean).map(Number); if (!kinds.length || kinds.some((k) => !Number.isInteger(k) || k < 0)) throw new Error(\"Give kinds as numbers.\"); opts.filter = { kinds }; }\n if (!confirm(\"Fork this relay into a new name\" + (opts.name ? \" (\" + opts.name + \")\" : \"\") + \"? It pulls \" + (f.scope.value === \"all\" ? \"everything\" : f.scope.value === \"mine\" ? \"your events\" : \"the chosen kinds\") + (opts.people ? \" and the people\" : \"\") + \", and only \" + (opts.holder ? \"that key\" : \"you\") + \" can claim it.\")) return;\n const r = await rpc(\"forkrelay\", opts);\n $(\"#forknote\").textContent = r.handover + \" Expires \" + fmtTime(r.expires_at) + \".\";\n $(\"#forkurl\").textContent = r.console;\n $(\"#forkresult\").classList.remove(\"hidden\");\n toast(\"Forked to \" + r.name);\n });\n $(\"#forkcopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#forkurl\").textContent); toast(\"copied\"); };\n $(\"#deleterelay\").onclick = guard(async () => {\n const name = host.split(\".\")[0];\n const typed = prompt(\"This deletes everything on \" + host + \" and gives the name up. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing was deleted.\"); return; }\n await rpc(\"deleterelay\", name);\n localStorage.removeItem(\"me\");\n location.href = \"/\";\n });\n $(\"#topup\").onsubmit = guard((ev) => topUp(+ev.target.sats.value));\n $(\"#inv-copy\").onclick = async () => { await navigator.clipboard.writeText($(\"#inv-text\").value); toast(\"invoice copied\"); };\n $$(\"button[data-copy]\").forEach((b) => { b.onclick = async () => { await navigator.clipboard.writeText(b.dataset.copy === \"ws\" ? wsURL : location.origin); toast(\"copied\"); }; });\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-act]\"); if (!b) return;\n const act = b.dataset.act, id = b.dataset.id;\n if (act === \"copy\") { await navigator.clipboard.writeText(id); toast(\"copied\"); return; }\n if ((act === \"banpubkey\" || act === \"banevent\" || act === \"resolve:ban\") && !confirm(act === \"banevent\" ? \"Delete this event and refuse it forever?\" : \"Ban this author and refuse everything they post?\")) return;\n const erase = (act === \"banpubkey\" || act === \"resolve:ban\") && confirm(\"Also erase everything they wrote and uploaded here?\");\n if (act === \"deleteblob\" && !confirm(\"Delete this file for good?\")) return;\n if (act === \"deletedump\" && !confirm(\"Delete this dump?\")) return;\n if (act === \"downloaddump\") { b.disabled = true; try { await downloadDump(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }\n if (act === \"restorelist\") {\n if (!signer.ready()) { toast(NO_SIGNER); return; }\n try {\n const preview = await rpc(\"restorelist\", id);\n const d = preview.diff || {};\n const added = (d.addedTags || []).map((t) => \"+ \" + JSON.stringify(t)).join(\"\\n\");\n const removed = (d.removedTags || []).map((t) => \"- \" + JSON.stringify(t)).join(\"\\n\");\n const changes = [added, removed, d.contentChanged ? \"content changed\" : \"content unchanged\"].filter(Boolean).join(\"\\n\");\n if (!confirm(\"Restore this list version?\\n\\n\" + (changes || \"No tag or content changes\") + \"\\n\\nIt will be signed and published as the newest version.\")) return;\n const signed = await signer.signEvent(preview.draft);\n const result = await bridge(\"/events\", signed);\n if (!result.accepted) throw new Error(result.message || \"The relay refused the restored list.\");\n toast(\"List restored\"); await loadListHistory(); await loadStorage();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if ((act === \"removemember\" || act === \"banpubkey\") && (window.__members || []).some((m) => m.invited_by === id) && confirm(\"Also remove everyone this member invited, and everyone they invited in turn?\")) {\n b.disabled = true;\n try { const r = await rpc(\"removesubtree\", id); if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase); toast(\"Removed \" + r.removed.length); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"removejob\" && !confirm(\"Remove this job?\")) return;\n if (act === \"purgekind\") {\n const kind = id === \"\" ? null : +id;\n const label = kind === null ? \"everything without its own rule\" : kindName(kind) + \" (kind \" + kind + \")\";\n const typed = prompt(\"Purge \" + label + \" older than how many days? 0 purges all of them. This cannot be undone.\", \"30\");\n if (typed === null) return;\n const days = Math.max(0, Math.floor(+typed || 0));\n b.disabled = true;\n try { const r = await rpc(\"purgekind\", kind, days); toast(\"Purged \" + r.deleted.toLocaleString() + \" events\"); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"saveretention\") {\n const kind = id === \"\" ? null : +id;\n const days = Math.max(0, Math.floor(+b.closest(\"tr\").querySelector(\".days\").value || 0));\n b.disabled = true;\n try { await rpc(\"setretention\", kind, days); toast(days ? \"Kept for \" + days + \" days\" : \"Kept forever\"); await loadStorage(); await loadInfo(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n b.disabled = true;\n try {\n if (act === \"savemember\") { const tr = b.closest(\"tr\"), role = tr.querySelector(\"select.role\"), keep = tr.querySelector(\".keep\"), cap = tr.querySelector(\".cap\"); await rpc(\"setmember\", id, { name: tr.querySelector(\".name\").value.trim() || null, note: tr.querySelector(\".note\").value, ...(role ? { role: role.value } : {}), ...(keep ? { keepDays: Math.max(0, Math.floor(+keep.value || 0)), maxBytes: Math.max(0, Math.floor(+cap.value || 0)) * 1024 } : {}) }); }\n else if (act.startsWith(\"resolve:\")) await rpc(\"resolvereport\", id, act.slice(8), erase);\n else if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase);\n else await rpc(act, act === \"unrulekind\" ? +id : id, \"\");\n toast(\"Done\"); await (myRole ? refresh() : loadMine());\n if (act === \"pinevent\" || act === \"unpinevent\") await loadPins();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // The share block: the card picture, the group naddr and its QR.\n let card = null;\n async function loadCard() {\n try {\n card = await (await fetch(\"/card.json\", { cache: \"no-store\" })).json();\n $(\"#cardimg\").src = \"/card.svg?t=\" + Date.now();\n $(\"#naddr\").textContent = card.naddr || \"\";\n const q = $(\"#naddrqr\");\n if (card.naddr) { q.src = \"/qr.svg?text=\" + encodeURIComponent(card.naddr); q.classList.remove(\"hidden\"); } else q.classList.add(\"hidden\");\n } catch { /* the card is decoration */ }\n renderApps();\n }\n // The app rows on the Connect section: one per client people actually use,\n // with the link that lands on this relay in it. Relay apps take the relay\n // or the group address; feed apps take the owner's profile with this relay\n // as the hint, since they have no notion of opening a relay.\n function renderApps() {\n const el = $(\"#apps\"); if (!el) return;\n const enc = encodeURIComponent;\n const nprofile = card && card.nprofile ? card.nprofile : \"\";\n const naddr = card && card.naddr ? card.naddr : \"\";\n const whose = me && owner && me === owner ? \"your\" : \"the owner's\";\n const link = (label, href) => '' + label + \"\";\n const app = (label, uri) => '' + label + \"\";\n const copy = (label, text) => '\";\n const row = (name, where, note, acts) => '
                                                                                                                                              ' + name + \"\" + where + \"

                                                                                                                                              \" + note + '

                                                                                                                                              ' + acts.filter(Boolean).join(\"\") + \"
                                                                                                                                              \";\n const profileNote = \"Opens \" + whose + \" profile with this relay attached.\";\n const groups = [\n [\"As a place\", \"These open the relay itself: its feed, its people, its group.\", [\n row(\"Jumble\", \"web\", \"A feed of everything on this relay.\", [link(\"Open\", \"https://jumble.social/?r=\" + enc(wsURL))]),\n row(\"Coracle\", \"web\", \"The relay's page: its feed and its people.\", [link(\"Open\", \"https://coracle.social/relays/\" + enc(host))]),\n row(\"Flotilla\", \"web, phone\", \"The relay as a space, with the group as a room.\", [link(\"Open\", \"https://app.flotilla.social/spaces/\" + enc(host)), naddr && app(\"Open group\", \"nostr:\" + naddr)]),\n row(\"0xchat\", \"phone\", \"The group, in a chat app.\", [naddr && app(\"Open group\", \"nostr:\" + naddr), naddr && copy(\"Copy naddr\", naddr)]),\n row(\"noStrudel\", \"web\", \"Relays, add this one, then open its page.\", [copy(\"Copy relay URL\", wsURL)]),\n ]],\n [\"Find me here\", \"Feed apps have no relay pages. They meet this relay through a profile link that names it, then keep it once it is in the relay settings.\", [\n row(\"Primal\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://primal.net/p/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"YakiHonne\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://yakihonne.com/profile/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"Damus\", \"iPhone\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Amethyst\", \"Android\", profileNote + \" Then Relays in the drawer.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Nostur\", \"iPhone, Mac\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n ]],\n ];\n if (info?.supported_grasps?.includes(\"GRASP-01\")) groups.push([\n \"Git repositories\", \"Use this relay with a Git client.\", [\n row(\"GitWorkshop\", \"web\", \"Browse this relay's Git repositories.\", [link(\"Open in app\", \"https://gitworkshop.dev/relay/\" + enc((wsURL.startsWith(\"ws://\") ? \"ws:\" : \"\") + host)), copy(\"Copy relay URL\", wsURL)]),\n ],\n ]);\n el.innerHTML = groups.map(([h, note, rows]) => '

                                                                                                                                              ' + h + '

                                                                                                                                              ' + note + '

                                                                                                                                              ' + rows.join(\"\") + \"
                                                                                                                                              \").join(\"\");\n $(\"#apps-ws\").textContent = wsURL;\n const tile = (label, text) => '
                                                                                                                                              ' + label + '\"QR
                                                                                                                                              ';\n $(\"#phones\").innerHTML = [nprofile && tile(\"Find me here, for a phone\", \"nostr:\" + nprofile), naddr && tile(\"The group, for a phone\", \"nostr:\" + naddr)].filter(Boolean).join(\"\");\n }\n // The folds are a group: opening one closes the rest. Browsers with the\n // details name attribute do this themselves; this covers the others.\n $$(\".folds details\").forEach((d) => d.addEventListener(\"toggle\", () => { if (d.open) $$(\".folds details\").forEach((o) => { if (o !== d && o.open) o.open = false; }); }));\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-copytext]\"); if (!b) return;\n try { await navigator.clipboard.writeText(b.dataset.copytext); toast(\"copied\"); } catch { /* no clipboard */ }\n });\n $(\"#copynaddr\").onclick = async () => { if (!card || !card.naddr) return; await navigator.clipboard.writeText(card.naddr); toast(\"copied naddr\"); };\n $(\"#copyembed\").onclick = async () => { await navigator.clipboard.writeText('\"''); toast(\"copied embed\"); };\n\n // A click on a key copies it; a double-click selects the whole key so the usual copy shortcut takes it too.\n document.addEventListener(\"dblclick\", (ev) => {\n const k = ev.target.closest(\".key\"); if (!k) return;\n const s = window.getSelection(), r = document.createRange(); r.selectNodeContents(k.querySelector(\".full\")); s.removeAllRanges(); s.addRange(r);\n });\n document.addEventListener(\"click\", async (ev) => {\n const k = ev.target.closest(\".key\"); if (!k || ev.detail > 1 || String(window.getSelection())) return;\n try { await navigator.clipboard.writeText(k.title); toast(\"copied \" + k.dataset.short); } catch { /* no clipboard */ }\n });\n // ---- boot ----\n try { await loadInfo(); } catch { renderHeader(); }\n await loadFuel();\n await loadCard();\n me = localStorage.getItem(\"me\");\n if (me && await resumeRemote()) { /* a remote session answers for itself when first used */ }\n else if (me && window.nostr) { try { const pk = await window.nostr.getPublicKey(); if (pk !== me) me = null; } catch { me = null; } }\n else if (me) me = null;\n renderHeader();\n renderApps();\n await loadAdmin();\n await loadPeople();\n})();\n"; diff --git a/src/relay.ts b/src/relay.ts index 3883936..44f05e7 100644 --- a/src/relay.ts +++ b/src/relay.ts @@ -1123,7 +1123,6 @@ export class Relay extends DurableObject { async alarm() { await pushTick(this); - const deliveryAt = await deliveryTick(this); return this.repositoryAccess.run("alarm", async () => { this.touch(); const t = now(); @@ -1132,6 +1131,7 @@ export class Relay extends DurableObject { await this.teardown(); return; } + const deliveryAt = await deliveryTick(this); const graspAt = await graspTick(this); await this.syncSites(); await queueMirrors(this); From f0d02c422a482275ae26405602c8b46fc440a8bd Mon Sep 17 00:00:00 2001 From: Dami Date: Fri, 4 Sep 2026 18:13:23 -0600 Subject: [PATCH 11/14] test: cover rebroadcast refusal cursor safety --- src/jobs.ts | 5 ++++- test/object/jobs.test.ts | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/jobs.ts b/src/jobs.ts index 1496b32..6d923f8 100644 --- a/src/jobs.ts +++ b/src/jobs.ts @@ -48,6 +48,7 @@ export interface Job { // readers and is the greatest cursor reached by any target. targetCursors?: Record; targetStatus?: Record; + targetAttempts?: Record; stored: number; skipped: number; blobs: number; @@ -108,7 +109,7 @@ export function checkJob(raw: unknown, relay: Relay): JobSpec | string { const every = r.every === undefined ? 0 : Number(r.every); if (!(EVERY as readonly number[]).includes(every)) return "invalid: every must be 0, 1, 6 or 24 hours"; if (kind === "push" && relay.settings.policy.reads === "members" && filter.kinds?.some(isPrivate)) return "restricted: a members-only relay does not rebroadcast private kinds"; - return { kind, label, relays, filter, every, running: false, startedAt: 0, rounds: 0, failures: 0, relayIndex: 0, cursor: 0, targetCursors: {}, targetStatus: {}, stored: 0, skipped: 0, blobs: 0, sent: 0, refused: 0, last: null }; + return { kind, label, relays, filter, every, running: false, startedAt: 0, rounds: 0, failures: 0, relayIndex: 0, cursor: 0, targetCursors: {}, targetStatus: {}, targetAttempts: {}, stored: 0, skipped: 0, blobs: 0, sent: 0, refused: 0, last: null }; } // relaysFromList reads the owner's kind 10002 stored on this relay and @@ -178,6 +179,7 @@ async function runPushRound(relay: Relay, job: Job): Promise<{ more: boolean; er if (job.filter.since) f.since = job.filter.since; job.targetCursors ??= {}; job.targetStatus ??= {}; + job.targetAttempts ??= {}; const membersOnly = relay.settings.policy.reads === "members"; let more = false, failed = "", reached = 0; const legacyCursor = job.cursor; @@ -203,6 +205,7 @@ async function runPushRound(relay: Relay, job: Job): Promise<{ more: boolean; er job.cursor = Math.max(job.cursor, rows[rows.length - 1].seq); } catch (err) { failed = url + ": " + (err instanceof Error ? err.message : String(err)); + job.targetAttempts[url] = (job.targetAttempts[url] ?? 0) + 1; job.targetStatus[url] = { status: "pending", error: failed, at: now() }; more = true; } diff --git a/test/object/jobs.test.ts b/test/object/jobs.test.ts index 30e40e4..995246d 100644 --- a/test/object/jobs.test.ts +++ b/test/object/jobs.test.ts @@ -125,6 +125,23 @@ describe("rebroadcast", () => { }); }); +describe("rebroadcast cursor safety", () => { + it("does not advance a target past an unprocessed refusal tail", async () => { + const owner = generateSecretKey(); + const src = "cursor-tail.bind.ws", target = "cursor-tail-target.bind.ws"; + const events = Array.from({ length: 6 }, (_, i) => ev(owner, 7, "tail-" + i)); + await relay(src, owner, events); + const targetOwner = generateSecretKey(); + await relay(target, targetOwner); + await rpc(target, targetOwner, "setpolicy", { writes: "owner" }); + const started = await rpc(src, owner, "addjob", { kind: "push", relays: ["wss://" + target], filter: { kinds: [7] } }); + const jobs = await drive(src, owner); + const job = jobs.find((j) => j.id === started.result.id)!; + expect(job.targetCursors?.["wss://" + target]).toBe(0); + expect((await (await WS.connect(target)).req({ kinds: [7] })).length).toBe(0); + }); +}); + describe("standing jobs", () => { it("runs a recurring pull on its interval, caps standing jobs, and can be removed", async () => { const owner = generateSecretKey(); From d51f289838c0565b8550ee9e34cdf48a849c791f Mon Sep 17 00:00:00 2001 From: Dami Date: Fri, 4 Sep 2026 18:08:08 -0600 Subject: [PATCH 12/14] backups: portable state, site and Git archives restore only onto a fresh relay --- README.md | 1 + docs/27-backups.md | 12 ++ src/backups.ts | 243 ++++++++++++++++++++++++++++++++++++ src/console/console.html | 6 + src/console/console.js | 10 ++ src/gen/console.ts | 4 +- src/manage.ts | 11 ++ src/routes.ts | 3 + test/object/backups.test.ts | 74 +++++++++++ 9 files changed, 362 insertions(+), 2 deletions(-) create mode 100644 docs/27-backups.md create mode 100644 src/backups.ts create mode 100644 test/object/backups.test.ts diff --git a/README.md b/README.md index 90d4dff..812e59d 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ Site hosting and mirroring are on by default; Marmot and GRASP are off until ena - [NIP-86 membership claims](docs/24-nip86-claims.md): create, list and revoke invitation codes through the standard management methods. - [NIP-9a relay push](docs/25-nip-9a-relay-push.md): opt-in callback delivery, privacy, bounds and operator setup. - [NIP-11 identifier compatibility](docs/26-nip11-compatibility.md): lettered capabilities and concrete client parser behavior. +- [Backups and fresh-relay restore](docs/27-backups.md): portable, integrity-checked recovery for relay state, files and Git. ### Protocol guides diff --git a/docs/27-backups.md b/docs/27-backups.md new file mode 100644 index 0000000..2836354 --- /dev/null +++ b/docs/27-backups.md @@ -0,0 +1,12 @@ +--- +title: backups and fresh-relay restore +audience: user +--- + +`backupnow` creates a private, portable archive containing the relay configuration, signed events, hosted site and media blobs, and hosted Git objects. The archive records the source relay identity public key, but never includes its private key. A fresh target generates a new relay identity and publishes new authority records after restore. The archive is bounded at 8 MiB and 12,000 objects because JSON parsing, base64 expansion and integrity copies share the Worker heap. Every byte has a SHA-256 entry hash and the archive has a manifest hash. + +`listbackups` lists archives and `deletebackup` removes one. An owner downloads an archive from `/backups/` with the same NIP-98 storage authorization used for dumps. + +To restore, claim no name on a fresh relay. POST the downloaded archive to `/backups/restore` with NIP-98 authorization by the owner key recorded in the manifest. Restore verifies the archive, checks every event and object, then applies configuration, events, blobs, and Git data. A claimed or non-empty target is refused. Fuel credits, credentials, push registrations, dumps, and transient jobs are not backup data. + +POST the same archive to `/backups/preview` first to receive the source identity, counts, configuration and fresh-target check without changing the target. diff --git a/src/backups.ts b/src/backups.ts new file mode 100644 index 0000000..7cfd041 --- /dev/null +++ b/src/backups.ts @@ -0,0 +1,243 @@ +// Portable relay backups: a bounded JSON archive containing the owner's +// configuration, signed events, Blossom blobs and hosted Git objects. The +// archive is sealed by a manifest hash before it is stored or restored. +import { sha256 } from "@noble/hashes/sha2.js"; +import { applyConfig, exportConfig, parseConfig } from "./config.ts"; +import { bytesToHex } from "./negentropy.ts"; +import { verifyNIP98 } from "./auth.ts"; +import { can } from "./roles.ts"; +import { now, validate, type Event } from "./event.ts"; +import { type Relay } from "./relay.ts"; +import { KIND_REPO, KIND_PUSH_REGISTRATION } from "./kinds.ts"; +import { parseRepositoryAnnouncement } from "./grasp-policy.ts"; + +export const BACKUP_FORMAT = "bind.ws/relay-backup/1"; +// JSON parsing, base64 expansion and integrity copies coexist in the Worker +// heap, so the portable form stays well below the platform heap ceiling. +export const BACKUP_MAX_BYTES = 8 * 1024 * 1024; +export const BACKUP_MAX_OBJECTS = 12_000; +export const BACKUP_ID_RE = /^[a-z0-9][a-z0-9_-]{2,63}$/; + +type Payload = { sha256: string; size: number; data: string }; +export type BackupArchive = { + format: typeof BACKUP_FORMAT; + manifest: { id: string; slug: string; owner: string; relayIdentity: string; createdAt: number; bytes: number; events: number; blobs: number; git: number; archiveSha256: string }; + config: unknown; + events: string[]; + blobs: (Payload & { sha256: string; type: string; uploader: string; uploaded: number })[]; + git: (Payload & { key: string })[]; +}; + +const enc = new TextEncoder(); +const dec = new TextDecoder(); +const toB64 = (bytes: Uint8Array) => { + let out = ""; + for (let i = 0; i < bytes.length; i += 0x8000) out += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + return btoa(out); +}; +const fromB64 = (value: string) => { + const raw = atob(value); + const out = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); + return out; +}; +const digest = (bytes: Uint8Array) => bytesToHex(sha256(bytes)); +const archiveKey = (relay: Relay, id: string) => `${relay.slug}/backups/${id}.json`; + +const bytesOf = (archive: BackupArchive) => enc.encode(JSON.stringify(archive)); +const unsignedBytes = (archive: BackupArchive) => { + const copy = structuredClone(archive); + copy.manifest.archiveSha256 = ""; + return enc.encode(JSON.stringify(copy)); +}; + +// createBackup snapshots the visible database and every tenant-owned media +// object. The cap makes the bounded in-memory archive explicit. +export async function createBackup(relay: Relay, id: string): Promise<{ manifest: BackupArchive["manifest"]; key: string } | string> { + if (!BACKUP_ID_RE.test(id)) return "invalid: backup id must be 3 to 64 lowercase letters, digits, dash or underscore"; + if (relay.settings.isUnclaimed() || relay.settings.leaseExpired(now())) return "restricted: relay is not active"; + const owner = relay.settings.policy.owner; + if (!owner) return "restricted: relay has no owner"; + const events: string[] = []; + let estimate = 1024; + let entries = 0; + const reserve = (bytes: number, count = 1) => { + // JSON strings and base64 coexist with decoded buffers during sealing. + const next = estimate + Math.ceil(bytes * 2) + 256; + if (next > BACKUP_MAX_BYTES || entries + count > BACKUP_MAX_OBJECTS) return false; + estimate = next; + entries += count; + return true; + }; + let seq = 0; + for (;;) { + const page = relay.store.dumpPage(seq, 50); + if (!page.length) break; + for (const x of page) { + try { + if ((JSON.parse(x.raw) as Event).kind === KIND_PUSH_REGISTRATION) continue; + if (!reserve(enc.encode(x.raw).length)) return "restricted: backup exceeds its bounded size or object limit"; + events.push(x.raw); + } catch { return "error: event serialization failed during backup"; } + } + seq = page[page.length - 1].seq; + } + const blobs: BackupArchive["blobs"] = []; + const blobRows = relay.sql.exec<{ sha256: string; size: number; type: string; uploader: string; uploaded: number }>(`SELECT * FROM blobs ORDER BY uploaded`).toArray(); + for (const b of blobRows) { + if (!reserve(b.size)) return "restricted: backup exceeds its bounded size or object limit"; + const obj = await relay.media.get(`${relay.slug}/${b.sha256}`); + if (!obj) return `error: blob ${b.sha256} disappeared during backup`; + const data = new Uint8Array(await obj.arrayBuffer()); + if (data.length !== b.size) return `error: blob ${b.sha256} changed during backup`; + if (data.length !== b.size || digest(data) !== b.sha256) return `error: blob ${b.sha256} failed integrity check`; + blobs.push({ ...b, data: toB64(data), sha256: b.sha256, size: data.length }); + } + const git: BackupArchive["git"] = []; + let cursor: string | undefined; + for (;;) { + const listed = await relay.media.list({ prefix: `${relay.slug}/git/`, cursor, limit: 1000 }); + for (const item of listed.objects) { + if (git.length + blobs.length >= BACKUP_MAX_OBJECTS) return "restricted: backup object limit reached; no complete archive"; + if (item.size > 4 * 1024 * 1024) return "restricted: Git object exceeds the backup object limit"; + if (!reserve(item.size)) return "restricted: backup exceeds its bounded size or object limit"; + const obj = await relay.media.get(item.key); + if (!obj) return `error: Git object ${item.key} disappeared during backup`; + const data = new Uint8Array(await obj.arrayBuffer()); + git.push({ key: item.key.slice(relay.slug.length + 1), sha256: digest(data), size: data.length, data: toB64(data) }); + } + if (!listed.truncated) break; + cursor = listed.cursor; + } + const config = exportConfig(relay.settings, relay.slug); + if (!reserve(enc.encode(JSON.stringify(config)).length)) return "restricted: backup exceeds its bounded size or object limit"; + const archive = { format: BACKUP_FORMAT, manifest: { id, slug: relay.slug, owner, relayIdentity: relay.identity.pubkey, createdAt: now(), bytes: 0, events: events.length, blobs: blobs.length, git: git.length, archiveSha256: "" }, config, events, blobs, git } as BackupArchive; + const bytes = bytesOf(archive); + archive.manifest.bytes = bytes.length; + archive.manifest.archiveSha256 = digest(unsignedBytes(archive)); + const finalBytes = bytesOf(archive); + if (finalBytes.length > BACKUP_MAX_BYTES) return "restricted: backup exceeds 8 MiB; use smaller retention or separate Git repositories"; + await relay.media.put(archiveKey(relay, id), finalBytes, { httpMetadata: { contentType: "application/json" } }); + relay.meterBytes(0, finalBytes.length); + return { manifest: archive.manifest, key: archiveKey(relay, id) }; +} + +export async function listBackups(relay: Relay) { + const listed = await relay.media.list({ prefix: `${relay.slug}/backups/` }); + return listed.objects.filter((x) => x.key.endsWith(".json")).map((x) => ({ id: x.key.slice(`${relay.slug}/backups/`.length, -5), bytes: x.size })); +} + +export async function deleteBackup(relay: Relay, id: string) { + if (!BACKUP_ID_RE.test(id)) return false; + await relay.media.delete(archiveKey(relay, id)); + return true; +} + +const checkedArchive = (bytes: Uint8Array): BackupArchive | string => { + if (bytes.length > BACKUP_MAX_BYTES) return "restricted: backup exceeds 8 MiB"; + let archive: BackupArchive; + try { archive = JSON.parse(dec.decode(bytes)) as BackupArchive; } catch { return "invalid: backup is not JSON"; } + if (!archive || archive.format !== BACKUP_FORMAT || !archive.manifest || !Array.isArray(archive.events) || !Array.isArray(archive.blobs) || !Array.isArray(archive.git)) return "invalid: unsupported backup format"; + if (archive.manifest.archiveSha256 !== digest(unsignedBytes(archive))) return "invalid: backup integrity check failed"; + if (archive.events.length + archive.blobs.length + archive.git.length > BACKUP_MAX_OBJECTS) return "restricted: backup object limit reached"; + for (const raw of archive.events) { + try { if (typeof raw !== "string" || validate(JSON.parse(raw) as Event)) return "invalid: backup contains an invalid event"; } + catch { return "invalid: backup contains malformed event JSON"; } + } + for (const b of archive.blobs) { let data: Uint8Array; try { data = fromB64(b.data); } catch { return "invalid: malformed blob data"; } if (data.length !== b.size || digest(data) !== b.sha256) return "invalid: blob integrity check failed"; } + for (const g of archive.git) { let data: Uint8Array; try { data = fromB64(g.data); } catch { return "invalid: malformed Git data"; } if (data.length !== g.size || digest(data) !== g.sha256 || !g.key.startsWith("git/")) return "invalid: Git integrity check failed"; } + return archive; +}; + +// restoreBackup only accepts an unclaimed, empty target. Events are already +// signed and validated in the archive; direct storage preserves private data +// and avoids applying the target's ordinary write policy to old history. +export async function restoreBackup(relay: Relay, bytes: Uint8Array, caller: string): Promise { + const archive = checkedArchive(bytes); + if (typeof archive === "string") return archive; + if (relay.settings.policy.owner !== "" || relay.settings.isLeased() || relay.sql.exec(`SELECT 1 FROM events LIMIT 1`).toArray().length || relay.sql.exec(`SELECT 1 FROM blobs LIMIT 1`).toArray().length || relay.sql.exec(`SELECT 1 FROM grasp_objects LIMIT 1`).toArray().length || (relay.slug !== "" && (await relay.media.list({ prefix: `${relay.slug}/`, limit: 1 })).objects.length)) return "restricted: restore requires a fresh, unclaimed relay"; + if (archive.manifest.owner !== caller) return "restricted: target signer is not the backup owner"; + const config = archive.config as Record; + const parsed = parseConfig(config, relay.settings.policy); + if (typeof parsed === "string") return parsed; + const staged: string[] = []; + try { + for (const b of archive.blobs) { + const data = fromB64(b.data); + const key = `${relay.slug}/${b.sha256}`; + await relay.media.put(key, data, { httpMetadata: { contentType: b.type } }); staged.push(key); + } + for (const g of archive.git) { const key = `${relay.slug}/${g.key}`; await relay.media.put(key, fromB64(g.data)); staged.push(key); } + } catch (error) { + await relay.media.delete(staged).catch(() => {}); + return "error: restore storage staging failed"; + } + try { + relay.storage.transactionSync(() => { + relay.settings.update({ owner: caller }); + applyConfig(relay.settings, parsed, now()); + for (const raw of archive.events) { + const e = JSON.parse(raw) as Event; + const error = relay.store.save(e, now()); + if (error && !error.startsWith("duplicate:")) throw new Error(`error: event restore stopped: ${error}`); + if (e.kind === KIND_REPO && parseRepositoryAnnouncement(e).value) relay.sql.exec(`INSERT OR IGNORE INTO grasp_hosted(id) VALUES(?)`, e.id); + } + for (const b of archive.blobs) relay.sql.exec(`INSERT OR REPLACE INTO blobs(sha256,size,type,uploader,uploaded) VALUES(?,?,?,?,?)`, b.sha256, b.size, b.type, b.uploader, b.uploaded); + for (const g of archive.git) relay.sql.exec(`INSERT OR REPLACE INTO grasp_objects(key,owner,size) VALUES(?,?,?)`, `${relay.slug}/${g.key}`, caller, g.size); + }); + } catch (error) { + await relay.media.delete(staged).catch(() => {}); + return error instanceof Error && error.message.startsWith("error:") ? error.message : "error: restore transaction failed"; + } + await relay.syncSites(); + await relay.publishMembership(); + await relay.publishDiscovery(); + return { restored: true, owner: caller, sourceRelayIdentity: archive.manifest.relayIdentity, targetRelayIdentity: relay.identity.pubkey, events: archive.events.length, blobs: archive.blobs.length, git: archive.git.length, bytes: bytes.length }; +} + +export async function backupDownload(relay: Relay, req: Request, id: string): Promise { + const auth = verifyNIP98(req.headers.get("authorization") ?? "", req.url, req.method, ""); + if (typeof auth === "string") return Response.json({ error: auth }, { status: 401 }); + if (!can(relay.settings.roleOf(auth.pubkey), "storage")) return Response.json({ error: "restricted: not the relay owner" }, { status: 403 }); + if (!BACKUP_ID_RE.test(id)) return Response.json({ error: "invalid: backup id" }, { status: 400 }); + const obj = await relay.media.get(archiveKey(relay, id)); + if (!obj) return Response.json({ error: "not found" }, { status: 404 }); + return new Response(obj.body, { headers: { "content-type": "application/json", "content-length": String(obj.size), "cache-control": "private, no-store", "content-disposition": `attachment; filename="${relay.slug}-${id}.json"` } }); +} + +async function readCapped(req: Request): Promise { + const len = Number(req.headers.get("content-length") ?? 0); + if (len > BACKUP_MAX_BYTES) return "restricted: backup exceeds 8 MiB"; + if (!req.body) return new Uint8Array(); + const reader = req.body.getReader(); const chunks: Uint8Array[] = []; let total = 0; + for (;;) { + const part = await reader.read(); + if (part.done) break; + total += part.value.byteLength; + if (total > BACKUP_MAX_BYTES) { await reader.cancel(); return "restricted: backup exceeds 8 MiB"; } + chunks.push(part.value); + } + const out = new Uint8Array(total); let at = 0; + for (const chunk of chunks) { out.set(chunk, at); at += chunk.length; } + return out; +} + +export async function restoreBackupRequest(relay: Relay, req: Request): Promise { + const input = await readCapped(req); + if (typeof input === "string") return Response.json({ error: input }, { status: 413 }); + const bytes = input; + const body = new TextDecoder().decode(bytes); + const auth = verifyNIP98(req.headers.get("authorization") ?? "", req.url, req.method, body); + if (typeof auth === "string") return Response.json({ error: auth }, { status: 401 }); + const parsed = checkedArchive(bytes); + if (new URL(req.url).pathname === "/backups/preview") { + if (typeof parsed === "string") return Response.json({ error: parsed }, { status: 400 }); + const cfg = parseConfig(parsed.config, relay.settings.policy); + if (typeof cfg === "string") return Response.json({ error: cfg }, { status: 400 }); + if (relay.settings.policy.owner !== "" || relay.settings.isLeased() || relay.sql.exec(`SELECT 1 FROM events LIMIT 1`).toArray().length || relay.sql.exec(`SELECT 1 FROM blobs LIMIT 1`).toArray().length || relay.sql.exec(`SELECT 1 FROM grasp_objects LIMIT 1`).toArray().length || (relay.slug !== "" && (await relay.media.list({ prefix: `${relay.slug}/`, limit: 1 })).objects.length)) return Response.json({ error: "restricted: restore requires a fresh, unclaimed relay" }, { status: 403 }); + if (parsed.manifest.owner !== auth.pubkey) return Response.json({ error: "restricted: target signer is not the backup owner" }, { status: 403 }); + return Response.json({ result: { preview: true, source: parsed.manifest, targetIsFresh: true, config: parsed.config, events: parsed.events.length, blobs: parsed.blobs.length, git: parsed.git.length, bytes: bytes.length } }); + } + const result = await restoreBackup(relay, bytes, auth.pubkey); + return typeof result === "string" ? Response.json({ error: result }, { status: result.startsWith("invalid:") ? 400 : 403 }) : Response.json({ result }); +} diff --git a/src/console/console.html b/src/console/console.html index 8ef96bb..9970cf9 100644 --- a/src/console/console.html +++ b/src/console/console.html @@ -328,6 +328,12 @@

                                                                                                                                              Import a file

                                                                                                                                              A JSONL of events, one per line, such as a dump or a strfry export, or a JSON array. Up to 64 MB. Signatures are checked; bans and kind rules apply; the write rule does not. Progress shows under Jobs.

                                                                                                                                              +
                                                                                                                                              +

                                                                                                                                              Backup and restore

                                                                                                                                              +

                                                                                                                                              A private archive includes configuration, events, site files and Git objects. It excludes keys, fuel, jobs and push registrations. Archives are limited to 8 MB and restore only works on a fresh relay.

                                                                                                                                              +
                                                                                                                                              +
                                                                                                                                                +
                                                                                                                                                diff --git a/src/console/console.js b/src/console/console.js index bc5c4ba..67b9726 100644 --- a/src/console/console.js +++ b/src/console/console.js @@ -874,6 +874,16 @@ ev.target.reset(); toast("Importing " + fmtBytes(r.bytes)); await pollJobs(); }); $("#dumpnow").onclick = guard(async () => { const d = await rpc("dumpnow"); toast("Dumped " + d.events.toLocaleString() + " events"); await loadStorage(); }); + async function backupRequest(path, method, body) { + if (!signer.ready()) throw new Error(NO_SIGNER); + const hash = await sha256hex(body || ""); + const token = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: "", tags: [["u", location.origin + path], ["method", method], ["payload", hash]] }); + return fetch(path, { method, headers: { authorization: "Nostr " + btoa(JSON.stringify(token)), ...(body ? { "content-type": "application/json" } : {}) }, body }); + } + $("#backupnow").onclick = guard(async () => { const id = $("#backupid").value.trim() || "backup"; const r = await rpc("backupnow", id); const resp = await backupRequest("/backups/" + id, "GET"); if (!resp.ok) throw new Error("backup download failed"); const a = document.createElement("a"); a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(".")[0] + "-" + id + ".json"; a.click(); URL.revokeObjectURL(a.href); toast("Backup downloaded"); await loadStorage(); void r; }); + async function selectedBackup() { const f = $("#backupfile").files[0]; if (!f) throw new Error("Choose a backup archive first."); if (f.size > 8 * 1024 * 1024) throw new Error("Backups are limited to 8 MB."); return f.text(); } + $("#backuppreview").onclick = guard(async () => { const body = await selectedBackup(); const r = await (await backupRequest("/backups/preview", "POST", body)).json(); if (r.error) throw new Error(r.error); alert(JSON.stringify(r.result, null, 2)); }); + $("#backuprestore").onclick = guard(async () => { const body = await selectedBackup(); if (!confirm("Restore this archive onto this fresh relay? This cannot be undone.")) return; const r = await (await backupRequest("/backups/restore", "POST", body)).json(); if (r.error) throw new Error(r.error); toast("Backup restored"); await loadInfo(); await loadAdmin(); }); $("#treeform").onsubmit = guard(async (ev) => { const f = ev.target; policy = await rpc("setpolicy", { memberInvites: { depth: Math.max(0, Math.floor(+f.depth.value || 0)), quota: Math.max(0, Math.floor(+f.quota.value || 0)) } }); diff --git a/src/gen/console.ts b/src/gen/console.ts index bd828a7..6344595 100644 --- a/src/gen/console.ts +++ b/src/gen/console.ts @@ -1,4 +1,4 @@ // Generated by scripts/build/build-console.mjs from src/console. Do not edit; run npm run build:console. -export const CONSOLE_HTML = "
                                                                                                                                                \n
                                                                                                                                                \n
                                                                                                                                                \"\"
                                                                                                                                                \n
                                                                                                                                                \"\"

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

                                                                                                                                                Nobody owns this relay yet.

                                                                                                                                                \n

                                                                                                                                                Claim it and it's yours: you decide who can post, who can read, and what stays. One signature with a nostr browser extension; no account, no email, no card.

                                                                                                                                                \n
                                                                                                                                                \n
                                                                                                                                                \n\n
                                                                                                                                                \n

                                                                                                                                                A temporary relay, for now.

                                                                                                                                                \n

                                                                                                                                                Anyone can read and write here until . Then everything on it is deleted and the name is freed. Claim it and it stays, events and files included: one signature with a nostr browser extension. Or claim a new name and pull this one into it from its Storage tab.

                                                                                                                                                \n
                                                                                                                                                \n
                                                                                                                                                \n\n
                                                                                                                                                \n

                                                                                                                                                Connect a remote signer.

                                                                                                                                                \n

                                                                                                                                                Your key stays in a signer app such as Amber or nsec.app; this page asks it to sign. On a phone, open the link and approve there. On a computer, paste the bunker:// URL the app gives you.

                                                                                                                                                \n \n
                                                                                                                                                \n \"QR\n

                                                                                                                                                \n
                                                                                                                                                \n\n
                                                                                                                                                \n
                                                                                                                                                \n

                                                                                                                                                \n

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

                                                                                                                                                Git repositories, ntig

                                                                                                                                                \n
                                                                                                                                                \n

                                                                                                                                                Use ordinary Git to clone a repository hosted here.

                                                                                                                                                \n
                                                                                                                                                Clone a repository
                                                                                                                                                \n

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

                                                                                                                                                \n

                                                                                                                                                To host your own, use a Nostr Git client to publish your repository and signed branch state to this relay before pushing to the same remote. Git hosting guide.

                                                                                                                                                \n
                                                                                                                                                \n
                                                                                                                                                \n
                                                                                                                                                \n

                                                                                                                                                Open it in an app

                                                                                                                                                \n

                                                                                                                                                Relay apps open this relay as a place. Feed apps do not: they find the owner here through a profile link that carries this relay as the hint, and learn the relay from that. Either way, add under the app's relay settings to post here.

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

                                                                                                                                                People

                                                                                                                                                \n

                                                                                                                                                Hidden from visitors. Only you see this list.

                                                                                                                                                \n
                                                                                                                                                \n
                                                                                                                                                \n\n
                                                                                                                                                \n

                                                                                                                                                Fuel

                                                                                                                                                \n

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

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

                                                                                                                                                \n
                                                                                                                                                sats
                                                                                                                                                \n
                                                                                                                                                \n

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

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

                                                                                                                                                Your invites

                                                                                                                                                \n

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

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

                                                                                                                                                  People

                                                                                                                                                  \n

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

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

                                                                                                                                                  Invites

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

                                                                                                                                                    Joining

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

                                                                                                                                                    Moderation

                                                                                                                                                    \n

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

                                                                                                                                                    \n
                                                                                                                                                    \n

                                                                                                                                                    Reports

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

                                                                                                                                                    Log

                                                                                                                                                    \n

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

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

                                                                                                                                                    Bans

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

                                                                                                                                                        Blocked addresses

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

                                                                                                                                                          Recent events

                                                                                                                                                          \n

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

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

                                                                                                                                                          Pinned

                                                                                                                                                          \n

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

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

                                                                                                                                                            Rules

                                                                                                                                                            \n

                                                                                                                                                            Bans apply regardless of these.

                                                                                                                                                            \n
                                                                                                                                                            \n

                                                                                                                                                            Presets

                                                                                                                                                            \n

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

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

                                                                                                                                                            \n
                                                                                                                                                            \n
                                                                                                                                                            \n
                                                                                                                                                            \n

                                                                                                                                                            Writes

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

                                                                                                                                                            Reads

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

                                                                                                                                                            Kinds

                                                                                                                                                            \n

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

                                                                                                                                                            \n
                                                                                                                                                            \n

                                                                                                                                                            Allowed:

                                                                                                                                                            \n

                                                                                                                                                            Blocked:

                                                                                                                                                            \n
                                                                                                                                                            \n
                                                                                                                                                            \n

                                                                                                                                                            Features

                                                                                                                                                            \n

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

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

                                                                                                                                                            Blocked words

                                                                                                                                                            \n

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

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

                                                                                                                                                            Identity

                                                                                                                                                            \n
                                                                                                                                                            \n

                                                                                                                                                            Profile

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

                                                                                                                                                            For directories

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

                                                                                                                                                            Your own domain

                                                                                                                                                            \n

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

                                                                                                                                                            \n
                                                                                                                                                            \n
                                                                                                                                                            \n

                                                                                                                                                            \n
                                                                                                                                                            \n
                                                                                                                                                            \n

                                                                                                                                                            Your relay lists

                                                                                                                                                            \n

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

                                                                                                                                                            \n
                                                                                                                                                            \n
                                                                                                                                                            \n
                                                                                                                                                            \n

                                                                                                                                                            Share

                                                                                                                                                            \n

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

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

                                                                                                                                                            Data

                                                                                                                                                            \n

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

                                                                                                                                                            \n
                                                                                                                                                            \n

                                                                                                                                                            Recover your lists

                                                                                                                                                            \n

                                                                                                                                                            Older signed versions of your follows, relay lists and bookmarks stay private here. Restore a version to review it, then sign and publish it from this relay.

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

                                                                                                                                                            By kind

                                                                                                                                                            \n
                                                                                                                                                            KindCountSizeOldestKeep for
                                                                                                                                                            \n
                                                                                                                                                            \n
                                                                                                                                                            \n

                                                                                                                                                            Files

                                                                                                                                                            \n
                                                                                                                                                            TimeFileSizeUploader
                                                                                                                                                            \n
                                                                                                                                                            \n
                                                                                                                                                            \n

                                                                                                                                                            Sites

                                                                                                                                                            \n

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

                                                                                                                                                            \n
                                                                                                                                                            AuthorNameURLFilesSizeExpiry
                                                                                                                                                            \n
                                                                                                                                                            \n
                                                                                                                                                            \n

                                                                                                                                                            Dumps

                                                                                                                                                            \n

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

                                                                                                                                                            \n
                                                                                                                                                            \n
                                                                                                                                                              \n
                                                                                                                                                              \n
                                                                                                                                                              \n

                                                                                                                                                              Import a file

                                                                                                                                                              \n

                                                                                                                                                              A JSONL of events, one per line, such as a dump or a strfry export, or a JSON array. Up to 64 MB. Signatures are checked; bans and kind rules apply; the write rule does not. Progress shows under Jobs.

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

                                                                                                                                                              Sync

                                                                                                                                                              \n

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

                                                                                                                                                              \n
                                                                                                                                                              \n

                                                                                                                                                              Jobs

                                                                                                                                                              \n

                                                                                                                                                              Work the relay does on its own, one round at a time. A pull copies what another relay has and this one lacks. Fetch my history pulls your own events from the relays in your relay list. Rebroadcast sends what is here to other relays. Bans and kind rules apply to what arrives.

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

                                                                                                                                                              Automatic delivery

                                                                                                                                                              \n

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

                                                                                                                                                              \n
                                                                                                                                                              EventTargetStatusAttemptsLast error
                                                                                                                                                              \n
                                                                                                                                                              \n
                                                                                                                                                              \n

                                                                                                                                                              Fork this relay

                                                                                                                                                              \n

                                                                                                                                                              A new name, temporary until claimed, filled from this relay. Yours to split a name by job, or somebody else's to hand them a community with its history. One fork an hour.

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

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

                                                                                                                                                              Views

                                                                                                                                                              \n

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

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

                                                                                                                                                              Health

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

                                                                                                                                                              Zaps received

                                                                                                                                                              \n
                                                                                                                                                              WhenFromSats
                                                                                                                                                              \n
                                                                                                                                                              \n
                                                                                                                                                              \n

                                                                                                                                                              Notifications

                                                                                                                                                              \n

                                                                                                                                                              The relay writes you a private message (NIP-17) with its own key. It lands here, in your inbox on your own relay, and on your DM relays when this relay holds your kind 10050.

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

                                                                                                                                                              Owner

                                                                                                                                                              \n

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

                                                                                                                                                              \n
                                                                                                                                                              \n

                                                                                                                                                              Configuration

                                                                                                                                                              \n

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

                                                                                                                                                              \n
                                                                                                                                                              \n
                                                                                                                                                              \n
                                                                                                                                                              \n

                                                                                                                                                              Transfer ownership

                                                                                                                                                              \n

                                                                                                                                                              Hands the relay to a member. You stay on as a moderator. The relay's key, events, files and fuel do not change. There is no undo.

                                                                                                                                                              \n
                                                                                                                                                              \n
                                                                                                                                                              \n
                                                                                                                                                              \n

                                                                                                                                                              If I lose my key

                                                                                                                                                              \n

                                                                                                                                                              Name a member as your heir. If you do not sign in here for the time you pick, the relay writes to you once a week for a month, then hands itself to the heir and keeps you on as a moderator. Any signed action on the relay resets the clock.

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

                                                                                                                                                              \n
                                                                                                                                                              \n
                                                                                                                                                              \n

                                                                                                                                                              Delete this relay

                                                                                                                                                              \n

                                                                                                                                                              Deletes every event, file, member, invite and setting, closes every connection, and returns the name to unclaimed for anyone to take. There is no undo.

                                                                                                                                                              \n \n
                                                                                                                                                              \n
                                                                                                                                                              \n
                                                                                                                                                              \n\n \n
                                                                                                                                                              \n"; +export const CONSOLE_HTML = "
                                                                                                                                                              \n
                                                                                                                                                              \n
                                                                                                                                                              \"\"
                                                                                                                                                              \n
                                                                                                                                                              \"\"

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

                                                                                                                                                              Nobody owns this relay yet.

                                                                                                                                                              \n

                                                                                                                                                              Claim it and it's yours: you decide who can post, who can read, and what stays. One signature with a nostr browser extension; no account, no email, no card.

                                                                                                                                                              \n
                                                                                                                                                              \n
                                                                                                                                                              \n\n
                                                                                                                                                              \n

                                                                                                                                                              A temporary relay, for now.

                                                                                                                                                              \n

                                                                                                                                                              Anyone can read and write here until . Then everything on it is deleted and the name is freed. Claim it and it stays, events and files included: one signature with a nostr browser extension. Or claim a new name and pull this one into it from its Storage tab.

                                                                                                                                                              \n
                                                                                                                                                              \n
                                                                                                                                                              \n\n
                                                                                                                                                              \n

                                                                                                                                                              Connect a remote signer.

                                                                                                                                                              \n

                                                                                                                                                              Your key stays in a signer app such as Amber or nsec.app; this page asks it to sign. On a phone, open the link and approve there. On a computer, paste the bunker:// URL the app gives you.

                                                                                                                                                              \n \n
                                                                                                                                                              \n \"QR\n

                                                                                                                                                              \n
                                                                                                                                                              \n\n
                                                                                                                                                              \n
                                                                                                                                                              \n

                                                                                                                                                              \n

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

                                                                                                                                                              Git repositories, ntig

                                                                                                                                                              \n
                                                                                                                                                              \n

                                                                                                                                                              Use ordinary Git to clone a repository hosted here.

                                                                                                                                                              \n
                                                                                                                                                              Clone a repository
                                                                                                                                                              \n

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

                                                                                                                                                              \n

                                                                                                                                                              To host your own, use a Nostr Git client to publish your repository and signed branch state to this relay before pushing to the same remote. Git hosting guide.

                                                                                                                                                              \n
                                                                                                                                                              \n
                                                                                                                                                              \n
                                                                                                                                                              \n

                                                                                                                                                              Open it in an app

                                                                                                                                                              \n

                                                                                                                                                              Relay apps open this relay as a place. Feed apps do not: they find the owner here through a profile link that carries this relay as the hint, and learn the relay from that. Either way, add under the app's relay settings to post here.

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

                                                                                                                                                              People

                                                                                                                                                              \n

                                                                                                                                                              Hidden from visitors. Only you see this list.

                                                                                                                                                              \n
                                                                                                                                                              \n
                                                                                                                                                              \n\n
                                                                                                                                                              \n

                                                                                                                                                              Fuel

                                                                                                                                                              \n

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

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

                                                                                                                                                              \n
                                                                                                                                                              sats
                                                                                                                                                              \n
                                                                                                                                                              \n

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

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

                                                                                                                                                              Your invites

                                                                                                                                                              \n

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

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

                                                                                                                                                                People

                                                                                                                                                                \n

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

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

                                                                                                                                                                Invites

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

                                                                                                                                                                  Joining

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

                                                                                                                                                                  Moderation

                                                                                                                                                                  \n

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

                                                                                                                                                                  \n
                                                                                                                                                                  \n

                                                                                                                                                                  Reports

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

                                                                                                                                                                  Log

                                                                                                                                                                  \n

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

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

                                                                                                                                                                  Bans

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

                                                                                                                                                                      Blocked addresses

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

                                                                                                                                                                        Recent events

                                                                                                                                                                        \n

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

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

                                                                                                                                                                        Pinned

                                                                                                                                                                        \n

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

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

                                                                                                                                                                          Rules

                                                                                                                                                                          \n

                                                                                                                                                                          Bans apply regardless of these.

                                                                                                                                                                          \n
                                                                                                                                                                          \n

                                                                                                                                                                          Presets

                                                                                                                                                                          \n

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

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

                                                                                                                                                                          \n
                                                                                                                                                                          \n
                                                                                                                                                                          \n
                                                                                                                                                                          \n

                                                                                                                                                                          Writes

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

                                                                                                                                                                          Reads

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

                                                                                                                                                                          Kinds

                                                                                                                                                                          \n

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

                                                                                                                                                                          \n
                                                                                                                                                                          \n

                                                                                                                                                                          Allowed:

                                                                                                                                                                          \n

                                                                                                                                                                          Blocked:

                                                                                                                                                                          \n
                                                                                                                                                                          \n
                                                                                                                                                                          \n

                                                                                                                                                                          Features

                                                                                                                                                                          \n

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

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

                                                                                                                                                                          Blocked words

                                                                                                                                                                          \n

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

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

                                                                                                                                                                          Identity

                                                                                                                                                                          \n
                                                                                                                                                                          \n

                                                                                                                                                                          Profile

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

                                                                                                                                                                          For directories

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

                                                                                                                                                                          Your own domain

                                                                                                                                                                          \n

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

                                                                                                                                                                          \n
                                                                                                                                                                          \n
                                                                                                                                                                          \n

                                                                                                                                                                          \n
                                                                                                                                                                          \n
                                                                                                                                                                          \n

                                                                                                                                                                          Your relay lists

                                                                                                                                                                          \n

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

                                                                                                                                                                          \n
                                                                                                                                                                          \n
                                                                                                                                                                          \n
                                                                                                                                                                          \n

                                                                                                                                                                          Share

                                                                                                                                                                          \n

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

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

                                                                                                                                                                          Data

                                                                                                                                                                          \n

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

                                                                                                                                                                          \n
                                                                                                                                                                          \n

                                                                                                                                                                          Recover your lists

                                                                                                                                                                          \n

                                                                                                                                                                          Older signed versions of your follows, relay lists and bookmarks stay private here. Restore a version to review it, then sign and publish it from this relay.

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

                                                                                                                                                                          By kind

                                                                                                                                                                          \n
                                                                                                                                                                          KindCountSizeOldestKeep for
                                                                                                                                                                          \n
                                                                                                                                                                          \n
                                                                                                                                                                          \n

                                                                                                                                                                          Files

                                                                                                                                                                          \n
                                                                                                                                                                          TimeFileSizeUploader
                                                                                                                                                                          \n
                                                                                                                                                                          \n
                                                                                                                                                                          \n

                                                                                                                                                                          Sites

                                                                                                                                                                          \n

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

                                                                                                                                                                          \n
                                                                                                                                                                          AuthorNameURLFilesSizeExpiry
                                                                                                                                                                          \n
                                                                                                                                                                          \n
                                                                                                                                                                          \n

                                                                                                                                                                          Dumps

                                                                                                                                                                          \n

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

                                                                                                                                                                          \n
                                                                                                                                                                          \n
                                                                                                                                                                            \n
                                                                                                                                                                            \n
                                                                                                                                                                            \n

                                                                                                                                                                            Import a file

                                                                                                                                                                            \n

                                                                                                                                                                            A JSONL of events, one per line, such as a dump or a strfry export, or a JSON array. Up to 64 MB. Signatures are checked; bans and kind rules apply; the write rule does not. Progress shows under Jobs.

                                                                                                                                                                            \n
                                                                                                                                                                            \n
                                                                                                                                                                            \n
                                                                                                                                                                            \n

                                                                                                                                                                            Backup and restore

                                                                                                                                                                            \n

                                                                                                                                                                            A private archive includes configuration, events, site files and Git objects. It excludes keys, fuel, jobs and push registrations. Archives are limited to 8 MB and restore only works on a fresh relay.

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

                                                                                                                                                                              Sync

                                                                                                                                                                              \n

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

                                                                                                                                                                              \n
                                                                                                                                                                              \n

                                                                                                                                                                              Jobs

                                                                                                                                                                              \n

                                                                                                                                                                              Work the relay does on its own, one round at a time. A pull copies what another relay has and this one lacks. Fetch my history pulls your own events from the relays in your relay list. Rebroadcast sends what is here to other relays. Bans and kind rules apply to what arrives.

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

                                                                                                                                                                              Automatic delivery

                                                                                                                                                                              \n

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

                                                                                                                                                                              \n
                                                                                                                                                                              EventTargetStatusAttemptsLast error
                                                                                                                                                                              \n
                                                                                                                                                                              \n
                                                                                                                                                                              \n

                                                                                                                                                                              Fork this relay

                                                                                                                                                                              \n

                                                                                                                                                                              A new name, temporary until claimed, filled from this relay. Yours to split a name by job, or somebody else's to hand them a community with its history. One fork an hour.

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

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

                                                                                                                                                                              Views

                                                                                                                                                                              \n

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

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

                                                                                                                                                                              Health

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

                                                                                                                                                                              Zaps received

                                                                                                                                                                              \n
                                                                                                                                                                              WhenFromSats
                                                                                                                                                                              \n
                                                                                                                                                                              \n
                                                                                                                                                                              \n

                                                                                                                                                                              Notifications

                                                                                                                                                                              \n

                                                                                                                                                                              The relay writes you a private message (NIP-17) with its own key. It lands here, in your inbox on your own relay, and on your DM relays when this relay holds your kind 10050.

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

                                                                                                                                                                              Owner

                                                                                                                                                                              \n

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

                                                                                                                                                                              \n
                                                                                                                                                                              \n

                                                                                                                                                                              Configuration

                                                                                                                                                                              \n

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

                                                                                                                                                                              \n
                                                                                                                                                                              \n
                                                                                                                                                                              \n
                                                                                                                                                                              \n

                                                                                                                                                                              Transfer ownership

                                                                                                                                                                              \n

                                                                                                                                                                              Hands the relay to a member. You stay on as a moderator. The relay's key, events, files and fuel do not change. There is no undo.

                                                                                                                                                                              \n
                                                                                                                                                                              \n
                                                                                                                                                                              \n
                                                                                                                                                                              \n

                                                                                                                                                                              If I lose my key

                                                                                                                                                                              \n

                                                                                                                                                                              Name a member as your heir. If you do not sign in here for the time you pick, the relay writes to you once a week for a month, then hands itself to the heir and keeps you on as a moderator. Any signed action on the relay resets the clock.

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

                                                                                                                                                                              \n
                                                                                                                                                                              \n
                                                                                                                                                                              \n

                                                                                                                                                                              Delete this relay

                                                                                                                                                                              \n

                                                                                                                                                                              Deletes every event, file, member, invite and setting, closes every connection, and returns the name to unclaimed for anyone to take. There is no undo.

                                                                                                                                                                              \n \n
                                                                                                                                                                              \n
                                                                                                                                                                              \n
                                                                                                                                                                              \n\n \n
                                                                                                                                                                              \n"; export const CONSOLE_CSS = "main { max-width: 64rem; }\n.mast { display: grid; grid-template-columns: 1fr auto; gap: 1.5rem 2rem; align-items: end; margin-bottom: 2rem; }\n.mast .wordmark { grid-column: 1 / -1; justify-self: center; margin-bottom: .5rem; }\n.mast .right { display: grid; gap: .7rem; justify-items: end; text-align: right; }\n.urlrow, .owner { display: inline-flex; align-items: center; gap: .4rem; color: var(--ink-2); }\n.owner b { color: var(--ink); } .owner em { font-style: normal; color: var(--forest); }\n.urlrow .ib, .owner .ib { width: 26px; height: 26px; margin-left: .2rem; box-shadow: 1px 1px 0 var(--ink); } .urlrow .ib svg, .owner .ib svg { width: 13px; height: 13px; }\n.care { display: flex; gap: .9rem; flex-wrap: wrap; }\n.care .g { display: grid; justify-items: center; gap: .3rem; width: 5.4rem; }\n.care .g i { display: grid; place-items: center; width: 44px; height: 44px; border: 2px solid var(--ink); border-radius: 10px; background: var(--paper); box-shadow: 2px 2px 0 var(--ink); position: relative; }\n.care .g i svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }\n.care .g.off i::after { content: \"\"; position: absolute; width: 3px; height: 40px; background: var(--ink); transform: rotate(45deg); border-radius: 2px; box-shadow: 0 0 0 2px var(--paper); }\n.care .g small { font: 500 10px/1.2 var(--mono); text-transform: uppercase; letter-spacing: .04em; color: var(--ink-2); text-align: center; }\n#unclaimed { background: var(--butter); }\n.doors { display: grid; grid-template-columns: repeat(4, 1fr); gap: .8rem; } @media (max-width: 52rem) { .doors { grid-template-columns: 1fr 1fr; } } @media (max-width: 30rem) { .doors { grid-template-columns: 1fr; } }\n.door { display: grid; grid-template-rows: auto 1fr; align-content: start; gap: .35rem; padding: .8rem .9rem; border: 2px solid var(--ink); border-radius: 12px; background: var(--paper); box-shadow: 3px 3px 0 var(--ink); min-width: 0; }\n.apps { display: grid; gap: 1.4rem; margin-top: 1rem; } .appgroup h4 { margin: 0 0 .2rem; font-size: 1rem; } .appgroup > .note { margin-top: 0; }\n.appgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: .8rem; } @media (max-width: 52rem) { .appgrid { grid-template-columns: 1fr 1fr; } } @media (max-width: 30rem) { .appgrid { grid-template-columns: 1fr; } }\n.app { display: grid; align-content: start; gap: .4rem; padding: .8rem .9rem; border: 2px solid var(--ink); border-radius: 12px; background: var(--paper); box-shadow: 3px 3px 0 var(--ink); min-width: 0; }\n.app-head { display: flex; align-items: baseline; gap: .5rem; flex-wrap: wrap; } .app-head b { font-size: 15px; } .app-head small { color: var(--ink-3); } .app p { margin: 0; font-size: 13px; color: var(--ink-2); }\n.app-acts { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .2rem; } .app-acts .btn { padding: .3rem .7rem; font-size: 13px; }\n.phones { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, max-content)); gap: .8rem; margin-top: 1rem; } .phones img { display: block; width: 150px; height: 150px; } .phones .door { justify-items: start; }\n.door small { font: 700 11px var(--sans); text-transform: uppercase; letter-spacing: .06em; color: var(--ink-2); }\n.door .v { display: grid; grid-template-columns: 1fr auto; align-items: start; gap: .5rem; font: 500 13px/1.45 var(--mono); } .door .v span { min-width: 0; overflow-wrap: anywhere; }\n.door .ib { width: 24px; height: 24px; box-shadow: 1px 1px 0 var(--ink); flex: 0 0 auto; } .door .ib svg { width: 12px; height: 12px; }\n#peoplesec { background: var(--peach); }\n.dir { display: flex; flex-wrap: wrap; gap: .5rem; }\n.who { display: inline-flex; align-items: center; gap: .1rem; padding: .3rem .7rem .3rem .4rem; border: 2px solid var(--ink); border-radius: 999px; background: var(--paper); box-shadow: 2px 2px 0 var(--ink); font-size: 14px; }\n.who .role { font: 700 10px var(--mono); text-transform: uppercase; letter-spacing: .05em; color: var(--forest); margin-left: .4rem; }\n.who.me { background: var(--sun); }\n#fuelsec { background: var(--mint); }\n.gauges { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }\n.gauge small { display: block; font-weight: 600; font-size: 13px; }\n.gauge .bar { position: relative; height: 22px; border: 2px solid var(--ink); border-radius: 999px; margin: .5rem 0 .35rem; overflow: hidden; padding: 3px; background: var(--paper) linear-gradient(90deg, transparent calc(25% - 1px), var(--line) calc(25% - 1px) 25%, transparent 25% calc(50% - 1px), var(--line) calc(50% - 1px) 50%, transparent 50% calc(75% - 1px), var(--line) calc(75% - 1px) 75%, transparent 75%); }\n.gauge .bar b { position: absolute; right: .6rem; top: 0; bottom: 0; display: flex; align-items: center; font: 600 11px var(--mono); color: var(--ink-3); pointer-events: none; }\n.gauge .bar i { display: block; height: 100%; width: 0; min-width: 14px; border-radius: 999px; background: repeating-linear-gradient(-45deg, var(--forest) 0 8px, var(--forest-2) 8px 16px); transition: width .4s; }\n.gauge .bar i.warm { background: repeating-linear-gradient(-45deg, #d9a52a 0 8px, var(--sun) 8px 16px); }\n.gauge .bar i.over { background: repeating-linear-gradient(-45deg, var(--red) 0 8px, #d4614c 8px 16px); }\n.gauge span { font-size: 13px; color: var(--ink-3); }\n.balance { margin: 1.2rem 0 0; color: var(--ink-2); } .balance b { color: var(--ink); font-weight: 700; }\n.sats { display: inline-flex; align-items: center; width: auto; } .sats input { font: 15px var(--sans); width: 6rem; text-align: right; border: 0; outline: none; padding: 0; background: transparent; color: var(--ink); } .sats span { color: var(--ink-3); padding-left: .4rem; }\n.topup { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; margin-top: .8rem; }\n.invoice { margin-top: 1rem; border: 2px dashed var(--ink); border-radius: 12px; padding: .9rem 1rem; background: var(--paper); }\n.invoice p { margin: 0 0 .6rem; color: var(--ink-2); font-size: 14px; } .invoice .state { font-size: 13px; color: var(--forest); }\n.invoice textarea { margin-top: .7rem; min-height: 3.4rem; font-family: var(--mono); font-size: 11.5px; color: var(--ink-3); }\ndetails.disclosure summary { cursor: pointer; font: 400 1.6rem/1 var(--display); list-style: none; display: flex; align-items: center; gap: .5rem; }\ndetails.disclosure summary::-webkit-details-marker { display: none; }\n.folds details + details, .folds #frontsec:not(.hidden) + details { border-top: 2px solid var(--line); margin-top: 1.2rem; padding-top: 1.2rem; } .folds #frontsec .metarow { margin-bottom: 0; } .folds .doors { margin-top: 1rem; } .folds > details > .block:first-of-type { margin-top: 1.4rem; }\ndetails.disclosure summary::before { content: \"+\"; font: 700 1.4rem var(--mono); width: 1.4rem; } details.disclosure[open] summary::before { content: \"–\"; }\n.about { display: grid; grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); gap: .6rem 1.2rem; margin-top: 1rem; font-size: 14px; }\n.about small { display: block; color: var(--ink-3); font-size: 12px; } .about div > div { overflow-wrap: anywhere; }\n.console { margin-top: 3rem; }\n.tabs { display: flex; gap: .45rem; flex-wrap: nowrap; align-items: flex-end; margin: 0 0 -2px 1rem; padding: 2px 3rem 2px 0; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; }\n.tabs.fade-r { -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 2.5rem), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 2.5rem), transparent); }\n.tabs.fade-l { -webkit-mask-image: linear-gradient(90deg, transparent, #000 2.5rem); mask-image: linear-gradient(90deg, transparent, #000 2.5rem); }\n.tabs.fade-l.fade-r { -webkit-mask-image: linear-gradient(90deg, transparent, #000 2.5rem, #000 calc(100% - 2.5rem), transparent); mask-image: linear-gradient(90deg, transparent, #000 2.5rem, #000 calc(100% - 2.5rem), transparent); } .tabs::-webkit-scrollbar { display: none; } .tabs a { flex: 0 0 auto; white-space: nowrap; }\n.tabs a { font: 600 14px var(--sans); padding: .55rem 1rem; border: 2px solid var(--ink); border-bottom: 0; border-radius: 12px 12px 0 0; background: var(--head); color: var(--ink-2); text-decoration: none; margin-bottom: -2px; position: relative; z-index: 1; }\n.tabs a.on { color: var(--ink); z-index: 3; padding-top: .7rem; } .tabs a.on:nth-child(4n+1) { background: var(--sun); } .tabs a.on:nth-child(4n+2) { background: var(--peach); } .tabs a.on:nth-child(4n+3) { background: var(--sky); } .tabs a.on:nth-child(4n+4) { background: var(--mint); }\n.count { display: inline-grid; place-items: center; min-width: 1.5em; height: 1.5em; padding: 0 .4em; margin-left: .45rem; border: 1.5px solid var(--ink); border-radius: 999px; background: var(--sun); color: var(--ink); font: 700 11px/1 var(--mono); vertical-align: middle; box-shadow: 1px 1px 0 var(--ink); } .count:empty { display: none; } h2 .count { font-size: 12px; vertical-align: .45em; }\n.panel { display: none; margin: 0; border-radius: 0 18px 18px 18px; position: relative; z-index: 2; }\n.panel.on { display: block; }\n.panel h2 { font-size: 2rem; }\n.block { margin-top: 1.8rem; }\n.two { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; } .two > * { min-width: 0; }\n.actions { display: flex; justify-content: flex-end; margin-top: 1.1rem; } .actions.left { justify-content: flex-start; }\n.addrow { display: flex; gap: .5rem; align-items: center; margin-bottom: .9rem; } .addrow input { flex: 1; min-width: 0; } .addrow label { display: inline-flex; align-items: center; gap: .35rem; flex: 0 0 auto; white-space: nowrap; font-size: 14px; color: var(--ink-2); cursor: pointer; } .addrow label input { flex: none; width: auto; margin: 0; accent-color: var(--forest); } .addrow .ib { width: 38px; height: 38px; flex: 0 0 auto; border-radius: 10px; }\n.choices { display: grid; gap: .5rem; }\n.choices label { display: grid; grid-template-columns: auto 1fr; gap: 0 .7rem; cursor: pointer; padding: .7rem .8rem; border: 2px solid var(--line-2); border-radius: 12px; background: var(--paper); }\n.choices label:has(:checked) { border-color: var(--ink); box-shadow: 3px 3px 0 var(--ink); }\n.choices input { grid-row: span 2; margin: .2rem 0 0; accent-color: var(--forest); }\n.choices b { font-weight: 600; } .choices small { color: var(--ink-2); font-size: 13px; }\n.limits { display: grid; grid-template-columns: 1fr 1fr; gap: .6rem 1.5rem; margin-top: 1.3rem; }\n.limits label { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: .6rem; } .limits label small { color: var(--ink-3); }\n.form { display: grid; grid-template-columns: 1fr 1fr; gap: .9rem 1.5rem; } .form label { display: grid; gap: .3rem; font-weight: 600; font-size: 14px; } .form .wide { grid-column: 1 / -1; }\n.danger-zone { border: 2px solid var(--red); border-radius: 12px; padding: 1rem 1.1rem; background: var(--red-soft); } .danger-zone h3 { color: var(--red); } .form label.switch { display: flex; flex-direction: row; align-items: center; gap: .6rem; font-weight: 600; font-size: 14px; } .switch input { accent-color: var(--forest); width: 18px; height: 18px; }\ntable { width: 100%; border-collapse: collapse; }\nth { text-align: left; font-size: 12px; font-weight: 600; color: var(--ink-3); padding: .4rem .5rem; border-bottom: 2px solid var(--ink); white-space: nowrap; }\ntd { padding: .5rem .5rem; vertical-align: middle; border-bottom: 1px solid var(--line); }\ntbody tr:nth-child(even) td { background: rgba(255,255,255,.55); }\nth.r, td.r { text-align: right; white-space: nowrap; } td.mono, td.dim { white-space: nowrap; } .events td.c:empty::before { content: \"(no content)\"; color: var(--ink-3); }\ntd .ib { width: 26px; height: 26px; box-shadow: 1px 1px 0 var(--ink); } td .ib svg { width: 13px; height: 13px; }\ntd input.txt { padding: .25rem .5rem; font-size: 14px; } td select.role { width: auto; min-width: 7rem; padding: .25rem .4rem; font-size: 13px; } .people-table input.name { width: 7rem; } .people-table input.note { width: 9.5rem; }\n.kind { font-family: var(--mono); font-size: 12px; background: var(--sun); border: 1.5px solid var(--ink); padding: 0 .5rem; border-radius: 999px; font-weight: 500; white-space: nowrap; }\n.events td.c { max-width: 0; width: 100%; overflow-x: auto; white-space: nowrap; scrollbar-width: thin; -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 2rem), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 2rem), transparent); }\n.people-table td.name { width: 12rem; }\n.ev { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 4px; vertical-align: -5px; margin-right: .45rem; font: 500 9px var(--mono); background: var(--line); color: var(--ink-2); font-style: normal; }\n.plain { list-style: none; margin: 0; padding: 0; } .plain li { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: .5rem; padding: .5rem 0; border-bottom: 2px dotted var(--line-2); } .plain li > span { min-width: 0; overflow-wrap: anywhere; } .plain li:last-child { border-bottom: 0; } .plain li.empty { grid-template-columns: 1fr; color: var(--ink-3); }\n.kindline { margin: .4rem 0; color: var(--ink-2); display: flex; flex-wrap: wrap; align-items: center; gap: .35rem; }\n.tag { display: inline-flex; align-items: center; gap: .2rem; font-family: var(--mono); font-size: 13px; padding: .05rem .2rem .05rem .55rem; border: 2px solid var(--ink); border-radius: 999px; background: var(--paper); box-shadow: 1px 1px 0 var(--ink); }\n.tag.plain { padding-right: .55rem; box-shadow: none; border-color: var(--line-2); color: var(--ink-3); } .tag.blk { color: var(--red); }\n.tag .ib { width: 18px; height: 18px; border-width: 1.5px; box-shadow: none; margin-left: .2rem; } .tag .ib svg { width: 9px; height: 9px; }\n.counters { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1.2rem; margin-bottom: 1.6rem; }\n.counter { display: grid; justify-items: center; align-content: center; gap: .15rem; min-height: 7.2rem; text-align: center; padding: 1rem .8rem; border: 2px solid var(--ink); border-radius: 16px; box-shadow: 4px 4px 0 var(--ink); background: var(--paper); }\n.counter:nth-child(1) { background: var(--sun); } .counter:nth-child(2) { background: var(--peach); } .counter:nth-child(3) { background: var(--sky); } .counter:nth-child(4) { background: var(--mint); }\n.counter .big { font: 400 2.6rem/1 var(--display); white-space: nowrap; } .counter small { display: block; margin-top: .35rem; font: 700 12px var(--sans); text-transform: uppercase; letter-spacing: .08em; } .counter .sub { font-size: 12px; color: var(--ink-2); }\n.kbar { display: flex; width: 100%; height: 22px; border: 2px solid var(--ink); border-radius: 999px; overflow: hidden; background: var(--paper); margin-bottom: .35rem; } .kbar i { display: block; height: 100%; border-right: 2px solid var(--ink); } .kbar i:last-child { border-right: 0; }\n.k1 { background: var(--ink); } .k2 { background: var(--forest); } .k3 { background: var(--id-3); } .k4 { background: var(--id-2); } .k5 { background: var(--line-2); }\n.legend { display: flex; flex-wrap: wrap; justify-content: center; gap: .1rem .6rem; font-family: var(--mono); font-size: 10.5px; text-transform: uppercase; } .legend i { display: inline-block; width: 9px; height: 9px; border: 1.5px solid var(--ink); border-radius: 2px; margin-right: .3rem; vertical-align: -1px; }\n.usage { display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: .8rem; } .usage div { padding: .7rem .9rem; border: 2px solid var(--ink); border-radius: 12px; background: var(--paper); } .usage small { display: block; color: var(--ink-3); font-size: 12px; } .usage b { font: 400 1.5rem/1.2 var(--display); }\n@media (max-width: 52rem) { .two, .gauges, .limits, .form, .counters { grid-template-columns: 1fr !important; } .addrow { flex-wrap: wrap; } .mast { grid-template-columns: 1fr; } .mast .right { justify-items: start; text-align: left; } }\n@media (max-width: 40rem) { .events:not(#kinds):not(#jobs) th:first-child, .events:not(#kinds):not(#jobs) td:first-child { display: none; } }\n\n.slegend { justify-content: flex-start; margin: 0 0 1.2rem; }\n#s-totals { margin-bottom: .4rem; }\ntd.keep { white-space: nowrap; } td.keep input { width: 5.5rem; display: inline-block; margin-right: .3rem; } td.keep input::placeholder { color: var(--ink-3); }\n#kinds tr.any td:first-child { font-weight: 600; }\n#kinds td:first-child .kind { display: inline-block; min-width: 5.2em; text-align: center; margin-right: .35rem; }\n/* Tables keep their columns and scroll inside the card on narrow screens instead of pushing the page sideways. */\n.scroll { overflow-x: auto; max-width: 100%; scrollbar-width: thin; } .scroll table { min-width: 100%; }\nsection, .card { overflow-x: clip; }\n.kind.sys { background: var(--mint); border-color: var(--forest); color: var(--forest); }\ntd.keep.sys { color: var(--ink-3); font-size: 13px; white-space: normal; }\n\n#console.mod .tabs a:not([data-tab=people]):not([data-tab=moderation]) { display: none; } #console.mod #thresholdform { display: none; }\n#members select.role { width: auto; padding: .1rem .3rem; font-size: 12px; margin-left: .3rem; }\n.wire-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .5rem 1.2rem; padding: .8rem 0; border-bottom: 1px solid var(--line); align-items: start; } .wire-row:first-child { border-top: 2px solid var(--ink); }\n.wire-main { min-width: 0; } .wire-side { display: grid; justify-items: end; gap: .45rem; max-width: 26rem; }\n.wire-acts { display: flex; gap: .4rem; white-space: nowrap; } .wire-acts .btn { padding: .35rem .75rem; font-size: 13px; }\n.wire-meta { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .35rem .5rem; align-items: center; font-size: 13px; color: var(--ink-2); text-align: right; } .wire-meta:empty { display: none; }\n.pill { display: inline-block; padding: .1rem .6rem; border: 1.5px solid var(--ink); border-radius: 999px; font: 600 12px var(--sans); color: var(--ink); background: var(--paper); } .pill.on { background: var(--mint); } .pill.off { color: var(--ink-3); } .pill.bad { color: var(--red); }\n.chip { display: inline-block; padding: .05rem .45rem; border: 1px solid var(--forest); border-radius: 6px; font: 12px var(--mono); color: var(--forest); } .chip.bad { border-color: var(--red); color: var(--red); }\n.share { display: grid; grid-template-columns: minmax(0, 3fr) minmax(14rem, 2fr); gap: 1.4rem; align-items: start; } .share #cardimg { width: 100%; height: auto; display: block; }\n.share-side { display: grid; gap: .8rem; justify-items: start; } .naddr { display: block; font-size: 12px; line-height: 1.5; word-break: break-all; color: var(--ink-2); }\n@media (max-width: 52rem) { .share { grid-template-columns: 1fr; } .wire-side { display: contents; } .wire-acts { justify-self: end; } .wire-meta { grid-column: 1 / -1; justify-content: flex-start; text-align: left; } }\n#presets .btn { margin: 0 .4rem .4rem 0; }\n.panel form h3.gap { margin-top: 1.4rem; }\n.key { position: relative; display: inline-block; font-family: var(--mono); } .key::before { content: attr(data-short); } .key .full { position: absolute; left: 0; top: 0; width: 1px; height: 1px; overflow: hidden; opacity: 0; white-space: nowrap; }\n.mast .banner { grid-column: 1 / -1; width: 100%; aspect-ratio: 4 / 1; overflow: hidden; border: 2px solid var(--ink); border-radius: 18px; box-shadow: 4px 4px 0 var(--ink); background: var(--paper); margin-bottom: .5rem; } .mast .banner img { width: 100%; height: 100%; object-fit: cover; display: block; }\n.mast .wordmark { display: flex; align-items: center; gap: 1rem; } .mast .icon { width: 64px; height: 64px; border-radius: 50%; border: 2px solid var(--ink); box-shadow: 3px 3px 0 var(--ink); background: var(--paper); object-fit: cover; }\n.metarow { display: flex; flex-wrap: wrap; gap: .4rem .6rem; align-items: center; margin: -.4rem 0 1rem; font-size: 13px; color: var(--ink-2); }\n.metarow .tag { display: inline-block; padding: .1rem .6rem; border: 1.5px solid var(--ink); border-radius: 999px; font: 600 12px var(--sans); color: var(--ink); background: var(--sun); }\n.metarow .sep { color: var(--ink-3); } .metarow a { color: var(--forest); text-decoration: none; border-bottom: 1.5px solid var(--forest); } .metarow a:hover { border-bottom-width: 2px; }\n.about a { color: var(--forest); text-decoration: none; border-bottom: 1.5px solid var(--forest); }\n.wire-acts label { display: inline-flex; align-items: center; gap: .35rem; font-size: 14px; color: var(--ink-2); cursor: pointer; } .wire-acts label input { margin: 0; accent-color: var(--forest); } .wire-row.dim .wire-main { color: var(--ink-3); }\n"; -export const CONSOLE_JS = "(async () => {\n const $ = (s) => document.querySelector(s);\n const $$ = (s) => document.querySelectorAll(s);\n const host = location.host;\n const wsURL = (location.protocol === \"https:\" ? \"wss://\" : \"ws://\") + host;\n const rpcURL = location.origin + \"/\";\n let info = null, me = null, owner = \"\", policy = null, fuel = null, people = null, myRole = \"\";\n\n const toast = (msg) => { const t = $(\"#toast\"); t.textContent = msg; t.classList.add(\"show\"); clearTimeout(t._t); t._t = setTimeout(() => t.classList.remove(\"show\"), 2600); };\n // guard wraps a handler: disables the button, shows errors as toasts. Defined first, since handlers below use it.\n const guard = (fn) => async (ev) => { ev.preventDefault(); const b = ev.submitter || ev.target; if (b) b.disabled = true; try { await fn(ev); } catch (e) { toast(e.message); } finally { if (b) b.disabled = false; } };\n const short = (hex) => hex ? hex.slice(0, 8) + \"…\" + hex.slice(-4) : \"\";\n // key shows a key short but keeps the whole of it in the DOM: selecting, double-clicking or clicking it copies the full hex.\n const key = (hex) => hex ? '' + hex + '' : '';\n const hue = (hex) => (parseInt(hex.slice(0, 2), 16) * 360 / 256).toFixed(0) + \"deg\";\n const av = (hex) => '';\n const fmtBytes = (n) => n < 1e6 ? (n / 1e3).toFixed(0) + \" KB\" : n < 1e9 ? (n / 1e6).toFixed(1) + \" MB\" : (n / 1e9).toFixed(2) + \" GB\";\n const fmtHours = (ms) => ms < 3600e3 ? Math.round(ms / 60e3) + \" min\" : (ms / 3600e3).toFixed(ms < 36e6 ? 1 : 0) + \" h\";\n const fuelOver = () => !!fuel && (fuel.eventBytes > fuel.freeEventBytes || fuel.mediaBytes > fuel.freeMediaBytes || fuel.activeMs > fuel.freeActiveMs || fuel.rowsWritten > fuel.freeRowsWritten);\n const fmtTime = (t) => t ? new Date(t * 1000).toLocaleString(undefined, { month: \"short\", day: \"numeric\", hour: \"2-digit\", minute: \"2-digit\" }) : \"–\";\n const fmtDay = (t) => t ? new Date(t * 1000).toLocaleDateString(undefined, { month: \"short\", day: \"numeric\" }) : \"–\";\n const ago = (t) => { const s = Math.max(0, Math.floor(Date.now() / 1000) - t); return s < 60 ? \"just now\" : s < 3600 ? Math.floor(s / 60) + \" min\" : s < 86400 ? Math.floor(s / 3600) + \" h\" : Math.floor(s / 86400) + \" d\"; };\n const esc = (s) => String(s ?? \"\").replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c]);\n\n const IC = {\n x: '',\n undo: '',\n check: '',\n trash: '',\n ban: '',\n banuser: '',\n eye: '',\n lock: '',\n pen: '',\n people: '',\n person: '',\n gauge: '',\n bolt: '',\n copy: '',\n pin: '',\n };\n const ib = (icon, label, act, id, extra) => '\";\n\n const CH = \"qpzry9x8gf2tvdw0s3jn54khce6mua7l\";\n function npubToHex(s) {\n s = s.trim().toLowerCase();\n if (/^[0-9a-f]{64}$/.test(s)) return s;\n if (!s.startsWith(\"npub1\")) return null;\n const data = s.slice(5, -6).split(\"\").map((c) => CH.indexOf(c));\n if (data.some((d) => d < 0)) return null;\n let bits = 0, acc = 0, out = [];\n for (const d of data) { acc = (acc << 5) | d; bits += 5; if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 255); } }\n return out.length === 32 ? out.map((b) => b.toString(16).padStart(2, \"0\")).join(\"\") : null;\n }\n\n async function sha256hex(s) {\n const b = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(s));\n return [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, \"0\")).join(\"\");\n }\n // ---- signing: a NIP-07 extension, or a NIP-46 remote signer ----\n // The remote path loads the bundled library on first use; its session\n // (client key, signer pubkey, relays) lives in localStorage until sign-out.\n let remote = null, lib = null, pendingNote = null;\n const NO_SIGNER = \"Install a nostr extension (Alby, nos2x, …) or connect a remote signer.\";\n const withTimeout = (p, ms, what) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(what + \" did not answer; is the signer app open?\")), ms))]);\n const signer = {\n ready: () => !!remote || !!window.nostr,\n async getPublicKey() { if (remote) return withTimeout(remote.getPublicKey(), 20000, \"The remote signer\"); if (window.nostr) return window.nostr.getPublicKey(); throw new Error(NO_SIGNER); },\n async signEvent(ev) { if (remote) return withTimeout(remote.signEvent(ev), 60000, \"The remote signer\"); if (window.nostr) return window.nostr.signEvent(ev); throw new Error(NO_SIGNER); },\n };\n async function signerLib() {\n if (lib) return lib;\n if (!window.NostrSigner) await new Promise((res, rej) => { const s = document.createElement(\"script\"); s.src = window.SIGNER_URL || \"/signer.js\"; s.onload = res; s.onerror = () => rej(new Error(\"Could not load the signer library.\")); document.head.appendChild(s); });\n return (lib = window.NostrSigner);\n }\n const onauth = (u) => window.open(u, \"_blank\");\n // The signer conversation rides this relay, and a relay may ask the socket to\n // authenticate before it delivers kind 24133 to its parties; answer with the\n // session's own key.\n const authPool = (L, sk) => new L.SimplePool({ automaticallyAuth: () => (evt) => Promise.resolve(L.finalizeEvent(evt, sk)) });\n const saveSession = (sk, s, secret) => localStorage.setItem(\"nip46\", JSON.stringify({ sk: lib.bytesToHex(sk), pubkey: s.bp.pubkey, relays: s.bp.relays, secret: secret || null }));\n async function resumeRemote() {\n const raw = localStorage.getItem(\"nip46\");\n if (!raw) return false;\n try {\n const s = JSON.parse(raw); const L = await signerLib();\n remote = L.BunkerSigner.fromBunker(L.hexToBytes(s.sk), { pubkey: s.pubkey, relays: s.relays, secret: s.secret }, { onauth, pool: authPool(L, L.hexToBytes(s.sk)) });\n return true;\n } catch { localStorage.removeItem(\"nip46\"); return false; }\n }\n async function connectBunker(input) {\n const L = await signerLib();\n const bp = await L.parseBunkerInput(input.trim());\n if (!bp) throw new Error(\"That is not a bunker:// URL.\");\n if (!bp.relays.length) throw new Error(\"The bunker URL names no relay.\");\n const sk = L.generateSecretKey();\n const s = L.BunkerSigner.fromBunker(sk, bp, { onauth, pool: authPool(L, sk) });\n await withTimeout(s.connect({ name: host, url: location.origin }), 60000, \"The signer\");\n await Promise.race([s.switchRelays(), new Promise((r) => setTimeout(r, 3000))]);\n remote = s; saveSession(sk, s, bp.secret);\n }\n // The nostrconnect:// flow: this relay carries the traffic, so no third relay is involved.\n let ncPending = null;\n async function offerNostrConnect() {\n if (ncPending) return ncPending;\n const L = await signerLib();\n const sk = L.generateSecretKey();\n const secret = L.bytesToHex(crypto.getRandomValues(new Uint8Array(8)));\n const uri = L.createNostrConnectURI({ clientPubkey: L.getPublicKey(sk), relays: [wsURL], secret, name: host, url: location.origin, perms: [\"sign_event:27235\", \"sign_event:9734\"] });\n $(\"#nclink\").href = uri; $(\"#ncnote\").textContent = \"\";\n $(\"#ncqr\").src = \"/qr.svg?text=\" + encodeURIComponent(uri); $(\"#ncqr\").classList.remove(\"hidden\");\n ncPending = L.BunkerSigner.fromURI(sk, uri, { onauth, pool: authPool(L, sk) }, 600000).then((s) => { remote = s; saveSession(sk, s, secret); ncPending = null; return s; }, (e) => { ncPending = null; throw e; });\n return ncPending;\n }\n async function remoteDone() {\n me = await signer.getPublicKey();\n localStorage.setItem(\"me\", me);\n $(\"#remotesec\").classList.add(\"hidden\");\n toast(\"Remote signer connected\");\n if (pendingNote && !owner) { const n = pendingNote; pendingNote = null; await claimNow(n); return; }\n pendingNote = null;\n renderHeader(); await loadAdmin(); await loadPeople();\n }\n function showRemote(note) {\n pendingNote = note || null;\n $(\"#remotesec\").classList.remove(\"hidden\");\n $(\"#remotesec\").scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n offerNostrConnect().then(remoteDone).catch((e) => { $(\"#ncnote\").textContent = e.message; });\n }\n\n async function rpc(method, ...params) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const body = JSON.stringify({ method, params });\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", rpcURL], [\"method\", \"POST\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(rpcURL, { method: \"POST\", headers: { \"content-type\": \"application/nostr+json+rpc\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body });\n const json = await resp.json();\n if (json.error) throw new Error(json.error);\n return json.result;\n }\n\n // ---- front of house ----\n // urlish shows a URL as a short link: the host and the start of the path, the whole thing on hover and as the target.\n class Html { constructor(s) { this.s = s; } }\n const urlish = (u) => {\n if (!u) return \"\";\n let label = u; try { const x = new URL(u); label = x.host + (x.pathname.length > 1 ? x.pathname.slice(0, 18) + (x.pathname.length > 18 ? \"\\u2026\" : \"\") : \"\"); } catch { /* not a URL */ }\n if (!/^https?:\\/\\//.test(u)) return u;\n return new Html('' + esc(label) + \"\");\n };\n function renderHeader() {\n const name = (info && info.name) || host.split(\".\")[0];\n $(\"#title\").textContent = name;\n document.title = name + \" - relay\";\n $(\"#url\").textContent = wsURL;\n $(\"#d-ws\").textContent = wsURL; $(\"#d-blossom\").textContent = location.origin; $(\"#d-nip05\").textContent = \"you@\" + host;\n $(\"#nip05-example\").textContent = \"alice@\" + host;\n const desc = (info && info.description) || \"\";\n $(\"#desc\").textContent = desc; $(\"#desc\").classList.toggle(\"hidden\", !desc);\n // What the owner declared about the relay, on the page, not only in the document clients read.\n const img = (id, url) => { const el = $(id); if (url) el.src = url; el.classList.toggle(\"hidden\", !url); };\n img(\"#bannerimg\", info?.banner); $(\"#banner\").classList.toggle(\"hidden\", !info?.banner);\n img(\"#iconimg\", info?.icon);\n const meta = [];\n for (const t of info?.tags || []) meta.push('' + esc(t) + \"\");\n const where = [...(info?.language_tags || []), ...(info?.relay_countries || [])];\n if (where.length) meta.push(\"\" + esc(where.join(\", \")) + \"\");\n const link = (label, href) => href ? '' + label + \"\" : \"\";\n const contact = info?.contact ? (/^(mailto:|https?:\\/\\/)/.test(info.contact) ? link(\"Contact\", info.contact) : \"\" + esc(info.contact) + \"\") : \"\";\n for (const x of [contact, link(\"Terms\", info?.terms_of_service), link(\"Posting policy\", info?.posting_policy), link(\"Privacy\", info?.privacy_policy)]) if (x) meta.push(x);\n $(\"#metarow\").innerHTML = meta.join('|'); $(\"#metarow\").classList.toggle(\"hidden\", meta.length === 0);\n $(\"#frontsec\").classList.toggle(\"hidden\", !desc && meta.length === 0);\n owner = (info && info.pubkey) || \"\";\n const isOwner = !!me && me === owner;\n const lease = !owner && info && info.lease ? info.lease : null;\n $(\"#ownerline\").innerHTML = owner ? 'run by ' + key(owner) + \"\" : lease ? \"temporary until \" + fmtDay(lease.expires_at) : \"unclaimed\";\n $(\"#owner-av\").classList.toggle(\"hidden\", !owner);\n if (owner) $(\"#owner-av\").style.setProperty(\"--h\", hue(owner));\n $(\"#who\").textContent = me ? (isOwner ? \"(that's you)\" : \"\") : \"\";\n $(\"#unclaimed\").classList.toggle(\"hidden\", !!owner || !!lease);\n $(\"#leased\").classList.toggle(\"hidden\", !lease);\n if (lease) {\n $(\"#lease-until\").textContent = fmtTime(lease.expires_at);\n $(\"#leasenote\").textContent = lease.holder ? \"Reserved for the key that asked for it; sign with that key.\" : \"\";\n }\n $(\"#signin\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signin46\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signout\").classList.toggle(\"hidden\", !me);\n $(\"#about\").innerHTML = [\n [\"Name\", info?.name], [\"Description\", info?.description], [\"Contact\", info?.contact], [\"Owner\", owner ? short(owner) : \"\"], [\"Relay key\", info?.self ? short(info.self) : \"\"], [\"Software\", urlish(info?.software)], [\"Version\", info?.version],\n [\"Max query\", info?.limitation?.max_limit], [\"Auth required\", String(!!info?.limitation?.auth_required)], [\"Restricted writes\", String(!!info?.limitation?.restricted_writes)], [\"Min PoW\", info?.limitation?.min_pow_difficulty || 0],\n [\"Tags\", info?.tags?.join(\", \")], [\"Languages\", info?.language_tags?.join(\", \")], [\"Countries\", info?.relay_countries?.join(\", \")],\n [\"Terms\", urlish(info?.terms_of_service)], [\"Posting policy\", urlish(info?.posting_policy)], [\"Privacy policy\", urlish(info?.privacy_policy)], [\"Icon\", urlish(info?.icon)], [\"Banner\", urlish(info?.banner)],\n [\"NIPs\", info?.supported_nips?.join(\" \")],\n ].map(([k, v]) => \"
                                                                                                                                                                              \" + k + \"\" + (v === undefined || v === \"\" ? '' : v instanceof Html ? v.s : esc(v)) + \"
                                                                                                                                                                              \").join(\"\");\n renderCare();\n }\n\n function renderCare() {\n const g = (icon, label, off) => '' + IC[icon] + \"\" + label + \"\";\n const writes = policy ? policy.writes : (info?.limitation?.restricted_writes ? \"allowlist\" : \"open\");\n const reads = policy ? policy.reads : (info?.limitation?.auth_required ? \"auth\" : \"open\");\n let out = writes === \"owner\" ? g(\"person\", \"only owner writes\") : writes === \"allowlist\" ? g(\"people\", \"members write\") : writes === \"wot\" ? g(\"people\", \"members and follows write\") : g(\"pen\", \"anyone writes\");\n out += reads === \"members\" ? g(\"people\", \"members read\") : reads === \"auth\" ? g(\"lock\", \"sign in to read\") : g(\"eye\", \"anyone reads\");\n if (fuel) {\n const over = fuelOver();\n out += fuel.outOfFuel ? g(\"gauge\", \"out of fuel\", true) : over ? g(\"bolt\", \"burning sats\") : g(\"gauge\", \"on free allowance\");\n }\n $(\"#care\").innerHTML = out;\n }\n\n async function loadPeople() {\n try { people = await (await fetch(\"/people\")).json(); } catch { return; }\n const isOwner = !!me && me === owner;\n let list = people.people || [];\n if (!people.public && isOwner && policy) list = (window.__members || []);\n $(\"#peoplesec\").classList.toggle(\"hidden\", !owner || list.length === 0);\n $(\"#people-note\").classList.toggle(\"hidden\", people.public);\n $(\"#people-count\").textContent = list.length || \"\";\n $(\"#dir\").innerHTML = list.map((m) => '' + av(m.pubkey) + '' + (m.name ? esc(m.name) + \"@\" + host : key(m.pubkey)) + \"\" + (m.role === \"owner\" ? 'owner' : \"\") + \"\").join(\"\");\n }\n\n async function loadInfo() {\n info = await (await fetch(\"/\", { headers: { accept: \"application/nostr+json\" } })).json();\n renderHeader();\n const git = info.supported_grasps?.includes(\"GRASP-01\");\n $(\"#git-connect\").classList.toggle(\"hidden\", !git);\n const clone = \"git clone '\" + location.origin + \"//.git'\";\n $(\"#git-clone\").textContent = clone;\n $(\"#git-copy\").dataset.copytext = clone;\n renderApps();\n }\n\n async function loadFuel() {\n try { fuel = await (await fetch(\"/fuel\")).json(); } catch { return; }\n $(\"#fuelsec\").classList.toggle(\"hidden\", !owner);\n const pct = (used, free) => Math.min(100, Math.round((used / Math.max(free, 1)) * 100));\n const gauge = (id, used, free, text) => { const g = $(\"#g-\" + id), share = free ? used / free : 0; g.style.width = pct(used, free) + \"%\"; g.classList.toggle(\"over\", share > 1); g.classList.toggle(\"warm\", share > 0.75 && share <= 1); $(\"#p-\" + id).textContent = share > 1 ? \"over\" : Math.round(share * 100) + \"%\"; $(\"#t-\" + id).textContent = text; };\n gauge(\"events\", fuel.eventBytes, fuel.freeEventBytes, fmtBytes(fuel.eventBytes) + \" of \" + fmtBytes(fuel.freeEventBytes) + \" free\");\n gauge(\"media\", fuel.mediaBytes, fuel.freeMediaBytes, fmtBytes(fuel.mediaBytes) + \" of \" + fmtBytes(fuel.freeMediaBytes) + \" free\");\n gauge(\"active\", fuel.activeMs, fuel.freeActiveMs, fmtHours(fuel.activeMs) + \" of \" + fmtHours(fuel.freeActiveMs) + \" free\");\n gauge(\"rows\", fuel.rowsWritten, fuel.freeRowsWritten, fuel.rowsWritten.toLocaleString() + \" of \" + fuel.freeRowsWritten.toLocaleString() + \" free\");\n const sats = Math.floor(fuel.balanceMsats / 1000);\n const r = fuel.rates;\n $(\"#fuel-balance\").innerHTML = fuel.outOfFuel\n ? 'out of fuel Writes are paused until someone tops up.'\n : \"Balance \" + sats.toLocaleString() + \" sats. Past the allowances, prices track what the hosting costs: \" + r.satsPerGBMonthEvents.toLocaleString() + \" sats per GB-month of events, \" + r.satsPerGBMonthMedia.toLocaleString() + \" per GB-month of files, \" + r.satsPerActiveHour.toLocaleString() + \" per hour awake, \" + r.satsPerMillionRows.toLocaleString() + \" per million rows written. Traffic is free.\";\n $(\"#topup\").classList.toggle(\"hidden\", !fuel.enabled);\n // Who paid is the owner's to see: it comes with the signed stats call.\n let credits = [];\n if (owner && signer.ready()) { try { credits = (await rpc(\"stats\")).credits || []; } catch { credits = []; } }\n $(\"#credits tbody\").innerHTML = credits.length ? credits.map((c) => '' + esc(fmtTime(c.at)) + '' + av(c.payer) + key(c.payer) + '' + Math.floor(c.msats / 1000).toLocaleString() + \"\").join(\"\") : 'no zaps yet';\n if (!fuel.enabled) $(\"#topup-note\").textContent = \"Top-ups are not enabled on this service yet.\";\n renderCare();\n renderFuelTile();\n }\n\n async function topUp(sats) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const msats = Math.round(sats * 1000);\n const zapRequest = await signer.signEvent({ kind: 9734, created_at: Math.floor(Date.now() / 1000), content: \"fuel for \" + host, tags: [[\"p\", fuel.servicePubkey], [\"amount\", String(msats)], [\"relays\", wsURL]] });\n const r = await (await fetch(\"/fuel/invoice\", { method: \"POST\", body: JSON.stringify({ zapRequest }) })).json();\n if (r.error) throw new Error(r.error);\n const inv = r.invoice;\n $(\"#invoice\").classList.remove(\"hidden\");\n $(\"#inv-text\").value = inv; $(\"#inv-link\").href = \"lightning:\" + inv; $(\"#inv-state\").textContent = \"waiting for payment…\";\n if (window.webln) { try { await window.webln.enable(); await window.webln.sendPayment(inv); } catch (e) { toast(e.message || \"wallet declined\"); } }\n const before = fuel.creditedMsats;\n for (let i = 0; i < 120; i++) {\n await new Promise((r) => setTimeout(r, 2500));\n await loadFuel();\n if (fuel.creditedMsats > before) { $(\"#inv-state\").textContent = \"paid, thank you\"; toast(\"Fuel credited\"); return; }\n }\n $(\"#inv-state\").textContent = \"no receipt yet; it can take a minute after paying\";\n }\n\n // ---- console ----\n function showTab(name) {\n if (name === \"content\" || name === \"storage\") name = \"data\";\n const known = myRole === \"moderator\" ? [\"people\", \"moderation\"] : [\"people\", \"moderation\", \"rules\", \"identity\", \"data\", \"sync\", \"views\", \"health\", \"owner\"];\n if (!known.includes(name)) name = \"people\";\n $$(\".tabs a\").forEach((a) => a.classList.toggle(\"on\", a.dataset.tab === name));\n $$(\".panel\").forEach((p) => p.classList.toggle(\"on\", p.dataset.panel === name));\n }\n // Tabs switch in place. A fragment still opens a tab when someone arrives with one,\n // but clicking never writes one, and any fragment already there is dropped.\n $$(\".tabs a\").forEach((a) => a.addEventListener(\"click\", (ev) => { ev.preventDefault(); showTab(a.dataset.tab); if (location.hash) history.replaceState(null, \"\", location.pathname + location.search); }));\n window.addEventListener(\"hashchange\", () => showTab(location.hash.slice(1)));\n // The strip fades on whichever side has more tabs off screen.\n const tabsFade = () => { const t = $(\"#tabs\"); t.classList.toggle(\"fade-l\", t.scrollLeft > 4); t.classList.toggle(\"fade-r\", t.scrollLeft + t.clientWidth < t.scrollWidth - 4); };\n $(\"#tabs\").addEventListener(\"scroll\", tabsFade); window.addEventListener(\"resize\", tabsFade); new ResizeObserver(tabsFade).observe($(\"#tabs\"));\n\n function renderFuelTile() {\n if (!fuel) return;\n const over = fuelOver();\n const sats = Math.max(0, Math.floor(fuel.balanceMsats / 1000));\n const d = new Date(), end = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1), days = Math.max(1, Math.ceil((end - Date.now()) / 86400000));\n if (fuel.outOfFuel) { $(\"#h-fuel\").textContent = \"0 sats\"; $(\"#h-fuel-label\").textContent = \"out of fuel\"; $(\"#h-fuel-sub\").textContent = \"writes are paused\"; }\n else if (over) { $(\"#h-fuel\").textContent = sats.toLocaleString() + \" sats\"; $(\"#h-fuel-label\").textContent = \"fuel left\"; $(\"#h-fuel-sub\").textContent = \"past the free allowance\"; }\n else { $(\"#h-fuel\").textContent = days + (days === 1 ? \" day\" : \" days\"); $(\"#h-fuel-label\").textContent = \"free allowance left\"; $(\"#h-fuel-sub\").textContent = sats ? \"then \" + sats.toLocaleString() + \" sats\" : \"then a top-up is needed\"; }\n $(\"#usage\").innerHTML = [[\"events stored\", fmtBytes(fuel.eventBytes)], [\"files stored\", fmtBytes(fuel.mediaBytes)], [\"awake this month\", fmtHours(fuel.activeMs)], [\"rows written\", fuel.rowsWritten.toLocaleString()], [\"rows read\", fuel.rowsRead.toLocaleString()], [\"received this month\", fmtBytes(fuel.bytesIn)], [\"served this month\", fmtBytes(fuel.bytesOut)], [\"charged\", Math.floor(fuel.chargedMsats / 1000).toLocaleString() + \" sats\"]]\n .map(([k, v]) => \"
                                                                                                                                                                              \" + k + \"\" + v + \"
                                                                                                                                                                              \").join(\"\");\n }\n\n function renderHealth(stats) {\n $(\"#h-last\").textContent = stats.newest ? ago(stats.newest) : \"none\";\n $(\"#h-last-sub\").textContent = stats.newest ? fmtTime(stats.newest) : \"no events yet\";\n $(\"#h-conns\").textContent = stats.connections;\n const buckets = [[\"notes\", \"k1\", [1]], [\"reactions\", \"k2\", [7]], [\"DMs\", \"k3\", [4, 14, 1059]], [\"long-form\", \"k4\", [30023]], [\"other\", \"k5\", null]];\n const sums = buckets.map(() => 0); let total = 0;\n for (const { kind, n } of stats.kinds || []) { total += n; const i = buckets.findIndex((b) => b[2] && b[2].includes(kind)); sums[i < 0 ? 4 : i] += n; }\n const parts = buckets.map((b, i) => [b[0], b[1], sums[i]]).filter((p) => p[2] > 0);\n $(\"#h-kinds\").innerHTML = total ? parts.map(([name, cls, n]) => '').join(\"\") : '';\n $(\"#h-kinds-legend\").innerHTML = total ? parts.map(([name, cls, n]) => '' + name + \" \" + Math.round(n * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n renderFuelTile();\n }\n\n async function loadAdmin() {\n const isOwner = !!me && me === owner;\n myRole = isOwner ? \"owner\" : \"\";\n let stats = null, p = null;\n // A signed-in member may be a moderator; the relay says by answering stats.\n if (me && owner && !isOwner) { try { [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]); myRole = \"moderator\"; } catch { myRole = \"\"; } }\n $(\"#console\").classList.toggle(\"hidden\", !myRole);\n loadMine();\n $(\"#console\").classList.toggle(\"mod\", myRole === \"moderator\");\n if (!myRole) { policy = null; renderCare(); return; }\n if (isOwner) [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]);\n policy = p;\n renderHealth(stats);\n renderCare();\n const fa = $(\"#access\"), fi = $(\"#identity\");\n fa.writes.value = p.writes; fa.reads.value = p.reads; fa.openKinds.value = (p.openKinds || []).join(\", \"); fa.guestReplies.checked = !!p.guestReplies;\n $(\"#wordsform\").words.value = (p.blockedWords || []).join(\"\\n\"); $(\"#wordsform\").inTags.checked = !!p.blockedWordsInTags; $(\"#thresholdform\").reportThreshold.value = p.reportThreshold || 0; fa.minPow.value = p.minPow; fa.maxFuture.value = p.maxFuture; fa.maxLimit.value = p.maxLimit; fa.maxSubs.value = p.maxSubs; fa.maxMessageKB.value = p.maxMessageKB;\n fa.eventsPerMinute.value = p.eventsPerMinute; fa.reqsPerMinute.value = p.reqsPerMinute; fa.maxBlobMB.value = p.maxBlobMB;\n renderFeatures(p.features || {});\n $(\"#push-policy-form\").elements.origins.value = (p.pushCallbacks || []).join(\"\\n\");\n $(\"#push-policy-form\").elements.lettered.checked = !!p.letteredNips;\n $(\"#push-policy-form\").elements.delivery.checked = !!p.delivery?.enabled;\n $(\"#push-policy-form\").elements.deliveryMax.value = p.delivery?.maxTargets || 8;\n fi.name.value = p.name; fi.contact.value = p.contact; fi.description.value = p.description; fi.icon.value = p.icon;\n const fj = $(\"#joinform\"); fj.joinTerms.value = p.joinTerms; fj.directoryPublic.checked = !!p.directoryPublic;\n loadCard();\n fi.banner.value = p.banner || \"\"; fi.postingPolicy.value = p.postingPolicy || \"\"; fi.privacyPolicy.value = p.privacyPolicy || \"\";\n fi.tags.value = (p.tags || []).join(\", \"); fi.languageTags.value = (p.languageTags || []).join(\", \"); fi.relayCountries.value = (p.relayCountries || []).join(\", \");\n const fn = $(\"#notify\"), nt = p.notify || {};\n fn.reports.checked = !!nt.reports; fn.fuel.checked = !!nt.fuel; fn.jobs.checked = !!nt.jobs; fn.succession.checked = !!nt.succession; fn.digest.checked = !!nt.digest;\n showTab(location.hash.slice(1));\n if (isOwner) { await renderPresets(); renderWire(); renderSuccession(); loadDomains(); }\n await Promise.all([loadLists(), loadEvents(true), loadPins(), ...(isOwner ? [loadStorage()] : [])]);\n }\n\n async function loadAudit(before) {\n const rows = await rpc(\"listaudit\", before || 0);\n const html = rows.map((r) => '' + esc(fmtTime(r.at)) + '' + av(r.actor) + key(r.actor) + '' + esc(r.action) + '' + (/^[0-9a-f]{64}$/.test(r.target) ? key(r.target) : esc(r.target)) + '' + esc(r.detail) + '').join(\"\");\n const tb = $(\"#audit tbody\");\n if (before) tb.insertAdjacentHTML(\"beforeend\", html); else tb.innerHTML = html || 'Nothing yet';\n $(\"#audit-more\").style.display = rows.length < 100 ? \"none\" : \"\";\n }\n $(\"#audit-more\").onclick = guard(async () => { const last = $(\"#audit tbody tr:last-child\"); await loadAudit(last ? +last.dataset.seq : 0); });\n async function loadLists() {\n loadAudit(0);\n const [mem, bans, bannedEvents, allow, block, invites, reports, blobs, blocks, sites] = await Promise.all([rpc(\"listmembers\"), rpc(\"listbannedpubkeys\"), rpc(\"listbannedevents\"), rpc(\"listallowedkinds\"), rpc(\"listblockedkinds\"), rpc(\"listinvites\"), rpc(\"listreports\"), rpc(\"listblobs\", 100), rpc(\"listblockedips\"), rpc(\"listsites\")]);\n const members = mem.members;\n window.__members = members;\n const roleCell = (m) => m.role === \"owner\" ? ' owner' : myRole === \"owner\" ? ' ' : m.role === \"moderator\" ? ' moderator' : \"\";\n const untouchable = (m) => m.role === \"owner\" || (myRole !== \"owner\" && m.role === \"moderator\");\n // The tree: everyone under whoever invited them, the owner and the owner's own additions at the root.\n const known = new Set(members.map((m) => m.pubkey));\n const byInviter = new Map();\n for (const m of members) { const k = m.role !== \"owner\" && known.has(m.invited_by) ? m.invited_by : \"\"; if (!byInviter.has(k)) byInviter.set(k, []); byInviter.get(k).push(m); }\n const ordered = [], placed = new Set();\n const walk = (k, depth) => { for (const m of byInviter.get(k) || []) { if (placed.has(m.pubkey)) continue; placed.add(m.pubkey); m.depth = depth; ordered.push(m); walk(m.pubkey, depth + 1); } };\n walk(\"\", 0);\n for (const m of members) if (!placed.has(m.pubkey)) { m.depth = 0; ordered.push(m); }\n const nameOf = (pk) => { const x = members.find((m) => m.pubkey === pk); return x && x.name ? x.name : short(pk); };\n const limits = (m) => m.role === \"owner\" ? \"\" : myRole === \"owner\"\n ? ''\n : '' + (m.keep_days ? m.keep_days + \" d\" : \"\") + (m.max_bytes ? \" \" + fmtBytes(m.max_bytes) : \"\") + \"\";\n $(\"#members tbody\").innerHTML = ordered.map((m) => '' + av(m.pubkey) + key(m.pubkey) + roleCell(m) + (m.depth ? ' via ' + esc(nameOf(m.invited_by)) + \"\" : \"\") + (m.invites ? ' ' + m.invites + \" inv\" : \"\") + '' + limits(m) + '' + esc(fmtDay(m.joined_at)) + \", \" + esc(m.via) + '' + ib(\"check\", \"Save\", \"savemember\", m.pubkey) + (untouchable(m) ? \"\" : ib(\"x\", \"Remove\", \"removemember\", m.pubkey) + ib(\"banuser\", \"Ban\", \"banpubkey\", m.pubkey, \"danger\")) + \"\").join(\"\");\n const tf = $(\"#treeform\");\n tf.classList.toggle(\"hidden\", myRole !== \"owner\");\n if (policy && policy.memberInvites) { tf.depth.value = policy.memberInvites.depth; tf.quota.value = policy.memberInvites.quota; }\n $(\"#transfer [name=pubkey]\").innerHTML = members.filter((m) => m.role !== \"owner\").map((m) => '\").join(\"\");\n $(\"#succession [name=heir]\").innerHTML = $(\"#transfer [name=pubkey]\").innerHTML;\n $(\"#tc-people\").textContent = members.length;\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#invites\").innerHTML = invites.length ? invites.map((i) => '
                                                                                                                                                                            • inv…' + i.code.slice(-8) + \" \" + esc(i.note || \"\") + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
                                                                                                                                                                            • \").join(\"\") : '
                                                                                                                                                                            • no invites
                                                                                                                                                                            • ';\n const person = (r, icon, label, act) => \"
                                                                                                                                                                            • \" + av(r.pubkey) + '' + short(r.pubkey) + \" \" + esc(r.reason || \"\") + \"\" + ib(icon, label, act, r.pubkey) + \"
                                                                                                                                                                            • \";\n $(\"#bans\").innerHTML = bans.length ? bans.map((r) => person(r, \"undo\", \"Unban\", \"unrulepubkey\")).join(\"\") : '
                                                                                                                                                                            • nobody banned
                                                                                                                                                                            • ';\n $(\"#blocks\").innerHTML = blocks.length ? blocks.map((r) => '
                                                                                                                                                                            • ip' + esc(r.ip) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"undo\", \"Unblock\", \"unblockip\", r.ip) + \"
                                                                                                                                                                            • \").join(\"\") : '
                                                                                                                                                                            • no addresses blocked
                                                                                                                                                                            • ';\n $(\"#banned-events\").innerHTML = bannedEvents.map((r) => '
                                                                                                                                                                            • ev' + key(r.id) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"check\", \"Allow again\", \"allowevent\", r.id) + \"
                                                                                                                                                                            • \").join(\"\");\n $(\"#reports tbody\").innerHTML = reports.length ? reports.map((r) => '' + esc(fmtTime(r.at)) + '' + esc(r.type || \"report\") + '' + av(r.target_pubkey) + key(r.target_pubkey) + (r.target_event ? (r.blob ? ' file ' + key(r.target_event) + \"\" : ' ev ' + key(r.target_event) + (r.hidden ? ' hidden' : \"\") + \"\") : \"\") + '' + esc(r.content) + '' + ib(\"check\", \"Dismiss\", \"resolve:dismiss\", r.id) + ib(\"trash\", r.blob ? \"Delete the file\" : \"Delete the event\", \"resolve:delete\", r.id) + ib(\"banuser\", \"Ban the author\", \"resolve:ban\", r.id, \"danger\") + \"\").join(\"\") : 'nothing reported';\n $(\"#reports-count\").textContent = reports.length || \"\"; $(\"#tc-reports\").textContent = reports.length || \"\";\n $(\"#blobs tbody\").innerHTML = blobs.length ? blobs.map((b) => '' + esc(fmtTime(b.uploaded)) + '' + key(b.sha256) + ' ' + esc(b.type) + '' + fmtBytes(b.size) + '' + av(b.uploader) + key(b.uploader) + '' + ib(\"trash\", \"Delete file\", \"deleteblob\", b.sha256, \"danger\") + \"\").join(\"\") : 'no uploads';\n $(\"#blobs-count\").textContent = blobs.length || \"\";\n $(\"#sites tbody\").innerHTML = sites.length ? sites.map((s) => '' + av(s.author) + key(s.author) + '' + esc(s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + '' + esc(s.url) + '' + esc(s.paths) + (s.missing ? ' ' + esc(s.missing) + ' missing' : '') + '' + fmtBytes(s.size) + '' + esc(s.expires_at ? fmtTime(s.expires_at) : \"never\") + '' + ib(\"trash\", \"Delete site\", \"deleteevent\", s.id, \"danger\") + '').join(\"\") : 'no sites yet';\n $(\"#sites-count\").textContent = sites.length || \"\";\n const tag = (k, cls) => '' + k + ib(\"x\", \"Remove rule\", \"unrulekind\", String(k)) + \"\";\n $(\"#kinds-allow\").innerHTML = allow.length ? allow.map((k) => tag(k, \"ok\")).join(\"\") : 'all';\n $(\"#kinds-block\").innerHTML = block.length ? block.map((k) => tag(k, \"blk\")).join(\"\") : 'none';\n await loadPeople();\n }\n\n const KIND_NAMES = { 0: \"profiles\", 1: \"notes\", 3: \"contacts\", 4: \"DMs\", 5: \"deletions\", 6: \"reposts\", 7: \"reactions\", 16: \"reposts\", 1059: \"gift wraps\", 1063: \"file headers\", 1111: \"comments\", 1984: \"reports\", 9734: \"zap requests\", 9735: \"zap receipts\", 9802: \"highlights\", 10002: \"relay lists\", 13534: \"roster\", 9000: \"group adds\", 9001: \"group removals\", 9021: \"join requests\", 9022: \"leave requests\", 39000: \"group info\", 39001: \"group admins\", 39002: \"group members\", 39003: \"group roles\", 30023: \"articles\", 30024: \"drafts\", 30078: \"app data\", 30311: \"live events\", 30818: \"wiki\" };\n const kindName = (k) => KIND_NAMES[k] || (k >= 20000 && k < 30000 ? \"ephemeral\" : k >= 30000 && k < 40000 ? \"addressable\" : k >= 10000 && k < 20000 ? \"replaceable\" : \"kind \" + k);\n const SYS_KINDS = new Set([0, 3, 10002, 9735, 13534, 8000, 8001, 9000, 9001, 39000, 39001, 39002, 39003]);\n let storage = null;\n\n // Features (settings.ts): a select each; search has three modes, the rest on or off.\n const FEATURES = [\n [\"search\", \"Search\", \"NIP-50. Prose indexes notes, threads, comments, highlights, articles and wiki pages; full indexes every public kind with content. A change applies to events from then on.\", [\"prose:prose\", \"full:full\", \"off:off\"]],\n [\"sync\", \"Sync\", \"NIP-77 reconciliation, which reads the whole matching set per sync.\"],\n [\"count\", \"Counts\", \"NIP-45 COUNT, with HLL sketches.\"],\n [\"discovery\", \"Discovery record\", \"NIP-66: the record the relay signs about itself, for crawlers.\"],\n [\"names\", \"Names\", \"NIP-05 addresses under this relay's domain.\"],\n [\"files\", \"Files\", \"Blossom and NIP-96: uploads, downloads and listings.\"],\n [\"pages\", \"Pages and feed\", \"Notes and articles as pages, and the Atom feed.\"],\n [\"sites\", \"Static websites\", \"NIP-5A sites on their own hostnames. Mirroring copies missing files into this relay and costs fuel.\", [\"mirror:on, mirror files\", \"proxy:on, fetch as needed\", \"off:off\"]],\n [\"marmot\", \"Marmot transport\", \"Signed KeyPackages and encrypted group messages, with account admission for ephemeral authors.\"],\n [\"grasp\", \"Git repositories\", \"GRASP Git hosting with admitted repository state. The prototype backend has bounded storage and compute limits.\"],\n [\"push\", \"Relay push\", \"NIP-9a callback delivery for members and the owner. Requires approved callback origins; advertises lettered NIP identifiers.\"],\n [\"signer\", \"Signer traffic\", \"NIP-46 remote signing carried for anyone, never stored.\"],\n ];\n function renderFeatures(f) {\n $(\"#features\").innerHTML = FEATURES.map(([k, title, about, modes]) => {\n const cur = k === \"sites\" ? (f.sites?.enabled === false ? \"off\" : f.sites?.mirror === false ? \"proxy\" : \"mirror\") : modes ? String(f[k] || \"prose\") : String(f[k] !== false);\n const opts = (modes || [\"true:on\", \"false:off\"]).map((m) => { const [v, l] = m.split(\":\"); return '\"; }).join(\"\");\n return \"\";\n }).join(\"\");\n }\n $(\"#features\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-feature]\"); if (!sel) return;\n const k = sel.dataset.feature, v = k === \"search\" || k === \"sites\" ? sel.value : sel.value === \"true\";\n policy = await rpc(\"setpolicy\", { features: { [k]: k === \"sites\" ? { enabled: v !== \"off\", mirror: v === \"mirror\" } : v } });\n toast(k === \"search\" ? \"Search: \" + v : (v ? \"Switched on \" : \"Switched off \") + k); await loadInfo();\n }));\n\n $(\"#push-policy-form\").addEventListener(\"submit\", guard(async (ev) => {\n ev.preventDefault();\n const form = ev.target;\n const origins = form.elements.origins.value.split(/\\s+/).filter(Boolean);\n const updated = await rpc(\"setpolicy\", { pushCallbacks: origins, letteredNips: form.elements.lettered.checked, delivery: { enabled: form.elements.delivery.checked, maxTargets: Math.max(1, Math.min(16, Math.floor(+form.elements.deliveryMax.value || 8))) } });\n if (JSON.stringify(updated.pushCallbacks) !== JSON.stringify([...new Set(origins.map((s) => s.replace(/\\/$/, \"\")))])) throw new Error(\"Use up to sixteen exact HTTPS origins, with no path or credentials.\");\n policy = updated;\n toast(\"Saved delivery policy\"); await loadInfo();\n }));\n\n async function loadViews() {\n let views;\n try { views = await rpc(\"listviews\"); } catch { return; }\n $(\"#views\").innerHTML = views.map((v) => {\n const runs = v.trigger === \"off\" ? \"off\" : v.trigger === \"live\" ? \"live, from memory\" : v.trigger === \"write\" ? \"on write and daily\" : v.trigger;\n const label = (c) => (c === \"off\" ? \"off\" : c === \"write\" ? \"on write\" : c === \"live\" ? \"on\" : c);\n const choices = [...v.choices, v.default].filter((c, i, a) => a.indexOf(c) === i);\n const pick = '\";\n const who = v.audience === \"members\" ? \"members\" + (v.stored ? \"\" : \", on request\") : \"anyone\";\n const last = v.trigger === \"live\" ? \"\" : v.last ? \"last run \" + fmtTime(v.last.at) : \"not run yet\";\n const rows = v.trigger === \"live\" ? \"no rows\" : v.last ? v.last.rows.toLocaleString() + \" rows\" : \"\";\n const meta = [runs, who, last, rows].filter(Boolean).map((x) => \"\" + esc(x) + \"\").join('|');\n return '
                                                                                                                                                                              ' + esc(v.name) + '
                                                                                                                                                                              ' + esc(v.about) + '
                                                                                                                                                                              ' + pick + (v.on ? 'Open' : \"\") + '
                                                                                                                                                                              ' + meta + \"
                                                                                                                                                                              \";\n }).join(\"\");\n }\n $(\"#views\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-view]\"); if (!sel) return;\n const value = sel.value === \"live\" ? true : sel.value;\n await rpc(\"setpolicy\", { views: { [sel.dataset.view]: value } });\n toast(sel.value === \"off\" ? \"Took down \" + sel.dataset.view : sel.dataset.view + \": \" + (sel.value === \"live\" ? \"on\" : sel.value === \"write\" ? \"on write\" : sel.value)); await loadViews(); await loadInfo();\n }));\n async function loadDumps() {\n const list = await rpc(\"listdumps\");\n const f = $(\"#dumpform\");\n if (policy) { f.dumps.value = policy.dumps || \"off\"; f.keep.value = policy.dumpsKeep || 7; }\n $(\"#dumps-count\").textContent = list.length || \"\";\n $(\"#dumps\").innerHTML = list.length ? list.map((d) => '
                                                                                                                                                                            • jsonl' + esc(d.name) + ' ' + d.events.toLocaleString() + \" events, \" + fmtBytes(d.bytes) + \"\" + ib(\"copy\", \"Download\", \"downloaddump\", d.name) + ib(\"trash\", \"Delete\", \"deletedump\", d.name, \"danger\") + \"
                                                                                                                                                                            • \").join(\"\") : '
                                                                                                                                                                            • no dumps yet
                                                                                                                                                                            • ';\n }\n // A dump is fetched with a signed request and handed to the browser as a file.\n async function downloadDump(name) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const url = location.origin + \"/dumps/\" + name;\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"GET\"]] });\n const resp = await fetch(url, { headers: { authorization: \"Nostr \" + btoa(JSON.stringify(ev)) } });\n if (!resp.ok) throw new Error((await resp.json()).error || \"download failed\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(\".\")[0] + \"-\" + name; a.click(); URL.revokeObjectURL(a.href);\n }\n // A plain member's own invites, when the owner lets members invite.\n async function loadMine() {\n const sec = $(\"#myinvites\");\n if (!me || !owner || myRole || !signer.ready()) { sec.classList.add(\"hidden\"); return; }\n let mine;\n try { mine = await rpc(\"listinvites\"); } catch { sec.classList.add(\"hidden\"); return; }\n sec.classList.remove(\"hidden\");\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#mine\").innerHTML = mine.length ? mine.map((i) => '
                                                                                                                                                                            • inv…' + i.code.slice(-8) + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
                                                                                                                                                                            • \").join(\"\") : '
                                                                                                                                                                            • no invites yet
                                                                                                                                                                            • ';\n }\n async function loadStorage() {\n storage = await rpc(\"storagestats\");\n pollJobs();\n loadViews();\n loadDumps().catch(() => {});\n const st = storage;\n const top = st.kinds.slice(0, 4), rest = st.kinds.slice(4).reduce((a, k) => a + k.bytes, 0);\n const parts = [...top.map((k, i) => [k.kind + \" \" + kindName(k.kind), \"k\" + (i + 1), k.bytes]), ...(rest ? [[\"other\", \"k5\", rest]] : [])];\n const total = st.eventBytes || 1;\n $(\"#s-bar\").innerHTML = parts.length ? parts.map(([n, c, b]) => '').join(\"\") : '';\n $(\"#s-legend\").innerHTML = parts.length ? parts.map(([n, c, b]) => '' + esc(n) + \" \" + Math.round(b * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n const overhead = Math.max(0, st.databaseBytes - st.eventBytes);\n $(\"#s-totals\").innerHTML = [[\"events\", st.events.toLocaleString() + \" \" + fmtBytes(st.eventBytes) + \"\"], [\"index and overhead\", fmtBytes(overhead)], [\"files\", st.blobs.toLocaleString() + \" \" + fmtBytes(st.mediaBytes) + \"\"]]\n .map(([k, v]) => \"
                                                                                                                                                                              \" + k + \"\" + v + \"
                                                                                                                                                                              \").join(\"\");\n const any = st.retention.find((r) => r.kind === null);\n const own = (kind) => { const r = st.retention.find((x) => x.kind === kind); return r ? r.days : \"\"; };\n const row = (k) => {\n const key = k.kind === null ? \"\" : String(k.kind);\n const placeholder = k.kind === null ? \"forever\" : k.replaceable || !any ? \"forever\" : any.days + \" (everything else)\";\n const pill = k.kind === null ? \"everything else\" : '' + k.kind + \" \" + esc(kindName(k.kind));\n const cells = k.protected\n ? 'required'\n : ' days' + ib(\"check\", \"Save keep-for rule\", \"saveretention\", key) + ib(\"trash\", \"Purge\", \"purgekind\", key, \"danger\") + \"\";\n return '' + pill + '' + (k.n === undefined ? \"\" : k.n.toLocaleString()) + '' + (k.bytes === undefined ? \"\" : fmtBytes(k.bytes)) + '' + (k.oldest ? esc(fmtDay(k.oldest)) : \"\") + \"\" + cells + \"\";\n };\n $(\"#kinds tbody\").innerHTML = [...st.kinds, { kind: null, days: any ? any.days : 0 }].map(row).join(\"\");\n loadListHistory().catch(() => {});\n }\n\n async function loadListHistory() {\n const rows = await rpc(\"listlisthistory\");\n const labels = { 3: \"follows\", 10002: \"relay list\", 10003: \"bookmarks\", 30003: \"bookmark list\" };\n $(\"#listhistory tbody\").innerHTML = rows.length ? rows.map((r) => '' + esc((labels[r.kind] || (\"kind \" + r.kind)) + (r.d ? \" / \" + r.d : \"\")) + '' + esc(fmtTime(r.created_at)) + '' + esc(fmtTime(r.saved_at)) + '' + ib(\"undo\", \"Restore this version\", \"restorelist\", r.event_id) + '').join(\"\") : 'no older list versions yet';\n }\n\n let searchQuery = \"\";\n async function loadEvents(reset) {\n const list = searchQuery ? await rpc(\"searchevents\", searchQuery, 200) : await rpc(\"listrecentevents\", reset ? 50 : 200);\n $(\"#events tbody\").innerHTML = list.length ? list.map((e) => '' + esc(fmtTime(e.created_at)) + '' + e.kind + '' + av(e.pubkey) + key(e.pubkey) + '' + esc(e.content) + '' + ib(\"pin\", \"Pin\", \"pinevent\", e.id) + ib(\"trash\", \"Delete event\", \"deleteevent\", e.id) + ib(\"ban\", \"Ban event\", \"banevent\", e.id, \"danger\") + ib(\"banuser\", \"Ban author\", \"banpubkey\", e.pubkey, \"danger\") + \"\").join(\"\") : 'no events yet';\n $(\"#more\").classList.toggle(\"hidden\", searchQuery || list.length < 50);\n }\n $(\"#searchform\").onsubmit = guard(async (ev) => {\n searchQuery = ev.target.q.value.trim();\n $(\"#searchclear\").classList.toggle(\"hidden\", !searchQuery);\n await loadEvents(true);\n });\n $(\"#searchclear\").onclick = guard(async () => {\n searchQuery = \"\"; $(\"#searchform\").reset(); $(\"#searchclear\").classList.add(\"hidden\");\n await loadEvents(true);\n });\n async function loadPins() {\n const pins = await rpc(\"listpins\");\n $(\"#pins-count\").textContent = pins.length || \"\";\n $(\"#pins\").innerHTML = pins.length ? pins.map((t) => '
                                                                                                                                                                            • ' + (t[0] === \"e\" ? \"event\" : \"address\") + '' + esc(t[1].length > 40 ? t[1].slice(0, 16) + \"\\u2026\" + t[1].slice(-8) : t[1]) + \"\" + ib(\"copy\", \"Copy\", \"copy\", t[1]) + ib(\"x\", \"Unpin\", \"unpinevent\", t[1]) + \"
                                                                                                                                                                            • \").join(\"\") : '
                                                                                                                                                                            • nothing pinned
                                                                                                                                                                            • ';\n }\n $(\"#pinform\").onsubmit = guard(async (ev) => {\n await rpc(\"pinevent\", ev.target.id.value.trim()); ev.target.reset(); toast(\"Pinned\"); await loadPins();\n });\n\n // ---- actions ----\n const refresh = () => Promise.all([loadLists(), loadEvents(true), loadStorage(), loadPins()]);\n\n $(\"#copy\").onclick = async () => { await navigator.clipboard.writeText(wsURL); toast(\"copied \" + wsURL); };\n $(\"#signin\").onclick = guard(async () => {\n if (!window.nostr) throw new Error(\"No nostr extension found. Install one (Alby, nos2x, …) and reload, or use a remote signer.\");\n me = await window.nostr.getPublicKey();\n localStorage.setItem(\"me\", me);\n renderHeader(); await loadAdmin(); await loadPeople();\n });\n $(\"#signin46\").onclick = () => showRemote(null);\n $$(\".remote\").forEach((b) => { b.onclick = () => showRemote($(\"#\" + b.dataset.note)); });\n $(\"#nccopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#nclink\").href); toast(\"Link copied; paste it into your signer app\"); };\n $(\"#bunkerform\").onsubmit = guard(async (ev) => {\n $(\"#remotenote\").textContent = \"Connecting…\";\n try { await connectBunker(ev.target.url.value); } catch (e) { $(\"#remotenote\").textContent = e.message; throw e; }\n $(\"#remotenote\").textContent = \"\"; ev.target.reset();\n await remoteDone();\n });\n $(\"#signout\").onclick = async () => {\n if (remote) { const r = remote; remote = null; try { await withTimeout(r.logout(), 5000, \"The signer\"); } catch { /* the session is gone either way */ } }\n localStorage.removeItem(\"nip46\");\n me = null; localStorage.removeItem(\"me\"); renderHeader(); await loadAdmin(); await loadPeople();\n };\n const claimNow = async (note) => {\n if (!signer.ready()) { note.textContent = \"Sign the claim with a nostr extension or a remote signer.\"; showRemote(note); return; }\n me = await signer.getPublicKey();\n const r = await rpc(\"claim\");\n if (!r.claimed) throw new Error(\"Somebody else claimed it first.\");\n localStorage.setItem(\"me\", me);\n toast(\"It's yours.\");\n await loadInfo(); await loadFuel(); await loadAdmin(); await loadPeople();\n if (r.converted && confirm(\"This relay began as a temporary one: anyone can write, and everything is deleted after 14 days. Switch to the default rules and keep everything from now on? Each rule can be changed later on the Rules and Storage tabs.\")) {\n await rpc(\"resetrules\"); toast(\"Rules reset\"); await loadInfo(); await loadAdmin();\n }\n };\n $(\"#claim\").onclick = guard(() => claimNow($(\"#claimnote\")));\n $(\"#claimlease\").onclick = guard(() => claimNow($(\"#leasenote\")));\n // Jobs run in the background; the table follows them while the tab is open.\n let jobsTimer = 0;\n const fmtJob = (j) => {\n const what = j.kind === \"mirror\" ? \"mirror site\" : j.kind === \"import\" ? \"import\" : j.label === \"backfill\" ? \"fetch my history\" : j.kind === \"pull\" ? \"pull\" : \"rebroadcast\";\n const f = [];\n if (j.filter.authors) f.push(j.filter.authors.length === 1 && j.filter.authors[0] === me ? \"my events\" : j.filter.authors.length + \" authors\");\n if (j.filter.kinds) f.push(\"kinds \" + j.filter.kinds.join(\", \"));\n if (j.filter.since) f.push(\"since \" + fmtDay(j.filter.since));\n const when = j.every ? \"every \" + (j.every === 24 ? \"day\" : j.every + \" h\") + (j.nextRun ? \", next \" + fmtTime(j.nextRun) : \"\") : \"once\";\n const l = j.last;\n const count = (stored, blobs, sent, refused) => (j.kind === \"mirror\" ? blobs.toLocaleString() + \" files mirrored\" : j.kind === \"import\" ? stored.toLocaleString() + \" events\" + ((j.last ? j.last.duplicates : j.duplicates) ? \", \" + (j.last ? j.last.duplicates : j.duplicates) + \" already here\" : \"\") : j.kind === \"pull\" ? stored.toLocaleString() + \" events\" + (blobs ? \", \" + blobs + \" files\" : \"\") : sent.toLocaleString() + \" sent\" + (refused ? \", \" + refused + \" refused\" : \"\"));\n const res = j.running ? \"running: \" + count(j.stored, j.blobs, j.sent, j.refused) + \"...\" : !l ? \"waiting\" : l.error ? \"failed: \" + l.error : count(l.stored, l.blobs, l.sent, l.refused) + (l.skipped ? \", \" + l.skipped + \" skipped\" : \"\") + \", \" + fmtTime(l.finishedAt);\n const sources = j.running ? j.pullSources : l?.sources;\n const details = sources?.length ? '
                                                                                                                                                                              Source results' + sources.map((s) => '

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

                                                                                                                                                                              ').join('') + '
                                                                                                                                                                              ' : '';\n const targets = j.kind === \"push\" && j.targetStatus ? \"
                                                                                                                                                                              \" + Object.entries(j.targetStatus).map(([u, s]) => esc(u) + \": \" + esc(s.status)).join(\"
                                                                                                                                                                              \") + \"
                                                                                                                                                                              \" : \"\";\n return \"\" + what + \"\" + j.relays.map(esc).join(\"
                                                                                                                                                                              \") + targets + \"\" + (f.join(\", \") || \"everything\") + \"\" + when + \"\" + esc(res) + details + \"\" + (j.running ? \"\" : ib(\"undo\", \"Run now\", \"runjob\", j.id)) + ib(\"x\", \"Remove\", \"removejob\", j.id) + \"\";\n };\n async function pollJobs() {\n clearTimeout(jobsTimer);\n let jobs;\n try { jobs = await rpc(\"listjobs\"); } catch { return; }\n $(\"#jobs tbody\").innerHTML = jobs.length ? jobs.map(fmtJob).join(\"\") : 'no jobs yet';\n if (isOwner) {\n try {\n const deliveries = await rpc(\"deliverystatus\");\n $(\"#deliveries tbody\").innerHTML = deliveries.length ? deliveries.map((d) => '' + esc(d.event_id.slice(0, 12)) + '' + esc(d.target) + '' + esc(d.status) + '' + d.attempts + '' + esc(d.error || \"\") + '').join(\"\") : 'no automatic deliveries yet';\n } catch { /* unavailable to non-owners */ }\n }\n if (jobs.some((j) => j.running || (j.nextRun && j.nextRun <= Math.floor(Date.now() / 1000) + 1))) jobsTimer = setTimeout(pollJobs, 3000);\n }\n const urls = (s) => s.split(/[\\s,]+/).map((u) => u.trim()).filter(Boolean);\n const kindsOf = (s) => s.split(/[\\s,]+/).map((k) => parseInt(k, 10)).filter((k) => Number.isInteger(k) && k >= 0);\n $(\"#pullform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"addjob\", { kind: \"pull\", relays: [f.url.value.trim()], every: +f.every.value });\n toast(+f.every.value ? \"Mirror scheduled\" : \"Pull started\"); f.reset(); await pollJobs();\n });\n $(\"#backfillform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"backfill\", urls(f.relays.value));\n toast(\"Fetching your history\"); f.reset(); await pollJobs();\n });\n $(\"#pushform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const filter = {};\n const kinds = kindsOf(f.kinds.value); if (kinds.length) filter.kinds = kinds;\n const days = +f.days.value; if (days > 0) filter.since = Math.floor(Date.now() / 1000) - days * 86400;\n await rpc(\"addjob\", { kind: \"push\", relays: urls(f.targets.value), filter, every: +f.every.value });\n toast(\"Rebroadcast started\"); f.reset(); await pollJobs();\n });\n // ---- presets: writes, reads, kinds and keep-for rules in one click ----\n let presets = null;\n async function renderPresets() {\n if (!presets) { try { presets = await rpc(\"listpresets\"); } catch { presets = []; } }\n $(\"#presets\").innerHTML = presets.map((p) => '\").join(\"\");\n $(\"#presetsourcerow\").classList.toggle(\"hidden\", !presets.some((p) => p.source));\n }\n $(\"#presets\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-preset]\"); if (!b) return;\n const p = (presets || []).find((x) => x.name === b.dataset.preset); if (!p) return;\n if (!confirm(p.title + \": \" + p.about + \"\\n\\nThis replaces the writes and reads rules, the directory setting, the kind rules and the keep-for rules. Limits, identity, people and bans stay.\")) return;\n const source = $(\"#presetsource\").value.trim();\n if (p.source === \"required\" && !source) { $(\"#presetnote\").textContent = p.title + \" needs a source relay to mirror; enter its wss:// URL first.\"; return; }\n b.disabled = true;\n try { policy = await rpc(\"applypreset\", p.name, p.source && source ? { source } : undefined); $(\"#presetnote\").textContent = \"Now: \" + p.about + (policy.job ? \" Mirroring \" + source + \" every \" + policy.job.every + \" h.\" : \"\"); toast(p.title + \" applied\"); await loadInfo(); await loadAdmin(); }\n catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // ---- wire me in: this relay in the owner's own lists ----\n // The lists are replaceable, so a fresh list with only this relay would\n // clobber the real one once it spread. Every publish starts from the\n // newest copy found here or on the indexers, verified when the signer\n // library is around, and adds or removes this relay in it.\n const INDEXERS = [\"wss://purplepag.es\", \"wss://relay.nostr.band\", \"wss://relay.damus.io\", \"wss://nos.lol\"];\n const LISTS = [\n { kind: 10002, tag: \"r\", title: \"Relay list\", nip: \"NIP-65\", about: \"where clients read your notes and send you mentions\" },\n { kind: 10050, tag: \"relay\", title: \"DM inbox\", nip: \"NIP-17\", about: \"where people send you private messages\" },\n { kind: 10007, tag: \"relay\", title: \"Search relays\", nip: \"NIP-51\", about: \"where clients run your searches\" },\n { kind: 10063, tag: \"server\", title: \"Blossom servers\", nip: \"BUD-03\", about: \"where clients upload and look for your files\" },\n ];\n const mineFor = (l) => (l.tag === \"server\" ? location.origin : wsURL);\n const normURL = (u) => { try { const x = new URL(String(u).trim()); return (x.host + x.pathname).replace(/\\/+$/, \"\").toLowerCase(); } catch { return String(u).trim().toLowerCase(); } };\n const isMine = (l, t) => t[0] === l.tag && normURL(t[1] || \"\") === normURL(mineFor(l));\n const listHas = (list, l) => !!list && list.tags.some((t) => isMine(l, t));\n // mergeList is pure: the newest list with this relay put first or taken out, every other tag kept.\n function mergeList(list, l, include) {\n const tags = (list ? list.tags : []).filter((t) => !isMine(l, t));\n if (include) tags.unshift([l.tag, mineFor(l)]);\n return { kind: l.kind, created_at: Math.floor(Date.now() / 1000), content: list ? list.content : \"\", tags };\n }\n const relaysIn = (list) => list.tags.filter((t) => (t[0] === \"r\" || t[0] === \"relay\") && /^wss?:\\/\\//i.test(t[1] || \"\")).map((t) => t[1].trim());\n // overWS opens one socket, sends one message, feeds answers to onMessage until it says done or time runs out.\n function overWS(url, ms, first, onMessage) {\n return new Promise((res) => {\n let ws = null, done = false, out = null;\n const finish = () => { if (done) return; done = true; clearTimeout(timer); try { if (ws) ws.close(); } catch { /* closed */ } res(out); };\n const timer = setTimeout(finish, ms);\n try { ws = new WebSocket(url); } catch { return finish(); }\n ws.onopen = () => ws.send(JSON.stringify(first));\n ws.onmessage = (m) => { let d; try { d = JSON.parse(m.data); } catch { return; } if (!Array.isArray(d)) return; if (onMessage(d, (v) => { out = v; })) finish(); };\n ws.onerror = finish; ws.onclose = finish;\n });\n }\n function fetchNewest(url, filter, ms) {\n let best = null;\n return overWS(url, ms, [\"REQ\", \"w\", filter], (d, set) => {\n if (d[0] === \"EVENT\" && d[1] === \"w\" && d[2] && (!best || d[2].created_at > best.created_at)) { best = d[2]; set(best); }\n return (d[0] === \"EOSE\" || d[0] === \"CLOSED\") && d[1] === \"w\";\n });\n }\n function publishTo(url, event, ms) {\n return overWS(url, ms, [\"EVENT\", event], (d, set) => { if (d[0] === \"OK\" && d[1] === event.id) { set({ url, ok: !!d[2], msg: d[3] || \"\" }); return true; } return false; })\n .then((r) => r || { url, ok: false, msg: \"no answer\" });\n }\n // bridge signs a NIP-98 request to this relay's HTTP door, so the owner's own reads and writes pass any read rule.\n async function bridge(path, body) {\n const url = location.origin + path, raw = JSON.stringify(body);\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"POST\"], [\"payload\", await sha256hex(raw)]] });\n const resp = await fetch(url, { method: \"POST\", headers: { \"content-type\": \"application/json\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body: raw });\n const json = await resp.json();\n if (!resp.ok) throw new Error(json.error || \"HTTP \" + resp.status);\n return json;\n }\n const wire = {};\n function renderWire() {\n const pill = (state) => {\n const cls = state === \"listed\" ? \"on\" : state === \"failed\" ? \"bad\" : state === \"not listed\" ? \"off\" : \"\";\n const label = state === \"checking\" || state === \"publishing\" ? state + \"\\u2026\" : state;\n return '' + esc(label) + \"\";\n };\n $(\"#wire\").innerHTML = LISTS.map((l) => {\n const w = wire[l.kind] || { state: \"\", results: [], list: null };\n const busy = w.state === \"checking\" || w.state === \"publishing\";\n const btn = (act, cls, label) => '\";\n const buttons = w.state === \"listed\" ? btn(\"remove\", \"\", \"Remove this relay\") : btn(\"add\", \"pri\", \"Add this relay\") + btn(\"check\", \"\", w.state ? \"Check again\" : \"Check\");\n const n = w.list ? w.list.tags.filter((t) => t[0] === l.tag).length : 0;\n const from = w.list ? \"\" + (n === 1 ? \"1 entry\" : n + \" entries\") + \", \" + fmtTime(w.list.created_at) + \"\" : w.state && !busy ? \"no list found\" : \"\";\n const sent = (w.results || []).map((r) => '' + esc(r.url.replace(/^wss?:\\/\\//, \"\")) + (r.ok ? \"\" : \" failed\") + \"\").join(\"\");\n const meta = (w.state ? pill(w.state) : \"\") + from + sent;\n return '
                                                                                                                                                                              ' + l.title + ' ' + l.nip + \"
                                                                                                                                                                              \" + l.about + '
                                                                                                                                                                              ' + buttons + '
                                                                                                                                                                              ' + meta + \"
                                                                                                                                                                              \";\n }).join(\"\");\n }\n async function wireCheck(l) {\n const w = (wire[l.kind] = { state: \"checking\", list: null, results: [] }); renderWire();\n const filter = { kinds: [l.kind], authors: [me], limit: 1 };\n const found = [];\n try { found.push(...(await bridge(\"/query\", [filter]))); } catch { /* the relay may hold none */ }\n const remote = await Promise.all(INDEXERS.map((u) => fetchNewest(u, filter, 4000)));\n let lib = null; try { lib = await signerLib(); } catch { /* unverified lists are still the owner's own, by pubkey */ }\n for (const e of remote) if (e && e.pubkey === me && e.kind === l.kind && Array.isArray(e.tags) && (!lib || lib.verifyEvent(e))) found.push(e);\n w.list = found.sort((a, b) => b.created_at - a.created_at)[0] || null;\n w.state = listHas(w.list, l) ? \"listed\" : \"not listed\"; renderWire();\n }\n async function wirePublish(l, include) {\n if (!wire[l.kind] || !wire[l.kind].state || wire[l.kind].state === \"failed\") await wireCheck(l);\n const w = wire[l.kind]; w.state = \"publishing\"; w.results = []; renderWire();\n try {\n const signed = await signer.signEvent(mergeList(w.list, l, include));\n const here = await bridge(\"/events\", signed).then((r) => ({ url: wsURL, ok: !!r.accepted, msg: r.message || \"\" })).catch((e) => ({ url: wsURL, ok: false, msg: e.message }));\n const targets = [...new Set([...relaysIn(signed), ...INDEXERS])].filter((u) => normURL(u) !== normURL(wsURL));\n const rest = await Promise.all(targets.map((u) => publishTo(u, signed, 6000)));\n w.results = [here, ...rest]; w.list = signed; w.state = include ? \"listed\" : \"not listed\";\n toast((include ? \"Added to your \" : \"Removed from your \") + l.title.toLowerCase());\n } catch (e) { w.state = \"failed\"; w.results = [{ url: wsURL, ok: false, msg: e.message }]; toast(e.message); }\n renderWire();\n }\n $(\"#wire\").addEventListener(\"click\", (ev) => {\n const b = ev.target.closest(\"button[data-wire]\"); if (!b) return;\n const l = LISTS.find((x) => String(x.kind) === b.dataset.wire); if (!l) return;\n if (b.dataset.do === \"check\") wireCheck(l); else wirePublish(l, b.dataset.do === \"add\");\n });\n\n // ---- custom domains: this relay under a hostname the owner controls ----\n let domainSites = [];\n const domainOptions = (current = \"\") => [{ label: \"\", title: \"This relay\" }, ...domainSites.map((s) => ({ label: s.label, title: (s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + \" / \" + key(s.author) })), ...(current && !domainSites.some((s) => s.label === current) ? [{ label: current, title: \"Site \" + current }] : [])].map((s) => '\").join(\"\");\n let domains = null; // null: not enabled on this host\n function renderDomains() {\n $(\"#domains\").innerHTML = (domains || []).map((d) => {\n const state = d.ready ? \"active\" : \"hostname \" + d.status.replace(/_/g, \" \") + \", certificate \" + d.sslStatus.replace(/_/g, \" \");\n const btn = (act, cls, label) => '\";\n const rows = d.ready ? \"\" : '
                                                                                                                                                                              ' + d.records.map((r) => \"\").join(\"\") + \"
                                                                                                                                                                              TypeNameValue
                                                                                                                                                                              \" + esc(r.type) + '' + esc(r.name) + '' + esc(r.value) + \"\" + esc(r.note) + \"
                                                                                                                                                                              \";\n return '
                                                                                                                                                                              ' + esc(d.host) + '' + esc(state) + \"\" + btn(\"check\", \"\", \"Check\") + btn(\"remove\", \"danger\", \"Remove\") + '
                                                                                                                                                                              \" + rows;\n }).join(\"\");\n }\n async function loadDomains() {\n try { [domains, domainSites] = await Promise.all([rpc(\"listdomains\"), rpc(\"listsites\")]); $(\"#adddomain select[name=site]\").innerHTML = domainOptions(); $(\"#domainnote\").textContent = domains.length ? \"\" : \"No custom domain yet.\"; $(\"#adddomain\").classList.remove(\"hidden\"); }\n catch (e) { domains = null; $(\"#domainnote\").textContent = /^unsupported/.test(e.message) ? \"Custom domains are not enabled on this host.\" : e.message; $(\"#adddomain\").classList.add(\"hidden\"); }\n renderDomains();\n }\n $(\"#adddomain\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"adddomain\", f.host.value.trim(), f.site.value); f.reset(); toast(\"Domain added; now create the CNAME\"); await loadDomains();\n });\n $(\"#domains\").addEventListener(\"change\", guard(async (ev) => {\n const select = ev.target.closest(\"select[data-domain-site]\"); if (!select) return;\n await rpc(\"setdomainsite\", select.dataset.domainSite, select.value);\n toast(\"Domain destination saved\"); await loadDomains();\n }));\n $(\"#domains\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-domain]\"); if (!b) return;\n const host = b.dataset.domain;\n if (b.dataset.do === \"remove\" && !confirm(\"Remove \" + host + \"? Its certificate goes with it and the name stops answering.\")) return;\n b.disabled = true;\n try {\n if (b.dataset.do === \"remove\") { await rpc(\"removedomain\", host); toast(\"Removed\"); }\n else { const d = await rpc(\"checkdomain\", host); toast(d.ready ? host + \" is live\" : \"Not yet: \" + (d.status === \"active\" ? \"certificate pending\" : \"waiting for the CNAME\")); }\n await loadDomains();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n $(\"#access\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const openKinds = f.openKinds.value.split(/[\\s,]+/).filter(Boolean).map(Number);\n if (openKinds.some((k) => !Number.isInteger(k) || k < 0 || k > 65535)) throw new Error(\"Open kinds must be whole numbers.\");\n policy = await rpc(\"setpolicy\", { writes: f.writes.value, reads: f.reads.value, openKinds, guestReplies: f.guestReplies.checked, minPow: +f.minPow.value, maxFuture: +f.maxFuture.value, maxLimit: +f.maxLimit.value, maxSubs: +f.maxSubs.value, maxMessageKB: +f.maxMessageKB.value, eventsPerMinute: +f.eventsPerMinute.value, reqsPerMinute: +f.reqsPerMinute.value, maxBlobMB: +f.maxBlobMB.value });\n toast(\"Rules saved\"); await loadInfo();\n });\n $(\"#notify\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { notify: { reports: f.reports.checked, fuel: f.fuel.checked, jobs: f.jobs.checked, succession: f.succession.checked, digest: f.digest.checked } });\n toast(\"Notifications saved\");\n });\n $(\"#notifytest\").onclick = guard(async () => {\n const r = await rpc(\"notifytest\");\n toast(r.sent ? \"Sent. Look for a message from the relay in your DMs.\" : \"Could not send.\");\n });\n $(\"#identity\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const list = (v) => v.split(\",\").map((s) => s.trim()).filter(Boolean);\n policy = await rpc(\"setpolicy\", { name: f.name.value, contact: f.contact.value, description: f.description.value, icon: f.icon.value,\n banner: f.banner.value, postingPolicy: f.postingPolicy.value, privacyPolicy: f.privacyPolicy.value, tags: list(f.tags.value), languageTags: list(f.languageTags.value), relayCountries: list(f.relayCountries.value) });\n toast(\"Identity saved\"); await loadInfo(); await loadPeople(); loadCard();\n });\n $(\"#addmember\").onsubmit = guard(async (ev) => {\n const f = ev.target; const pk = npubToHex(f.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"setmember\", pk, { name: f.name.value.trim() || null, note: f.note.value }); f.reset(); toast(\"Member added\"); await loadLists();\n });\n $(\"#addblock\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"blockip\", f.ip.value.trim(), f.reason.value); f.reset(); toast(\"Blocked\"); await loadLists();\n });\n $(\"#wordsform\").onsubmit = guard(async (ev) => {\n const words = ev.target.words.value.split(\"\\n\").map((s) => s.trim()).filter(Boolean);\n const kept = await rpc(\"setblockedwords\", words);\n policy = await rpc(\"setpolicy\", { blockedWordsInTags: ev.target.inTags.checked });\n ev.target.words.value = kept.join(\"\\n\"); toast(kept.length ? kept.length + \" words blocked\" : \"No words blocked\");\n });\n $(\"#thresholdform\").onsubmit = guard(async (ev) => {\n policy = await rpc(\"setpolicy\", { reportThreshold: +ev.target.reportThreshold.value });\n toast(policy.reportThreshold ? \"Hidden after \" + policy.reportThreshold + \" reports\" : \"Never hidden by reports\"); await loadLists();\n });\n $(\"#addban\").onsubmit = guard(async (ev) => {\n const pk = npubToHex(ev.target.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"banpubkey\", pk, ev.target.reason.value, ev.target.erase.checked); ev.target.reset(); toast(ev.target.erase.checked ? \"Banned and erased\" : \"Banned\"); await loadLists();\n });\n $(\"#mintinvite\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const inv = await rpc(\"createinvite\", +f.ttl.value, +f.max.value, f.note.value);\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n f.note.value = \"\"; toast(\"Invite created and copied\"); await loadLists();\n });\n $(\"#kindform\").onsubmit = guard(async (ev) => {\n const rule = ev.submitter.value; const k = +ev.target.kind.value;\n await rpc(rule === \"allow\" ? \"allowkind\" : \"disallowkind\", k); ev.target.reset(); toast((rule === \"allow\" ? \"Allowed kind \" : \"Blocked kind \") + k); await loadLists();\n });\n $(\"#more\").onclick = guard(() => loadEvents(false));\n $(\"#dumpform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { dumps: f.dumps.value, dumpsKeep: Math.max(1, Math.min(60, Math.floor(+f.keep.value || 7))) });\n toast(policy.dumps === \"off\" ? \"Dumps off\" : \"Dumping \" + policy.dumps); await loadDumps();\n });\n $(\"#importform\").onsubmit = guard(async (ev) => {\n const file = ev.target.file.files[0]; if (!file) return;\n if (!signer.ready()) throw new Error(NO_SIGNER);\n if (file.size > 64 * 1024 * 1024) throw new Error(\"At most 64 MB per import.\");\n const body = await file.text();\n const url = location.origin + \"/import?name=\" + encodeURIComponent(file.name);\n const token = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"PUT\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(url, { method: \"PUT\", headers: { authorization: \"Nostr \" + btoa(JSON.stringify(token)), \"content-type\": \"application/x-ndjson\" }, body });\n const r = await resp.json();\n if (!resp.ok) throw new Error(r.error || \"import failed\");\n ev.target.reset(); toast(\"Importing \" + fmtBytes(r.bytes)); await pollJobs();\n });\n $(\"#dumpnow\").onclick = guard(async () => { const d = await rpc(\"dumpnow\"); toast(\"Dumped \" + d.events.toLocaleString() + \" events\"); await loadStorage(); });\n $(\"#treeform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { memberInvites: { depth: Math.max(0, Math.floor(+f.depth.value || 0)), quota: Math.max(0, Math.floor(+f.quota.value || 0)) } });\n toast(policy.memberInvites.depth ? \"Members may invite\" : \"Only you and moderators invite\");\n });\n $(\"#mintmine\").onclick = guard(async () => {\n const inv = await rpc(\"createinvite\", 259200, 1, \"\");\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n toast(\"Invite created and copied\"); await loadMine();\n });\n $(\"#exportcfg\").onclick = guard(async () => {\n const cfg = await rpc(\"exportconfig\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(new Blob([JSON.stringify(cfg, null, 2)], { type: \"application/json\" }));\n a.download = host.split(\".\")[0] + \".bind.ws.config.json\";\n a.click(); URL.revokeObjectURL(a.href);\n });\n $(\"#importcfg\").onclick = () => $(\"#cfgfile\").click();\n $(\"#cfgfile\").onchange = guard(async (ev) => {\n const file = ev.target.files[0]; if (!file) return;\n const cfg = JSON.parse(await file.text());\n const check = await rpc(\"importconfig\", cfg, { dryRun: true });\n const lines = check.changes.summary.length ? check.changes.summary.join(\"\\n\") : \"Nothing would change.\";\n const dropped = check.warnings.length ? \"\\n\\nNot taken:\\n\" + check.warnings.join(\"\\n\") : \"\";\n if (!confirm(\"Apply \" + file.name + \" to this relay?\\n\\n\" + lines + dropped)) { ev.target.value = \"\"; return; }\n await rpc(\"importconfig\", cfg); ev.target.value = \"\"; toast(\"Configuration imported\"); await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // Succession: the status line under the heir form, from successionstatus.\n async function renderSuccession() {\n const el = $(\"#successionnote\"), f = $(\"#succession\");\n let st;\n try { st = await rpc(\"successionstatus\"); } catch { el.textContent = \"\"; return; }\n const sc = st.succession;\n $(\"#clearsuccession\").classList.toggle(\"hidden\", !sc);\n f.querySelector(\"button.btn:not(#clearsuccession)\").textContent = sc ? \"Change heir\" : \"Set heir\";\n if (!sc) { el.textContent = \"No heir named. Last signed in \" + fmtTime(st.ownerSeenAt) + \".\"; return; }\n if (f.heir.querySelector('[value=\"' + sc.heir + '\"]')) f.heir.value = sc.heir;\n f.afterDays.value = String(sc.afterDays);\n const who = (window.__members || []).find((m) => m.pubkey === sc.heir);\n const heir = who && who.name ? who.name + \"@\" + host : short(sc.heir);\n el.textContent = \"Heir: \" + heir + \". Last signed in \" + fmtTime(st.ownerSeenAt) + (st.silentDays ? \" (\" + st.silentDays + \" days ago)\" : \"\") + \". \" +\n (st.warning ? \"The warning month is running: the relay goes to \" + heir + \" on \" + fmtDay(st.handoverAt) + \" unless you sign in.\" : \"If you stay away, it goes to \" + heir + \" around \" + fmtDay(st.handoverAt) + \".\") +\n (st.log && st.log.length ? \" Handed over before: \" + st.log.map((l) => fmtDay(l.at) + \" to \" + short(l.to)).join(\", \") + \".\" : \"\");\n }\n $(\"#succession\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"setsuccession\", { heir: f.heir.value, afterDays: +f.afterDays.value });\n toast(\"Heir set\"); await loadAdmin();\n });\n $(\"#clearsuccession\").onclick = guard(async () => {\n await rpc(\"clearsuccession\");\n toast(\"Heir cleared\"); await loadAdmin();\n });\n $(\"#transfer\").onsubmit = guard(async (ev) => {\n const pk = ev.target.pubkey.value; if (!pk) return;\n const name = host.split(\".\")[0];\n const typed = prompt(\"This hands \" + host + \" to \" + short(pk) + \" for good. You stay on as a moderator. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing changed.\"); return; }\n await rpc(\"transferowner\", pk);\n toast(\"Transferred\");\n await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // ---- fork: a new name with this relay's events, claim reserved for a key ----\n $(\"#forkform\").scope.onchange = (ev) => $(\"#forkkinds\").classList.toggle(\"hidden\", ev.target.value !== \"kinds\");\n $(\"#joinform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { joinTerms: f.joinTerms.value, directoryPublic: f.directoryPublic.checked });\n toast(\"Saved\"); await loadInfo(); await loadPeople();\n });\n $(\"#forkform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const opts = { people: f.people.checked };\n if (f.name.value.trim()) opts.name = f.name.value.trim().toLowerCase();\n if (f.holder.value.trim()) { const pk = npubToHex(f.holder.value); if (!pk) throw new Error(\"That is not a pubkey.\"); opts.holder = pk; }\n if (f.scope.value === \"mine\") opts.filter = { authors: [me] };\n if (f.scope.value === \"kinds\") { const kinds = f.kinds.value.split(/[\\s,]+/).filter(Boolean).map(Number); if (!kinds.length || kinds.some((k) => !Number.isInteger(k) || k < 0)) throw new Error(\"Give kinds as numbers.\"); opts.filter = { kinds }; }\n if (!confirm(\"Fork this relay into a new name\" + (opts.name ? \" (\" + opts.name + \")\" : \"\") + \"? It pulls \" + (f.scope.value === \"all\" ? \"everything\" : f.scope.value === \"mine\" ? \"your events\" : \"the chosen kinds\") + (opts.people ? \" and the people\" : \"\") + \", and only \" + (opts.holder ? \"that key\" : \"you\") + \" can claim it.\")) return;\n const r = await rpc(\"forkrelay\", opts);\n $(\"#forknote\").textContent = r.handover + \" Expires \" + fmtTime(r.expires_at) + \".\";\n $(\"#forkurl\").textContent = r.console;\n $(\"#forkresult\").classList.remove(\"hidden\");\n toast(\"Forked to \" + r.name);\n });\n $(\"#forkcopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#forkurl\").textContent); toast(\"copied\"); };\n $(\"#deleterelay\").onclick = guard(async () => {\n const name = host.split(\".\")[0];\n const typed = prompt(\"This deletes everything on \" + host + \" and gives the name up. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing was deleted.\"); return; }\n await rpc(\"deleterelay\", name);\n localStorage.removeItem(\"me\");\n location.href = \"/\";\n });\n $(\"#topup\").onsubmit = guard((ev) => topUp(+ev.target.sats.value));\n $(\"#inv-copy\").onclick = async () => { await navigator.clipboard.writeText($(\"#inv-text\").value); toast(\"invoice copied\"); };\n $$(\"button[data-copy]\").forEach((b) => { b.onclick = async () => { await navigator.clipboard.writeText(b.dataset.copy === \"ws\" ? wsURL : location.origin); toast(\"copied\"); }; });\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-act]\"); if (!b) return;\n const act = b.dataset.act, id = b.dataset.id;\n if (act === \"copy\") { await navigator.clipboard.writeText(id); toast(\"copied\"); return; }\n if ((act === \"banpubkey\" || act === \"banevent\" || act === \"resolve:ban\") && !confirm(act === \"banevent\" ? \"Delete this event and refuse it forever?\" : \"Ban this author and refuse everything they post?\")) return;\n const erase = (act === \"banpubkey\" || act === \"resolve:ban\") && confirm(\"Also erase everything they wrote and uploaded here?\");\n if (act === \"deleteblob\" && !confirm(\"Delete this file for good?\")) return;\n if (act === \"deletedump\" && !confirm(\"Delete this dump?\")) return;\n if (act === \"downloaddump\") { b.disabled = true; try { await downloadDump(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }\n if (act === \"restorelist\") {\n if (!signer.ready()) { toast(NO_SIGNER); return; }\n try {\n const preview = await rpc(\"restorelist\", id);\n const d = preview.diff || {};\n const added = (d.addedTags || []).map((t) => \"+ \" + JSON.stringify(t)).join(\"\\n\");\n const removed = (d.removedTags || []).map((t) => \"- \" + JSON.stringify(t)).join(\"\\n\");\n const changes = [added, removed, d.contentChanged ? \"content changed\" : \"content unchanged\"].filter(Boolean).join(\"\\n\");\n if (!confirm(\"Restore this list version?\\n\\n\" + (changes || \"No tag or content changes\") + \"\\n\\nIt will be signed and published as the newest version.\")) return;\n const signed = await signer.signEvent(preview.draft);\n const result = await bridge(\"/events\", signed);\n if (!result.accepted) throw new Error(result.message || \"The relay refused the restored list.\");\n toast(\"List restored\"); await loadListHistory(); await loadStorage();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if ((act === \"removemember\" || act === \"banpubkey\") && (window.__members || []).some((m) => m.invited_by === id) && confirm(\"Also remove everyone this member invited, and everyone they invited in turn?\")) {\n b.disabled = true;\n try { const r = await rpc(\"removesubtree\", id); if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase); toast(\"Removed \" + r.removed.length); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"removejob\" && !confirm(\"Remove this job?\")) return;\n if (act === \"purgekind\") {\n const kind = id === \"\" ? null : +id;\n const label = kind === null ? \"everything without its own rule\" : kindName(kind) + \" (kind \" + kind + \")\";\n const typed = prompt(\"Purge \" + label + \" older than how many days? 0 purges all of them. This cannot be undone.\", \"30\");\n if (typed === null) return;\n const days = Math.max(0, Math.floor(+typed || 0));\n b.disabled = true;\n try { const r = await rpc(\"purgekind\", kind, days); toast(\"Purged \" + r.deleted.toLocaleString() + \" events\"); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"saveretention\") {\n const kind = id === \"\" ? null : +id;\n const days = Math.max(0, Math.floor(+b.closest(\"tr\").querySelector(\".days\").value || 0));\n b.disabled = true;\n try { await rpc(\"setretention\", kind, days); toast(days ? \"Kept for \" + days + \" days\" : \"Kept forever\"); await loadStorage(); await loadInfo(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n b.disabled = true;\n try {\n if (act === \"savemember\") { const tr = b.closest(\"tr\"), role = tr.querySelector(\"select.role\"), keep = tr.querySelector(\".keep\"), cap = tr.querySelector(\".cap\"); await rpc(\"setmember\", id, { name: tr.querySelector(\".name\").value.trim() || null, note: tr.querySelector(\".note\").value, ...(role ? { role: role.value } : {}), ...(keep ? { keepDays: Math.max(0, Math.floor(+keep.value || 0)), maxBytes: Math.max(0, Math.floor(+cap.value || 0)) * 1024 } : {}) }); }\n else if (act.startsWith(\"resolve:\")) await rpc(\"resolvereport\", id, act.slice(8), erase);\n else if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase);\n else await rpc(act, act === \"unrulekind\" ? +id : id, \"\");\n toast(\"Done\"); await (myRole ? refresh() : loadMine());\n if (act === \"pinevent\" || act === \"unpinevent\") await loadPins();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // The share block: the card picture, the group naddr and its QR.\n let card = null;\n async function loadCard() {\n try {\n card = await (await fetch(\"/card.json\", { cache: \"no-store\" })).json();\n $(\"#cardimg\").src = \"/card.svg?t=\" + Date.now();\n $(\"#naddr\").textContent = card.naddr || \"\";\n const q = $(\"#naddrqr\");\n if (card.naddr) { q.src = \"/qr.svg?text=\" + encodeURIComponent(card.naddr); q.classList.remove(\"hidden\"); } else q.classList.add(\"hidden\");\n } catch { /* the card is decoration */ }\n renderApps();\n }\n // The app rows on the Connect section: one per client people actually use,\n // with the link that lands on this relay in it. Relay apps take the relay\n // or the group address; feed apps take the owner's profile with this relay\n // as the hint, since they have no notion of opening a relay.\n function renderApps() {\n const el = $(\"#apps\"); if (!el) return;\n const enc = encodeURIComponent;\n const nprofile = card && card.nprofile ? card.nprofile : \"\";\n const naddr = card && card.naddr ? card.naddr : \"\";\n const whose = me && owner && me === owner ? \"your\" : \"the owner's\";\n const link = (label, href) => '' + label + \"\";\n const app = (label, uri) => '' + label + \"\";\n const copy = (label, text) => '\";\n const row = (name, where, note, acts) => '
                                                                                                                                                                              ' + name + \"\" + where + \"

                                                                                                                                                                              \" + note + '

                                                                                                                                                                              ' + acts.filter(Boolean).join(\"\") + \"
                                                                                                                                                                              \";\n const profileNote = \"Opens \" + whose + \" profile with this relay attached.\";\n const groups = [\n [\"As a place\", \"These open the relay itself: its feed, its people, its group.\", [\n row(\"Jumble\", \"web\", \"A feed of everything on this relay.\", [link(\"Open\", \"https://jumble.social/?r=\" + enc(wsURL))]),\n row(\"Coracle\", \"web\", \"The relay's page: its feed and its people.\", [link(\"Open\", \"https://coracle.social/relays/\" + enc(host))]),\n row(\"Flotilla\", \"web, phone\", \"The relay as a space, with the group as a room.\", [link(\"Open\", \"https://app.flotilla.social/spaces/\" + enc(host)), naddr && app(\"Open group\", \"nostr:\" + naddr)]),\n row(\"0xchat\", \"phone\", \"The group, in a chat app.\", [naddr && app(\"Open group\", \"nostr:\" + naddr), naddr && copy(\"Copy naddr\", naddr)]),\n row(\"noStrudel\", \"web\", \"Relays, add this one, then open its page.\", [copy(\"Copy relay URL\", wsURL)]),\n ]],\n [\"Find me here\", \"Feed apps have no relay pages. They meet this relay through a profile link that names it, then keep it once it is in the relay settings.\", [\n row(\"Primal\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://primal.net/p/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"YakiHonne\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://yakihonne.com/profile/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"Damus\", \"iPhone\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Amethyst\", \"Android\", profileNote + \" Then Relays in the drawer.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Nostur\", \"iPhone, Mac\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n ]],\n ];\n if (info?.supported_grasps?.includes(\"GRASP-01\")) groups.push([\n \"Git repositories\", \"Use this relay with a Git client.\", [\n row(\"GitWorkshop\", \"web\", \"Browse this relay's Git repositories.\", [link(\"Open in app\", \"https://gitworkshop.dev/relay/\" + enc((wsURL.startsWith(\"ws://\") ? \"ws:\" : \"\") + host)), copy(\"Copy relay URL\", wsURL)]),\n ],\n ]);\n el.innerHTML = groups.map(([h, note, rows]) => '

                                                                                                                                                                              ' + h + '

                                                                                                                                                                              ' + note + '

                                                                                                                                                                              ' + rows.join(\"\") + \"
                                                                                                                                                                              \").join(\"\");\n $(\"#apps-ws\").textContent = wsURL;\n const tile = (label, text) => '
                                                                                                                                                                              ' + label + '\"QR
                                                                                                                                                                              ';\n $(\"#phones\").innerHTML = [nprofile && tile(\"Find me here, for a phone\", \"nostr:\" + nprofile), naddr && tile(\"The group, for a phone\", \"nostr:\" + naddr)].filter(Boolean).join(\"\");\n }\n // The folds are a group: opening one closes the rest. Browsers with the\n // details name attribute do this themselves; this covers the others.\n $$(\".folds details\").forEach((d) => d.addEventListener(\"toggle\", () => { if (d.open) $$(\".folds details\").forEach((o) => { if (o !== d && o.open) o.open = false; }); }));\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-copytext]\"); if (!b) return;\n try { await navigator.clipboard.writeText(b.dataset.copytext); toast(\"copied\"); } catch { /* no clipboard */ }\n });\n $(\"#copynaddr\").onclick = async () => { if (!card || !card.naddr) return; await navigator.clipboard.writeText(card.naddr); toast(\"copied naddr\"); };\n $(\"#copyembed\").onclick = async () => { await navigator.clipboard.writeText('\"''); toast(\"copied embed\"); };\n\n // A click on a key copies it; a double-click selects the whole key so the usual copy shortcut takes it too.\n document.addEventListener(\"dblclick\", (ev) => {\n const k = ev.target.closest(\".key\"); if (!k) return;\n const s = window.getSelection(), r = document.createRange(); r.selectNodeContents(k.querySelector(\".full\")); s.removeAllRanges(); s.addRange(r);\n });\n document.addEventListener(\"click\", async (ev) => {\n const k = ev.target.closest(\".key\"); if (!k || ev.detail > 1 || String(window.getSelection())) return;\n try { await navigator.clipboard.writeText(k.title); toast(\"copied \" + k.dataset.short); } catch { /* no clipboard */ }\n });\n // ---- boot ----\n try { await loadInfo(); } catch { renderHeader(); }\n await loadFuel();\n await loadCard();\n me = localStorage.getItem(\"me\");\n if (me && await resumeRemote()) { /* a remote session answers for itself when first used */ }\n else if (me && window.nostr) { try { const pk = await window.nostr.getPublicKey(); if (pk !== me) me = null; } catch { me = null; } }\n else if (me) me = null;\n renderHeader();\n renderApps();\n await loadAdmin();\n await loadPeople();\n})();\n"; +export const CONSOLE_JS = "(async () => {\n const $ = (s) => document.querySelector(s);\n const $$ = (s) => document.querySelectorAll(s);\n const host = location.host;\n const wsURL = (location.protocol === \"https:\" ? \"wss://\" : \"ws://\") + host;\n const rpcURL = location.origin + \"/\";\n let info = null, me = null, owner = \"\", policy = null, fuel = null, people = null, myRole = \"\";\n\n const toast = (msg) => { const t = $(\"#toast\"); t.textContent = msg; t.classList.add(\"show\"); clearTimeout(t._t); t._t = setTimeout(() => t.classList.remove(\"show\"), 2600); };\n // guard wraps a handler: disables the button, shows errors as toasts. Defined first, since handlers below use it.\n const guard = (fn) => async (ev) => { ev.preventDefault(); const b = ev.submitter || ev.target; if (b) b.disabled = true; try { await fn(ev); } catch (e) { toast(e.message); } finally { if (b) b.disabled = false; } };\n const short = (hex) => hex ? hex.slice(0, 8) + \"…\" + hex.slice(-4) : \"\";\n // key shows a key short but keeps the whole of it in the DOM: selecting, double-clicking or clicking it copies the full hex.\n const key = (hex) => hex ? '' + hex + '' : '';\n const hue = (hex) => (parseInt(hex.slice(0, 2), 16) * 360 / 256).toFixed(0) + \"deg\";\n const av = (hex) => '';\n const fmtBytes = (n) => n < 1e6 ? (n / 1e3).toFixed(0) + \" KB\" : n < 1e9 ? (n / 1e6).toFixed(1) + \" MB\" : (n / 1e9).toFixed(2) + \" GB\";\n const fmtHours = (ms) => ms < 3600e3 ? Math.round(ms / 60e3) + \" min\" : (ms / 3600e3).toFixed(ms < 36e6 ? 1 : 0) + \" h\";\n const fuelOver = () => !!fuel && (fuel.eventBytes > fuel.freeEventBytes || fuel.mediaBytes > fuel.freeMediaBytes || fuel.activeMs > fuel.freeActiveMs || fuel.rowsWritten > fuel.freeRowsWritten);\n const fmtTime = (t) => t ? new Date(t * 1000).toLocaleString(undefined, { month: \"short\", day: \"numeric\", hour: \"2-digit\", minute: \"2-digit\" }) : \"–\";\n const fmtDay = (t) => t ? new Date(t * 1000).toLocaleDateString(undefined, { month: \"short\", day: \"numeric\" }) : \"–\";\n const ago = (t) => { const s = Math.max(0, Math.floor(Date.now() / 1000) - t); return s < 60 ? \"just now\" : s < 3600 ? Math.floor(s / 60) + \" min\" : s < 86400 ? Math.floor(s / 3600) + \" h\" : Math.floor(s / 86400) + \" d\"; };\n const esc = (s) => String(s ?? \"\").replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c]);\n\n const IC = {\n x: '',\n undo: '',\n check: '',\n trash: '',\n ban: '',\n banuser: '',\n eye: '',\n lock: '',\n pen: '',\n people: '',\n person: '',\n gauge: '',\n bolt: '',\n copy: '',\n pin: '',\n };\n const ib = (icon, label, act, id, extra) => '\";\n\n const CH = \"qpzry9x8gf2tvdw0s3jn54khce6mua7l\";\n function npubToHex(s) {\n s = s.trim().toLowerCase();\n if (/^[0-9a-f]{64}$/.test(s)) return s;\n if (!s.startsWith(\"npub1\")) return null;\n const data = s.slice(5, -6).split(\"\").map((c) => CH.indexOf(c));\n if (data.some((d) => d < 0)) return null;\n let bits = 0, acc = 0, out = [];\n for (const d of data) { acc = (acc << 5) | d; bits += 5; if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 255); } }\n return out.length === 32 ? out.map((b) => b.toString(16).padStart(2, \"0\")).join(\"\") : null;\n }\n\n async function sha256hex(s) {\n const b = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(s));\n return [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, \"0\")).join(\"\");\n }\n // ---- signing: a NIP-07 extension, or a NIP-46 remote signer ----\n // The remote path loads the bundled library on first use; its session\n // (client key, signer pubkey, relays) lives in localStorage until sign-out.\n let remote = null, lib = null, pendingNote = null;\n const NO_SIGNER = \"Install a nostr extension (Alby, nos2x, …) or connect a remote signer.\";\n const withTimeout = (p, ms, what) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(what + \" did not answer; is the signer app open?\")), ms))]);\n const signer = {\n ready: () => !!remote || !!window.nostr,\n async getPublicKey() { if (remote) return withTimeout(remote.getPublicKey(), 20000, \"The remote signer\"); if (window.nostr) return window.nostr.getPublicKey(); throw new Error(NO_SIGNER); },\n async signEvent(ev) { if (remote) return withTimeout(remote.signEvent(ev), 60000, \"The remote signer\"); if (window.nostr) return window.nostr.signEvent(ev); throw new Error(NO_SIGNER); },\n };\n async function signerLib() {\n if (lib) return lib;\n if (!window.NostrSigner) await new Promise((res, rej) => { const s = document.createElement(\"script\"); s.src = window.SIGNER_URL || \"/signer.js\"; s.onload = res; s.onerror = () => rej(new Error(\"Could not load the signer library.\")); document.head.appendChild(s); });\n return (lib = window.NostrSigner);\n }\n const onauth = (u) => window.open(u, \"_blank\");\n // The signer conversation rides this relay, and a relay may ask the socket to\n // authenticate before it delivers kind 24133 to its parties; answer with the\n // session's own key.\n const authPool = (L, sk) => new L.SimplePool({ automaticallyAuth: () => (evt) => Promise.resolve(L.finalizeEvent(evt, sk)) });\n const saveSession = (sk, s, secret) => localStorage.setItem(\"nip46\", JSON.stringify({ sk: lib.bytesToHex(sk), pubkey: s.bp.pubkey, relays: s.bp.relays, secret: secret || null }));\n async function resumeRemote() {\n const raw = localStorage.getItem(\"nip46\");\n if (!raw) return false;\n try {\n const s = JSON.parse(raw); const L = await signerLib();\n remote = L.BunkerSigner.fromBunker(L.hexToBytes(s.sk), { pubkey: s.pubkey, relays: s.relays, secret: s.secret }, { onauth, pool: authPool(L, L.hexToBytes(s.sk)) });\n return true;\n } catch { localStorage.removeItem(\"nip46\"); return false; }\n }\n async function connectBunker(input) {\n const L = await signerLib();\n const bp = await L.parseBunkerInput(input.trim());\n if (!bp) throw new Error(\"That is not a bunker:// URL.\");\n if (!bp.relays.length) throw new Error(\"The bunker URL names no relay.\");\n const sk = L.generateSecretKey();\n const s = L.BunkerSigner.fromBunker(sk, bp, { onauth, pool: authPool(L, sk) });\n await withTimeout(s.connect({ name: host, url: location.origin }), 60000, \"The signer\");\n await Promise.race([s.switchRelays(), new Promise((r) => setTimeout(r, 3000))]);\n remote = s; saveSession(sk, s, bp.secret);\n }\n // The nostrconnect:// flow: this relay carries the traffic, so no third relay is involved.\n let ncPending = null;\n async function offerNostrConnect() {\n if (ncPending) return ncPending;\n const L = await signerLib();\n const sk = L.generateSecretKey();\n const secret = L.bytesToHex(crypto.getRandomValues(new Uint8Array(8)));\n const uri = L.createNostrConnectURI({ clientPubkey: L.getPublicKey(sk), relays: [wsURL], secret, name: host, url: location.origin, perms: [\"sign_event:27235\", \"sign_event:9734\"] });\n $(\"#nclink\").href = uri; $(\"#ncnote\").textContent = \"\";\n $(\"#ncqr\").src = \"/qr.svg?text=\" + encodeURIComponent(uri); $(\"#ncqr\").classList.remove(\"hidden\");\n ncPending = L.BunkerSigner.fromURI(sk, uri, { onauth, pool: authPool(L, sk) }, 600000).then((s) => { remote = s; saveSession(sk, s, secret); ncPending = null; return s; }, (e) => { ncPending = null; throw e; });\n return ncPending;\n }\n async function remoteDone() {\n me = await signer.getPublicKey();\n localStorage.setItem(\"me\", me);\n $(\"#remotesec\").classList.add(\"hidden\");\n toast(\"Remote signer connected\");\n if (pendingNote && !owner) { const n = pendingNote; pendingNote = null; await claimNow(n); return; }\n pendingNote = null;\n renderHeader(); await loadAdmin(); await loadPeople();\n }\n function showRemote(note) {\n pendingNote = note || null;\n $(\"#remotesec\").classList.remove(\"hidden\");\n $(\"#remotesec\").scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n offerNostrConnect().then(remoteDone).catch((e) => { $(\"#ncnote\").textContent = e.message; });\n }\n\n async function rpc(method, ...params) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const body = JSON.stringify({ method, params });\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", rpcURL], [\"method\", \"POST\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(rpcURL, { method: \"POST\", headers: { \"content-type\": \"application/nostr+json+rpc\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body });\n const json = await resp.json();\n if (json.error) throw new Error(json.error);\n return json.result;\n }\n\n // ---- front of house ----\n // urlish shows a URL as a short link: the host and the start of the path, the whole thing on hover and as the target.\n class Html { constructor(s) { this.s = s; } }\n const urlish = (u) => {\n if (!u) return \"\";\n let label = u; try { const x = new URL(u); label = x.host + (x.pathname.length > 1 ? x.pathname.slice(0, 18) + (x.pathname.length > 18 ? \"\\u2026\" : \"\") : \"\"); } catch { /* not a URL */ }\n if (!/^https?:\\/\\//.test(u)) return u;\n return new Html('' + esc(label) + \"\");\n };\n function renderHeader() {\n const name = (info && info.name) || host.split(\".\")[0];\n $(\"#title\").textContent = name;\n document.title = name + \" - relay\";\n $(\"#url\").textContent = wsURL;\n $(\"#d-ws\").textContent = wsURL; $(\"#d-blossom\").textContent = location.origin; $(\"#d-nip05\").textContent = \"you@\" + host;\n $(\"#nip05-example\").textContent = \"alice@\" + host;\n const desc = (info && info.description) || \"\";\n $(\"#desc\").textContent = desc; $(\"#desc\").classList.toggle(\"hidden\", !desc);\n // What the owner declared about the relay, on the page, not only in the document clients read.\n const img = (id, url) => { const el = $(id); if (url) el.src = url; el.classList.toggle(\"hidden\", !url); };\n img(\"#bannerimg\", info?.banner); $(\"#banner\").classList.toggle(\"hidden\", !info?.banner);\n img(\"#iconimg\", info?.icon);\n const meta = [];\n for (const t of info?.tags || []) meta.push('' + esc(t) + \"\");\n const where = [...(info?.language_tags || []), ...(info?.relay_countries || [])];\n if (where.length) meta.push(\"\" + esc(where.join(\", \")) + \"\");\n const link = (label, href) => href ? '' + label + \"\" : \"\";\n const contact = info?.contact ? (/^(mailto:|https?:\\/\\/)/.test(info.contact) ? link(\"Contact\", info.contact) : \"\" + esc(info.contact) + \"\") : \"\";\n for (const x of [contact, link(\"Terms\", info?.terms_of_service), link(\"Posting policy\", info?.posting_policy), link(\"Privacy\", info?.privacy_policy)]) if (x) meta.push(x);\n $(\"#metarow\").innerHTML = meta.join('|'); $(\"#metarow\").classList.toggle(\"hidden\", meta.length === 0);\n $(\"#frontsec\").classList.toggle(\"hidden\", !desc && meta.length === 0);\n owner = (info && info.pubkey) || \"\";\n const isOwner = !!me && me === owner;\n const lease = !owner && info && info.lease ? info.lease : null;\n $(\"#ownerline\").innerHTML = owner ? 'run by ' + key(owner) + \"\" : lease ? \"temporary until \" + fmtDay(lease.expires_at) : \"unclaimed\";\n $(\"#owner-av\").classList.toggle(\"hidden\", !owner);\n if (owner) $(\"#owner-av\").style.setProperty(\"--h\", hue(owner));\n $(\"#who\").textContent = me ? (isOwner ? \"(that's you)\" : \"\") : \"\";\n $(\"#unclaimed\").classList.toggle(\"hidden\", !!owner || !!lease);\n $(\"#leased\").classList.toggle(\"hidden\", !lease);\n if (lease) {\n $(\"#lease-until\").textContent = fmtTime(lease.expires_at);\n $(\"#leasenote\").textContent = lease.holder ? \"Reserved for the key that asked for it; sign with that key.\" : \"\";\n }\n $(\"#signin\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signin46\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signout\").classList.toggle(\"hidden\", !me);\n $(\"#about\").innerHTML = [\n [\"Name\", info?.name], [\"Description\", info?.description], [\"Contact\", info?.contact], [\"Owner\", owner ? short(owner) : \"\"], [\"Relay key\", info?.self ? short(info.self) : \"\"], [\"Software\", urlish(info?.software)], [\"Version\", info?.version],\n [\"Max query\", info?.limitation?.max_limit], [\"Auth required\", String(!!info?.limitation?.auth_required)], [\"Restricted writes\", String(!!info?.limitation?.restricted_writes)], [\"Min PoW\", info?.limitation?.min_pow_difficulty || 0],\n [\"Tags\", info?.tags?.join(\", \")], [\"Languages\", info?.language_tags?.join(\", \")], [\"Countries\", info?.relay_countries?.join(\", \")],\n [\"Terms\", urlish(info?.terms_of_service)], [\"Posting policy\", urlish(info?.posting_policy)], [\"Privacy policy\", urlish(info?.privacy_policy)], [\"Icon\", urlish(info?.icon)], [\"Banner\", urlish(info?.banner)],\n [\"NIPs\", info?.supported_nips?.join(\" \")],\n ].map(([k, v]) => \"
                                                                                                                                                                              \" + k + \"\" + (v === undefined || v === \"\" ? '' : v instanceof Html ? v.s : esc(v)) + \"
                                                                                                                                                                              \").join(\"\");\n renderCare();\n }\n\n function renderCare() {\n const g = (icon, label, off) => '' + IC[icon] + \"\" + label + \"\";\n const writes = policy ? policy.writes : (info?.limitation?.restricted_writes ? \"allowlist\" : \"open\");\n const reads = policy ? policy.reads : (info?.limitation?.auth_required ? \"auth\" : \"open\");\n let out = writes === \"owner\" ? g(\"person\", \"only owner writes\") : writes === \"allowlist\" ? g(\"people\", \"members write\") : writes === \"wot\" ? g(\"people\", \"members and follows write\") : g(\"pen\", \"anyone writes\");\n out += reads === \"members\" ? g(\"people\", \"members read\") : reads === \"auth\" ? g(\"lock\", \"sign in to read\") : g(\"eye\", \"anyone reads\");\n if (fuel) {\n const over = fuelOver();\n out += fuel.outOfFuel ? g(\"gauge\", \"out of fuel\", true) : over ? g(\"bolt\", \"burning sats\") : g(\"gauge\", \"on free allowance\");\n }\n $(\"#care\").innerHTML = out;\n }\n\n async function loadPeople() {\n try { people = await (await fetch(\"/people\")).json(); } catch { return; }\n const isOwner = !!me && me === owner;\n let list = people.people || [];\n if (!people.public && isOwner && policy) list = (window.__members || []);\n $(\"#peoplesec\").classList.toggle(\"hidden\", !owner || list.length === 0);\n $(\"#people-note\").classList.toggle(\"hidden\", people.public);\n $(\"#people-count\").textContent = list.length || \"\";\n $(\"#dir\").innerHTML = list.map((m) => '' + av(m.pubkey) + '' + (m.name ? esc(m.name) + \"@\" + host : key(m.pubkey)) + \"\" + (m.role === \"owner\" ? 'owner' : \"\") + \"\").join(\"\");\n }\n\n async function loadInfo() {\n info = await (await fetch(\"/\", { headers: { accept: \"application/nostr+json\" } })).json();\n renderHeader();\n const git = info.supported_grasps?.includes(\"GRASP-01\");\n $(\"#git-connect\").classList.toggle(\"hidden\", !git);\n const clone = \"git clone '\" + location.origin + \"//.git'\";\n $(\"#git-clone\").textContent = clone;\n $(\"#git-copy\").dataset.copytext = clone;\n renderApps();\n }\n\n async function loadFuel() {\n try { fuel = await (await fetch(\"/fuel\")).json(); } catch { return; }\n $(\"#fuelsec\").classList.toggle(\"hidden\", !owner);\n const pct = (used, free) => Math.min(100, Math.round((used / Math.max(free, 1)) * 100));\n const gauge = (id, used, free, text) => { const g = $(\"#g-\" + id), share = free ? used / free : 0; g.style.width = pct(used, free) + \"%\"; g.classList.toggle(\"over\", share > 1); g.classList.toggle(\"warm\", share > 0.75 && share <= 1); $(\"#p-\" + id).textContent = share > 1 ? \"over\" : Math.round(share * 100) + \"%\"; $(\"#t-\" + id).textContent = text; };\n gauge(\"events\", fuel.eventBytes, fuel.freeEventBytes, fmtBytes(fuel.eventBytes) + \" of \" + fmtBytes(fuel.freeEventBytes) + \" free\");\n gauge(\"media\", fuel.mediaBytes, fuel.freeMediaBytes, fmtBytes(fuel.mediaBytes) + \" of \" + fmtBytes(fuel.freeMediaBytes) + \" free\");\n gauge(\"active\", fuel.activeMs, fuel.freeActiveMs, fmtHours(fuel.activeMs) + \" of \" + fmtHours(fuel.freeActiveMs) + \" free\");\n gauge(\"rows\", fuel.rowsWritten, fuel.freeRowsWritten, fuel.rowsWritten.toLocaleString() + \" of \" + fuel.freeRowsWritten.toLocaleString() + \" free\");\n const sats = Math.floor(fuel.balanceMsats / 1000);\n const r = fuel.rates;\n $(\"#fuel-balance\").innerHTML = fuel.outOfFuel\n ? 'out of fuel Writes are paused until someone tops up.'\n : \"Balance \" + sats.toLocaleString() + \" sats. Past the allowances, prices track what the hosting costs: \" + r.satsPerGBMonthEvents.toLocaleString() + \" sats per GB-month of events, \" + r.satsPerGBMonthMedia.toLocaleString() + \" per GB-month of files, \" + r.satsPerActiveHour.toLocaleString() + \" per hour awake, \" + r.satsPerMillionRows.toLocaleString() + \" per million rows written. Traffic is free.\";\n $(\"#topup\").classList.toggle(\"hidden\", !fuel.enabled);\n // Who paid is the owner's to see: it comes with the signed stats call.\n let credits = [];\n if (owner && signer.ready()) { try { credits = (await rpc(\"stats\")).credits || []; } catch { credits = []; } }\n $(\"#credits tbody\").innerHTML = credits.length ? credits.map((c) => '' + esc(fmtTime(c.at)) + '' + av(c.payer) + key(c.payer) + '' + Math.floor(c.msats / 1000).toLocaleString() + \"\").join(\"\") : 'no zaps yet';\n if (!fuel.enabled) $(\"#topup-note\").textContent = \"Top-ups are not enabled on this service yet.\";\n renderCare();\n renderFuelTile();\n }\n\n async function topUp(sats) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const msats = Math.round(sats * 1000);\n const zapRequest = await signer.signEvent({ kind: 9734, created_at: Math.floor(Date.now() / 1000), content: \"fuel for \" + host, tags: [[\"p\", fuel.servicePubkey], [\"amount\", String(msats)], [\"relays\", wsURL]] });\n const r = await (await fetch(\"/fuel/invoice\", { method: \"POST\", body: JSON.stringify({ zapRequest }) })).json();\n if (r.error) throw new Error(r.error);\n const inv = r.invoice;\n $(\"#invoice\").classList.remove(\"hidden\");\n $(\"#inv-text\").value = inv; $(\"#inv-link\").href = \"lightning:\" + inv; $(\"#inv-state\").textContent = \"waiting for payment…\";\n if (window.webln) { try { await window.webln.enable(); await window.webln.sendPayment(inv); } catch (e) { toast(e.message || \"wallet declined\"); } }\n const before = fuel.creditedMsats;\n for (let i = 0; i < 120; i++) {\n await new Promise((r) => setTimeout(r, 2500));\n await loadFuel();\n if (fuel.creditedMsats > before) { $(\"#inv-state\").textContent = \"paid, thank you\"; toast(\"Fuel credited\"); return; }\n }\n $(\"#inv-state\").textContent = \"no receipt yet; it can take a minute after paying\";\n }\n\n // ---- console ----\n function showTab(name) {\n if (name === \"content\" || name === \"storage\") name = \"data\";\n const known = myRole === \"moderator\" ? [\"people\", \"moderation\"] : [\"people\", \"moderation\", \"rules\", \"identity\", \"data\", \"sync\", \"views\", \"health\", \"owner\"];\n if (!known.includes(name)) name = \"people\";\n $$(\".tabs a\").forEach((a) => a.classList.toggle(\"on\", a.dataset.tab === name));\n $$(\".panel\").forEach((p) => p.classList.toggle(\"on\", p.dataset.panel === name));\n }\n // Tabs switch in place. A fragment still opens a tab when someone arrives with one,\n // but clicking never writes one, and any fragment already there is dropped.\n $$(\".tabs a\").forEach((a) => a.addEventListener(\"click\", (ev) => { ev.preventDefault(); showTab(a.dataset.tab); if (location.hash) history.replaceState(null, \"\", location.pathname + location.search); }));\n window.addEventListener(\"hashchange\", () => showTab(location.hash.slice(1)));\n // The strip fades on whichever side has more tabs off screen.\n const tabsFade = () => { const t = $(\"#tabs\"); t.classList.toggle(\"fade-l\", t.scrollLeft > 4); t.classList.toggle(\"fade-r\", t.scrollLeft + t.clientWidth < t.scrollWidth - 4); };\n $(\"#tabs\").addEventListener(\"scroll\", tabsFade); window.addEventListener(\"resize\", tabsFade); new ResizeObserver(tabsFade).observe($(\"#tabs\"));\n\n function renderFuelTile() {\n if (!fuel) return;\n const over = fuelOver();\n const sats = Math.max(0, Math.floor(fuel.balanceMsats / 1000));\n const d = new Date(), end = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1), days = Math.max(1, Math.ceil((end - Date.now()) / 86400000));\n if (fuel.outOfFuel) { $(\"#h-fuel\").textContent = \"0 sats\"; $(\"#h-fuel-label\").textContent = \"out of fuel\"; $(\"#h-fuel-sub\").textContent = \"writes are paused\"; }\n else if (over) { $(\"#h-fuel\").textContent = sats.toLocaleString() + \" sats\"; $(\"#h-fuel-label\").textContent = \"fuel left\"; $(\"#h-fuel-sub\").textContent = \"past the free allowance\"; }\n else { $(\"#h-fuel\").textContent = days + (days === 1 ? \" day\" : \" days\"); $(\"#h-fuel-label\").textContent = \"free allowance left\"; $(\"#h-fuel-sub\").textContent = sats ? \"then \" + sats.toLocaleString() + \" sats\" : \"then a top-up is needed\"; }\n $(\"#usage\").innerHTML = [[\"events stored\", fmtBytes(fuel.eventBytes)], [\"files stored\", fmtBytes(fuel.mediaBytes)], [\"awake this month\", fmtHours(fuel.activeMs)], [\"rows written\", fuel.rowsWritten.toLocaleString()], [\"rows read\", fuel.rowsRead.toLocaleString()], [\"received this month\", fmtBytes(fuel.bytesIn)], [\"served this month\", fmtBytes(fuel.bytesOut)], [\"charged\", Math.floor(fuel.chargedMsats / 1000).toLocaleString() + \" sats\"]]\n .map(([k, v]) => \"
                                                                                                                                                                              \" + k + \"\" + v + \"
                                                                                                                                                                              \").join(\"\");\n }\n\n function renderHealth(stats) {\n $(\"#h-last\").textContent = stats.newest ? ago(stats.newest) : \"none\";\n $(\"#h-last-sub\").textContent = stats.newest ? fmtTime(stats.newest) : \"no events yet\";\n $(\"#h-conns\").textContent = stats.connections;\n const buckets = [[\"notes\", \"k1\", [1]], [\"reactions\", \"k2\", [7]], [\"DMs\", \"k3\", [4, 14, 1059]], [\"long-form\", \"k4\", [30023]], [\"other\", \"k5\", null]];\n const sums = buckets.map(() => 0); let total = 0;\n for (const { kind, n } of stats.kinds || []) { total += n; const i = buckets.findIndex((b) => b[2] && b[2].includes(kind)); sums[i < 0 ? 4 : i] += n; }\n const parts = buckets.map((b, i) => [b[0], b[1], sums[i]]).filter((p) => p[2] > 0);\n $(\"#h-kinds\").innerHTML = total ? parts.map(([name, cls, n]) => '').join(\"\") : '';\n $(\"#h-kinds-legend\").innerHTML = total ? parts.map(([name, cls, n]) => '' + name + \" \" + Math.round(n * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n renderFuelTile();\n }\n\n async function loadAdmin() {\n const isOwner = !!me && me === owner;\n myRole = isOwner ? \"owner\" : \"\";\n let stats = null, p = null;\n // A signed-in member may be a moderator; the relay says by answering stats.\n if (me && owner && !isOwner) { try { [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]); myRole = \"moderator\"; } catch { myRole = \"\"; } }\n $(\"#console\").classList.toggle(\"hidden\", !myRole);\n loadMine();\n $(\"#console\").classList.toggle(\"mod\", myRole === \"moderator\");\n if (!myRole) { policy = null; renderCare(); return; }\n if (isOwner) [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]);\n policy = p;\n renderHealth(stats);\n renderCare();\n const fa = $(\"#access\"), fi = $(\"#identity\");\n fa.writes.value = p.writes; fa.reads.value = p.reads; fa.openKinds.value = (p.openKinds || []).join(\", \"); fa.guestReplies.checked = !!p.guestReplies;\n $(\"#wordsform\").words.value = (p.blockedWords || []).join(\"\\n\"); $(\"#wordsform\").inTags.checked = !!p.blockedWordsInTags; $(\"#thresholdform\").reportThreshold.value = p.reportThreshold || 0; fa.minPow.value = p.minPow; fa.maxFuture.value = p.maxFuture; fa.maxLimit.value = p.maxLimit; fa.maxSubs.value = p.maxSubs; fa.maxMessageKB.value = p.maxMessageKB;\n fa.eventsPerMinute.value = p.eventsPerMinute; fa.reqsPerMinute.value = p.reqsPerMinute; fa.maxBlobMB.value = p.maxBlobMB;\n renderFeatures(p.features || {});\n $(\"#push-policy-form\").elements.origins.value = (p.pushCallbacks || []).join(\"\\n\");\n $(\"#push-policy-form\").elements.lettered.checked = !!p.letteredNips;\n $(\"#push-policy-form\").elements.delivery.checked = !!p.delivery?.enabled;\n $(\"#push-policy-form\").elements.deliveryMax.value = p.delivery?.maxTargets || 8;\n fi.name.value = p.name; fi.contact.value = p.contact; fi.description.value = p.description; fi.icon.value = p.icon;\n const fj = $(\"#joinform\"); fj.joinTerms.value = p.joinTerms; fj.directoryPublic.checked = !!p.directoryPublic;\n loadCard();\n fi.banner.value = p.banner || \"\"; fi.postingPolicy.value = p.postingPolicy || \"\"; fi.privacyPolicy.value = p.privacyPolicy || \"\";\n fi.tags.value = (p.tags || []).join(\", \"); fi.languageTags.value = (p.languageTags || []).join(\", \"); fi.relayCountries.value = (p.relayCountries || []).join(\", \");\n const fn = $(\"#notify\"), nt = p.notify || {};\n fn.reports.checked = !!nt.reports; fn.fuel.checked = !!nt.fuel; fn.jobs.checked = !!nt.jobs; fn.succession.checked = !!nt.succession; fn.digest.checked = !!nt.digest;\n showTab(location.hash.slice(1));\n if (isOwner) { await renderPresets(); renderWire(); renderSuccession(); loadDomains(); }\n await Promise.all([loadLists(), loadEvents(true), loadPins(), ...(isOwner ? [loadStorage()] : [])]);\n }\n\n async function loadAudit(before) {\n const rows = await rpc(\"listaudit\", before || 0);\n const html = rows.map((r) => '' + esc(fmtTime(r.at)) + '' + av(r.actor) + key(r.actor) + '' + esc(r.action) + '' + (/^[0-9a-f]{64}$/.test(r.target) ? key(r.target) : esc(r.target)) + '' + esc(r.detail) + '').join(\"\");\n const tb = $(\"#audit tbody\");\n if (before) tb.insertAdjacentHTML(\"beforeend\", html); else tb.innerHTML = html || 'Nothing yet';\n $(\"#audit-more\").style.display = rows.length < 100 ? \"none\" : \"\";\n }\n $(\"#audit-more\").onclick = guard(async () => { const last = $(\"#audit tbody tr:last-child\"); await loadAudit(last ? +last.dataset.seq : 0); });\n async function loadLists() {\n loadAudit(0);\n const [mem, bans, bannedEvents, allow, block, invites, reports, blobs, blocks, sites] = await Promise.all([rpc(\"listmembers\"), rpc(\"listbannedpubkeys\"), rpc(\"listbannedevents\"), rpc(\"listallowedkinds\"), rpc(\"listblockedkinds\"), rpc(\"listinvites\"), rpc(\"listreports\"), rpc(\"listblobs\", 100), rpc(\"listblockedips\"), rpc(\"listsites\")]);\n const members = mem.members;\n window.__members = members;\n const roleCell = (m) => m.role === \"owner\" ? ' owner' : myRole === \"owner\" ? ' ' : m.role === \"moderator\" ? ' moderator' : \"\";\n const untouchable = (m) => m.role === \"owner\" || (myRole !== \"owner\" && m.role === \"moderator\");\n // The tree: everyone under whoever invited them, the owner and the owner's own additions at the root.\n const known = new Set(members.map((m) => m.pubkey));\n const byInviter = new Map();\n for (const m of members) { const k = m.role !== \"owner\" && known.has(m.invited_by) ? m.invited_by : \"\"; if (!byInviter.has(k)) byInviter.set(k, []); byInviter.get(k).push(m); }\n const ordered = [], placed = new Set();\n const walk = (k, depth) => { for (const m of byInviter.get(k) || []) { if (placed.has(m.pubkey)) continue; placed.add(m.pubkey); m.depth = depth; ordered.push(m); walk(m.pubkey, depth + 1); } };\n walk(\"\", 0);\n for (const m of members) if (!placed.has(m.pubkey)) { m.depth = 0; ordered.push(m); }\n const nameOf = (pk) => { const x = members.find((m) => m.pubkey === pk); return x && x.name ? x.name : short(pk); };\n const limits = (m) => m.role === \"owner\" ? \"\" : myRole === \"owner\"\n ? ''\n : '' + (m.keep_days ? m.keep_days + \" d\" : \"\") + (m.max_bytes ? \" \" + fmtBytes(m.max_bytes) : \"\") + \"\";\n $(\"#members tbody\").innerHTML = ordered.map((m) => '' + av(m.pubkey) + key(m.pubkey) + roleCell(m) + (m.depth ? ' via ' + esc(nameOf(m.invited_by)) + \"\" : \"\") + (m.invites ? ' ' + m.invites + \" inv\" : \"\") + '' + limits(m) + '' + esc(fmtDay(m.joined_at)) + \", \" + esc(m.via) + '' + ib(\"check\", \"Save\", \"savemember\", m.pubkey) + (untouchable(m) ? \"\" : ib(\"x\", \"Remove\", \"removemember\", m.pubkey) + ib(\"banuser\", \"Ban\", \"banpubkey\", m.pubkey, \"danger\")) + \"\").join(\"\");\n const tf = $(\"#treeform\");\n tf.classList.toggle(\"hidden\", myRole !== \"owner\");\n if (policy && policy.memberInvites) { tf.depth.value = policy.memberInvites.depth; tf.quota.value = policy.memberInvites.quota; }\n $(\"#transfer [name=pubkey]\").innerHTML = members.filter((m) => m.role !== \"owner\").map((m) => '\").join(\"\");\n $(\"#succession [name=heir]\").innerHTML = $(\"#transfer [name=pubkey]\").innerHTML;\n $(\"#tc-people\").textContent = members.length;\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#invites\").innerHTML = invites.length ? invites.map((i) => '
                                                                                                                                                                            • inv…' + i.code.slice(-8) + \" \" + esc(i.note || \"\") + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
                                                                                                                                                                            • \").join(\"\") : '
                                                                                                                                                                            • no invites
                                                                                                                                                                            • ';\n const person = (r, icon, label, act) => \"
                                                                                                                                                                            • \" + av(r.pubkey) + '' + short(r.pubkey) + \" \" + esc(r.reason || \"\") + \"\" + ib(icon, label, act, r.pubkey) + \"
                                                                                                                                                                            • \";\n $(\"#bans\").innerHTML = bans.length ? bans.map((r) => person(r, \"undo\", \"Unban\", \"unrulepubkey\")).join(\"\") : '
                                                                                                                                                                            • nobody banned
                                                                                                                                                                            • ';\n $(\"#blocks\").innerHTML = blocks.length ? blocks.map((r) => '
                                                                                                                                                                            • ip' + esc(r.ip) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"undo\", \"Unblock\", \"unblockip\", r.ip) + \"
                                                                                                                                                                            • \").join(\"\") : '
                                                                                                                                                                            • no addresses blocked
                                                                                                                                                                            • ';\n $(\"#banned-events\").innerHTML = bannedEvents.map((r) => '
                                                                                                                                                                            • ev' + key(r.id) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"check\", \"Allow again\", \"allowevent\", r.id) + \"
                                                                                                                                                                            • \").join(\"\");\n $(\"#reports tbody\").innerHTML = reports.length ? reports.map((r) => '' + esc(fmtTime(r.at)) + '' + esc(r.type || \"report\") + '' + av(r.target_pubkey) + key(r.target_pubkey) + (r.target_event ? (r.blob ? ' file ' + key(r.target_event) + \"\" : ' ev ' + key(r.target_event) + (r.hidden ? ' hidden' : \"\") + \"\") : \"\") + '' + esc(r.content) + '' + ib(\"check\", \"Dismiss\", \"resolve:dismiss\", r.id) + ib(\"trash\", r.blob ? \"Delete the file\" : \"Delete the event\", \"resolve:delete\", r.id) + ib(\"banuser\", \"Ban the author\", \"resolve:ban\", r.id, \"danger\") + \"\").join(\"\") : 'nothing reported';\n $(\"#reports-count\").textContent = reports.length || \"\"; $(\"#tc-reports\").textContent = reports.length || \"\";\n $(\"#blobs tbody\").innerHTML = blobs.length ? blobs.map((b) => '' + esc(fmtTime(b.uploaded)) + '' + key(b.sha256) + ' ' + esc(b.type) + '' + fmtBytes(b.size) + '' + av(b.uploader) + key(b.uploader) + '' + ib(\"trash\", \"Delete file\", \"deleteblob\", b.sha256, \"danger\") + \"\").join(\"\") : 'no uploads';\n $(\"#blobs-count\").textContent = blobs.length || \"\";\n $(\"#sites tbody\").innerHTML = sites.length ? sites.map((s) => '' + av(s.author) + key(s.author) + '' + esc(s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + '' + esc(s.url) + '' + esc(s.paths) + (s.missing ? ' ' + esc(s.missing) + ' missing' : '') + '' + fmtBytes(s.size) + '' + esc(s.expires_at ? fmtTime(s.expires_at) : \"never\") + '' + ib(\"trash\", \"Delete site\", \"deleteevent\", s.id, \"danger\") + '').join(\"\") : 'no sites yet';\n $(\"#sites-count\").textContent = sites.length || \"\";\n const tag = (k, cls) => '' + k + ib(\"x\", \"Remove rule\", \"unrulekind\", String(k)) + \"\";\n $(\"#kinds-allow\").innerHTML = allow.length ? allow.map((k) => tag(k, \"ok\")).join(\"\") : 'all';\n $(\"#kinds-block\").innerHTML = block.length ? block.map((k) => tag(k, \"blk\")).join(\"\") : 'none';\n await loadPeople();\n }\n\n const KIND_NAMES = { 0: \"profiles\", 1: \"notes\", 3: \"contacts\", 4: \"DMs\", 5: \"deletions\", 6: \"reposts\", 7: \"reactions\", 16: \"reposts\", 1059: \"gift wraps\", 1063: \"file headers\", 1111: \"comments\", 1984: \"reports\", 9734: \"zap requests\", 9735: \"zap receipts\", 9802: \"highlights\", 10002: \"relay lists\", 13534: \"roster\", 9000: \"group adds\", 9001: \"group removals\", 9021: \"join requests\", 9022: \"leave requests\", 39000: \"group info\", 39001: \"group admins\", 39002: \"group members\", 39003: \"group roles\", 30023: \"articles\", 30024: \"drafts\", 30078: \"app data\", 30311: \"live events\", 30818: \"wiki\" };\n const kindName = (k) => KIND_NAMES[k] || (k >= 20000 && k < 30000 ? \"ephemeral\" : k >= 30000 && k < 40000 ? \"addressable\" : k >= 10000 && k < 20000 ? \"replaceable\" : \"kind \" + k);\n const SYS_KINDS = new Set([0, 3, 10002, 9735, 13534, 8000, 8001, 9000, 9001, 39000, 39001, 39002, 39003]);\n let storage = null;\n\n // Features (settings.ts): a select each; search has three modes, the rest on or off.\n const FEATURES = [\n [\"search\", \"Search\", \"NIP-50. Prose indexes notes, threads, comments, highlights, articles and wiki pages; full indexes every public kind with content. A change applies to events from then on.\", [\"prose:prose\", \"full:full\", \"off:off\"]],\n [\"sync\", \"Sync\", \"NIP-77 reconciliation, which reads the whole matching set per sync.\"],\n [\"count\", \"Counts\", \"NIP-45 COUNT, with HLL sketches.\"],\n [\"discovery\", \"Discovery record\", \"NIP-66: the record the relay signs about itself, for crawlers.\"],\n [\"names\", \"Names\", \"NIP-05 addresses under this relay's domain.\"],\n [\"files\", \"Files\", \"Blossom and NIP-96: uploads, downloads and listings.\"],\n [\"pages\", \"Pages and feed\", \"Notes and articles as pages, and the Atom feed.\"],\n [\"sites\", \"Static websites\", \"NIP-5A sites on their own hostnames. Mirroring copies missing files into this relay and costs fuel.\", [\"mirror:on, mirror files\", \"proxy:on, fetch as needed\", \"off:off\"]],\n [\"marmot\", \"Marmot transport\", \"Signed KeyPackages and encrypted group messages, with account admission for ephemeral authors.\"],\n [\"grasp\", \"Git repositories\", \"GRASP Git hosting with admitted repository state. The prototype backend has bounded storage and compute limits.\"],\n [\"push\", \"Relay push\", \"NIP-9a callback delivery for members and the owner. Requires approved callback origins; advertises lettered NIP identifiers.\"],\n [\"signer\", \"Signer traffic\", \"NIP-46 remote signing carried for anyone, never stored.\"],\n ];\n function renderFeatures(f) {\n $(\"#features\").innerHTML = FEATURES.map(([k, title, about, modes]) => {\n const cur = k === \"sites\" ? (f.sites?.enabled === false ? \"off\" : f.sites?.mirror === false ? \"proxy\" : \"mirror\") : modes ? String(f[k] || \"prose\") : String(f[k] !== false);\n const opts = (modes || [\"true:on\", \"false:off\"]).map((m) => { const [v, l] = m.split(\":\"); return '\"; }).join(\"\");\n return \"\";\n }).join(\"\");\n }\n $(\"#features\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-feature]\"); if (!sel) return;\n const k = sel.dataset.feature, v = k === \"search\" || k === \"sites\" ? sel.value : sel.value === \"true\";\n policy = await rpc(\"setpolicy\", { features: { [k]: k === \"sites\" ? { enabled: v !== \"off\", mirror: v === \"mirror\" } : v } });\n toast(k === \"search\" ? \"Search: \" + v : (v ? \"Switched on \" : \"Switched off \") + k); await loadInfo();\n }));\n\n $(\"#push-policy-form\").addEventListener(\"submit\", guard(async (ev) => {\n ev.preventDefault();\n const form = ev.target;\n const origins = form.elements.origins.value.split(/\\s+/).filter(Boolean);\n const updated = await rpc(\"setpolicy\", { pushCallbacks: origins, letteredNips: form.elements.lettered.checked, delivery: { enabled: form.elements.delivery.checked, maxTargets: Math.max(1, Math.min(16, Math.floor(+form.elements.deliveryMax.value || 8))) } });\n if (JSON.stringify(updated.pushCallbacks) !== JSON.stringify([...new Set(origins.map((s) => s.replace(/\\/$/, \"\")))])) throw new Error(\"Use up to sixteen exact HTTPS origins, with no path or credentials.\");\n policy = updated;\n toast(\"Saved delivery policy\"); await loadInfo();\n }));\n\n async function loadViews() {\n let views;\n try { views = await rpc(\"listviews\"); } catch { return; }\n $(\"#views\").innerHTML = views.map((v) => {\n const runs = v.trigger === \"off\" ? \"off\" : v.trigger === \"live\" ? \"live, from memory\" : v.trigger === \"write\" ? \"on write and daily\" : v.trigger;\n const label = (c) => (c === \"off\" ? \"off\" : c === \"write\" ? \"on write\" : c === \"live\" ? \"on\" : c);\n const choices = [...v.choices, v.default].filter((c, i, a) => a.indexOf(c) === i);\n const pick = '\";\n const who = v.audience === \"members\" ? \"members\" + (v.stored ? \"\" : \", on request\") : \"anyone\";\n const last = v.trigger === \"live\" ? \"\" : v.last ? \"last run \" + fmtTime(v.last.at) : \"not run yet\";\n const rows = v.trigger === \"live\" ? \"no rows\" : v.last ? v.last.rows.toLocaleString() + \" rows\" : \"\";\n const meta = [runs, who, last, rows].filter(Boolean).map((x) => \"\" + esc(x) + \"\").join('|');\n return '
                                                                                                                                                                              ' + esc(v.name) + '
                                                                                                                                                                              ' + esc(v.about) + '
                                                                                                                                                                              ' + pick + (v.on ? 'Open' : \"\") + '
                                                                                                                                                                              ' + meta + \"
                                                                                                                                                                              \";\n }).join(\"\");\n }\n $(\"#views\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-view]\"); if (!sel) return;\n const value = sel.value === \"live\" ? true : sel.value;\n await rpc(\"setpolicy\", { views: { [sel.dataset.view]: value } });\n toast(sel.value === \"off\" ? \"Took down \" + sel.dataset.view : sel.dataset.view + \": \" + (sel.value === \"live\" ? \"on\" : sel.value === \"write\" ? \"on write\" : sel.value)); await loadViews(); await loadInfo();\n }));\n async function loadDumps() {\n const list = await rpc(\"listdumps\");\n const f = $(\"#dumpform\");\n if (policy) { f.dumps.value = policy.dumps || \"off\"; f.keep.value = policy.dumpsKeep || 7; }\n $(\"#dumps-count\").textContent = list.length || \"\";\n $(\"#dumps\").innerHTML = list.length ? list.map((d) => '
                                                                                                                                                                            • jsonl' + esc(d.name) + ' ' + d.events.toLocaleString() + \" events, \" + fmtBytes(d.bytes) + \"\" + ib(\"copy\", \"Download\", \"downloaddump\", d.name) + ib(\"trash\", \"Delete\", \"deletedump\", d.name, \"danger\") + \"
                                                                                                                                                                            • \").join(\"\") : '
                                                                                                                                                                            • no dumps yet
                                                                                                                                                                            • ';\n }\n // A dump is fetched with a signed request and handed to the browser as a file.\n async function downloadDump(name) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const url = location.origin + \"/dumps/\" + name;\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"GET\"]] });\n const resp = await fetch(url, { headers: { authorization: \"Nostr \" + btoa(JSON.stringify(ev)) } });\n if (!resp.ok) throw new Error((await resp.json()).error || \"download failed\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(\".\")[0] + \"-\" + name; a.click(); URL.revokeObjectURL(a.href);\n }\n // A plain member's own invites, when the owner lets members invite.\n async function loadMine() {\n const sec = $(\"#myinvites\");\n if (!me || !owner || myRole || !signer.ready()) { sec.classList.add(\"hidden\"); return; }\n let mine;\n try { mine = await rpc(\"listinvites\"); } catch { sec.classList.add(\"hidden\"); return; }\n sec.classList.remove(\"hidden\");\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#mine\").innerHTML = mine.length ? mine.map((i) => '
                                                                                                                                                                            • inv…' + i.code.slice(-8) + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
                                                                                                                                                                            • \").join(\"\") : '
                                                                                                                                                                            • no invites yet
                                                                                                                                                                            • ';\n }\n async function loadStorage() {\n storage = await rpc(\"storagestats\");\n pollJobs();\n loadViews();\n loadDumps().catch(() => {});\n const st = storage;\n const top = st.kinds.slice(0, 4), rest = st.kinds.slice(4).reduce((a, k) => a + k.bytes, 0);\n const parts = [...top.map((k, i) => [k.kind + \" \" + kindName(k.kind), \"k\" + (i + 1), k.bytes]), ...(rest ? [[\"other\", \"k5\", rest]] : [])];\n const total = st.eventBytes || 1;\n $(\"#s-bar\").innerHTML = parts.length ? parts.map(([n, c, b]) => '').join(\"\") : '';\n $(\"#s-legend\").innerHTML = parts.length ? parts.map(([n, c, b]) => '' + esc(n) + \" \" + Math.round(b * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n const overhead = Math.max(0, st.databaseBytes - st.eventBytes);\n $(\"#s-totals\").innerHTML = [[\"events\", st.events.toLocaleString() + \" \" + fmtBytes(st.eventBytes) + \"\"], [\"index and overhead\", fmtBytes(overhead)], [\"files\", st.blobs.toLocaleString() + \" \" + fmtBytes(st.mediaBytes) + \"\"]]\n .map(([k, v]) => \"
                                                                                                                                                                              \" + k + \"\" + v + \"
                                                                                                                                                                              \").join(\"\");\n const any = st.retention.find((r) => r.kind === null);\n const own = (kind) => { const r = st.retention.find((x) => x.kind === kind); return r ? r.days : \"\"; };\n const row = (k) => {\n const key = k.kind === null ? \"\" : String(k.kind);\n const placeholder = k.kind === null ? \"forever\" : k.replaceable || !any ? \"forever\" : any.days + \" (everything else)\";\n const pill = k.kind === null ? \"everything else\" : '' + k.kind + \" \" + esc(kindName(k.kind));\n const cells = k.protected\n ? 'required'\n : ' days' + ib(\"check\", \"Save keep-for rule\", \"saveretention\", key) + ib(\"trash\", \"Purge\", \"purgekind\", key, \"danger\") + \"\";\n return '' + pill + '' + (k.n === undefined ? \"\" : k.n.toLocaleString()) + '' + (k.bytes === undefined ? \"\" : fmtBytes(k.bytes)) + '' + (k.oldest ? esc(fmtDay(k.oldest)) : \"\") + \"\" + cells + \"\";\n };\n $(\"#kinds tbody\").innerHTML = [...st.kinds, { kind: null, days: any ? any.days : 0 }].map(row).join(\"\");\n loadListHistory().catch(() => {});\n }\n\n async function loadListHistory() {\n const rows = await rpc(\"listlisthistory\");\n const labels = { 3: \"follows\", 10002: \"relay list\", 10003: \"bookmarks\", 30003: \"bookmark list\" };\n $(\"#listhistory tbody\").innerHTML = rows.length ? rows.map((r) => '' + esc((labels[r.kind] || (\"kind \" + r.kind)) + (r.d ? \" / \" + r.d : \"\")) + '' + esc(fmtTime(r.created_at)) + '' + esc(fmtTime(r.saved_at)) + '' + ib(\"undo\", \"Restore this version\", \"restorelist\", r.event_id) + '').join(\"\") : 'no older list versions yet';\n }\n\n let searchQuery = \"\";\n async function loadEvents(reset) {\n const list = searchQuery ? await rpc(\"searchevents\", searchQuery, 200) : await rpc(\"listrecentevents\", reset ? 50 : 200);\n $(\"#events tbody\").innerHTML = list.length ? list.map((e) => '' + esc(fmtTime(e.created_at)) + '' + e.kind + '' + av(e.pubkey) + key(e.pubkey) + '' + esc(e.content) + '' + ib(\"pin\", \"Pin\", \"pinevent\", e.id) + ib(\"trash\", \"Delete event\", \"deleteevent\", e.id) + ib(\"ban\", \"Ban event\", \"banevent\", e.id, \"danger\") + ib(\"banuser\", \"Ban author\", \"banpubkey\", e.pubkey, \"danger\") + \"\").join(\"\") : 'no events yet';\n $(\"#more\").classList.toggle(\"hidden\", searchQuery || list.length < 50);\n }\n $(\"#searchform\").onsubmit = guard(async (ev) => {\n searchQuery = ev.target.q.value.trim();\n $(\"#searchclear\").classList.toggle(\"hidden\", !searchQuery);\n await loadEvents(true);\n });\n $(\"#searchclear\").onclick = guard(async () => {\n searchQuery = \"\"; $(\"#searchform\").reset(); $(\"#searchclear\").classList.add(\"hidden\");\n await loadEvents(true);\n });\n async function loadPins() {\n const pins = await rpc(\"listpins\");\n $(\"#pins-count\").textContent = pins.length || \"\";\n $(\"#pins\").innerHTML = pins.length ? pins.map((t) => '
                                                                                                                                                                            • ' + (t[0] === \"e\" ? \"event\" : \"address\") + '' + esc(t[1].length > 40 ? t[1].slice(0, 16) + \"\\u2026\" + t[1].slice(-8) : t[1]) + \"\" + ib(\"copy\", \"Copy\", \"copy\", t[1]) + ib(\"x\", \"Unpin\", \"unpinevent\", t[1]) + \"
                                                                                                                                                                            • \").join(\"\") : '
                                                                                                                                                                            • nothing pinned
                                                                                                                                                                            • ';\n }\n $(\"#pinform\").onsubmit = guard(async (ev) => {\n await rpc(\"pinevent\", ev.target.id.value.trim()); ev.target.reset(); toast(\"Pinned\"); await loadPins();\n });\n\n // ---- actions ----\n const refresh = () => Promise.all([loadLists(), loadEvents(true), loadStorage(), loadPins()]);\n\n $(\"#copy\").onclick = async () => { await navigator.clipboard.writeText(wsURL); toast(\"copied \" + wsURL); };\n $(\"#signin\").onclick = guard(async () => {\n if (!window.nostr) throw new Error(\"No nostr extension found. Install one (Alby, nos2x, …) and reload, or use a remote signer.\");\n me = await window.nostr.getPublicKey();\n localStorage.setItem(\"me\", me);\n renderHeader(); await loadAdmin(); await loadPeople();\n });\n $(\"#signin46\").onclick = () => showRemote(null);\n $$(\".remote\").forEach((b) => { b.onclick = () => showRemote($(\"#\" + b.dataset.note)); });\n $(\"#nccopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#nclink\").href); toast(\"Link copied; paste it into your signer app\"); };\n $(\"#bunkerform\").onsubmit = guard(async (ev) => {\n $(\"#remotenote\").textContent = \"Connecting…\";\n try { await connectBunker(ev.target.url.value); } catch (e) { $(\"#remotenote\").textContent = e.message; throw e; }\n $(\"#remotenote\").textContent = \"\"; ev.target.reset();\n await remoteDone();\n });\n $(\"#signout\").onclick = async () => {\n if (remote) { const r = remote; remote = null; try { await withTimeout(r.logout(), 5000, \"The signer\"); } catch { /* the session is gone either way */ } }\n localStorage.removeItem(\"nip46\");\n me = null; localStorage.removeItem(\"me\"); renderHeader(); await loadAdmin(); await loadPeople();\n };\n const claimNow = async (note) => {\n if (!signer.ready()) { note.textContent = \"Sign the claim with a nostr extension or a remote signer.\"; showRemote(note); return; }\n me = await signer.getPublicKey();\n const r = await rpc(\"claim\");\n if (!r.claimed) throw new Error(\"Somebody else claimed it first.\");\n localStorage.setItem(\"me\", me);\n toast(\"It's yours.\");\n await loadInfo(); await loadFuel(); await loadAdmin(); await loadPeople();\n if (r.converted && confirm(\"This relay began as a temporary one: anyone can write, and everything is deleted after 14 days. Switch to the default rules and keep everything from now on? Each rule can be changed later on the Rules and Storage tabs.\")) {\n await rpc(\"resetrules\"); toast(\"Rules reset\"); await loadInfo(); await loadAdmin();\n }\n };\n $(\"#claim\").onclick = guard(() => claimNow($(\"#claimnote\")));\n $(\"#claimlease\").onclick = guard(() => claimNow($(\"#leasenote\")));\n // Jobs run in the background; the table follows them while the tab is open.\n let jobsTimer = 0;\n const fmtJob = (j) => {\n const what = j.kind === \"mirror\" ? \"mirror site\" : j.kind === \"import\" ? \"import\" : j.label === \"backfill\" ? \"fetch my history\" : j.kind === \"pull\" ? \"pull\" : \"rebroadcast\";\n const f = [];\n if (j.filter.authors) f.push(j.filter.authors.length === 1 && j.filter.authors[0] === me ? \"my events\" : j.filter.authors.length + \" authors\");\n if (j.filter.kinds) f.push(\"kinds \" + j.filter.kinds.join(\", \"));\n if (j.filter.since) f.push(\"since \" + fmtDay(j.filter.since));\n const when = j.every ? \"every \" + (j.every === 24 ? \"day\" : j.every + \" h\") + (j.nextRun ? \", next \" + fmtTime(j.nextRun) : \"\") : \"once\";\n const l = j.last;\n const count = (stored, blobs, sent, refused) => (j.kind === \"mirror\" ? blobs.toLocaleString() + \" files mirrored\" : j.kind === \"import\" ? stored.toLocaleString() + \" events\" + ((j.last ? j.last.duplicates : j.duplicates) ? \", \" + (j.last ? j.last.duplicates : j.duplicates) + \" already here\" : \"\") : j.kind === \"pull\" ? stored.toLocaleString() + \" events\" + (blobs ? \", \" + blobs + \" files\" : \"\") : sent.toLocaleString() + \" sent\" + (refused ? \", \" + refused + \" refused\" : \"\"));\n const res = j.running ? \"running: \" + count(j.stored, j.blobs, j.sent, j.refused) + \"...\" : !l ? \"waiting\" : l.error ? \"failed: \" + l.error : count(l.stored, l.blobs, l.sent, l.refused) + (l.skipped ? \", \" + l.skipped + \" skipped\" : \"\") + \", \" + fmtTime(l.finishedAt);\n const sources = j.running ? j.pullSources : l?.sources;\n const details = sources?.length ? '
                                                                                                                                                                              Source results' + sources.map((s) => '

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

                                                                                                                                                                              ').join('') + '
                                                                                                                                                                              ' : '';\n const targets = j.kind === \"push\" && j.targetStatus ? \"
                                                                                                                                                                              \" + Object.entries(j.targetStatus).map(([u, s]) => esc(u) + \": \" + esc(s.status)).join(\"
                                                                                                                                                                              \") + \"
                                                                                                                                                                              \" : \"\";\n return \"\" + what + \"\" + j.relays.map(esc).join(\"
                                                                                                                                                                              \") + targets + \"\" + (f.join(\", \") || \"everything\") + \"\" + when + \"\" + esc(res) + details + \"\" + (j.running ? \"\" : ib(\"undo\", \"Run now\", \"runjob\", j.id)) + ib(\"x\", \"Remove\", \"removejob\", j.id) + \"\";\n };\n async function pollJobs() {\n clearTimeout(jobsTimer);\n let jobs;\n try { jobs = await rpc(\"listjobs\"); } catch { return; }\n $(\"#jobs tbody\").innerHTML = jobs.length ? jobs.map(fmtJob).join(\"\") : 'no jobs yet';\n if (isOwner) {\n try {\n const deliveries = await rpc(\"deliverystatus\");\n $(\"#deliveries tbody\").innerHTML = deliveries.length ? deliveries.map((d) => '' + esc(d.event_id.slice(0, 12)) + '' + esc(d.target) + '' + esc(d.status) + '' + d.attempts + '' + esc(d.error || \"\") + '').join(\"\") : 'no automatic deliveries yet';\n } catch { /* unavailable to non-owners */ }\n }\n if (jobs.some((j) => j.running || (j.nextRun && j.nextRun <= Math.floor(Date.now() / 1000) + 1))) jobsTimer = setTimeout(pollJobs, 3000);\n }\n const urls = (s) => s.split(/[\\s,]+/).map((u) => u.trim()).filter(Boolean);\n const kindsOf = (s) => s.split(/[\\s,]+/).map((k) => parseInt(k, 10)).filter((k) => Number.isInteger(k) && k >= 0);\n $(\"#pullform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"addjob\", { kind: \"pull\", relays: [f.url.value.trim()], every: +f.every.value });\n toast(+f.every.value ? \"Mirror scheduled\" : \"Pull started\"); f.reset(); await pollJobs();\n });\n $(\"#backfillform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"backfill\", urls(f.relays.value));\n toast(\"Fetching your history\"); f.reset(); await pollJobs();\n });\n $(\"#pushform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const filter = {};\n const kinds = kindsOf(f.kinds.value); if (kinds.length) filter.kinds = kinds;\n const days = +f.days.value; if (days > 0) filter.since = Math.floor(Date.now() / 1000) - days * 86400;\n await rpc(\"addjob\", { kind: \"push\", relays: urls(f.targets.value), filter, every: +f.every.value });\n toast(\"Rebroadcast started\"); f.reset(); await pollJobs();\n });\n // ---- presets: writes, reads, kinds and keep-for rules in one click ----\n let presets = null;\n async function renderPresets() {\n if (!presets) { try { presets = await rpc(\"listpresets\"); } catch { presets = []; } }\n $(\"#presets\").innerHTML = presets.map((p) => '\").join(\"\");\n $(\"#presetsourcerow\").classList.toggle(\"hidden\", !presets.some((p) => p.source));\n }\n $(\"#presets\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-preset]\"); if (!b) return;\n const p = (presets || []).find((x) => x.name === b.dataset.preset); if (!p) return;\n if (!confirm(p.title + \": \" + p.about + \"\\n\\nThis replaces the writes and reads rules, the directory setting, the kind rules and the keep-for rules. Limits, identity, people and bans stay.\")) return;\n const source = $(\"#presetsource\").value.trim();\n if (p.source === \"required\" && !source) { $(\"#presetnote\").textContent = p.title + \" needs a source relay to mirror; enter its wss:// URL first.\"; return; }\n b.disabled = true;\n try { policy = await rpc(\"applypreset\", p.name, p.source && source ? { source } : undefined); $(\"#presetnote\").textContent = \"Now: \" + p.about + (policy.job ? \" Mirroring \" + source + \" every \" + policy.job.every + \" h.\" : \"\"); toast(p.title + \" applied\"); await loadInfo(); await loadAdmin(); }\n catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // ---- wire me in: this relay in the owner's own lists ----\n // The lists are replaceable, so a fresh list with only this relay would\n // clobber the real one once it spread. Every publish starts from the\n // newest copy found here or on the indexers, verified when the signer\n // library is around, and adds or removes this relay in it.\n const INDEXERS = [\"wss://purplepag.es\", \"wss://relay.nostr.band\", \"wss://relay.damus.io\", \"wss://nos.lol\"];\n const LISTS = [\n { kind: 10002, tag: \"r\", title: \"Relay list\", nip: \"NIP-65\", about: \"where clients read your notes and send you mentions\" },\n { kind: 10050, tag: \"relay\", title: \"DM inbox\", nip: \"NIP-17\", about: \"where people send you private messages\" },\n { kind: 10007, tag: \"relay\", title: \"Search relays\", nip: \"NIP-51\", about: \"where clients run your searches\" },\n { kind: 10063, tag: \"server\", title: \"Blossom servers\", nip: \"BUD-03\", about: \"where clients upload and look for your files\" },\n ];\n const mineFor = (l) => (l.tag === \"server\" ? location.origin : wsURL);\n const normURL = (u) => { try { const x = new URL(String(u).trim()); return (x.host + x.pathname).replace(/\\/+$/, \"\").toLowerCase(); } catch { return String(u).trim().toLowerCase(); } };\n const isMine = (l, t) => t[0] === l.tag && normURL(t[1] || \"\") === normURL(mineFor(l));\n const listHas = (list, l) => !!list && list.tags.some((t) => isMine(l, t));\n // mergeList is pure: the newest list with this relay put first or taken out, every other tag kept.\n function mergeList(list, l, include) {\n const tags = (list ? list.tags : []).filter((t) => !isMine(l, t));\n if (include) tags.unshift([l.tag, mineFor(l)]);\n return { kind: l.kind, created_at: Math.floor(Date.now() / 1000), content: list ? list.content : \"\", tags };\n }\n const relaysIn = (list) => list.tags.filter((t) => (t[0] === \"r\" || t[0] === \"relay\") && /^wss?:\\/\\//i.test(t[1] || \"\")).map((t) => t[1].trim());\n // overWS opens one socket, sends one message, feeds answers to onMessage until it says done or time runs out.\n function overWS(url, ms, first, onMessage) {\n return new Promise((res) => {\n let ws = null, done = false, out = null;\n const finish = () => { if (done) return; done = true; clearTimeout(timer); try { if (ws) ws.close(); } catch { /* closed */ } res(out); };\n const timer = setTimeout(finish, ms);\n try { ws = new WebSocket(url); } catch { return finish(); }\n ws.onopen = () => ws.send(JSON.stringify(first));\n ws.onmessage = (m) => { let d; try { d = JSON.parse(m.data); } catch { return; } if (!Array.isArray(d)) return; if (onMessage(d, (v) => { out = v; })) finish(); };\n ws.onerror = finish; ws.onclose = finish;\n });\n }\n function fetchNewest(url, filter, ms) {\n let best = null;\n return overWS(url, ms, [\"REQ\", \"w\", filter], (d, set) => {\n if (d[0] === \"EVENT\" && d[1] === \"w\" && d[2] && (!best || d[2].created_at > best.created_at)) { best = d[2]; set(best); }\n return (d[0] === \"EOSE\" || d[0] === \"CLOSED\") && d[1] === \"w\";\n });\n }\n function publishTo(url, event, ms) {\n return overWS(url, ms, [\"EVENT\", event], (d, set) => { if (d[0] === \"OK\" && d[1] === event.id) { set({ url, ok: !!d[2], msg: d[3] || \"\" }); return true; } return false; })\n .then((r) => r || { url, ok: false, msg: \"no answer\" });\n }\n // bridge signs a NIP-98 request to this relay's HTTP door, so the owner's own reads and writes pass any read rule.\n async function bridge(path, body) {\n const url = location.origin + path, raw = JSON.stringify(body);\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"POST\"], [\"payload\", await sha256hex(raw)]] });\n const resp = await fetch(url, { method: \"POST\", headers: { \"content-type\": \"application/json\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body: raw });\n const json = await resp.json();\n if (!resp.ok) throw new Error(json.error || \"HTTP \" + resp.status);\n return json;\n }\n const wire = {};\n function renderWire() {\n const pill = (state) => {\n const cls = state === \"listed\" ? \"on\" : state === \"failed\" ? \"bad\" : state === \"not listed\" ? \"off\" : \"\";\n const label = state === \"checking\" || state === \"publishing\" ? state + \"\\u2026\" : state;\n return '' + esc(label) + \"\";\n };\n $(\"#wire\").innerHTML = LISTS.map((l) => {\n const w = wire[l.kind] || { state: \"\", results: [], list: null };\n const busy = w.state === \"checking\" || w.state === \"publishing\";\n const btn = (act, cls, label) => '\";\n const buttons = w.state === \"listed\" ? btn(\"remove\", \"\", \"Remove this relay\") : btn(\"add\", \"pri\", \"Add this relay\") + btn(\"check\", \"\", w.state ? \"Check again\" : \"Check\");\n const n = w.list ? w.list.tags.filter((t) => t[0] === l.tag).length : 0;\n const from = w.list ? \"\" + (n === 1 ? \"1 entry\" : n + \" entries\") + \", \" + fmtTime(w.list.created_at) + \"\" : w.state && !busy ? \"no list found\" : \"\";\n const sent = (w.results || []).map((r) => '' + esc(r.url.replace(/^wss?:\\/\\//, \"\")) + (r.ok ? \"\" : \" failed\") + \"\").join(\"\");\n const meta = (w.state ? pill(w.state) : \"\") + from + sent;\n return '
                                                                                                                                                                              ' + l.title + ' ' + l.nip + \"
                                                                                                                                                                              \" + l.about + '
                                                                                                                                                                              ' + buttons + '
                                                                                                                                                                              ' + meta + \"
                                                                                                                                                                              \";\n }).join(\"\");\n }\n async function wireCheck(l) {\n const w = (wire[l.kind] = { state: \"checking\", list: null, results: [] }); renderWire();\n const filter = { kinds: [l.kind], authors: [me], limit: 1 };\n const found = [];\n try { found.push(...(await bridge(\"/query\", [filter]))); } catch { /* the relay may hold none */ }\n const remote = await Promise.all(INDEXERS.map((u) => fetchNewest(u, filter, 4000)));\n let lib = null; try { lib = await signerLib(); } catch { /* unverified lists are still the owner's own, by pubkey */ }\n for (const e of remote) if (e && e.pubkey === me && e.kind === l.kind && Array.isArray(e.tags) && (!lib || lib.verifyEvent(e))) found.push(e);\n w.list = found.sort((a, b) => b.created_at - a.created_at)[0] || null;\n w.state = listHas(w.list, l) ? \"listed\" : \"not listed\"; renderWire();\n }\n async function wirePublish(l, include) {\n if (!wire[l.kind] || !wire[l.kind].state || wire[l.kind].state === \"failed\") await wireCheck(l);\n const w = wire[l.kind]; w.state = \"publishing\"; w.results = []; renderWire();\n try {\n const signed = await signer.signEvent(mergeList(w.list, l, include));\n const here = await bridge(\"/events\", signed).then((r) => ({ url: wsURL, ok: !!r.accepted, msg: r.message || \"\" })).catch((e) => ({ url: wsURL, ok: false, msg: e.message }));\n const targets = [...new Set([...relaysIn(signed), ...INDEXERS])].filter((u) => normURL(u) !== normURL(wsURL));\n const rest = await Promise.all(targets.map((u) => publishTo(u, signed, 6000)));\n w.results = [here, ...rest]; w.list = signed; w.state = include ? \"listed\" : \"not listed\";\n toast((include ? \"Added to your \" : \"Removed from your \") + l.title.toLowerCase());\n } catch (e) { w.state = \"failed\"; w.results = [{ url: wsURL, ok: false, msg: e.message }]; toast(e.message); }\n renderWire();\n }\n $(\"#wire\").addEventListener(\"click\", (ev) => {\n const b = ev.target.closest(\"button[data-wire]\"); if (!b) return;\n const l = LISTS.find((x) => String(x.kind) === b.dataset.wire); if (!l) return;\n if (b.dataset.do === \"check\") wireCheck(l); else wirePublish(l, b.dataset.do === \"add\");\n });\n\n // ---- custom domains: this relay under a hostname the owner controls ----\n let domainSites = [];\n const domainOptions = (current = \"\") => [{ label: \"\", title: \"This relay\" }, ...domainSites.map((s) => ({ label: s.label, title: (s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + \" / \" + key(s.author) })), ...(current && !domainSites.some((s) => s.label === current) ? [{ label: current, title: \"Site \" + current }] : [])].map((s) => '\").join(\"\");\n let domains = null; // null: not enabled on this host\n function renderDomains() {\n $(\"#domains\").innerHTML = (domains || []).map((d) => {\n const state = d.ready ? \"active\" : \"hostname \" + d.status.replace(/_/g, \" \") + \", certificate \" + d.sslStatus.replace(/_/g, \" \");\n const btn = (act, cls, label) => '\";\n const rows = d.ready ? \"\" : '
                                                                                                                                                                              ' + d.records.map((r) => \"\").join(\"\") + \"
                                                                                                                                                                              TypeNameValue
                                                                                                                                                                              \" + esc(r.type) + '' + esc(r.name) + '' + esc(r.value) + \"\" + esc(r.note) + \"
                                                                                                                                                                              \";\n return '
                                                                                                                                                                              ' + esc(d.host) + '' + esc(state) + \"\" + btn(\"check\", \"\", \"Check\") + btn(\"remove\", \"danger\", \"Remove\") + '
                                                                                                                                                                              \" + rows;\n }).join(\"\");\n }\n async function loadDomains() {\n try { [domains, domainSites] = await Promise.all([rpc(\"listdomains\"), rpc(\"listsites\")]); $(\"#adddomain select[name=site]\").innerHTML = domainOptions(); $(\"#domainnote\").textContent = domains.length ? \"\" : \"No custom domain yet.\"; $(\"#adddomain\").classList.remove(\"hidden\"); }\n catch (e) { domains = null; $(\"#domainnote\").textContent = /^unsupported/.test(e.message) ? \"Custom domains are not enabled on this host.\" : e.message; $(\"#adddomain\").classList.add(\"hidden\"); }\n renderDomains();\n }\n $(\"#adddomain\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"adddomain\", f.host.value.trim(), f.site.value); f.reset(); toast(\"Domain added; now create the CNAME\"); await loadDomains();\n });\n $(\"#domains\").addEventListener(\"change\", guard(async (ev) => {\n const select = ev.target.closest(\"select[data-domain-site]\"); if (!select) return;\n await rpc(\"setdomainsite\", select.dataset.domainSite, select.value);\n toast(\"Domain destination saved\"); await loadDomains();\n }));\n $(\"#domains\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-domain]\"); if (!b) return;\n const host = b.dataset.domain;\n if (b.dataset.do === \"remove\" && !confirm(\"Remove \" + host + \"? Its certificate goes with it and the name stops answering.\")) return;\n b.disabled = true;\n try {\n if (b.dataset.do === \"remove\") { await rpc(\"removedomain\", host); toast(\"Removed\"); }\n else { const d = await rpc(\"checkdomain\", host); toast(d.ready ? host + \" is live\" : \"Not yet: \" + (d.status === \"active\" ? \"certificate pending\" : \"waiting for the CNAME\")); }\n await loadDomains();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n $(\"#access\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const openKinds = f.openKinds.value.split(/[\\s,]+/).filter(Boolean).map(Number);\n if (openKinds.some((k) => !Number.isInteger(k) || k < 0 || k > 65535)) throw new Error(\"Open kinds must be whole numbers.\");\n policy = await rpc(\"setpolicy\", { writes: f.writes.value, reads: f.reads.value, openKinds, guestReplies: f.guestReplies.checked, minPow: +f.minPow.value, maxFuture: +f.maxFuture.value, maxLimit: +f.maxLimit.value, maxSubs: +f.maxSubs.value, maxMessageKB: +f.maxMessageKB.value, eventsPerMinute: +f.eventsPerMinute.value, reqsPerMinute: +f.reqsPerMinute.value, maxBlobMB: +f.maxBlobMB.value });\n toast(\"Rules saved\"); await loadInfo();\n });\n $(\"#notify\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { notify: { reports: f.reports.checked, fuel: f.fuel.checked, jobs: f.jobs.checked, succession: f.succession.checked, digest: f.digest.checked } });\n toast(\"Notifications saved\");\n });\n $(\"#notifytest\").onclick = guard(async () => {\n const r = await rpc(\"notifytest\");\n toast(r.sent ? \"Sent. Look for a message from the relay in your DMs.\" : \"Could not send.\");\n });\n $(\"#identity\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const list = (v) => v.split(\",\").map((s) => s.trim()).filter(Boolean);\n policy = await rpc(\"setpolicy\", { name: f.name.value, contact: f.contact.value, description: f.description.value, icon: f.icon.value,\n banner: f.banner.value, postingPolicy: f.postingPolicy.value, privacyPolicy: f.privacyPolicy.value, tags: list(f.tags.value), languageTags: list(f.languageTags.value), relayCountries: list(f.relayCountries.value) });\n toast(\"Identity saved\"); await loadInfo(); await loadPeople(); loadCard();\n });\n $(\"#addmember\").onsubmit = guard(async (ev) => {\n const f = ev.target; const pk = npubToHex(f.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"setmember\", pk, { name: f.name.value.trim() || null, note: f.note.value }); f.reset(); toast(\"Member added\"); await loadLists();\n });\n $(\"#addblock\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"blockip\", f.ip.value.trim(), f.reason.value); f.reset(); toast(\"Blocked\"); await loadLists();\n });\n $(\"#wordsform\").onsubmit = guard(async (ev) => {\n const words = ev.target.words.value.split(\"\\n\").map((s) => s.trim()).filter(Boolean);\n const kept = await rpc(\"setblockedwords\", words);\n policy = await rpc(\"setpolicy\", { blockedWordsInTags: ev.target.inTags.checked });\n ev.target.words.value = kept.join(\"\\n\"); toast(kept.length ? kept.length + \" words blocked\" : \"No words blocked\");\n });\n $(\"#thresholdform\").onsubmit = guard(async (ev) => {\n policy = await rpc(\"setpolicy\", { reportThreshold: +ev.target.reportThreshold.value });\n toast(policy.reportThreshold ? \"Hidden after \" + policy.reportThreshold + \" reports\" : \"Never hidden by reports\"); await loadLists();\n });\n $(\"#addban\").onsubmit = guard(async (ev) => {\n const pk = npubToHex(ev.target.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"banpubkey\", pk, ev.target.reason.value, ev.target.erase.checked); ev.target.reset(); toast(ev.target.erase.checked ? \"Banned and erased\" : \"Banned\"); await loadLists();\n });\n $(\"#mintinvite\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const inv = await rpc(\"createinvite\", +f.ttl.value, +f.max.value, f.note.value);\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n f.note.value = \"\"; toast(\"Invite created and copied\"); await loadLists();\n });\n $(\"#kindform\").onsubmit = guard(async (ev) => {\n const rule = ev.submitter.value; const k = +ev.target.kind.value;\n await rpc(rule === \"allow\" ? \"allowkind\" : \"disallowkind\", k); ev.target.reset(); toast((rule === \"allow\" ? \"Allowed kind \" : \"Blocked kind \") + k); await loadLists();\n });\n $(\"#more\").onclick = guard(() => loadEvents(false));\n $(\"#dumpform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { dumps: f.dumps.value, dumpsKeep: Math.max(1, Math.min(60, Math.floor(+f.keep.value || 7))) });\n toast(policy.dumps === \"off\" ? \"Dumps off\" : \"Dumping \" + policy.dumps); await loadDumps();\n });\n $(\"#importform\").onsubmit = guard(async (ev) => {\n const file = ev.target.file.files[0]; if (!file) return;\n if (!signer.ready()) throw new Error(NO_SIGNER);\n if (file.size > 64 * 1024 * 1024) throw new Error(\"At most 64 MB per import.\");\n const body = await file.text();\n const url = location.origin + \"/import?name=\" + encodeURIComponent(file.name);\n const token = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"PUT\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(url, { method: \"PUT\", headers: { authorization: \"Nostr \" + btoa(JSON.stringify(token)), \"content-type\": \"application/x-ndjson\" }, body });\n const r = await resp.json();\n if (!resp.ok) throw new Error(r.error || \"import failed\");\n ev.target.reset(); toast(\"Importing \" + fmtBytes(r.bytes)); await pollJobs();\n });\n $(\"#dumpnow\").onclick = guard(async () => { const d = await rpc(\"dumpnow\"); toast(\"Dumped \" + d.events.toLocaleString() + \" events\"); await loadStorage(); });\n async function backupRequest(path, method, body) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const hash = await sha256hex(body || \"\");\n const token = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", location.origin + path], [\"method\", method], [\"payload\", hash]] });\n return fetch(path, { method, headers: { authorization: \"Nostr \" + btoa(JSON.stringify(token)), ...(body ? { \"content-type\": \"application/json\" } : {}) }, body });\n }\n $(\"#backupnow\").onclick = guard(async () => { const id = $(\"#backupid\").value.trim() || \"backup\"; const r = await rpc(\"backupnow\", id); const resp = await backupRequest(\"/backups/\" + id, \"GET\"); if (!resp.ok) throw new Error(\"backup download failed\"); const a = document.createElement(\"a\"); a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(\".\")[0] + \"-\" + id + \".json\"; a.click(); URL.revokeObjectURL(a.href); toast(\"Backup downloaded\"); await loadStorage(); void r; });\n async function selectedBackup() { const f = $(\"#backupfile\").files[0]; if (!f) throw new Error(\"Choose a backup archive first.\"); if (f.size > 8 * 1024 * 1024) throw new Error(\"Backups are limited to 8 MB.\"); return f.text(); }\n $(\"#backuppreview\").onclick = guard(async () => { const body = await selectedBackup(); const r = await (await backupRequest(\"/backups/preview\", \"POST\", body)).json(); if (r.error) throw new Error(r.error); alert(JSON.stringify(r.result, null, 2)); });\n $(\"#backuprestore\").onclick = guard(async () => { const body = await selectedBackup(); if (!confirm(\"Restore this archive onto this fresh relay? This cannot be undone.\")) return; const r = await (await backupRequest(\"/backups/restore\", \"POST\", body)).json(); if (r.error) throw new Error(r.error); toast(\"Backup restored\"); await loadInfo(); await loadAdmin(); });\n $(\"#treeform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { memberInvites: { depth: Math.max(0, Math.floor(+f.depth.value || 0)), quota: Math.max(0, Math.floor(+f.quota.value || 0)) } });\n toast(policy.memberInvites.depth ? \"Members may invite\" : \"Only you and moderators invite\");\n });\n $(\"#mintmine\").onclick = guard(async () => {\n const inv = await rpc(\"createinvite\", 259200, 1, \"\");\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n toast(\"Invite created and copied\"); await loadMine();\n });\n $(\"#exportcfg\").onclick = guard(async () => {\n const cfg = await rpc(\"exportconfig\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(new Blob([JSON.stringify(cfg, null, 2)], { type: \"application/json\" }));\n a.download = host.split(\".\")[0] + \".bind.ws.config.json\";\n a.click(); URL.revokeObjectURL(a.href);\n });\n $(\"#importcfg\").onclick = () => $(\"#cfgfile\").click();\n $(\"#cfgfile\").onchange = guard(async (ev) => {\n const file = ev.target.files[0]; if (!file) return;\n const cfg = JSON.parse(await file.text());\n const check = await rpc(\"importconfig\", cfg, { dryRun: true });\n const lines = check.changes.summary.length ? check.changes.summary.join(\"\\n\") : \"Nothing would change.\";\n const dropped = check.warnings.length ? \"\\n\\nNot taken:\\n\" + check.warnings.join(\"\\n\") : \"\";\n if (!confirm(\"Apply \" + file.name + \" to this relay?\\n\\n\" + lines + dropped)) { ev.target.value = \"\"; return; }\n await rpc(\"importconfig\", cfg); ev.target.value = \"\"; toast(\"Configuration imported\"); await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // Succession: the status line under the heir form, from successionstatus.\n async function renderSuccession() {\n const el = $(\"#successionnote\"), f = $(\"#succession\");\n let st;\n try { st = await rpc(\"successionstatus\"); } catch { el.textContent = \"\"; return; }\n const sc = st.succession;\n $(\"#clearsuccession\").classList.toggle(\"hidden\", !sc);\n f.querySelector(\"button.btn:not(#clearsuccession)\").textContent = sc ? \"Change heir\" : \"Set heir\";\n if (!sc) { el.textContent = \"No heir named. Last signed in \" + fmtTime(st.ownerSeenAt) + \".\"; return; }\n if (f.heir.querySelector('[value=\"' + sc.heir + '\"]')) f.heir.value = sc.heir;\n f.afterDays.value = String(sc.afterDays);\n const who = (window.__members || []).find((m) => m.pubkey === sc.heir);\n const heir = who && who.name ? who.name + \"@\" + host : short(sc.heir);\n el.textContent = \"Heir: \" + heir + \". Last signed in \" + fmtTime(st.ownerSeenAt) + (st.silentDays ? \" (\" + st.silentDays + \" days ago)\" : \"\") + \". \" +\n (st.warning ? \"The warning month is running: the relay goes to \" + heir + \" on \" + fmtDay(st.handoverAt) + \" unless you sign in.\" : \"If you stay away, it goes to \" + heir + \" around \" + fmtDay(st.handoverAt) + \".\") +\n (st.log && st.log.length ? \" Handed over before: \" + st.log.map((l) => fmtDay(l.at) + \" to \" + short(l.to)).join(\", \") + \".\" : \"\");\n }\n $(\"#succession\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"setsuccession\", { heir: f.heir.value, afterDays: +f.afterDays.value });\n toast(\"Heir set\"); await loadAdmin();\n });\n $(\"#clearsuccession\").onclick = guard(async () => {\n await rpc(\"clearsuccession\");\n toast(\"Heir cleared\"); await loadAdmin();\n });\n $(\"#transfer\").onsubmit = guard(async (ev) => {\n const pk = ev.target.pubkey.value; if (!pk) return;\n const name = host.split(\".\")[0];\n const typed = prompt(\"This hands \" + host + \" to \" + short(pk) + \" for good. You stay on as a moderator. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing changed.\"); return; }\n await rpc(\"transferowner\", pk);\n toast(\"Transferred\");\n await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // ---- fork: a new name with this relay's events, claim reserved for a key ----\n $(\"#forkform\").scope.onchange = (ev) => $(\"#forkkinds\").classList.toggle(\"hidden\", ev.target.value !== \"kinds\");\n $(\"#joinform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { joinTerms: f.joinTerms.value, directoryPublic: f.directoryPublic.checked });\n toast(\"Saved\"); await loadInfo(); await loadPeople();\n });\n $(\"#forkform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const opts = { people: f.people.checked };\n if (f.name.value.trim()) opts.name = f.name.value.trim().toLowerCase();\n if (f.holder.value.trim()) { const pk = npubToHex(f.holder.value); if (!pk) throw new Error(\"That is not a pubkey.\"); opts.holder = pk; }\n if (f.scope.value === \"mine\") opts.filter = { authors: [me] };\n if (f.scope.value === \"kinds\") { const kinds = f.kinds.value.split(/[\\s,]+/).filter(Boolean).map(Number); if (!kinds.length || kinds.some((k) => !Number.isInteger(k) || k < 0)) throw new Error(\"Give kinds as numbers.\"); opts.filter = { kinds }; }\n if (!confirm(\"Fork this relay into a new name\" + (opts.name ? \" (\" + opts.name + \")\" : \"\") + \"? It pulls \" + (f.scope.value === \"all\" ? \"everything\" : f.scope.value === \"mine\" ? \"your events\" : \"the chosen kinds\") + (opts.people ? \" and the people\" : \"\") + \", and only \" + (opts.holder ? \"that key\" : \"you\") + \" can claim it.\")) return;\n const r = await rpc(\"forkrelay\", opts);\n $(\"#forknote\").textContent = r.handover + \" Expires \" + fmtTime(r.expires_at) + \".\";\n $(\"#forkurl\").textContent = r.console;\n $(\"#forkresult\").classList.remove(\"hidden\");\n toast(\"Forked to \" + r.name);\n });\n $(\"#forkcopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#forkurl\").textContent); toast(\"copied\"); };\n $(\"#deleterelay\").onclick = guard(async () => {\n const name = host.split(\".\")[0];\n const typed = prompt(\"This deletes everything on \" + host + \" and gives the name up. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing was deleted.\"); return; }\n await rpc(\"deleterelay\", name);\n localStorage.removeItem(\"me\");\n location.href = \"/\";\n });\n $(\"#topup\").onsubmit = guard((ev) => topUp(+ev.target.sats.value));\n $(\"#inv-copy\").onclick = async () => { await navigator.clipboard.writeText($(\"#inv-text\").value); toast(\"invoice copied\"); };\n $$(\"button[data-copy]\").forEach((b) => { b.onclick = async () => { await navigator.clipboard.writeText(b.dataset.copy === \"ws\" ? wsURL : location.origin); toast(\"copied\"); }; });\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-act]\"); if (!b) return;\n const act = b.dataset.act, id = b.dataset.id;\n if (act === \"copy\") { await navigator.clipboard.writeText(id); toast(\"copied\"); return; }\n if ((act === \"banpubkey\" || act === \"banevent\" || act === \"resolve:ban\") && !confirm(act === \"banevent\" ? \"Delete this event and refuse it forever?\" : \"Ban this author and refuse everything they post?\")) return;\n const erase = (act === \"banpubkey\" || act === \"resolve:ban\") && confirm(\"Also erase everything they wrote and uploaded here?\");\n if (act === \"deleteblob\" && !confirm(\"Delete this file for good?\")) return;\n if (act === \"deletedump\" && !confirm(\"Delete this dump?\")) return;\n if (act === \"downloaddump\") { b.disabled = true; try { await downloadDump(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }\n if (act === \"restorelist\") {\n if (!signer.ready()) { toast(NO_SIGNER); return; }\n try {\n const preview = await rpc(\"restorelist\", id);\n const d = preview.diff || {};\n const added = (d.addedTags || []).map((t) => \"+ \" + JSON.stringify(t)).join(\"\\n\");\n const removed = (d.removedTags || []).map((t) => \"- \" + JSON.stringify(t)).join(\"\\n\");\n const changes = [added, removed, d.contentChanged ? \"content changed\" : \"content unchanged\"].filter(Boolean).join(\"\\n\");\n if (!confirm(\"Restore this list version?\\n\\n\" + (changes || \"No tag or content changes\") + \"\\n\\nIt will be signed and published as the newest version.\")) return;\n const signed = await signer.signEvent(preview.draft);\n const result = await bridge(\"/events\", signed);\n if (!result.accepted) throw new Error(result.message || \"The relay refused the restored list.\");\n toast(\"List restored\"); await loadListHistory(); await loadStorage();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if ((act === \"removemember\" || act === \"banpubkey\") && (window.__members || []).some((m) => m.invited_by === id) && confirm(\"Also remove everyone this member invited, and everyone they invited in turn?\")) {\n b.disabled = true;\n try { const r = await rpc(\"removesubtree\", id); if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase); toast(\"Removed \" + r.removed.length); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"removejob\" && !confirm(\"Remove this job?\")) return;\n if (act === \"purgekind\") {\n const kind = id === \"\" ? null : +id;\n const label = kind === null ? \"everything without its own rule\" : kindName(kind) + \" (kind \" + kind + \")\";\n const typed = prompt(\"Purge \" + label + \" older than how many days? 0 purges all of them. This cannot be undone.\", \"30\");\n if (typed === null) return;\n const days = Math.max(0, Math.floor(+typed || 0));\n b.disabled = true;\n try { const r = await rpc(\"purgekind\", kind, days); toast(\"Purged \" + r.deleted.toLocaleString() + \" events\"); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"saveretention\") {\n const kind = id === \"\" ? null : +id;\n const days = Math.max(0, Math.floor(+b.closest(\"tr\").querySelector(\".days\").value || 0));\n b.disabled = true;\n try { await rpc(\"setretention\", kind, days); toast(days ? \"Kept for \" + days + \" days\" : \"Kept forever\"); await loadStorage(); await loadInfo(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n b.disabled = true;\n try {\n if (act === \"savemember\") { const tr = b.closest(\"tr\"), role = tr.querySelector(\"select.role\"), keep = tr.querySelector(\".keep\"), cap = tr.querySelector(\".cap\"); await rpc(\"setmember\", id, { name: tr.querySelector(\".name\").value.trim() || null, note: tr.querySelector(\".note\").value, ...(role ? { role: role.value } : {}), ...(keep ? { keepDays: Math.max(0, Math.floor(+keep.value || 0)), maxBytes: Math.max(0, Math.floor(+cap.value || 0)) * 1024 } : {}) }); }\n else if (act.startsWith(\"resolve:\")) await rpc(\"resolvereport\", id, act.slice(8), erase);\n else if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase);\n else await rpc(act, act === \"unrulekind\" ? +id : id, \"\");\n toast(\"Done\"); await (myRole ? refresh() : loadMine());\n if (act === \"pinevent\" || act === \"unpinevent\") await loadPins();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // The share block: the card picture, the group naddr and its QR.\n let card = null;\n async function loadCard() {\n try {\n card = await (await fetch(\"/card.json\", { cache: \"no-store\" })).json();\n $(\"#cardimg\").src = \"/card.svg?t=\" + Date.now();\n $(\"#naddr\").textContent = card.naddr || \"\";\n const q = $(\"#naddrqr\");\n if (card.naddr) { q.src = \"/qr.svg?text=\" + encodeURIComponent(card.naddr); q.classList.remove(\"hidden\"); } else q.classList.add(\"hidden\");\n } catch { /* the card is decoration */ }\n renderApps();\n }\n // The app rows on the Connect section: one per client people actually use,\n // with the link that lands on this relay in it. Relay apps take the relay\n // or the group address; feed apps take the owner's profile with this relay\n // as the hint, since they have no notion of opening a relay.\n function renderApps() {\n const el = $(\"#apps\"); if (!el) return;\n const enc = encodeURIComponent;\n const nprofile = card && card.nprofile ? card.nprofile : \"\";\n const naddr = card && card.naddr ? card.naddr : \"\";\n const whose = me && owner && me === owner ? \"your\" : \"the owner's\";\n const link = (label, href) => '' + label + \"\";\n const app = (label, uri) => '' + label + \"\";\n const copy = (label, text) => '\";\n const row = (name, where, note, acts) => '
                                                                                                                                                                              ' + name + \"\" + where + \"

                                                                                                                                                                              \" + note + '

                                                                                                                                                                              ' + acts.filter(Boolean).join(\"\") + \"
                                                                                                                                                                              \";\n const profileNote = \"Opens \" + whose + \" profile with this relay attached.\";\n const groups = [\n [\"As a place\", \"These open the relay itself: its feed, its people, its group.\", [\n row(\"Jumble\", \"web\", \"A feed of everything on this relay.\", [link(\"Open\", \"https://jumble.social/?r=\" + enc(wsURL))]),\n row(\"Coracle\", \"web\", \"The relay's page: its feed and its people.\", [link(\"Open\", \"https://coracle.social/relays/\" + enc(host))]),\n row(\"Flotilla\", \"web, phone\", \"The relay as a space, with the group as a room.\", [link(\"Open\", \"https://app.flotilla.social/spaces/\" + enc(host)), naddr && app(\"Open group\", \"nostr:\" + naddr)]),\n row(\"0xchat\", \"phone\", \"The group, in a chat app.\", [naddr && app(\"Open group\", \"nostr:\" + naddr), naddr && copy(\"Copy naddr\", naddr)]),\n row(\"noStrudel\", \"web\", \"Relays, add this one, then open its page.\", [copy(\"Copy relay URL\", wsURL)]),\n ]],\n [\"Find me here\", \"Feed apps have no relay pages. They meet this relay through a profile link that names it, then keep it once it is in the relay settings.\", [\n row(\"Primal\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://primal.net/p/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"YakiHonne\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://yakihonne.com/profile/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"Damus\", \"iPhone\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Amethyst\", \"Android\", profileNote + \" Then Relays in the drawer.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Nostur\", \"iPhone, Mac\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n ]],\n ];\n if (info?.supported_grasps?.includes(\"GRASP-01\")) groups.push([\n \"Git repositories\", \"Use this relay with a Git client.\", [\n row(\"GitWorkshop\", \"web\", \"Browse this relay's Git repositories.\", [link(\"Open in app\", \"https://gitworkshop.dev/relay/\" + enc((wsURL.startsWith(\"ws://\") ? \"ws:\" : \"\") + host)), copy(\"Copy relay URL\", wsURL)]),\n ],\n ]);\n el.innerHTML = groups.map(([h, note, rows]) => '

                                                                                                                                                                              ' + h + '

                                                                                                                                                                              ' + note + '

                                                                                                                                                                              ' + rows.join(\"\") + \"
                                                                                                                                                                              \").join(\"\");\n $(\"#apps-ws\").textContent = wsURL;\n const tile = (label, text) => '
                                                                                                                                                                              ' + label + '\"QR
                                                                                                                                                                              ';\n $(\"#phones\").innerHTML = [nprofile && tile(\"Find me here, for a phone\", \"nostr:\" + nprofile), naddr && tile(\"The group, for a phone\", \"nostr:\" + naddr)].filter(Boolean).join(\"\");\n }\n // The folds are a group: opening one closes the rest. Browsers with the\n // details name attribute do this themselves; this covers the others.\n $$(\".folds details\").forEach((d) => d.addEventListener(\"toggle\", () => { if (d.open) $$(\".folds details\").forEach((o) => { if (o !== d && o.open) o.open = false; }); }));\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-copytext]\"); if (!b) return;\n try { await navigator.clipboard.writeText(b.dataset.copytext); toast(\"copied\"); } catch { /* no clipboard */ }\n });\n $(\"#copynaddr\").onclick = async () => { if (!card || !card.naddr) return; await navigator.clipboard.writeText(card.naddr); toast(\"copied naddr\"); };\n $(\"#copyembed\").onclick = async () => { await navigator.clipboard.writeText('\"''); toast(\"copied embed\"); };\n\n // A click on a key copies it; a double-click selects the whole key so the usual copy shortcut takes it too.\n document.addEventListener(\"dblclick\", (ev) => {\n const k = ev.target.closest(\".key\"); if (!k) return;\n const s = window.getSelection(), r = document.createRange(); r.selectNodeContents(k.querySelector(\".full\")); s.removeAllRanges(); s.addRange(r);\n });\n document.addEventListener(\"click\", async (ev) => {\n const k = ev.target.closest(\".key\"); if (!k || ev.detail > 1 || String(window.getSelection())) return;\n try { await navigator.clipboard.writeText(k.title); toast(\"copied \" + k.dataset.short); } catch { /* no clipboard */ }\n });\n // ---- boot ----\n try { await loadInfo(); } catch { renderHeader(); }\n await loadFuel();\n await loadCard();\n me = localStorage.getItem(\"me\");\n if (me && await resumeRemote()) { /* a remote session answers for itself when first used */ }\n else if (me && window.nostr) { try { const pk = await window.nostr.getPublicKey(); if (pk !== me) me = null; } catch { me = null; } }\n else if (me) me = null;\n renderHeader();\n renderApps();\n await loadAdmin();\n await loadPeople();\n})();\n"; diff --git a/src/manage.ts b/src/manage.ts index b690d82..ed77ab0 100644 --- a/src/manage.ts +++ b/src/manage.ts @@ -27,6 +27,7 @@ import { addDomain, checkDomain, listDomains, removeDomain, setDomainSite } from import { verifyNIP98 } from "./auth.ts"; import { SITE_KINDS, checkSite, siteLabel, sitePaths } from "./sites.ts"; import { gitStorage } from "./git-storage.ts"; +import { createBackup, deleteBackup, listBackups } from "./backups.ts"; // A call: the relay and the request, who is calling and as what, the // parameters with their readers, and how to answer. @@ -680,6 +681,16 @@ export const METHODS: Record = { }, }, dumpnow: { action: "storage", run: async ({ relay, t, reply }) => reply({ result: await writeDump(relay, t) }) }, + backupnow: { + action: "storage", + run: async ({ relay, params, reply }) => { + const id = typeof params[0] === "string" && params[0] ? params[0] : `backup-${now()}`; + const result = await createBackup(relay, id); + return typeof result === "string" ? reply({ error: result }, result.startsWith("invalid:") ? 400 : 403) : reply({ result: { ...result.manifest, url: "/backups/" + id } }); + }, + }, + listbackups: { action: "storage", reads: true, run: async ({ relay, reply }) => reply({ result: await listBackups(relay) }) }, + deletebackup: { action: "storage", run: async ({ relay, str, reply }) => reply({ result: await deleteBackup(relay, str(0)) }) }, setsuccession: { action: "transfer", run: async ({ relay, s, params, str, num, hex64, reply }) => { diff --git a/src/routes.ts b/src/routes.ts index 59e2aff..a156139 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -15,6 +15,7 @@ import { isWebAddressRequest, webAddress } from "./nipad.ts"; import { verifyNIP98, whoAsks } from "./auth.ts"; import { checkInvite, claimInviteRequest, invitePage, termsPage } from "./invites.ts"; import { dumpDownload } from "./dumps.ts"; +import { backupDownload, restoreBackupRequest } from "./backups.ts"; import { serveView } from "./views.ts"; import { importUpload } from "./imports.ts"; import { blossom, isBlobPath } from "./blossom.ts"; @@ -102,6 +103,8 @@ export const ROUTES: Route[] = [ { when: post(is("/api/invites/claim")), gated: true, answer: claimInviteRequest }, // Dumps, views, imports (dumps.ts, views.ts, imports.ts). { when: get(under("/dumps/")), answer: dumpDownload }, + { when: get(under("/backups/")), answer: (relay, req, url) => backupDownload(relay, req, url.pathname.slice("/backups/".length)) }, + { when: (url, req) => req.method === "POST" && (url.pathname === "/backups/restore" || url.pathname === "/backups/preview"), answer: restoreBackupRequest }, { when: get(under("/view/")), answer: (relay, req) => serveView(relay, req, verifyNIP98) }, { when: (url, req) => req.method === "PUT" && url.pathname === "/import", answer: importUpload }, // Files: Blossom on its paths (blossom.ts), NIP-96 on its own (nip96.ts). diff --git a/test/object/backups.test.ts b/test/object/backups.test.ts new file mode 100644 index 0000000..b9771b1 --- /dev/null +++ b/test/object/backups.test.ts @@ -0,0 +1,74 @@ +// Portable backup archives cover configuration, events, site media and Git +// bytes, while refusing tampering, the wrong signer and non-fresh targets. +import { env, runInDurableObject } from "cloudflare:test"; +import { describe, expect, it } from "vitest"; +import { generateSecretKey } from "nostr-tools/pure"; +import { createBackup, restoreBackup } from "../../src/backups.ts"; +import { storeBlob } from "../../src/blossom.ts"; +import type { Relay } from "../../src/relay.ts"; +import { ev, pk, rpc } from "../helpers/relay.ts"; +import { WS } from "../helpers/ws.ts"; + +describe("portable backups", () => { + it("round trips configuration, events, a media blob and a Git object, with preview and exclusions", async () => { + const source = "backup-source.bind.ws"; + const owner = generateSecretKey(), writer = generateSecretKey(); + await rpc(source, owner, "claim"); + await rpc(source, owner, "setpolicy", { name: "Recovered", reads: "members" }); + const c = await WS.connect(source); + await c.ok(ev(writer, 1, "retained")); + const archive = await runInDurableObject(env.RELAY.getByName("backup-source"), async (relay: Relay) => { + await storeBlob(relay, new TextEncoder().encode("site bytes"), "text/plain", pk(writer), 10); + await relay.media.put("backup-source/git/test-object", new TextEncoder().encode("git bytes")); + await relay.store.save(ev(owner, 30390, '{"callback":"https://secret.invalid"}'), 10); + const result = await createBackup(relay, "roundtrip"); + expect(typeof result).not.toBe("string"); + const object = await relay.media.get("backup-source/backups/roundtrip.json"); + const bytes = new Uint8Array(await object!.arrayBuffer()); + expect(new TextDecoder().decode(bytes)).not.toContain("secret.invalid"); + return bytes; + }); + const preview = await runInDurableObject(env.RELAY.getByName("backup-target"), async (relay: Relay) => { + relay.slug = "backup-target"; + const before = Number(relay.sql.exec(`SELECT count(*) AS n FROM events`).one().n ?? 0); + const result = await restoreBackup(relay, archive, pk(owner)); + expect(typeof result).not.toBe("string"); + expect(relay.sql.exec(`SELECT count(*) AS n FROM events`).one().n).toBeGreaterThan(before); + return result as { blobs: number; git: number }; + }); + expect(preview.blobs).toBe(1); + expect(preview.git).toBe(1); + await runInDurableObject(env.RELAY.getByName("backup-target"), async (relay: Relay) => { + expect(relay.settings.policy.name).toBe("Recovered"); + expect((await relay.media.get("backup-target/" + ""))).toBeNull(); + expect((await relay.media.get("backup-target/" + "git/test-object"))?.size).toBe(9); + }); + }); + + it("rejects tampering and wrong owner before changing a fresh target, and rejects a non-fresh target", async () => { + const source = "backup-tamper.bind.ws"; + const owner = generateSecretKey(), wrong = generateSecretKey(); + await rpc(source, owner, "claim"); + const archive = await runInDurableObject(env.RELAY.getByName("backup-tamper"), async (relay: Relay) => { + await relay.store.save(ev(owner, 1, "safe"), 1); + await createBackup(relay, "tamper"); + const object = await relay.media.get("backup-tamper/backups/tamper.json"); + return new Uint8Array(await object!.arrayBuffer()); + }); + const damaged = archive.slice(); damaged[damaged.length - 4] ^= 1; + const result = await runInDurableObject(env.RELAY.getByName("backup-tamper-target"), async (relay: Relay) => { + relay.slug = "backup-tamper-target"; + expect(await restoreBackup(relay, damaged, pk(owner))).toMatch(/^invalid:/); + expect(relay.settings.policy.owner).toBe(""); + expect(await restoreBackup(relay, archive, pk(wrong))).toContain("backup owner"); + expect(relay.settings.policy.owner).toBe(""); + return restoreBackup(relay, archive, pk(owner)); + }); + expect(typeof result).not.toBe("string"); + const nonfresh = await runInDurableObject(env.RELAY.getByName("backup-nonfresh"), async (relay: Relay) => { + relay.settings.update({ owner: pk(owner) }); + return restoreBackup(relay, archive, pk(owner)); + }); + expect(nonfresh).toContain("fresh"); + }); +}); From 7a14ca2c6b8ed0e149472e25d7aed3e30ef03c4a Mon Sep 17 00:00:00 2001 From: Dami Date: Fri, 4 Sep 2026 18:19:20 -0600 Subject: [PATCH 13/14] recovery: portable backups preserve private state and restore through authenticated fresh-target previews Includes bounded archives, atomic restore metadata, source-owner authorization, retained list and visibility state, billed archive storage, and usable backup controls. Integration tests cover HTTP restore, upload caps, import fallback, owner isolation and independent target retries. --- README.md | 3 +- docs/04-data-and-names.md | 10 ++++- docs/13-scripts-and-agents.md | 1 + docs/27-backups.md | 12 ----- src/backups.ts | 61 ++++++++++++++++++++----- src/console/console.html | 22 ++++++--- src/console/console.js | 25 +++++++++-- src/gen/console.ts | 4 +- src/jobs.ts | 11 +++-- src/relay.ts | 7 ++- test/object/backups.test.ts | 84 +++++++++++++++++++++++++++++++++-- test/object/delivery.test.ts | 23 ++++++++++ test/object/exposure.test.ts | 3 ++ test/object/jobs.test.ts | 20 +++++++++ test/object/lease.test.ts | 8 ++-- 15 files changed, 244 insertions(+), 50 deletions(-) delete mode 100644 docs/27-backups.md diff --git a/README.md b/README.md index 812e59d..68d3a66 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Site hosting and mirroring are on by default; Marmot and GRASP are off until ena - [Relay configuration](docs/01-relay-configuration.md): the console, tab by tab. - [Understanding fuel](docs/02-understanding-fuel.md): what is measured, what is free, what a zap buys. - [People and groups](docs/03-people-and-groups.md): members, invites, moderators, groups, handing over. -- [Data and names](docs/04-data-and-names.md): jobs, dumps, presets, forks, leaving. +- [Data and names](docs/04-data-and-names.md): list recovery, delivery, imports, backups, presets and forks. - [Your relay on the web](docs/05-your-relay-on-the-web.md): pages, static sites, Git hosting, feed, card, custom domains and media. **For scripts and agents** @@ -65,7 +65,6 @@ Site hosting and mirroring are on by default; Marmot and GRASP are off until ena - [NIP-86 membership claims](docs/24-nip86-claims.md): create, list and revoke invitation codes through the standard management methods. - [NIP-9a relay push](docs/25-nip-9a-relay-push.md): opt-in callback delivery, privacy, bounds and operator setup. - [NIP-11 identifier compatibility](docs/26-nip11-compatibility.md): lettered capabilities and concrete client parser behavior. -- [Backups and fresh-relay restore](docs/27-backups.md): portable, integrity-checked recovery for relay state, files and Git. ### Protocol guides diff --git a/docs/04-data-and-names.md b/docs/04-data-and-names.md index d79f2e6..3f5c747 100644 --- a/docs/04-data-and-names.md +++ b/docs/04-data-and-names.md @@ -45,6 +45,14 @@ The reverse of a dump. Under **Import a file** on the Data tab, pick a JSONL of Under **Dumps**, choose daily or weekly and how many to keep, seven by default. The relay writes every event as one JSONL file and keeps the newest few. **Dump now** writes one on the spot. Each file lists its event count and size, with download and delete. Downloading takes your signature; the files are never a public link. +## Portable backups + +On the Data tab, **Create and download** makes a private archive with configuration, signed events, saved list history, hidden and pending state, site/media blobs and Git objects. Existing archives can be downloaded or deleted. Each binary object and the archive have SHA-256 integrity checks; event signatures are checked on restore. Stored archives count toward fuel. + +Open a fresh, unclaimed relay and choose the archive in **Restore a backup**. Sign with the original owner's key, preview its source identity, counts and configuration, then restore. The target must be empty and unleased. Restore stages files before applying the database in one transaction. The source identity is recorded; the target gets a new relay key. Signed site and Git references still name their original URLs, so publish updated service references from your client after moving. + +This portable format is for small relays: at most 8 MiB per archive and 12,000 entries, including state records. Memory budgeting can refuse an archive below the wire limit. Larger relays need the separate event dump, configuration export, Blossom download and Git clone paths. Credentials, fuel credits, custom domains, leases, succession, callback registrations, existing dumps/backups and transient jobs are excluded. Configure those again on the target. Owner archives contain private relay data; keep the downloaded file private. + ## Presets and one name per job Names are cheap, so a relay does not have to do everything. On the Rules tab, **Presets** sets writes, reads, the directory, the kind rules and the keep-for rules in one click. Limits, identity, people and bans stay. Your own profile and lists always land, whatever the kind rules say. @@ -89,7 +97,7 @@ Your data is yours. A relay that speaks sync can pull the events your read rule nak sync -a wss://.bind.ws wss:// ``` -Or download a dump. +Or download a dump or a portable backup. Event sync and JSONL dumps do not contain site files or Git packs. An unfiltered bind.ws pull can copy Blossom files, but it does not copy GRASP repositories. diff --git a/docs/13-scripts-and-agents.md b/docs/13-scripts-and-agents.md index 91c4797..fee344c 100644 --- a/docs/13-scripts-and-agents.md +++ b/docs/13-scripts-and-agents.md @@ -144,6 +144,7 @@ The bridge takes the same header. `POST /events` answers `{ event_id, accepted, - `gitstorage owner identifier`: an owner-only inventory of one accepted GRASP repository. The result compares bounded physical R2 listing with live Git dependencies and reports physical, live, unreferenced and unknown objects by class, SQL reservations and the byte difference. It never deletes data. - `deleteblob sha256`. - `listdumps`, `dumpnow`, `deletedump name`. +- `backupnow [id]`, `listbackups`, `deletebackup id`: portable archives; owner NIP-98 downloads use `/backups/id`. POST the exact archive with a payload-bound NIP-98 signature to `/backups/preview`, then separately sign `/backups/restore` on a fresh target. Both require the archived owner key. Archives are capped at 8 MiB and 12,000 entries. - `listlisthistory`: private older list versions belonging to the authenticated owner, moderator or member. - `restorelist eventId`: an unsigned `draft` and `diff` (added tags, removed tags, content changed) for one saved version belonging to that signer. Sign the draft in the client and publish it normally. diff --git a/docs/27-backups.md b/docs/27-backups.md deleted file mode 100644 index 2836354..0000000 --- a/docs/27-backups.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: backups and fresh-relay restore -audience: user ---- - -`backupnow` creates a private, portable archive containing the relay configuration, signed events, hosted site and media blobs, and hosted Git objects. The archive records the source relay identity public key, but never includes its private key. A fresh target generates a new relay identity and publishes new authority records after restore. The archive is bounded at 8 MiB and 12,000 objects because JSON parsing, base64 expansion and integrity copies share the Worker heap. Every byte has a SHA-256 entry hash and the archive has a manifest hash. - -`listbackups` lists archives and `deletebackup` removes one. An owner downloads an archive from `/backups/` with the same NIP-98 storage authorization used for dumps. - -To restore, claim no name on a fresh relay. POST the downloaded archive to `/backups/restore` with NIP-98 authorization by the owner key recorded in the manifest. Restore verifies the archive, checks every event and object, then applies configuration, events, blobs, and Git data. A claimed or non-empty target is refused. Fuel credits, credentials, push registrations, dumps, and transient jobs are not backup data. - -POST the same archive to `/backups/preview` first to receive the source identity, counts, configuration and fresh-target check without changing the target. diff --git a/src/backups.ts b/src/backups.ts index 7cfd041..9caae42 100644 --- a/src/backups.ts +++ b/src/backups.ts @@ -6,16 +6,19 @@ import { applyConfig, exportConfig, parseConfig } from "./config.ts"; import { bytesToHex } from "./negentropy.ts"; import { verifyNIP98 } from "./auth.ts"; import { can } from "./roles.ts"; -import { now, validate, type Event } from "./event.ts"; +import { expiration, now, validate, type Event } from "./event.ts"; import { type Relay } from "./relay.ts"; -import { KIND_REPO, KIND_PUSH_REGISTRATION } from "./kinds.ts"; -import { parseRepositoryAnnouncement } from "./grasp-policy.ts"; +import { KIND_PUSH_REGISTRATION } from "./kinds.ts"; +import { archiveCurrent, isListKind } from "./list-history.ts"; +import { Settings } from "./settings.ts"; export const BACKUP_FORMAT = "bind.ws/relay-backup/1"; // JSON parsing, base64 expansion and integrity copies coexist in the Worker // heap, so the portable form stays well below the platform heap ceiling. export const BACKUP_MAX_BYTES = 8 * 1024 * 1024; export const BACKUP_MAX_OBJECTS = 12_000; +export const BACKUP_SCHEMA = `CREATE TABLE IF NOT EXISTS backups (id TEXT PRIMARY KEY, bytes INTEGER NOT NULL);`; +export const backupBytes = (relay: Relay): number => relay.sql.exec<{ n: number }>(`SELECT coalesce(sum(bytes),0) n FROM backups`).one().n; export const BACKUP_ID_RE = /^[a-z0-9][a-z0-9_-]{2,63}$/; type Payload = { sha256: string; size: number; data: string }; @@ -26,6 +29,7 @@ export type BackupArchive = { events: string[]; blobs: (Payload & { sha256: string; type: string; uploader: string; uploaded: number })[]; git: (Payload & { key: string })[]; + state?: { listHistory: string[]; hidden: string[]; hosted: string[]; pending: { id: string; until: number }[] }; }; const enc = new TextEncoder(); @@ -71,7 +75,7 @@ export async function createBackup(relay: Relay, id: string): Promise<{ manifest }; let seq = 0; for (;;) { - const page = relay.store.dumpPage(seq, 50); + const page = relay.store.dumpPage(seq, 1); if (!page.length) break; for (const x of page) { try { @@ -83,11 +87,12 @@ export async function createBackup(relay: Relay, id: string): Promise<{ manifest seq = page[page.length - 1].seq; } const blobs: BackupArchive["blobs"] = []; - const blobRows = relay.sql.exec<{ sha256: string; size: number; type: string; uploader: string; uploaded: number }>(`SELECT * FROM blobs ORDER BY uploaded`).toArray(); + const blobRows = relay.sql.exec<{ sha256: string; size: number; type: string; uploader: string; uploaded: number }>(`SELECT * FROM blobs ORDER BY uploaded LIMIT 12001`).toArray(); for (const b of blobRows) { if (!reserve(b.size)) return "restricted: backup exceeds its bounded size or object limit"; const obj = await relay.media.get(`${relay.slug}/${b.sha256}`); if (!obj) return `error: blob ${b.sha256} disappeared during backup`; + if (obj.size !== b.size) return "error: blob size changed during backup"; const data = new Uint8Array(await obj.arrayBuffer()); if (data.length !== b.size) return `error: blob ${b.sha256} changed during backup`; if (data.length !== b.size || digest(data) !== b.sha256) return `error: blob ${b.sha256} failed integrity check`; @@ -111,12 +116,19 @@ export async function createBackup(relay: Relay, id: string): Promise<{ manifest } const config = exportConfig(relay.settings, relay.slug); if (!reserve(enc.encode(JSON.stringify(config)).length)) return "restricted: backup exceeds its bounded size or object limit"; - const archive = { format: BACKUP_FORMAT, manifest: { id, slug: relay.slug, owner, relayIdentity: relay.identity.pubkey, createdAt: now(), bytes: 0, events: events.length, blobs: blobs.length, git: git.length, archiveSha256: "" }, config, events, blobs, git } as BackupArchive; - const bytes = bytesOf(archive); - archive.manifest.bytes = bytes.length; + const state = { listHistory: [] as string[], hidden: [...relay.settings.hiddenEvents], hosted: relay.sql.exec<{ id: string }>(`SELECT id FROM grasp_hosted`).toArray().map((r) => r.id), pending: relay.sql.exec<{ id: string; until: number }>(`SELECT id,until FROM grasp_pending`).toArray() }; + for (const row of relay.sql.exec<{ raw: string }>(`SELECT raw FROM list_history WHERE expires=0 OR expires>? ORDER BY saved_at`, now())) { + if (!reserve(enc.encode(row.raw).length)) return "restricted: backup history exceeds its bounded size"; + state.listHistory.push(row.raw); + } + if (!reserve(enc.encode(JSON.stringify({ ...state, listHistory: [] })).length, state.hidden.length + state.hosted.length + state.pending.length)) return "restricted: backup state exceeds its bounded size"; + const archive = { format: BACKUP_FORMAT, manifest: { id, slug: relay.slug, owner, relayIdentity: relay.identity.pubkey, createdAt: now(), bytes: 0, events: events.length, blobs: blobs.length, git: git.length, archiveSha256: "" }, config, events, blobs, git, state } as BackupArchive; + archive.manifest.archiveSha256 = "0".repeat(64); + for (let i = 0; i < 4; i++) archive.manifest.bytes = bytesOf(archive).length; archive.manifest.archiveSha256 = digest(unsignedBytes(archive)); const finalBytes = bytesOf(archive); if (finalBytes.length > BACKUP_MAX_BYTES) return "restricted: backup exceeds 8 MiB; use smaller retention or separate Git repositories"; + relay.sql.exec(`INSERT OR REPLACE INTO backups(id,bytes) VALUES(?,?)`, id, finalBytes.length); await relay.media.put(archiveKey(relay, id), finalBytes, { httpMetadata: { contentType: "application/json" } }); relay.meterBytes(0, finalBytes.length); return { manifest: archive.manifest, key: archiveKey(relay, id) }; @@ -130,6 +142,7 @@ export async function listBackups(relay: Relay) { export async function deleteBackup(relay: Relay, id: string) { if (!BACKUP_ID_RE.test(id)) return false; await relay.media.delete(archiveKey(relay, id)); + relay.sql.exec(`DELETE FROM backups WHERE id=?`, id); return true; } @@ -141,11 +154,22 @@ const checkedArchive = (bytes: Uint8Array): BackupArchive | string => { if (archive.manifest.archiveSha256 !== digest(unsignedBytes(archive))) return "invalid: backup integrity check failed"; if (archive.events.length + archive.blobs.length + archive.git.length > BACKUP_MAX_OBJECTS) return "restricted: backup object limit reached"; for (const raw of archive.events) { - try { if (typeof raw !== "string" || validate(JSON.parse(raw) as Event)) return "invalid: backup contains an invalid event"; } + try { if (typeof raw !== "string" || (validate(JSON.parse(raw) as Event) || JSON.parse(raw).kind === KIND_PUSH_REGISTRATION)) return "invalid: backup contains an invalid event"; } catch { return "invalid: backup contains malformed event JSON"; } } for (const b of archive.blobs) { let data: Uint8Array; try { data = fromB64(b.data); } catch { return "invalid: malformed blob data"; } if (data.length !== b.size || digest(data) !== b.sha256) return "invalid: blob integrity check failed"; } - for (const g of archive.git) { let data: Uint8Array; try { data = fromB64(g.data); } catch { return "invalid: malformed Git data"; } if (data.length !== g.size || digest(data) !== g.sha256 || !g.key.startsWith("git/")) return "invalid: Git integrity check failed"; } + for (const g of archive.git) { let data: Uint8Array; try { data = fromB64(g.data); } catch { return "invalid: malformed Git data"; } if (data.length !== g.size || digest(data) !== g.sha256 || typeof g.key !== "string" || !g.key.startsWith("git/")) return "invalid: Git integrity check failed"; } + const state = archive.state; + if (state) { + if (!Array.isArray(state.listHistory) || !Array.isArray(state.hidden) || !Array.isArray(state.hosted) || !Array.isArray(state.pending)) return "invalid: backup state malformed"; + if (archive.events.length + archive.blobs.length + archive.git.length + state.listHistory.length + state.hidden.length + state.hosted.length + state.pending.length > BACKUP_MAX_OBJECTS) return "restricted: backup object limit reached"; + const hex = (v: unknown) => typeof v === "string" && /^[0-9a-f]{64}$/.test(v); + if (state.hidden.some((v) => !hex(v)) || state.hosted.some((v) => !hex(v)) || state.pending.some((v) => !v || !hex(v.id) || !Number.isSafeInteger(v.until))) return "invalid: backup state malformed"; + for (const raw of state.listHistory) { + try { const event = JSON.parse(raw); if (typeof raw !== "string" || validate(event) || !isListKind(event.kind)) return "invalid: backup list history malformed"; } + catch { return "invalid: backup list history malformed"; } + } + } return archive; }; @@ -180,12 +204,22 @@ export async function restoreBackup(relay: Relay, bytes: Uint8Array, caller: str const e = JSON.parse(raw) as Event; const error = relay.store.save(e, now()); if (error && !error.startsWith("duplicate:")) throw new Error(`error: event restore stopped: ${error}`); - if (e.kind === KIND_REPO && parseRepositoryAnnouncement(e).value) relay.sql.exec(`INSERT OR IGNORE INTO grasp_hosted(id) VALUES(?)`, e.id); + + } + for (const raw of archive.state?.listHistory ?? []) { + const event = JSON.parse(raw) as Event; + if (!expiration(event) || expiration(event) > now()) archiveCurrent((q, ...args) => relay.sql.exec(q, ...args as SqlStorageValue[]), event, now()); } + for (const id of archive.state?.hidden ?? []) relay.settings.setEvent(id, "hide"); + for (const id of archive.state?.hosted ?? []) relay.sql.exec(`INSERT OR IGNORE INTO grasp_hosted(id) SELECT id FROM events WHERE id=?`, id); + for (const row of archive.state?.pending ?? []) relay.sql.exec(`INSERT OR IGNORE INTO grasp_pending(id,until) SELECT id,? FROM events WHERE id=?`, row.until, row.id); for (const b of archive.blobs) relay.sql.exec(`INSERT OR REPLACE INTO blobs(sha256,size,type,uploader,uploaded) VALUES(?,?,?,?,?)`, b.sha256, b.size, b.type, b.uploader, b.uploaded); for (const g of archive.git) relay.sql.exec(`INSERT OR REPLACE INTO grasp_objects(key,owner,size) VALUES(?,?,?)`, `${relay.slug}/${g.key}`, caller, g.size); }); } catch (error) { + relay.settings = new Settings(relay.sql); + relay.settings.load(); + relay.store.hidden = relay.settings.hiddenEvents; await relay.media.delete(staged).catch(() => {}); return error instanceof Error && error.message.startsWith("error:") ? error.message : "error: restore transaction failed"; } @@ -223,14 +257,17 @@ async function readCapped(req: Request): Promise { } export async function restoreBackupRequest(relay: Relay, req: Request): Promise { + const preauth = verifyNIP98(req.headers.get("authorization") ?? "", req.url, req.method, ""); + if (typeof preauth === "string") return Response.json({ error: preauth }, { status: 401 }); + if (relay.settings.policy.owner !== "" || relay.settings.isLeased()) return Response.json({ error: "restricted: restore requires a fresh, unclaimed relay" }, { status: 403 }); const input = await readCapped(req); if (typeof input === "string") return Response.json({ error: input }, { status: 413 }); const bytes = input; const body = new TextDecoder().decode(bytes); const auth = verifyNIP98(req.headers.get("authorization") ?? "", req.url, req.method, body); if (typeof auth === "string") return Response.json({ error: auth }, { status: 401 }); - const parsed = checkedArchive(bytes); if (new URL(req.url).pathname === "/backups/preview") { + const parsed = checkedArchive(bytes); if (typeof parsed === "string") return Response.json({ error: parsed }, { status: 400 }); const cfg = parseConfig(parsed.config, relay.settings.policy); if (typeof cfg === "string") return Response.json({ error: cfg }, { status: 400 }); diff --git a/src/console/console.html b/src/console/console.html index 9970cf9..95cf9a3 100644 --- a/src/console/console.html +++ b/src/console/console.html @@ -17,6 +17,15 @@

                                                                                                                                                                              Nobody owns this relay yet.

                                                                                                                                                                              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.

                                                                                                                                                                              +
                                                                                                                                                                              +

                                                                                                                                                                              Restore a backup

                                                                                                                                                                              +

                                                                                                                                                                              Choose a private bind.ws archive to inspect it before restoring. Preview does not claim this relay. Restore accepts only an empty relay and the archive owner's signature.

                                                                                                                                                                              +
                                                                                                                                                                              + +
                                                                                                                                                                              +
                                                                                                                                                                              +
                                                                                                                                                                              +
                                                                                                                                                                              diff --git a/src/console/console.js b/src/console/console.js index 67b9726..75e0b12 100644 --- a/src/console/console.js +++ b/src/console/console.js @@ -473,6 +473,11 @@ $("#dumps-count").textContent = list.length || ""; $("#dumps").innerHTML = list.length ? list.map((d) => '
                                                                                                                                                                            • jsonl' + esc(d.name) + ' ' + d.events.toLocaleString() + " events, " + fmtBytes(d.bytes) + "" + ib("copy", "Download", "downloaddump", d.name) + ib("trash", "Delete", "deletedump", d.name, "danger") + "
                                                                                                                                                                            • ").join("") : '
                                                                                                                                                                            • no dumps yet
                                                                                                                                                                            • '; } + async function loadBackups() { + const list = await rpc("listbackups"); + $("#backups-count").textContent = list.length || ""; + $("#backups").innerHTML = list.length ? list.map((b) => '
                                                                                                                                                                            • backup' + esc(b.id) + ' ' + fmtBytes(b.bytes) + '' + ib("copy", "Download", "downloadbackup", b.id) + ib("trash", "Delete", "deletebackup", b.id, "danger") + '
                                                                                                                                                                            • ').join("") : '
                                                                                                                                                                            • no backups yet
                                                                                                                                                                            • '; + } // A dump is fetched with a signed request and handed to the browser as a file. async function downloadDump(name) { if (!signer.ready()) throw new Error(NO_SIGNER); @@ -483,6 +488,11 @@ const a = document.createElement("a"); a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(".")[0] + "-" + name; a.click(); URL.revokeObjectURL(a.href); } + async function downloadBackup(id) { + const resp = await backupRequest("/backups/" + encodeURIComponent(id), "GET"); + if (!resp.ok) throw new Error("backup download failed"); + const a = document.createElement("a"); a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(".")[0] + "-" + id + ".json"; a.click(); URL.revokeObjectURL(a.href); + } // A plain member's own invites, when the owner lets members invite. async function loadMine() { const sec = $("#myinvites"); @@ -498,6 +508,7 @@ pollJobs(); loadViews(); loadDumps().catch(() => {}); + loadBackups().catch(() => {}); const st = storage; const top = st.kinds.slice(0, 4), rest = st.kinds.slice(4).reduce((a, k) => a + k.bytes, 0); const parts = [...top.map((k, i) => [k.kind + " " + kindName(k.kind), "k" + (i + 1), k.bytes]), ...(rest ? [["other", "k5", rest]] : [])]; @@ -612,7 +623,7 @@ let jobs; try { jobs = await rpc("listjobs"); } catch { return; } $("#jobs tbody").innerHTML = jobs.length ? jobs.map(fmtJob).join("") : 'no jobs yet'; - if (isOwner) { + if (myRole === "owner") { try { const deliveries = await rpc("deliverystatus"); $("#deliveries tbody").innerHTML = deliveries.length ? deliveries.map((d) => '' + esc(d.event_id.slice(0, 12)) + '' + esc(d.target) + '' + esc(d.status) + '' + d.attempts + '' + esc(d.error || "") + '').join("") : 'no automatic deliveries yet'; @@ -881,9 +892,12 @@ return fetch(path, { method, headers: { authorization: "Nostr " + btoa(JSON.stringify(token)), ...(body ? { "content-type": "application/json" } : {}) }, body }); } $("#backupnow").onclick = guard(async () => { const id = $("#backupid").value.trim() || "backup"; const r = await rpc("backupnow", id); const resp = await backupRequest("/backups/" + id, "GET"); if (!resp.ok) throw new Error("backup download failed"); const a = document.createElement("a"); a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(".")[0] + "-" + id + ".json"; a.click(); URL.revokeObjectURL(a.href); toast("Backup downloaded"); await loadStorage(); void r; }); - async function selectedBackup() { const f = $("#backupfile").files[0]; if (!f) throw new Error("Choose a backup archive first."); if (f.size > 8 * 1024 * 1024) throw new Error("Backups are limited to 8 MB."); return f.text(); } - $("#backuppreview").onclick = guard(async () => { const body = await selectedBackup(); const r = await (await backupRequest("/backups/preview", "POST", body)).json(); if (r.error) throw new Error(r.error); alert(JSON.stringify(r.result, null, 2)); }); - $("#backuprestore").onclick = guard(async () => { const body = await selectedBackup(); if (!confirm("Restore this archive onto this fresh relay? This cannot be undone.")) return; const r = await (await backupRequest("/backups/restore", "POST", body)).json(); if (r.error) throw new Error(r.error); toast("Backup restored"); await loadInfo(); await loadAdmin(); }); + async function selectedBackup() { const f = $("#backupfile").files[0]; if (!f) throw new Error("Choose a backup archive first."); if (f.size > 8 * 1024 * 1024) throw new Error("Backups are limited to 8 MB."); return { file: f, body: await f.text() }; } + let backupPreview = null; + $("#backupfile").onchange = () => { backupPreview = null; $("#backup-preview").textContent = ""; $("#backuprestore").disabled = true; }; + $("#backupremote").onclick = () => showRemote(null); + $("#backuppreview").onclick = guard(async () => { const selected = await selectedBackup(); const r = await (await backupRequest("/backups/preview", "POST", selected.body)).json(); if (r.error) throw new Error(r.error); backupPreview = { file: selected.file, body: selected.body }; $("#backuprestore").disabled = false; const s = r.result.source; $("#backup-preview").innerHTML = '

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

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

                                                                                                                                                                              ' + esc(JSON.stringify(r.result.config, null, 2)) + '
                                                                                                                                                                              '; }); + $("#backuprestore").onclick = guard(async () => { const selected = await selectedBackup(); if (!backupPreview || backupPreview.file !== selected.file || backupPreview.body !== selected.body) throw new Error("Preview this exact archive before restoring."); if (!confirm("Restore this archive onto this fresh relay? This cannot be undone.")) return; const r = await (await backupRequest("/backups/restore", "POST", selected.body)).json(); if (r.error) throw new Error(r.error); me = await signer.getPublicKey(); localStorage.setItem("me", me); toast("Backup restored"); await loadInfo(); await loadFuel(); await loadAdmin(); await loadPeople(); }); $("#treeform").onsubmit = guard(async (ev) => { const f = ev.target; policy = await rpc("setpolicy", { memberInvites: { depth: Math.max(0, Math.floor(+f.depth.value || 0)), quota: Math.max(0, Math.floor(+f.quota.value || 0)) } }); @@ -990,6 +1004,9 @@ if (act === "deleteblob" && !confirm("Delete this file for good?")) return; if (act === "deletedump" && !confirm("Delete this dump?")) return; if (act === "downloaddump") { b.disabled = true; try { await downloadDump(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; } + if (act === "deletebackup" && !confirm("Delete this backup?")) return; + if (act === "downloadbackup") { b.disabled = true; try { await downloadBackup(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; } + if (act === "deletebackup") { b.disabled = true; try { await rpc("deletebackup", id); toast("Backup deleted"); await loadBackups(); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; } if (act === "restorelist") { if (!signer.ready()) { toast(NO_SIGNER); return; } try { diff --git a/src/gen/console.ts b/src/gen/console.ts index 6344595..34a6366 100644 --- a/src/gen/console.ts +++ b/src/gen/console.ts @@ -1,4 +1,4 @@ // Generated by scripts/build/build-console.mjs from src/console. Do not edit; run npm run build:console. -export const CONSOLE_HTML = "
                                                                                                                                                                              \n
                                                                                                                                                                              \n
                                                                                                                                                                              \"\"
                                                                                                                                                                              \n
                                                                                                                                                                              \"\"

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

                                                                                                                                                                              Nobody owns this relay yet.

                                                                                                                                                                              \n

                                                                                                                                                                              Claim it and it's yours: you decide who can post, who can read, and what stays. One signature with a nostr browser extension; no account, no email, no card.

                                                                                                                                                                              \n
                                                                                                                                                                              \n
                                                                                                                                                                              \n\n
                                                                                                                                                                              \n

                                                                                                                                                                              A temporary relay, for now.

                                                                                                                                                                              \n

                                                                                                                                                                              Anyone can read and write here until . Then everything on it is deleted and the name is freed. Claim it and it stays, events and files included: one signature with a nostr browser extension. Or claim a new name and pull this one into it from its Storage tab.

                                                                                                                                                                              \n
                                                                                                                                                                              \n
                                                                                                                                                                              \n\n
                                                                                                                                                                              \n

                                                                                                                                                                              Connect a remote signer.

                                                                                                                                                                              \n

                                                                                                                                                                              Your key stays in a signer app such as Amber or nsec.app; this page asks it to sign. On a phone, open the link and approve there. On a computer, paste the bunker:// URL the app gives you.

                                                                                                                                                                              \n \n
                                                                                                                                                                              \n \"QR\n

                                                                                                                                                                              \n
                                                                                                                                                                              \n\n
                                                                                                                                                                              \n
                                                                                                                                                                              \n

                                                                                                                                                                              \n

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

                                                                                                                                                                              Git repositories, ntig

                                                                                                                                                                              \n
                                                                                                                                                                              \n

                                                                                                                                                                              Use ordinary Git to clone a repository hosted here.

                                                                                                                                                                              \n
                                                                                                                                                                              Clone a repository
                                                                                                                                                                              \n

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

                                                                                                                                                                              \n

                                                                                                                                                                              To host your own, use a Nostr Git client to publish your repository and signed branch state to this relay before pushing to the same remote. Git hosting guide.

                                                                                                                                                                              \n
                                                                                                                                                                              \n
                                                                                                                                                                              \n
                                                                                                                                                                              \n

                                                                                                                                                                              Open it in an app

                                                                                                                                                                              \n

                                                                                                                                                                              Relay apps open this relay as a place. Feed apps do not: they find the owner here through a profile link that carries this relay as the hint, and learn the relay from that. Either way, add under the app's relay settings to post here.

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

                                                                                                                                                                              People

                                                                                                                                                                              \n

                                                                                                                                                                              Hidden from visitors. Only you see this list.

                                                                                                                                                                              \n
                                                                                                                                                                              \n
                                                                                                                                                                              \n\n
                                                                                                                                                                              \n

                                                                                                                                                                              Fuel

                                                                                                                                                                              \n

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

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

                                                                                                                                                                              \n
                                                                                                                                                                              sats
                                                                                                                                                                              \n
                                                                                                                                                                              \n

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

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

                                                                                                                                                                              Your invites

                                                                                                                                                                              \n

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

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

                                                                                                                                                                                People

                                                                                                                                                                                \n

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

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

                                                                                                                                                                                Invites

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

                                                                                                                                                                                  Joining

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

                                                                                                                                                                                  Moderation

                                                                                                                                                                                  \n

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

                                                                                                                                                                                  \n
                                                                                                                                                                                  \n

                                                                                                                                                                                  Reports

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

                                                                                                                                                                                  Log

                                                                                                                                                                                  \n

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

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

                                                                                                                                                                                  Bans

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

                                                                                                                                                                                      Blocked addresses

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

                                                                                                                                                                                        Recent events

                                                                                                                                                                                        \n

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

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

                                                                                                                                                                                        Pinned

                                                                                                                                                                                        \n

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

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

                                                                                                                                                                                          Rules

                                                                                                                                                                                          \n

                                                                                                                                                                                          Bans apply regardless of these.

                                                                                                                                                                                          \n
                                                                                                                                                                                          \n

                                                                                                                                                                                          Presets

                                                                                                                                                                                          \n

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

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

                                                                                                                                                                                          \n
                                                                                                                                                                                          \n
                                                                                                                                                                                          \n
                                                                                                                                                                                          \n

                                                                                                                                                                                          Writes

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

                                                                                                                                                                                          Reads

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

                                                                                                                                                                                          Kinds

                                                                                                                                                                                          \n

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

                                                                                                                                                                                          \n
                                                                                                                                                                                          \n

                                                                                                                                                                                          Allowed:

                                                                                                                                                                                          \n

                                                                                                                                                                                          Blocked:

                                                                                                                                                                                          \n
                                                                                                                                                                                          \n
                                                                                                                                                                                          \n

                                                                                                                                                                                          Features

                                                                                                                                                                                          \n

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

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

                                                                                                                                                                                          Blocked words

                                                                                                                                                                                          \n

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

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

                                                                                                                                                                                          Identity

                                                                                                                                                                                          \n
                                                                                                                                                                                          \n

                                                                                                                                                                                          Profile

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

                                                                                                                                                                                          For directories

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

                                                                                                                                                                                          Your own domain

                                                                                                                                                                                          \n

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

                                                                                                                                                                                          \n
                                                                                                                                                                                          \n
                                                                                                                                                                                          \n

                                                                                                                                                                                          \n
                                                                                                                                                                                          \n
                                                                                                                                                                                          \n

                                                                                                                                                                                          Your relay lists

                                                                                                                                                                                          \n

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

                                                                                                                                                                                          \n
                                                                                                                                                                                          \n
                                                                                                                                                                                          \n
                                                                                                                                                                                          \n

                                                                                                                                                                                          Share

                                                                                                                                                                                          \n

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

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

                                                                                                                                                                                          Data

                                                                                                                                                                                          \n

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

                                                                                                                                                                                          \n
                                                                                                                                                                                          \n

                                                                                                                                                                                          Recover your lists

                                                                                                                                                                                          \n

                                                                                                                                                                                          Older signed versions of your follows, relay lists and bookmarks stay private here. Restore a version to review it, then sign and publish it from this relay.

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

                                                                                                                                                                                          By kind

                                                                                                                                                                                          \n
                                                                                                                                                                                          KindCountSizeOldestKeep for
                                                                                                                                                                                          \n
                                                                                                                                                                                          \n
                                                                                                                                                                                          \n

                                                                                                                                                                                          Files

                                                                                                                                                                                          \n
                                                                                                                                                                                          TimeFileSizeUploader
                                                                                                                                                                                          \n
                                                                                                                                                                                          \n
                                                                                                                                                                                          \n

                                                                                                                                                                                          Sites

                                                                                                                                                                                          \n

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

                                                                                                                                                                                          \n
                                                                                                                                                                                          AuthorNameURLFilesSizeExpiry
                                                                                                                                                                                          \n
                                                                                                                                                                                          \n
                                                                                                                                                                                          \n

                                                                                                                                                                                          Dumps

                                                                                                                                                                                          \n

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

                                                                                                                                                                                          \n
                                                                                                                                                                                          \n
                                                                                                                                                                                            \n
                                                                                                                                                                                            \n
                                                                                                                                                                                            \n

                                                                                                                                                                                            Import a file

                                                                                                                                                                                            \n

                                                                                                                                                                                            A JSONL of events, one per line, such as a dump or a strfry export, or a JSON array. Up to 64 MB. Signatures are checked; bans and kind rules apply; the write rule does not. Progress shows under Jobs.

                                                                                                                                                                                            \n
                                                                                                                                                                                            \n
                                                                                                                                                                                            \n
                                                                                                                                                                                            \n

                                                                                                                                                                                            Backup and restore

                                                                                                                                                                                            \n

                                                                                                                                                                                            A private archive includes configuration, events, site files and Git objects. It excludes keys, fuel, jobs and push registrations. Archives are limited to 8 MB and restore only works on a fresh relay.

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

                                                                                                                                                                                              Sync

                                                                                                                                                                                              \n

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

                                                                                                                                                                                              \n
                                                                                                                                                                                              \n

                                                                                                                                                                                              Jobs

                                                                                                                                                                                              \n

                                                                                                                                                                                              Work the relay does on its own, one round at a time. A pull copies what another relay has and this one lacks. Fetch my history pulls your own events from the relays in your relay list. Rebroadcast sends what is here to other relays. Bans and kind rules apply to what arrives.

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

                                                                                                                                                                                              Automatic delivery

                                                                                                                                                                                              \n

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

                                                                                                                                                                                              \n
                                                                                                                                                                                              EventTargetStatusAttemptsLast error
                                                                                                                                                                                              \n
                                                                                                                                                                                              \n
                                                                                                                                                                                              \n

                                                                                                                                                                                              Fork this relay

                                                                                                                                                                                              \n

                                                                                                                                                                                              A new name, temporary until claimed, filled from this relay. Yours to split a name by job, or somebody else's to hand them a community with its history. One fork an hour.

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

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

                                                                                                                                                                                              Views

                                                                                                                                                                                              \n

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

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

                                                                                                                                                                                              Health

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

                                                                                                                                                                                              Zaps received

                                                                                                                                                                                              \n
                                                                                                                                                                                              WhenFromSats
                                                                                                                                                                                              \n
                                                                                                                                                                                              \n
                                                                                                                                                                                              \n

                                                                                                                                                                                              Notifications

                                                                                                                                                                                              \n

                                                                                                                                                                                              The relay writes you a private message (NIP-17) with its own key. It lands here, in your inbox on your own relay, and on your DM relays when this relay holds your kind 10050.

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

                                                                                                                                                                                              Owner

                                                                                                                                                                                              \n

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

                                                                                                                                                                                              \n
                                                                                                                                                                                              \n

                                                                                                                                                                                              Configuration

                                                                                                                                                                                              \n

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

                                                                                                                                                                                              \n
                                                                                                                                                                                              \n
                                                                                                                                                                                              \n
                                                                                                                                                                                              \n

                                                                                                                                                                                              Transfer ownership

                                                                                                                                                                                              \n

                                                                                                                                                                                              Hands the relay to a member. You stay on as a moderator. The relay's key, events, files and fuel do not change. There is no undo.

                                                                                                                                                                                              \n
                                                                                                                                                                                              \n
                                                                                                                                                                                              \n
                                                                                                                                                                                              \n

                                                                                                                                                                                              If I lose my key

                                                                                                                                                                                              \n

                                                                                                                                                                                              Name a member as your heir. If you do not sign in here for the time you pick, the relay writes to you once a week for a month, then hands itself to the heir and keeps you on as a moderator. Any signed action on the relay resets the clock.

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

                                                                                                                                                                                              \n
                                                                                                                                                                                              \n
                                                                                                                                                                                              \n

                                                                                                                                                                                              Delete this relay

                                                                                                                                                                                              \n

                                                                                                                                                                                              Deletes every event, file, member, invite and setting, closes every connection, and returns the name to unclaimed for anyone to take. There is no undo.

                                                                                                                                                                                              \n \n
                                                                                                                                                                                              \n
                                                                                                                                                                                              \n
                                                                                                                                                                                              \n\n \n
                                                                                                                                                                                              \n"; +export const CONSOLE_HTML = "
                                                                                                                                                                                              \n
                                                                                                                                                                                              \n
                                                                                                                                                                                              \"\"
                                                                                                                                                                                              \n
                                                                                                                                                                                              \"\"

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

                                                                                                                                                                                              Nobody owns this relay yet.

                                                                                                                                                                                              \n

                                                                                                                                                                                              Claim it and it's yours: you decide who can post, who can read, and what stays. One signature with a nostr browser extension; no account, no email, no card.

                                                                                                                                                                                              \n
                                                                                                                                                                                              \n
                                                                                                                                                                                              \n

                                                                                                                                                                                              Restore a backup

                                                                                                                                                                                              \n

                                                                                                                                                                                              Choose a private bind.ws archive to inspect it before restoring. Preview does not claim this relay. Restore accepts only an empty relay and the archive owner's signature.

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

                                                                                                                                                                                              A temporary relay, for now.

                                                                                                                                                                                              \n

                                                                                                                                                                                              Anyone can read and write here until . Then everything on it is deleted and the name is freed. Claim it and it stays, events and files included: one signature with a nostr browser extension. Or claim a new name and pull this one into it from its Storage tab.

                                                                                                                                                                                              \n
                                                                                                                                                                                              \n
                                                                                                                                                                                              \n\n
                                                                                                                                                                                              \n

                                                                                                                                                                                              Connect a remote signer.

                                                                                                                                                                                              \n

                                                                                                                                                                                              Your key stays in a signer app such as Amber or nsec.app; this page asks it to sign. On a phone, open the link and approve there. On a computer, paste the bunker:// URL the app gives you.

                                                                                                                                                                                              \n \n
                                                                                                                                                                                              \n \"QR\n

                                                                                                                                                                                              \n
                                                                                                                                                                                              \n\n
                                                                                                                                                                                              \n
                                                                                                                                                                                              \n

                                                                                                                                                                                              \n

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

                                                                                                                                                                                              Git repositories, ntig

                                                                                                                                                                                              \n
                                                                                                                                                                                              \n

                                                                                                                                                                                              Use ordinary Git to clone a repository hosted here.

                                                                                                                                                                                              \n
                                                                                                                                                                                              Clone a repository
                                                                                                                                                                                              \n

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

                                                                                                                                                                                              \n

                                                                                                                                                                                              To host your own, use a Nostr Git client to publish your repository and signed branch state to this relay before pushing to the same remote. Git hosting guide.

                                                                                                                                                                                              \n
                                                                                                                                                                                              \n
                                                                                                                                                                                              \n
                                                                                                                                                                                              \n

                                                                                                                                                                                              Open it in an app

                                                                                                                                                                                              \n

                                                                                                                                                                                              Relay apps open this relay as a place. Feed apps do not: they find the owner here through a profile link that carries this relay as the hint, and learn the relay from that. Either way, add under the app's relay settings to post here.

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

                                                                                                                                                                                              People

                                                                                                                                                                                              \n

                                                                                                                                                                                              Hidden from visitors. Only you see this list.

                                                                                                                                                                                              \n
                                                                                                                                                                                              \n
                                                                                                                                                                                              \n\n
                                                                                                                                                                                              \n

                                                                                                                                                                                              Fuel

                                                                                                                                                                                              \n

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

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

                                                                                                                                                                                              \n
                                                                                                                                                                                              sats
                                                                                                                                                                                              \n
                                                                                                                                                                                              \n

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

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

                                                                                                                                                                                              Your invites

                                                                                                                                                                                              \n

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

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

                                                                                                                                                                                                People

                                                                                                                                                                                                \n

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

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

                                                                                                                                                                                                Invites

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

                                                                                                                                                                                                  Joining

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

                                                                                                                                                                                                  Moderation

                                                                                                                                                                                                  \n

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

                                                                                                                                                                                                  \n
                                                                                                                                                                                                  \n

                                                                                                                                                                                                  Reports

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

                                                                                                                                                                                                  Log

                                                                                                                                                                                                  \n

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

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

                                                                                                                                                                                                  Bans

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

                                                                                                                                                                                                      Blocked addresses

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

                                                                                                                                                                                                        Recent events

                                                                                                                                                                                                        \n

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

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

                                                                                                                                                                                                        Pinned

                                                                                                                                                                                                        \n

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

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

                                                                                                                                                                                                          Rules

                                                                                                                                                                                                          \n

                                                                                                                                                                                                          Bans apply regardless of these.

                                                                                                                                                                                                          \n
                                                                                                                                                                                                          \n

                                                                                                                                                                                                          Presets

                                                                                                                                                                                                          \n

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

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

                                                                                                                                                                                                          \n
                                                                                                                                                                                                          \n
                                                                                                                                                                                                          \n
                                                                                                                                                                                                          \n

                                                                                                                                                                                                          Writes

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

                                                                                                                                                                                                          Reads

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

                                                                                                                                                                                                          Kinds

                                                                                                                                                                                                          \n

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

                                                                                                                                                                                                          \n
                                                                                                                                                                                                          \n

                                                                                                                                                                                                          Allowed:

                                                                                                                                                                                                          \n

                                                                                                                                                                                                          Blocked:

                                                                                                                                                                                                          \n
                                                                                                                                                                                                          \n
                                                                                                                                                                                                          \n

                                                                                                                                                                                                          Features

                                                                                                                                                                                                          \n

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

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

                                                                                                                                                                                                          Blocked words

                                                                                                                                                                                                          \n

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

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

                                                                                                                                                                                                          Identity

                                                                                                                                                                                                          \n
                                                                                                                                                                                                          \n

                                                                                                                                                                                                          Profile

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

                                                                                                                                                                                                          For directories

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

                                                                                                                                                                                                          Your own domain

                                                                                                                                                                                                          \n

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

                                                                                                                                                                                                          \n
                                                                                                                                                                                                          \n
                                                                                                                                                                                                          \n

                                                                                                                                                                                                          \n
                                                                                                                                                                                                          \n
                                                                                                                                                                                                          \n

                                                                                                                                                                                                          Your relay lists

                                                                                                                                                                                                          \n

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

                                                                                                                                                                                                          \n
                                                                                                                                                                                                          \n
                                                                                                                                                                                                          \n
                                                                                                                                                                                                          \n

                                                                                                                                                                                                          Share

                                                                                                                                                                                                          \n

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

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

                                                                                                                                                                                                          Data

                                                                                                                                                                                                          \n

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

                                                                                                                                                                                                          \n
                                                                                                                                                                                                          \n

                                                                                                                                                                                                          Recover your lists

                                                                                                                                                                                                          \n

                                                                                                                                                                                                          Older signed versions of your follows, relay lists and bookmarks stay private here. Restore a version to review it, then sign and publish it from this relay.

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

                                                                                                                                                                                                          By kind

                                                                                                                                                                                                          \n
                                                                                                                                                                                                          KindCountSizeOldestKeep for
                                                                                                                                                                                                          \n
                                                                                                                                                                                                          \n
                                                                                                                                                                                                          \n

                                                                                                                                                                                                          Files

                                                                                                                                                                                                          \n
                                                                                                                                                                                                          TimeFileSizeUploader
                                                                                                                                                                                                          \n
                                                                                                                                                                                                          \n
                                                                                                                                                                                                          \n

                                                                                                                                                                                                          Sites

                                                                                                                                                                                                          \n

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

                                                                                                                                                                                                          \n
                                                                                                                                                                                                          AuthorNameURLFilesSizeExpiry
                                                                                                                                                                                                          \n
                                                                                                                                                                                                          \n
                                                                                                                                                                                                          \n

                                                                                                                                                                                                          Dumps

                                                                                                                                                                                                          \n

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

                                                                                                                                                                                                          \n
                                                                                                                                                                                                          \n
                                                                                                                                                                                                            \n
                                                                                                                                                                                                            \n
                                                                                                                                                                                                            \n

                                                                                                                                                                                                            Backups

                                                                                                                                                                                                            \n

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

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

                                                                                                                                                                                                              Import a file

                                                                                                                                                                                                              \n

                                                                                                                                                                                                              A JSONL of events, one per line, such as a dump or a strfry export, or a JSON array. Up to 64 MB. Signatures are checked; bans and kind rules apply; the write rule does not. Progress shows under Jobs.

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

                                                                                                                                                                                                              Sync

                                                                                                                                                                                                              \n

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

                                                                                                                                                                                                              \n
                                                                                                                                                                                                              \n

                                                                                                                                                                                                              Jobs

                                                                                                                                                                                                              \n

                                                                                                                                                                                                              Work the relay does on its own, one round at a time. A pull copies what another relay has and this one lacks. Fetch my history pulls your own events from the relays in your relay list. Rebroadcast sends what is here to other relays. Bans and kind rules apply to what arrives.

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

                                                                                                                                                                                                              Automatic delivery

                                                                                                                                                                                                              \n

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

                                                                                                                                                                                                              \n
                                                                                                                                                                                                              EventTargetStatusAttemptsLast error
                                                                                                                                                                                                              \n
                                                                                                                                                                                                              \n
                                                                                                                                                                                                              \n

                                                                                                                                                                                                              Fork this relay

                                                                                                                                                                                                              \n

                                                                                                                                                                                                              A new name, temporary until claimed, filled from this relay. Yours to split a name by job, or somebody else's to hand them a community with its history. One fork an hour.

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

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

                                                                                                                                                                                                              Views

                                                                                                                                                                                                              \n

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

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

                                                                                                                                                                                                              Health

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

                                                                                                                                                                                                              Zaps received

                                                                                                                                                                                                              \n
                                                                                                                                                                                                              WhenFromSats
                                                                                                                                                                                                              \n
                                                                                                                                                                                                              \n
                                                                                                                                                                                                              \n

                                                                                                                                                                                                              Notifications

                                                                                                                                                                                                              \n

                                                                                                                                                                                                              The relay writes you a private message (NIP-17) with its own key. It lands here, in your inbox on your own relay, and on your DM relays when this relay holds your kind 10050.

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

                                                                                                                                                                                                              Owner

                                                                                                                                                                                                              \n

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

                                                                                                                                                                                                              \n
                                                                                                                                                                                                              \n

                                                                                                                                                                                                              Configuration

                                                                                                                                                                                                              \n

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

                                                                                                                                                                                                              \n
                                                                                                                                                                                                              \n
                                                                                                                                                                                                              \n
                                                                                                                                                                                                              \n

                                                                                                                                                                                                              Transfer ownership

                                                                                                                                                                                                              \n

                                                                                                                                                                                                              Hands the relay to a member. You stay on as a moderator. The relay's key, events, files and fuel do not change. There is no undo.

                                                                                                                                                                                                              \n
                                                                                                                                                                                                              \n
                                                                                                                                                                                                              \n
                                                                                                                                                                                                              \n

                                                                                                                                                                                                              If I lose my key

                                                                                                                                                                                                              \n

                                                                                                                                                                                                              Name a member as your heir. If you do not sign in here for the time you pick, the relay writes to you once a week for a month, then hands itself to the heir and keeps you on as a moderator. Any signed action on the relay resets the clock.

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

                                                                                                                                                                                                              \n
                                                                                                                                                                                                              \n
                                                                                                                                                                                                              \n

                                                                                                                                                                                                              Delete this relay

                                                                                                                                                                                                              \n

                                                                                                                                                                                                              Deletes every event, file, member, invite and setting, closes every connection, and returns the name to unclaimed for anyone to take. There is no undo.

                                                                                                                                                                                                              \n \n
                                                                                                                                                                                                              \n
                                                                                                                                                                                                              \n
                                                                                                                                                                                                              \n\n \n
                                                                                                                                                                                                              \n"; export const CONSOLE_CSS = "main { max-width: 64rem; }\n.mast { display: grid; grid-template-columns: 1fr auto; gap: 1.5rem 2rem; align-items: end; margin-bottom: 2rem; }\n.mast .wordmark { grid-column: 1 / -1; justify-self: center; margin-bottom: .5rem; }\n.mast .right { display: grid; gap: .7rem; justify-items: end; text-align: right; }\n.urlrow, .owner { display: inline-flex; align-items: center; gap: .4rem; color: var(--ink-2); }\n.owner b { color: var(--ink); } .owner em { font-style: normal; color: var(--forest); }\n.urlrow .ib, .owner .ib { width: 26px; height: 26px; margin-left: .2rem; box-shadow: 1px 1px 0 var(--ink); } .urlrow .ib svg, .owner .ib svg { width: 13px; height: 13px; }\n.care { display: flex; gap: .9rem; flex-wrap: wrap; }\n.care .g { display: grid; justify-items: center; gap: .3rem; width: 5.4rem; }\n.care .g i { display: grid; place-items: center; width: 44px; height: 44px; border: 2px solid var(--ink); border-radius: 10px; background: var(--paper); box-shadow: 2px 2px 0 var(--ink); position: relative; }\n.care .g i svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }\n.care .g.off i::after { content: \"\"; position: absolute; width: 3px; height: 40px; background: var(--ink); transform: rotate(45deg); border-radius: 2px; box-shadow: 0 0 0 2px var(--paper); }\n.care .g small { font: 500 10px/1.2 var(--mono); text-transform: uppercase; letter-spacing: .04em; color: var(--ink-2); text-align: center; }\n#unclaimed { background: var(--butter); }\n.doors { display: grid; grid-template-columns: repeat(4, 1fr); gap: .8rem; } @media (max-width: 52rem) { .doors { grid-template-columns: 1fr 1fr; } } @media (max-width: 30rem) { .doors { grid-template-columns: 1fr; } }\n.door { display: grid; grid-template-rows: auto 1fr; align-content: start; gap: .35rem; padding: .8rem .9rem; border: 2px solid var(--ink); border-radius: 12px; background: var(--paper); box-shadow: 3px 3px 0 var(--ink); min-width: 0; }\n.apps { display: grid; gap: 1.4rem; margin-top: 1rem; } .appgroup h4 { margin: 0 0 .2rem; font-size: 1rem; } .appgroup > .note { margin-top: 0; }\n.appgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: .8rem; } @media (max-width: 52rem) { .appgrid { grid-template-columns: 1fr 1fr; } } @media (max-width: 30rem) { .appgrid { grid-template-columns: 1fr; } }\n.app { display: grid; align-content: start; gap: .4rem; padding: .8rem .9rem; border: 2px solid var(--ink); border-radius: 12px; background: var(--paper); box-shadow: 3px 3px 0 var(--ink); min-width: 0; }\n.app-head { display: flex; align-items: baseline; gap: .5rem; flex-wrap: wrap; } .app-head b { font-size: 15px; } .app-head small { color: var(--ink-3); } .app p { margin: 0; font-size: 13px; color: var(--ink-2); }\n.app-acts { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .2rem; } .app-acts .btn { padding: .3rem .7rem; font-size: 13px; }\n.phones { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, max-content)); gap: .8rem; margin-top: 1rem; } .phones img { display: block; width: 150px; height: 150px; } .phones .door { justify-items: start; }\n.door small { font: 700 11px var(--sans); text-transform: uppercase; letter-spacing: .06em; color: var(--ink-2); }\n.door .v { display: grid; grid-template-columns: 1fr auto; align-items: start; gap: .5rem; font: 500 13px/1.45 var(--mono); } .door .v span { min-width: 0; overflow-wrap: anywhere; }\n.door .ib { width: 24px; height: 24px; box-shadow: 1px 1px 0 var(--ink); flex: 0 0 auto; } .door .ib svg { width: 12px; height: 12px; }\n#peoplesec { background: var(--peach); }\n.dir { display: flex; flex-wrap: wrap; gap: .5rem; }\n.who { display: inline-flex; align-items: center; gap: .1rem; padding: .3rem .7rem .3rem .4rem; border: 2px solid var(--ink); border-radius: 999px; background: var(--paper); box-shadow: 2px 2px 0 var(--ink); font-size: 14px; }\n.who .role { font: 700 10px var(--mono); text-transform: uppercase; letter-spacing: .05em; color: var(--forest); margin-left: .4rem; }\n.who.me { background: var(--sun); }\n#fuelsec { background: var(--mint); }\n.gauges { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }\n.gauge small { display: block; font-weight: 600; font-size: 13px; }\n.gauge .bar { position: relative; height: 22px; border: 2px solid var(--ink); border-radius: 999px; margin: .5rem 0 .35rem; overflow: hidden; padding: 3px; background: var(--paper) linear-gradient(90deg, transparent calc(25% - 1px), var(--line) calc(25% - 1px) 25%, transparent 25% calc(50% - 1px), var(--line) calc(50% - 1px) 50%, transparent 50% calc(75% - 1px), var(--line) calc(75% - 1px) 75%, transparent 75%); }\n.gauge .bar b { position: absolute; right: .6rem; top: 0; bottom: 0; display: flex; align-items: center; font: 600 11px var(--mono); color: var(--ink-3); pointer-events: none; }\n.gauge .bar i { display: block; height: 100%; width: 0; min-width: 14px; border-radius: 999px; background: repeating-linear-gradient(-45deg, var(--forest) 0 8px, var(--forest-2) 8px 16px); transition: width .4s; }\n.gauge .bar i.warm { background: repeating-linear-gradient(-45deg, #d9a52a 0 8px, var(--sun) 8px 16px); }\n.gauge .bar i.over { background: repeating-linear-gradient(-45deg, var(--red) 0 8px, #d4614c 8px 16px); }\n.gauge span { font-size: 13px; color: var(--ink-3); }\n.balance { margin: 1.2rem 0 0; color: var(--ink-2); } .balance b { color: var(--ink); font-weight: 700; }\n.sats { display: inline-flex; align-items: center; width: auto; } .sats input { font: 15px var(--sans); width: 6rem; text-align: right; border: 0; outline: none; padding: 0; background: transparent; color: var(--ink); } .sats span { color: var(--ink-3); padding-left: .4rem; }\n.topup { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; margin-top: .8rem; }\n.invoice { margin-top: 1rem; border: 2px dashed var(--ink); border-radius: 12px; padding: .9rem 1rem; background: var(--paper); }\n.invoice p { margin: 0 0 .6rem; color: var(--ink-2); font-size: 14px; } .invoice .state { font-size: 13px; color: var(--forest); }\n.invoice textarea { margin-top: .7rem; min-height: 3.4rem; font-family: var(--mono); font-size: 11.5px; color: var(--ink-3); }\ndetails.disclosure summary { cursor: pointer; font: 400 1.6rem/1 var(--display); list-style: none; display: flex; align-items: center; gap: .5rem; }\ndetails.disclosure summary::-webkit-details-marker { display: none; }\n.folds details + details, .folds #frontsec:not(.hidden) + details { border-top: 2px solid var(--line); margin-top: 1.2rem; padding-top: 1.2rem; } .folds #frontsec .metarow { margin-bottom: 0; } .folds .doors { margin-top: 1rem; } .folds > details > .block:first-of-type { margin-top: 1.4rem; }\ndetails.disclosure summary::before { content: \"+\"; font: 700 1.4rem var(--mono); width: 1.4rem; } details.disclosure[open] summary::before { content: \"–\"; }\n.about { display: grid; grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); gap: .6rem 1.2rem; margin-top: 1rem; font-size: 14px; }\n.about small { display: block; color: var(--ink-3); font-size: 12px; } .about div > div { overflow-wrap: anywhere; }\n.console { margin-top: 3rem; }\n.tabs { display: flex; gap: .45rem; flex-wrap: nowrap; align-items: flex-end; margin: 0 0 -2px 1rem; padding: 2px 3rem 2px 0; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; }\n.tabs.fade-r { -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 2.5rem), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 2.5rem), transparent); }\n.tabs.fade-l { -webkit-mask-image: linear-gradient(90deg, transparent, #000 2.5rem); mask-image: linear-gradient(90deg, transparent, #000 2.5rem); }\n.tabs.fade-l.fade-r { -webkit-mask-image: linear-gradient(90deg, transparent, #000 2.5rem, #000 calc(100% - 2.5rem), transparent); mask-image: linear-gradient(90deg, transparent, #000 2.5rem, #000 calc(100% - 2.5rem), transparent); } .tabs::-webkit-scrollbar { display: none; } .tabs a { flex: 0 0 auto; white-space: nowrap; }\n.tabs a { font: 600 14px var(--sans); padding: .55rem 1rem; border: 2px solid var(--ink); border-bottom: 0; border-radius: 12px 12px 0 0; background: var(--head); color: var(--ink-2); text-decoration: none; margin-bottom: -2px; position: relative; z-index: 1; }\n.tabs a.on { color: var(--ink); z-index: 3; padding-top: .7rem; } .tabs a.on:nth-child(4n+1) { background: var(--sun); } .tabs a.on:nth-child(4n+2) { background: var(--peach); } .tabs a.on:nth-child(4n+3) { background: var(--sky); } .tabs a.on:nth-child(4n+4) { background: var(--mint); }\n.count { display: inline-grid; place-items: center; min-width: 1.5em; height: 1.5em; padding: 0 .4em; margin-left: .45rem; border: 1.5px solid var(--ink); border-radius: 999px; background: var(--sun); color: var(--ink); font: 700 11px/1 var(--mono); vertical-align: middle; box-shadow: 1px 1px 0 var(--ink); } .count:empty { display: none; } h2 .count { font-size: 12px; vertical-align: .45em; }\n.panel { display: none; margin: 0; border-radius: 0 18px 18px 18px; position: relative; z-index: 2; }\n.panel.on { display: block; }\n.panel h2 { font-size: 2rem; }\n.block { margin-top: 1.8rem; }\n.two { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; } .two > * { min-width: 0; }\n.actions { display: flex; justify-content: flex-end; margin-top: 1.1rem; } .actions.left { justify-content: flex-start; }\n.addrow { display: flex; gap: .5rem; align-items: center; margin-bottom: .9rem; } .addrow input { flex: 1; min-width: 0; } .addrow label { display: inline-flex; align-items: center; gap: .35rem; flex: 0 0 auto; white-space: nowrap; font-size: 14px; color: var(--ink-2); cursor: pointer; } .addrow label input { flex: none; width: auto; margin: 0; accent-color: var(--forest); } .addrow .ib { width: 38px; height: 38px; flex: 0 0 auto; border-radius: 10px; }\n.choices { display: grid; gap: .5rem; }\n.choices label { display: grid; grid-template-columns: auto 1fr; gap: 0 .7rem; cursor: pointer; padding: .7rem .8rem; border: 2px solid var(--line-2); border-radius: 12px; background: var(--paper); }\n.choices label:has(:checked) { border-color: var(--ink); box-shadow: 3px 3px 0 var(--ink); }\n.choices input { grid-row: span 2; margin: .2rem 0 0; accent-color: var(--forest); }\n.choices b { font-weight: 600; } .choices small { color: var(--ink-2); font-size: 13px; }\n.limits { display: grid; grid-template-columns: 1fr 1fr; gap: .6rem 1.5rem; margin-top: 1.3rem; }\n.limits label { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: .6rem; } .limits label small { color: var(--ink-3); }\n.form { display: grid; grid-template-columns: 1fr 1fr; gap: .9rem 1.5rem; } .form label { display: grid; gap: .3rem; font-weight: 600; font-size: 14px; } .form .wide { grid-column: 1 / -1; }\n.danger-zone { border: 2px solid var(--red); border-radius: 12px; padding: 1rem 1.1rem; background: var(--red-soft); } .danger-zone h3 { color: var(--red); } .form label.switch { display: flex; flex-direction: row; align-items: center; gap: .6rem; font-weight: 600; font-size: 14px; } .switch input { accent-color: var(--forest); width: 18px; height: 18px; }\ntable { width: 100%; border-collapse: collapse; }\nth { text-align: left; font-size: 12px; font-weight: 600; color: var(--ink-3); padding: .4rem .5rem; border-bottom: 2px solid var(--ink); white-space: nowrap; }\ntd { padding: .5rem .5rem; vertical-align: middle; border-bottom: 1px solid var(--line); }\ntbody tr:nth-child(even) td { background: rgba(255,255,255,.55); }\nth.r, td.r { text-align: right; white-space: nowrap; } td.mono, td.dim { white-space: nowrap; } .events td.c:empty::before { content: \"(no content)\"; color: var(--ink-3); }\ntd .ib { width: 26px; height: 26px; box-shadow: 1px 1px 0 var(--ink); } td .ib svg { width: 13px; height: 13px; }\ntd input.txt { padding: .25rem .5rem; font-size: 14px; } td select.role { width: auto; min-width: 7rem; padding: .25rem .4rem; font-size: 13px; } .people-table input.name { width: 7rem; } .people-table input.note { width: 9.5rem; }\n.kind { font-family: var(--mono); font-size: 12px; background: var(--sun); border: 1.5px solid var(--ink); padding: 0 .5rem; border-radius: 999px; font-weight: 500; white-space: nowrap; }\n.events td.c { max-width: 0; width: 100%; overflow-x: auto; white-space: nowrap; scrollbar-width: thin; -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 2rem), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 2rem), transparent); }\n.people-table td.name { width: 12rem; }\n.ev { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 4px; vertical-align: -5px; margin-right: .45rem; font: 500 9px var(--mono); background: var(--line); color: var(--ink-2); font-style: normal; }\n.plain { list-style: none; margin: 0; padding: 0; } .plain li { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: .5rem; padding: .5rem 0; border-bottom: 2px dotted var(--line-2); } .plain li > span { min-width: 0; overflow-wrap: anywhere; } .plain li:last-child { border-bottom: 0; } .plain li.empty { grid-template-columns: 1fr; color: var(--ink-3); }\n.kindline { margin: .4rem 0; color: var(--ink-2); display: flex; flex-wrap: wrap; align-items: center; gap: .35rem; }\n.tag { display: inline-flex; align-items: center; gap: .2rem; font-family: var(--mono); font-size: 13px; padding: .05rem .2rem .05rem .55rem; border: 2px solid var(--ink); border-radius: 999px; background: var(--paper); box-shadow: 1px 1px 0 var(--ink); }\n.tag.plain { padding-right: .55rem; box-shadow: none; border-color: var(--line-2); color: var(--ink-3); } .tag.blk { color: var(--red); }\n.tag .ib { width: 18px; height: 18px; border-width: 1.5px; box-shadow: none; margin-left: .2rem; } .tag .ib svg { width: 9px; height: 9px; }\n.counters { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1.2rem; margin-bottom: 1.6rem; }\n.counter { display: grid; justify-items: center; align-content: center; gap: .15rem; min-height: 7.2rem; text-align: center; padding: 1rem .8rem; border: 2px solid var(--ink); border-radius: 16px; box-shadow: 4px 4px 0 var(--ink); background: var(--paper); }\n.counter:nth-child(1) { background: var(--sun); } .counter:nth-child(2) { background: var(--peach); } .counter:nth-child(3) { background: var(--sky); } .counter:nth-child(4) { background: var(--mint); }\n.counter .big { font: 400 2.6rem/1 var(--display); white-space: nowrap; } .counter small { display: block; margin-top: .35rem; font: 700 12px var(--sans); text-transform: uppercase; letter-spacing: .08em; } .counter .sub { font-size: 12px; color: var(--ink-2); }\n.kbar { display: flex; width: 100%; height: 22px; border: 2px solid var(--ink); border-radius: 999px; overflow: hidden; background: var(--paper); margin-bottom: .35rem; } .kbar i { display: block; height: 100%; border-right: 2px solid var(--ink); } .kbar i:last-child { border-right: 0; }\n.k1 { background: var(--ink); } .k2 { background: var(--forest); } .k3 { background: var(--id-3); } .k4 { background: var(--id-2); } .k5 { background: var(--line-2); }\n.legend { display: flex; flex-wrap: wrap; justify-content: center; gap: .1rem .6rem; font-family: var(--mono); font-size: 10.5px; text-transform: uppercase; } .legend i { display: inline-block; width: 9px; height: 9px; border: 1.5px solid var(--ink); border-radius: 2px; margin-right: .3rem; vertical-align: -1px; }\n.usage { display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: .8rem; } .usage div { padding: .7rem .9rem; border: 2px solid var(--ink); border-radius: 12px; background: var(--paper); } .usage small { display: block; color: var(--ink-3); font-size: 12px; } .usage b { font: 400 1.5rem/1.2 var(--display); }\n@media (max-width: 52rem) { .two, .gauges, .limits, .form, .counters { grid-template-columns: 1fr !important; } .addrow { flex-wrap: wrap; } .mast { grid-template-columns: 1fr; } .mast .right { justify-items: start; text-align: left; } }\n@media (max-width: 40rem) { .events:not(#kinds):not(#jobs) th:first-child, .events:not(#kinds):not(#jobs) td:first-child { display: none; } }\n\n.slegend { justify-content: flex-start; margin: 0 0 1.2rem; }\n#s-totals { margin-bottom: .4rem; }\ntd.keep { white-space: nowrap; } td.keep input { width: 5.5rem; display: inline-block; margin-right: .3rem; } td.keep input::placeholder { color: var(--ink-3); }\n#kinds tr.any td:first-child { font-weight: 600; }\n#kinds td:first-child .kind { display: inline-block; min-width: 5.2em; text-align: center; margin-right: .35rem; }\n/* Tables keep their columns and scroll inside the card on narrow screens instead of pushing the page sideways. */\n.scroll { overflow-x: auto; max-width: 100%; scrollbar-width: thin; } .scroll table { min-width: 100%; }\nsection, .card { overflow-x: clip; }\n.kind.sys { background: var(--mint); border-color: var(--forest); color: var(--forest); }\ntd.keep.sys { color: var(--ink-3); font-size: 13px; white-space: normal; }\n\n#console.mod .tabs a:not([data-tab=people]):not([data-tab=moderation]) { display: none; } #console.mod #thresholdform { display: none; }\n#members select.role { width: auto; padding: .1rem .3rem; font-size: 12px; margin-left: .3rem; }\n.wire-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .5rem 1.2rem; padding: .8rem 0; border-bottom: 1px solid var(--line); align-items: start; } .wire-row:first-child { border-top: 2px solid var(--ink); }\n.wire-main { min-width: 0; } .wire-side { display: grid; justify-items: end; gap: .45rem; max-width: 26rem; }\n.wire-acts { display: flex; gap: .4rem; white-space: nowrap; } .wire-acts .btn { padding: .35rem .75rem; font-size: 13px; }\n.wire-meta { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .35rem .5rem; align-items: center; font-size: 13px; color: var(--ink-2); text-align: right; } .wire-meta:empty { display: none; }\n.pill { display: inline-block; padding: .1rem .6rem; border: 1.5px solid var(--ink); border-radius: 999px; font: 600 12px var(--sans); color: var(--ink); background: var(--paper); } .pill.on { background: var(--mint); } .pill.off { color: var(--ink-3); } .pill.bad { color: var(--red); }\n.chip { display: inline-block; padding: .05rem .45rem; border: 1px solid var(--forest); border-radius: 6px; font: 12px var(--mono); color: var(--forest); } .chip.bad { border-color: var(--red); color: var(--red); }\n.share { display: grid; grid-template-columns: minmax(0, 3fr) minmax(14rem, 2fr); gap: 1.4rem; align-items: start; } .share #cardimg { width: 100%; height: auto; display: block; }\n.share-side { display: grid; gap: .8rem; justify-items: start; } .naddr { display: block; font-size: 12px; line-height: 1.5; word-break: break-all; color: var(--ink-2); }\n@media (max-width: 52rem) { .share { grid-template-columns: 1fr; } .wire-side { display: contents; } .wire-acts { justify-self: end; } .wire-meta { grid-column: 1 / -1; justify-content: flex-start; text-align: left; } }\n#presets .btn { margin: 0 .4rem .4rem 0; }\n.panel form h3.gap { margin-top: 1.4rem; }\n.key { position: relative; display: inline-block; font-family: var(--mono); } .key::before { content: attr(data-short); } .key .full { position: absolute; left: 0; top: 0; width: 1px; height: 1px; overflow: hidden; opacity: 0; white-space: nowrap; }\n.mast .banner { grid-column: 1 / -1; width: 100%; aspect-ratio: 4 / 1; overflow: hidden; border: 2px solid var(--ink); border-radius: 18px; box-shadow: 4px 4px 0 var(--ink); background: var(--paper); margin-bottom: .5rem; } .mast .banner img { width: 100%; height: 100%; object-fit: cover; display: block; }\n.mast .wordmark { display: flex; align-items: center; gap: 1rem; } .mast .icon { width: 64px; height: 64px; border-radius: 50%; border: 2px solid var(--ink); box-shadow: 3px 3px 0 var(--ink); background: var(--paper); object-fit: cover; }\n.metarow { display: flex; flex-wrap: wrap; gap: .4rem .6rem; align-items: center; margin: -.4rem 0 1rem; font-size: 13px; color: var(--ink-2); }\n.metarow .tag { display: inline-block; padding: .1rem .6rem; border: 1.5px solid var(--ink); border-radius: 999px; font: 600 12px var(--sans); color: var(--ink); background: var(--sun); }\n.metarow .sep { color: var(--ink-3); } .metarow a { color: var(--forest); text-decoration: none; border-bottom: 1.5px solid var(--forest); } .metarow a:hover { border-bottom-width: 2px; }\n.about a { color: var(--forest); text-decoration: none; border-bottom: 1.5px solid var(--forest); }\n.wire-acts label { display: inline-flex; align-items: center; gap: .35rem; font-size: 14px; color: var(--ink-2); cursor: pointer; } .wire-acts label input { margin: 0; accent-color: var(--forest); } .wire-row.dim .wire-main { color: var(--ink-3); }\n"; -export const CONSOLE_JS = "(async () => {\n const $ = (s) => document.querySelector(s);\n const $$ = (s) => document.querySelectorAll(s);\n const host = location.host;\n const wsURL = (location.protocol === \"https:\" ? \"wss://\" : \"ws://\") + host;\n const rpcURL = location.origin + \"/\";\n let info = null, me = null, owner = \"\", policy = null, fuel = null, people = null, myRole = \"\";\n\n const toast = (msg) => { const t = $(\"#toast\"); t.textContent = msg; t.classList.add(\"show\"); clearTimeout(t._t); t._t = setTimeout(() => t.classList.remove(\"show\"), 2600); };\n // guard wraps a handler: disables the button, shows errors as toasts. Defined first, since handlers below use it.\n const guard = (fn) => async (ev) => { ev.preventDefault(); const b = ev.submitter || ev.target; if (b) b.disabled = true; try { await fn(ev); } catch (e) { toast(e.message); } finally { if (b) b.disabled = false; } };\n const short = (hex) => hex ? hex.slice(0, 8) + \"…\" + hex.slice(-4) : \"\";\n // key shows a key short but keeps the whole of it in the DOM: selecting, double-clicking or clicking it copies the full hex.\n const key = (hex) => hex ? '' + hex + '' : '';\n const hue = (hex) => (parseInt(hex.slice(0, 2), 16) * 360 / 256).toFixed(0) + \"deg\";\n const av = (hex) => '';\n const fmtBytes = (n) => n < 1e6 ? (n / 1e3).toFixed(0) + \" KB\" : n < 1e9 ? (n / 1e6).toFixed(1) + \" MB\" : (n / 1e9).toFixed(2) + \" GB\";\n const fmtHours = (ms) => ms < 3600e3 ? Math.round(ms / 60e3) + \" min\" : (ms / 3600e3).toFixed(ms < 36e6 ? 1 : 0) + \" h\";\n const fuelOver = () => !!fuel && (fuel.eventBytes > fuel.freeEventBytes || fuel.mediaBytes > fuel.freeMediaBytes || fuel.activeMs > fuel.freeActiveMs || fuel.rowsWritten > fuel.freeRowsWritten);\n const fmtTime = (t) => t ? new Date(t * 1000).toLocaleString(undefined, { month: \"short\", day: \"numeric\", hour: \"2-digit\", minute: \"2-digit\" }) : \"–\";\n const fmtDay = (t) => t ? new Date(t * 1000).toLocaleDateString(undefined, { month: \"short\", day: \"numeric\" }) : \"–\";\n const ago = (t) => { const s = Math.max(0, Math.floor(Date.now() / 1000) - t); return s < 60 ? \"just now\" : s < 3600 ? Math.floor(s / 60) + \" min\" : s < 86400 ? Math.floor(s / 3600) + \" h\" : Math.floor(s / 86400) + \" d\"; };\n const esc = (s) => String(s ?? \"\").replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c]);\n\n const IC = {\n x: '',\n undo: '',\n check: '',\n trash: '',\n ban: '',\n banuser: '',\n eye: '',\n lock: '',\n pen: '',\n people: '',\n person: '',\n gauge: '',\n bolt: '',\n copy: '',\n pin: '',\n };\n const ib = (icon, label, act, id, extra) => '\";\n\n const CH = \"qpzry9x8gf2tvdw0s3jn54khce6mua7l\";\n function npubToHex(s) {\n s = s.trim().toLowerCase();\n if (/^[0-9a-f]{64}$/.test(s)) return s;\n if (!s.startsWith(\"npub1\")) return null;\n const data = s.slice(5, -6).split(\"\").map((c) => CH.indexOf(c));\n if (data.some((d) => d < 0)) return null;\n let bits = 0, acc = 0, out = [];\n for (const d of data) { acc = (acc << 5) | d; bits += 5; if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 255); } }\n return out.length === 32 ? out.map((b) => b.toString(16).padStart(2, \"0\")).join(\"\") : null;\n }\n\n async function sha256hex(s) {\n const b = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(s));\n return [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, \"0\")).join(\"\");\n }\n // ---- signing: a NIP-07 extension, or a NIP-46 remote signer ----\n // The remote path loads the bundled library on first use; its session\n // (client key, signer pubkey, relays) lives in localStorage until sign-out.\n let remote = null, lib = null, pendingNote = null;\n const NO_SIGNER = \"Install a nostr extension (Alby, nos2x, …) or connect a remote signer.\";\n const withTimeout = (p, ms, what) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(what + \" did not answer; is the signer app open?\")), ms))]);\n const signer = {\n ready: () => !!remote || !!window.nostr,\n async getPublicKey() { if (remote) return withTimeout(remote.getPublicKey(), 20000, \"The remote signer\"); if (window.nostr) return window.nostr.getPublicKey(); throw new Error(NO_SIGNER); },\n async signEvent(ev) { if (remote) return withTimeout(remote.signEvent(ev), 60000, \"The remote signer\"); if (window.nostr) return window.nostr.signEvent(ev); throw new Error(NO_SIGNER); },\n };\n async function signerLib() {\n if (lib) return lib;\n if (!window.NostrSigner) await new Promise((res, rej) => { const s = document.createElement(\"script\"); s.src = window.SIGNER_URL || \"/signer.js\"; s.onload = res; s.onerror = () => rej(new Error(\"Could not load the signer library.\")); document.head.appendChild(s); });\n return (lib = window.NostrSigner);\n }\n const onauth = (u) => window.open(u, \"_blank\");\n // The signer conversation rides this relay, and a relay may ask the socket to\n // authenticate before it delivers kind 24133 to its parties; answer with the\n // session's own key.\n const authPool = (L, sk) => new L.SimplePool({ automaticallyAuth: () => (evt) => Promise.resolve(L.finalizeEvent(evt, sk)) });\n const saveSession = (sk, s, secret) => localStorage.setItem(\"nip46\", JSON.stringify({ sk: lib.bytesToHex(sk), pubkey: s.bp.pubkey, relays: s.bp.relays, secret: secret || null }));\n async function resumeRemote() {\n const raw = localStorage.getItem(\"nip46\");\n if (!raw) return false;\n try {\n const s = JSON.parse(raw); const L = await signerLib();\n remote = L.BunkerSigner.fromBunker(L.hexToBytes(s.sk), { pubkey: s.pubkey, relays: s.relays, secret: s.secret }, { onauth, pool: authPool(L, L.hexToBytes(s.sk)) });\n return true;\n } catch { localStorage.removeItem(\"nip46\"); return false; }\n }\n async function connectBunker(input) {\n const L = await signerLib();\n const bp = await L.parseBunkerInput(input.trim());\n if (!bp) throw new Error(\"That is not a bunker:// URL.\");\n if (!bp.relays.length) throw new Error(\"The bunker URL names no relay.\");\n const sk = L.generateSecretKey();\n const s = L.BunkerSigner.fromBunker(sk, bp, { onauth, pool: authPool(L, sk) });\n await withTimeout(s.connect({ name: host, url: location.origin }), 60000, \"The signer\");\n await Promise.race([s.switchRelays(), new Promise((r) => setTimeout(r, 3000))]);\n remote = s; saveSession(sk, s, bp.secret);\n }\n // The nostrconnect:// flow: this relay carries the traffic, so no third relay is involved.\n let ncPending = null;\n async function offerNostrConnect() {\n if (ncPending) return ncPending;\n const L = await signerLib();\n const sk = L.generateSecretKey();\n const secret = L.bytesToHex(crypto.getRandomValues(new Uint8Array(8)));\n const uri = L.createNostrConnectURI({ clientPubkey: L.getPublicKey(sk), relays: [wsURL], secret, name: host, url: location.origin, perms: [\"sign_event:27235\", \"sign_event:9734\"] });\n $(\"#nclink\").href = uri; $(\"#ncnote\").textContent = \"\";\n $(\"#ncqr\").src = \"/qr.svg?text=\" + encodeURIComponent(uri); $(\"#ncqr\").classList.remove(\"hidden\");\n ncPending = L.BunkerSigner.fromURI(sk, uri, { onauth, pool: authPool(L, sk) }, 600000).then((s) => { remote = s; saveSession(sk, s, secret); ncPending = null; return s; }, (e) => { ncPending = null; throw e; });\n return ncPending;\n }\n async function remoteDone() {\n me = await signer.getPublicKey();\n localStorage.setItem(\"me\", me);\n $(\"#remotesec\").classList.add(\"hidden\");\n toast(\"Remote signer connected\");\n if (pendingNote && !owner) { const n = pendingNote; pendingNote = null; await claimNow(n); return; }\n pendingNote = null;\n renderHeader(); await loadAdmin(); await loadPeople();\n }\n function showRemote(note) {\n pendingNote = note || null;\n $(\"#remotesec\").classList.remove(\"hidden\");\n $(\"#remotesec\").scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n offerNostrConnect().then(remoteDone).catch((e) => { $(\"#ncnote\").textContent = e.message; });\n }\n\n async function rpc(method, ...params) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const body = JSON.stringify({ method, params });\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", rpcURL], [\"method\", \"POST\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(rpcURL, { method: \"POST\", headers: { \"content-type\": \"application/nostr+json+rpc\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body });\n const json = await resp.json();\n if (json.error) throw new Error(json.error);\n return json.result;\n }\n\n // ---- front of house ----\n // urlish shows a URL as a short link: the host and the start of the path, the whole thing on hover and as the target.\n class Html { constructor(s) { this.s = s; } }\n const urlish = (u) => {\n if (!u) return \"\";\n let label = u; try { const x = new URL(u); label = x.host + (x.pathname.length > 1 ? x.pathname.slice(0, 18) + (x.pathname.length > 18 ? \"\\u2026\" : \"\") : \"\"); } catch { /* not a URL */ }\n if (!/^https?:\\/\\//.test(u)) return u;\n return new Html('' + esc(label) + \"\");\n };\n function renderHeader() {\n const name = (info && info.name) || host.split(\".\")[0];\n $(\"#title\").textContent = name;\n document.title = name + \" - relay\";\n $(\"#url\").textContent = wsURL;\n $(\"#d-ws\").textContent = wsURL; $(\"#d-blossom\").textContent = location.origin; $(\"#d-nip05\").textContent = \"you@\" + host;\n $(\"#nip05-example\").textContent = \"alice@\" + host;\n const desc = (info && info.description) || \"\";\n $(\"#desc\").textContent = desc; $(\"#desc\").classList.toggle(\"hidden\", !desc);\n // What the owner declared about the relay, on the page, not only in the document clients read.\n const img = (id, url) => { const el = $(id); if (url) el.src = url; el.classList.toggle(\"hidden\", !url); };\n img(\"#bannerimg\", info?.banner); $(\"#banner\").classList.toggle(\"hidden\", !info?.banner);\n img(\"#iconimg\", info?.icon);\n const meta = [];\n for (const t of info?.tags || []) meta.push('' + esc(t) + \"\");\n const where = [...(info?.language_tags || []), ...(info?.relay_countries || [])];\n if (where.length) meta.push(\"\" + esc(where.join(\", \")) + \"\");\n const link = (label, href) => href ? '' + label + \"\" : \"\";\n const contact = info?.contact ? (/^(mailto:|https?:\\/\\/)/.test(info.contact) ? link(\"Contact\", info.contact) : \"\" + esc(info.contact) + \"\") : \"\";\n for (const x of [contact, link(\"Terms\", info?.terms_of_service), link(\"Posting policy\", info?.posting_policy), link(\"Privacy\", info?.privacy_policy)]) if (x) meta.push(x);\n $(\"#metarow\").innerHTML = meta.join('|'); $(\"#metarow\").classList.toggle(\"hidden\", meta.length === 0);\n $(\"#frontsec\").classList.toggle(\"hidden\", !desc && meta.length === 0);\n owner = (info && info.pubkey) || \"\";\n const isOwner = !!me && me === owner;\n const lease = !owner && info && info.lease ? info.lease : null;\n $(\"#ownerline\").innerHTML = owner ? 'run by ' + key(owner) + \"\" : lease ? \"temporary until \" + fmtDay(lease.expires_at) : \"unclaimed\";\n $(\"#owner-av\").classList.toggle(\"hidden\", !owner);\n if (owner) $(\"#owner-av\").style.setProperty(\"--h\", hue(owner));\n $(\"#who\").textContent = me ? (isOwner ? \"(that's you)\" : \"\") : \"\";\n $(\"#unclaimed\").classList.toggle(\"hidden\", !!owner || !!lease);\n $(\"#leased\").classList.toggle(\"hidden\", !lease);\n if (lease) {\n $(\"#lease-until\").textContent = fmtTime(lease.expires_at);\n $(\"#leasenote\").textContent = lease.holder ? \"Reserved for the key that asked for it; sign with that key.\" : \"\";\n }\n $(\"#signin\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signin46\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signout\").classList.toggle(\"hidden\", !me);\n $(\"#about\").innerHTML = [\n [\"Name\", info?.name], [\"Description\", info?.description], [\"Contact\", info?.contact], [\"Owner\", owner ? short(owner) : \"\"], [\"Relay key\", info?.self ? short(info.self) : \"\"], [\"Software\", urlish(info?.software)], [\"Version\", info?.version],\n [\"Max query\", info?.limitation?.max_limit], [\"Auth required\", String(!!info?.limitation?.auth_required)], [\"Restricted writes\", String(!!info?.limitation?.restricted_writes)], [\"Min PoW\", info?.limitation?.min_pow_difficulty || 0],\n [\"Tags\", info?.tags?.join(\", \")], [\"Languages\", info?.language_tags?.join(\", \")], [\"Countries\", info?.relay_countries?.join(\", \")],\n [\"Terms\", urlish(info?.terms_of_service)], [\"Posting policy\", urlish(info?.posting_policy)], [\"Privacy policy\", urlish(info?.privacy_policy)], [\"Icon\", urlish(info?.icon)], [\"Banner\", urlish(info?.banner)],\n [\"NIPs\", info?.supported_nips?.join(\" \")],\n ].map(([k, v]) => \"
                                                                                                                                                                                                              \" + k + \"\" + (v === undefined || v === \"\" ? '' : v instanceof Html ? v.s : esc(v)) + \"
                                                                                                                                                                                                              \").join(\"\");\n renderCare();\n }\n\n function renderCare() {\n const g = (icon, label, off) => '' + IC[icon] + \"\" + label + \"\";\n const writes = policy ? policy.writes : (info?.limitation?.restricted_writes ? \"allowlist\" : \"open\");\n const reads = policy ? policy.reads : (info?.limitation?.auth_required ? \"auth\" : \"open\");\n let out = writes === \"owner\" ? g(\"person\", \"only owner writes\") : writes === \"allowlist\" ? g(\"people\", \"members write\") : writes === \"wot\" ? g(\"people\", \"members and follows write\") : g(\"pen\", \"anyone writes\");\n out += reads === \"members\" ? g(\"people\", \"members read\") : reads === \"auth\" ? g(\"lock\", \"sign in to read\") : g(\"eye\", \"anyone reads\");\n if (fuel) {\n const over = fuelOver();\n out += fuel.outOfFuel ? g(\"gauge\", \"out of fuel\", true) : over ? g(\"bolt\", \"burning sats\") : g(\"gauge\", \"on free allowance\");\n }\n $(\"#care\").innerHTML = out;\n }\n\n async function loadPeople() {\n try { people = await (await fetch(\"/people\")).json(); } catch { return; }\n const isOwner = !!me && me === owner;\n let list = people.people || [];\n if (!people.public && isOwner && policy) list = (window.__members || []);\n $(\"#peoplesec\").classList.toggle(\"hidden\", !owner || list.length === 0);\n $(\"#people-note\").classList.toggle(\"hidden\", people.public);\n $(\"#people-count\").textContent = list.length || \"\";\n $(\"#dir\").innerHTML = list.map((m) => '' + av(m.pubkey) + '' + (m.name ? esc(m.name) + \"@\" + host : key(m.pubkey)) + \"\" + (m.role === \"owner\" ? 'owner' : \"\") + \"\").join(\"\");\n }\n\n async function loadInfo() {\n info = await (await fetch(\"/\", { headers: { accept: \"application/nostr+json\" } })).json();\n renderHeader();\n const git = info.supported_grasps?.includes(\"GRASP-01\");\n $(\"#git-connect\").classList.toggle(\"hidden\", !git);\n const clone = \"git clone '\" + location.origin + \"//.git'\";\n $(\"#git-clone\").textContent = clone;\n $(\"#git-copy\").dataset.copytext = clone;\n renderApps();\n }\n\n async function loadFuel() {\n try { fuel = await (await fetch(\"/fuel\")).json(); } catch { return; }\n $(\"#fuelsec\").classList.toggle(\"hidden\", !owner);\n const pct = (used, free) => Math.min(100, Math.round((used / Math.max(free, 1)) * 100));\n const gauge = (id, used, free, text) => { const g = $(\"#g-\" + id), share = free ? used / free : 0; g.style.width = pct(used, free) + \"%\"; g.classList.toggle(\"over\", share > 1); g.classList.toggle(\"warm\", share > 0.75 && share <= 1); $(\"#p-\" + id).textContent = share > 1 ? \"over\" : Math.round(share * 100) + \"%\"; $(\"#t-\" + id).textContent = text; };\n gauge(\"events\", fuel.eventBytes, fuel.freeEventBytes, fmtBytes(fuel.eventBytes) + \" of \" + fmtBytes(fuel.freeEventBytes) + \" free\");\n gauge(\"media\", fuel.mediaBytes, fuel.freeMediaBytes, fmtBytes(fuel.mediaBytes) + \" of \" + fmtBytes(fuel.freeMediaBytes) + \" free\");\n gauge(\"active\", fuel.activeMs, fuel.freeActiveMs, fmtHours(fuel.activeMs) + \" of \" + fmtHours(fuel.freeActiveMs) + \" free\");\n gauge(\"rows\", fuel.rowsWritten, fuel.freeRowsWritten, fuel.rowsWritten.toLocaleString() + \" of \" + fuel.freeRowsWritten.toLocaleString() + \" free\");\n const sats = Math.floor(fuel.balanceMsats / 1000);\n const r = fuel.rates;\n $(\"#fuel-balance\").innerHTML = fuel.outOfFuel\n ? 'out of fuel Writes are paused until someone tops up.'\n : \"Balance \" + sats.toLocaleString() + \" sats. Past the allowances, prices track what the hosting costs: \" + r.satsPerGBMonthEvents.toLocaleString() + \" sats per GB-month of events, \" + r.satsPerGBMonthMedia.toLocaleString() + \" per GB-month of files, \" + r.satsPerActiveHour.toLocaleString() + \" per hour awake, \" + r.satsPerMillionRows.toLocaleString() + \" per million rows written. Traffic is free.\";\n $(\"#topup\").classList.toggle(\"hidden\", !fuel.enabled);\n // Who paid is the owner's to see: it comes with the signed stats call.\n let credits = [];\n if (owner && signer.ready()) { try { credits = (await rpc(\"stats\")).credits || []; } catch { credits = []; } }\n $(\"#credits tbody\").innerHTML = credits.length ? credits.map((c) => '' + esc(fmtTime(c.at)) + '' + av(c.payer) + key(c.payer) + '' + Math.floor(c.msats / 1000).toLocaleString() + \"\").join(\"\") : 'no zaps yet';\n if (!fuel.enabled) $(\"#topup-note\").textContent = \"Top-ups are not enabled on this service yet.\";\n renderCare();\n renderFuelTile();\n }\n\n async function topUp(sats) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const msats = Math.round(sats * 1000);\n const zapRequest = await signer.signEvent({ kind: 9734, created_at: Math.floor(Date.now() / 1000), content: \"fuel for \" + host, tags: [[\"p\", fuel.servicePubkey], [\"amount\", String(msats)], [\"relays\", wsURL]] });\n const r = await (await fetch(\"/fuel/invoice\", { method: \"POST\", body: JSON.stringify({ zapRequest }) })).json();\n if (r.error) throw new Error(r.error);\n const inv = r.invoice;\n $(\"#invoice\").classList.remove(\"hidden\");\n $(\"#inv-text\").value = inv; $(\"#inv-link\").href = \"lightning:\" + inv; $(\"#inv-state\").textContent = \"waiting for payment…\";\n if (window.webln) { try { await window.webln.enable(); await window.webln.sendPayment(inv); } catch (e) { toast(e.message || \"wallet declined\"); } }\n const before = fuel.creditedMsats;\n for (let i = 0; i < 120; i++) {\n await new Promise((r) => setTimeout(r, 2500));\n await loadFuel();\n if (fuel.creditedMsats > before) { $(\"#inv-state\").textContent = \"paid, thank you\"; toast(\"Fuel credited\"); return; }\n }\n $(\"#inv-state\").textContent = \"no receipt yet; it can take a minute after paying\";\n }\n\n // ---- console ----\n function showTab(name) {\n if (name === \"content\" || name === \"storage\") name = \"data\";\n const known = myRole === \"moderator\" ? [\"people\", \"moderation\"] : [\"people\", \"moderation\", \"rules\", \"identity\", \"data\", \"sync\", \"views\", \"health\", \"owner\"];\n if (!known.includes(name)) name = \"people\";\n $$(\".tabs a\").forEach((a) => a.classList.toggle(\"on\", a.dataset.tab === name));\n $$(\".panel\").forEach((p) => p.classList.toggle(\"on\", p.dataset.panel === name));\n }\n // Tabs switch in place. A fragment still opens a tab when someone arrives with one,\n // but clicking never writes one, and any fragment already there is dropped.\n $$(\".tabs a\").forEach((a) => a.addEventListener(\"click\", (ev) => { ev.preventDefault(); showTab(a.dataset.tab); if (location.hash) history.replaceState(null, \"\", location.pathname + location.search); }));\n window.addEventListener(\"hashchange\", () => showTab(location.hash.slice(1)));\n // The strip fades on whichever side has more tabs off screen.\n const tabsFade = () => { const t = $(\"#tabs\"); t.classList.toggle(\"fade-l\", t.scrollLeft > 4); t.classList.toggle(\"fade-r\", t.scrollLeft + t.clientWidth < t.scrollWidth - 4); };\n $(\"#tabs\").addEventListener(\"scroll\", tabsFade); window.addEventListener(\"resize\", tabsFade); new ResizeObserver(tabsFade).observe($(\"#tabs\"));\n\n function renderFuelTile() {\n if (!fuel) return;\n const over = fuelOver();\n const sats = Math.max(0, Math.floor(fuel.balanceMsats / 1000));\n const d = new Date(), end = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1), days = Math.max(1, Math.ceil((end - Date.now()) / 86400000));\n if (fuel.outOfFuel) { $(\"#h-fuel\").textContent = \"0 sats\"; $(\"#h-fuel-label\").textContent = \"out of fuel\"; $(\"#h-fuel-sub\").textContent = \"writes are paused\"; }\n else if (over) { $(\"#h-fuel\").textContent = sats.toLocaleString() + \" sats\"; $(\"#h-fuel-label\").textContent = \"fuel left\"; $(\"#h-fuel-sub\").textContent = \"past the free allowance\"; }\n else { $(\"#h-fuel\").textContent = days + (days === 1 ? \" day\" : \" days\"); $(\"#h-fuel-label\").textContent = \"free allowance left\"; $(\"#h-fuel-sub\").textContent = sats ? \"then \" + sats.toLocaleString() + \" sats\" : \"then a top-up is needed\"; }\n $(\"#usage\").innerHTML = [[\"events stored\", fmtBytes(fuel.eventBytes)], [\"files stored\", fmtBytes(fuel.mediaBytes)], [\"awake this month\", fmtHours(fuel.activeMs)], [\"rows written\", fuel.rowsWritten.toLocaleString()], [\"rows read\", fuel.rowsRead.toLocaleString()], [\"received this month\", fmtBytes(fuel.bytesIn)], [\"served this month\", fmtBytes(fuel.bytesOut)], [\"charged\", Math.floor(fuel.chargedMsats / 1000).toLocaleString() + \" sats\"]]\n .map(([k, v]) => \"
                                                                                                                                                                                                              \" + k + \"\" + v + \"
                                                                                                                                                                                                              \").join(\"\");\n }\n\n function renderHealth(stats) {\n $(\"#h-last\").textContent = stats.newest ? ago(stats.newest) : \"none\";\n $(\"#h-last-sub\").textContent = stats.newest ? fmtTime(stats.newest) : \"no events yet\";\n $(\"#h-conns\").textContent = stats.connections;\n const buckets = [[\"notes\", \"k1\", [1]], [\"reactions\", \"k2\", [7]], [\"DMs\", \"k3\", [4, 14, 1059]], [\"long-form\", \"k4\", [30023]], [\"other\", \"k5\", null]];\n const sums = buckets.map(() => 0); let total = 0;\n for (const { kind, n } of stats.kinds || []) { total += n; const i = buckets.findIndex((b) => b[2] && b[2].includes(kind)); sums[i < 0 ? 4 : i] += n; }\n const parts = buckets.map((b, i) => [b[0], b[1], sums[i]]).filter((p) => p[2] > 0);\n $(\"#h-kinds\").innerHTML = total ? parts.map(([name, cls, n]) => '').join(\"\") : '';\n $(\"#h-kinds-legend\").innerHTML = total ? parts.map(([name, cls, n]) => '' + name + \" \" + Math.round(n * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n renderFuelTile();\n }\n\n async function loadAdmin() {\n const isOwner = !!me && me === owner;\n myRole = isOwner ? \"owner\" : \"\";\n let stats = null, p = null;\n // A signed-in member may be a moderator; the relay says by answering stats.\n if (me && owner && !isOwner) { try { [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]); myRole = \"moderator\"; } catch { myRole = \"\"; } }\n $(\"#console\").classList.toggle(\"hidden\", !myRole);\n loadMine();\n $(\"#console\").classList.toggle(\"mod\", myRole === \"moderator\");\n if (!myRole) { policy = null; renderCare(); return; }\n if (isOwner) [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]);\n policy = p;\n renderHealth(stats);\n renderCare();\n const fa = $(\"#access\"), fi = $(\"#identity\");\n fa.writes.value = p.writes; fa.reads.value = p.reads; fa.openKinds.value = (p.openKinds || []).join(\", \"); fa.guestReplies.checked = !!p.guestReplies;\n $(\"#wordsform\").words.value = (p.blockedWords || []).join(\"\\n\"); $(\"#wordsform\").inTags.checked = !!p.blockedWordsInTags; $(\"#thresholdform\").reportThreshold.value = p.reportThreshold || 0; fa.minPow.value = p.minPow; fa.maxFuture.value = p.maxFuture; fa.maxLimit.value = p.maxLimit; fa.maxSubs.value = p.maxSubs; fa.maxMessageKB.value = p.maxMessageKB;\n fa.eventsPerMinute.value = p.eventsPerMinute; fa.reqsPerMinute.value = p.reqsPerMinute; fa.maxBlobMB.value = p.maxBlobMB;\n renderFeatures(p.features || {});\n $(\"#push-policy-form\").elements.origins.value = (p.pushCallbacks || []).join(\"\\n\");\n $(\"#push-policy-form\").elements.lettered.checked = !!p.letteredNips;\n $(\"#push-policy-form\").elements.delivery.checked = !!p.delivery?.enabled;\n $(\"#push-policy-form\").elements.deliveryMax.value = p.delivery?.maxTargets || 8;\n fi.name.value = p.name; fi.contact.value = p.contact; fi.description.value = p.description; fi.icon.value = p.icon;\n const fj = $(\"#joinform\"); fj.joinTerms.value = p.joinTerms; fj.directoryPublic.checked = !!p.directoryPublic;\n loadCard();\n fi.banner.value = p.banner || \"\"; fi.postingPolicy.value = p.postingPolicy || \"\"; fi.privacyPolicy.value = p.privacyPolicy || \"\";\n fi.tags.value = (p.tags || []).join(\", \"); fi.languageTags.value = (p.languageTags || []).join(\", \"); fi.relayCountries.value = (p.relayCountries || []).join(\", \");\n const fn = $(\"#notify\"), nt = p.notify || {};\n fn.reports.checked = !!nt.reports; fn.fuel.checked = !!nt.fuel; fn.jobs.checked = !!nt.jobs; fn.succession.checked = !!nt.succession; fn.digest.checked = !!nt.digest;\n showTab(location.hash.slice(1));\n if (isOwner) { await renderPresets(); renderWire(); renderSuccession(); loadDomains(); }\n await Promise.all([loadLists(), loadEvents(true), loadPins(), ...(isOwner ? [loadStorage()] : [])]);\n }\n\n async function loadAudit(before) {\n const rows = await rpc(\"listaudit\", before || 0);\n const html = rows.map((r) => '' + esc(fmtTime(r.at)) + '' + av(r.actor) + key(r.actor) + '' + esc(r.action) + '' + (/^[0-9a-f]{64}$/.test(r.target) ? key(r.target) : esc(r.target)) + '' + esc(r.detail) + '').join(\"\");\n const tb = $(\"#audit tbody\");\n if (before) tb.insertAdjacentHTML(\"beforeend\", html); else tb.innerHTML = html || 'Nothing yet';\n $(\"#audit-more\").style.display = rows.length < 100 ? \"none\" : \"\";\n }\n $(\"#audit-more\").onclick = guard(async () => { const last = $(\"#audit tbody tr:last-child\"); await loadAudit(last ? +last.dataset.seq : 0); });\n async function loadLists() {\n loadAudit(0);\n const [mem, bans, bannedEvents, allow, block, invites, reports, blobs, blocks, sites] = await Promise.all([rpc(\"listmembers\"), rpc(\"listbannedpubkeys\"), rpc(\"listbannedevents\"), rpc(\"listallowedkinds\"), rpc(\"listblockedkinds\"), rpc(\"listinvites\"), rpc(\"listreports\"), rpc(\"listblobs\", 100), rpc(\"listblockedips\"), rpc(\"listsites\")]);\n const members = mem.members;\n window.__members = members;\n const roleCell = (m) => m.role === \"owner\" ? ' owner' : myRole === \"owner\" ? ' ' : m.role === \"moderator\" ? ' moderator' : \"\";\n const untouchable = (m) => m.role === \"owner\" || (myRole !== \"owner\" && m.role === \"moderator\");\n // The tree: everyone under whoever invited them, the owner and the owner's own additions at the root.\n const known = new Set(members.map((m) => m.pubkey));\n const byInviter = new Map();\n for (const m of members) { const k = m.role !== \"owner\" && known.has(m.invited_by) ? m.invited_by : \"\"; if (!byInviter.has(k)) byInviter.set(k, []); byInviter.get(k).push(m); }\n const ordered = [], placed = new Set();\n const walk = (k, depth) => { for (const m of byInviter.get(k) || []) { if (placed.has(m.pubkey)) continue; placed.add(m.pubkey); m.depth = depth; ordered.push(m); walk(m.pubkey, depth + 1); } };\n walk(\"\", 0);\n for (const m of members) if (!placed.has(m.pubkey)) { m.depth = 0; ordered.push(m); }\n const nameOf = (pk) => { const x = members.find((m) => m.pubkey === pk); return x && x.name ? x.name : short(pk); };\n const limits = (m) => m.role === \"owner\" ? \"\" : myRole === \"owner\"\n ? ''\n : '' + (m.keep_days ? m.keep_days + \" d\" : \"\") + (m.max_bytes ? \" \" + fmtBytes(m.max_bytes) : \"\") + \"\";\n $(\"#members tbody\").innerHTML = ordered.map((m) => '' + av(m.pubkey) + key(m.pubkey) + roleCell(m) + (m.depth ? ' via ' + esc(nameOf(m.invited_by)) + \"\" : \"\") + (m.invites ? ' ' + m.invites + \" inv\" : \"\") + '' + limits(m) + '' + esc(fmtDay(m.joined_at)) + \", \" + esc(m.via) + '' + ib(\"check\", \"Save\", \"savemember\", m.pubkey) + (untouchable(m) ? \"\" : ib(\"x\", \"Remove\", \"removemember\", m.pubkey) + ib(\"banuser\", \"Ban\", \"banpubkey\", m.pubkey, \"danger\")) + \"\").join(\"\");\n const tf = $(\"#treeform\");\n tf.classList.toggle(\"hidden\", myRole !== \"owner\");\n if (policy && policy.memberInvites) { tf.depth.value = policy.memberInvites.depth; tf.quota.value = policy.memberInvites.quota; }\n $(\"#transfer [name=pubkey]\").innerHTML = members.filter((m) => m.role !== \"owner\").map((m) => '\").join(\"\");\n $(\"#succession [name=heir]\").innerHTML = $(\"#transfer [name=pubkey]\").innerHTML;\n $(\"#tc-people\").textContent = members.length;\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#invites\").innerHTML = invites.length ? invites.map((i) => '
                                                                                                                                                                                                            • inv…' + i.code.slice(-8) + \" \" + esc(i.note || \"\") + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
                                                                                                                                                                                                            • \").join(\"\") : '
                                                                                                                                                                                                            • no invites
                                                                                                                                                                                                            • ';\n const person = (r, icon, label, act) => \"
                                                                                                                                                                                                            • \" + av(r.pubkey) + '' + short(r.pubkey) + \" \" + esc(r.reason || \"\") + \"\" + ib(icon, label, act, r.pubkey) + \"
                                                                                                                                                                                                            • \";\n $(\"#bans\").innerHTML = bans.length ? bans.map((r) => person(r, \"undo\", \"Unban\", \"unrulepubkey\")).join(\"\") : '
                                                                                                                                                                                                            • nobody banned
                                                                                                                                                                                                            • ';\n $(\"#blocks\").innerHTML = blocks.length ? blocks.map((r) => '
                                                                                                                                                                                                            • ip' + esc(r.ip) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"undo\", \"Unblock\", \"unblockip\", r.ip) + \"
                                                                                                                                                                                                            • \").join(\"\") : '
                                                                                                                                                                                                            • no addresses blocked
                                                                                                                                                                                                            • ';\n $(\"#banned-events\").innerHTML = bannedEvents.map((r) => '
                                                                                                                                                                                                            • ev' + key(r.id) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"check\", \"Allow again\", \"allowevent\", r.id) + \"
                                                                                                                                                                                                            • \").join(\"\");\n $(\"#reports tbody\").innerHTML = reports.length ? reports.map((r) => '' + esc(fmtTime(r.at)) + '' + esc(r.type || \"report\") + '' + av(r.target_pubkey) + key(r.target_pubkey) + (r.target_event ? (r.blob ? ' file ' + key(r.target_event) + \"\" : ' ev ' + key(r.target_event) + (r.hidden ? ' hidden' : \"\") + \"\") : \"\") + '' + esc(r.content) + '' + ib(\"check\", \"Dismiss\", \"resolve:dismiss\", r.id) + ib(\"trash\", r.blob ? \"Delete the file\" : \"Delete the event\", \"resolve:delete\", r.id) + ib(\"banuser\", \"Ban the author\", \"resolve:ban\", r.id, \"danger\") + \"\").join(\"\") : 'nothing reported';\n $(\"#reports-count\").textContent = reports.length || \"\"; $(\"#tc-reports\").textContent = reports.length || \"\";\n $(\"#blobs tbody\").innerHTML = blobs.length ? blobs.map((b) => '' + esc(fmtTime(b.uploaded)) + '' + key(b.sha256) + ' ' + esc(b.type) + '' + fmtBytes(b.size) + '' + av(b.uploader) + key(b.uploader) + '' + ib(\"trash\", \"Delete file\", \"deleteblob\", b.sha256, \"danger\") + \"\").join(\"\") : 'no uploads';\n $(\"#blobs-count\").textContent = blobs.length || \"\";\n $(\"#sites tbody\").innerHTML = sites.length ? sites.map((s) => '' + av(s.author) + key(s.author) + '' + esc(s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + '' + esc(s.url) + '' + esc(s.paths) + (s.missing ? ' ' + esc(s.missing) + ' missing' : '') + '' + fmtBytes(s.size) + '' + esc(s.expires_at ? fmtTime(s.expires_at) : \"never\") + '' + ib(\"trash\", \"Delete site\", \"deleteevent\", s.id, \"danger\") + '').join(\"\") : 'no sites yet';\n $(\"#sites-count\").textContent = sites.length || \"\";\n const tag = (k, cls) => '' + k + ib(\"x\", \"Remove rule\", \"unrulekind\", String(k)) + \"\";\n $(\"#kinds-allow\").innerHTML = allow.length ? allow.map((k) => tag(k, \"ok\")).join(\"\") : 'all';\n $(\"#kinds-block\").innerHTML = block.length ? block.map((k) => tag(k, \"blk\")).join(\"\") : 'none';\n await loadPeople();\n }\n\n const KIND_NAMES = { 0: \"profiles\", 1: \"notes\", 3: \"contacts\", 4: \"DMs\", 5: \"deletions\", 6: \"reposts\", 7: \"reactions\", 16: \"reposts\", 1059: \"gift wraps\", 1063: \"file headers\", 1111: \"comments\", 1984: \"reports\", 9734: \"zap requests\", 9735: \"zap receipts\", 9802: \"highlights\", 10002: \"relay lists\", 13534: \"roster\", 9000: \"group adds\", 9001: \"group removals\", 9021: \"join requests\", 9022: \"leave requests\", 39000: \"group info\", 39001: \"group admins\", 39002: \"group members\", 39003: \"group roles\", 30023: \"articles\", 30024: \"drafts\", 30078: \"app data\", 30311: \"live events\", 30818: \"wiki\" };\n const kindName = (k) => KIND_NAMES[k] || (k >= 20000 && k < 30000 ? \"ephemeral\" : k >= 30000 && k < 40000 ? \"addressable\" : k >= 10000 && k < 20000 ? \"replaceable\" : \"kind \" + k);\n const SYS_KINDS = new Set([0, 3, 10002, 9735, 13534, 8000, 8001, 9000, 9001, 39000, 39001, 39002, 39003]);\n let storage = null;\n\n // Features (settings.ts): a select each; search has three modes, the rest on or off.\n const FEATURES = [\n [\"search\", \"Search\", \"NIP-50. Prose indexes notes, threads, comments, highlights, articles and wiki pages; full indexes every public kind with content. A change applies to events from then on.\", [\"prose:prose\", \"full:full\", \"off:off\"]],\n [\"sync\", \"Sync\", \"NIP-77 reconciliation, which reads the whole matching set per sync.\"],\n [\"count\", \"Counts\", \"NIP-45 COUNT, with HLL sketches.\"],\n [\"discovery\", \"Discovery record\", \"NIP-66: the record the relay signs about itself, for crawlers.\"],\n [\"names\", \"Names\", \"NIP-05 addresses under this relay's domain.\"],\n [\"files\", \"Files\", \"Blossom and NIP-96: uploads, downloads and listings.\"],\n [\"pages\", \"Pages and feed\", \"Notes and articles as pages, and the Atom feed.\"],\n [\"sites\", \"Static websites\", \"NIP-5A sites on their own hostnames. Mirroring copies missing files into this relay and costs fuel.\", [\"mirror:on, mirror files\", \"proxy:on, fetch as needed\", \"off:off\"]],\n [\"marmot\", \"Marmot transport\", \"Signed KeyPackages and encrypted group messages, with account admission for ephemeral authors.\"],\n [\"grasp\", \"Git repositories\", \"GRASP Git hosting with admitted repository state. The prototype backend has bounded storage and compute limits.\"],\n [\"push\", \"Relay push\", \"NIP-9a callback delivery for members and the owner. Requires approved callback origins; advertises lettered NIP identifiers.\"],\n [\"signer\", \"Signer traffic\", \"NIP-46 remote signing carried for anyone, never stored.\"],\n ];\n function renderFeatures(f) {\n $(\"#features\").innerHTML = FEATURES.map(([k, title, about, modes]) => {\n const cur = k === \"sites\" ? (f.sites?.enabled === false ? \"off\" : f.sites?.mirror === false ? \"proxy\" : \"mirror\") : modes ? String(f[k] || \"prose\") : String(f[k] !== false);\n const opts = (modes || [\"true:on\", \"false:off\"]).map((m) => { const [v, l] = m.split(\":\"); return '\"; }).join(\"\");\n return \"\";\n }).join(\"\");\n }\n $(\"#features\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-feature]\"); if (!sel) return;\n const k = sel.dataset.feature, v = k === \"search\" || k === \"sites\" ? sel.value : sel.value === \"true\";\n policy = await rpc(\"setpolicy\", { features: { [k]: k === \"sites\" ? { enabled: v !== \"off\", mirror: v === \"mirror\" } : v } });\n toast(k === \"search\" ? \"Search: \" + v : (v ? \"Switched on \" : \"Switched off \") + k); await loadInfo();\n }));\n\n $(\"#push-policy-form\").addEventListener(\"submit\", guard(async (ev) => {\n ev.preventDefault();\n const form = ev.target;\n const origins = form.elements.origins.value.split(/\\s+/).filter(Boolean);\n const updated = await rpc(\"setpolicy\", { pushCallbacks: origins, letteredNips: form.elements.lettered.checked, delivery: { enabled: form.elements.delivery.checked, maxTargets: Math.max(1, Math.min(16, Math.floor(+form.elements.deliveryMax.value || 8))) } });\n if (JSON.stringify(updated.pushCallbacks) !== JSON.stringify([...new Set(origins.map((s) => s.replace(/\\/$/, \"\")))])) throw new Error(\"Use up to sixteen exact HTTPS origins, with no path or credentials.\");\n policy = updated;\n toast(\"Saved delivery policy\"); await loadInfo();\n }));\n\n async function loadViews() {\n let views;\n try { views = await rpc(\"listviews\"); } catch { return; }\n $(\"#views\").innerHTML = views.map((v) => {\n const runs = v.trigger === \"off\" ? \"off\" : v.trigger === \"live\" ? \"live, from memory\" : v.trigger === \"write\" ? \"on write and daily\" : v.trigger;\n const label = (c) => (c === \"off\" ? \"off\" : c === \"write\" ? \"on write\" : c === \"live\" ? \"on\" : c);\n const choices = [...v.choices, v.default].filter((c, i, a) => a.indexOf(c) === i);\n const pick = '\";\n const who = v.audience === \"members\" ? \"members\" + (v.stored ? \"\" : \", on request\") : \"anyone\";\n const last = v.trigger === \"live\" ? \"\" : v.last ? \"last run \" + fmtTime(v.last.at) : \"not run yet\";\n const rows = v.trigger === \"live\" ? \"no rows\" : v.last ? v.last.rows.toLocaleString() + \" rows\" : \"\";\n const meta = [runs, who, last, rows].filter(Boolean).map((x) => \"\" + esc(x) + \"\").join('|');\n return '
                                                                                                                                                                                                              ' + esc(v.name) + '
                                                                                                                                                                                                              ' + esc(v.about) + '
                                                                                                                                                                                                              ' + pick + (v.on ? 'Open' : \"\") + '
                                                                                                                                                                                                              ' + meta + \"
                                                                                                                                                                                                              \";\n }).join(\"\");\n }\n $(\"#views\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-view]\"); if (!sel) return;\n const value = sel.value === \"live\" ? true : sel.value;\n await rpc(\"setpolicy\", { views: { [sel.dataset.view]: value } });\n toast(sel.value === \"off\" ? \"Took down \" + sel.dataset.view : sel.dataset.view + \": \" + (sel.value === \"live\" ? \"on\" : sel.value === \"write\" ? \"on write\" : sel.value)); await loadViews(); await loadInfo();\n }));\n async function loadDumps() {\n const list = await rpc(\"listdumps\");\n const f = $(\"#dumpform\");\n if (policy) { f.dumps.value = policy.dumps || \"off\"; f.keep.value = policy.dumpsKeep || 7; }\n $(\"#dumps-count\").textContent = list.length || \"\";\n $(\"#dumps\").innerHTML = list.length ? list.map((d) => '
                                                                                                                                                                                                            • jsonl' + esc(d.name) + ' ' + d.events.toLocaleString() + \" events, \" + fmtBytes(d.bytes) + \"\" + ib(\"copy\", \"Download\", \"downloaddump\", d.name) + ib(\"trash\", \"Delete\", \"deletedump\", d.name, \"danger\") + \"
                                                                                                                                                                                                            • \").join(\"\") : '
                                                                                                                                                                                                            • no dumps yet
                                                                                                                                                                                                            • ';\n }\n // A dump is fetched with a signed request and handed to the browser as a file.\n async function downloadDump(name) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const url = location.origin + \"/dumps/\" + name;\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"GET\"]] });\n const resp = await fetch(url, { headers: { authorization: \"Nostr \" + btoa(JSON.stringify(ev)) } });\n if (!resp.ok) throw new Error((await resp.json()).error || \"download failed\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(\".\")[0] + \"-\" + name; a.click(); URL.revokeObjectURL(a.href);\n }\n // A plain member's own invites, when the owner lets members invite.\n async function loadMine() {\n const sec = $(\"#myinvites\");\n if (!me || !owner || myRole || !signer.ready()) { sec.classList.add(\"hidden\"); return; }\n let mine;\n try { mine = await rpc(\"listinvites\"); } catch { sec.classList.add(\"hidden\"); return; }\n sec.classList.remove(\"hidden\");\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#mine\").innerHTML = mine.length ? mine.map((i) => '
                                                                                                                                                                                                            • inv…' + i.code.slice(-8) + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
                                                                                                                                                                                                            • \").join(\"\") : '
                                                                                                                                                                                                            • no invites yet
                                                                                                                                                                                                            • ';\n }\n async function loadStorage() {\n storage = await rpc(\"storagestats\");\n pollJobs();\n loadViews();\n loadDumps().catch(() => {});\n const st = storage;\n const top = st.kinds.slice(0, 4), rest = st.kinds.slice(4).reduce((a, k) => a + k.bytes, 0);\n const parts = [...top.map((k, i) => [k.kind + \" \" + kindName(k.kind), \"k\" + (i + 1), k.bytes]), ...(rest ? [[\"other\", \"k5\", rest]] : [])];\n const total = st.eventBytes || 1;\n $(\"#s-bar\").innerHTML = parts.length ? parts.map(([n, c, b]) => '').join(\"\") : '';\n $(\"#s-legend\").innerHTML = parts.length ? parts.map(([n, c, b]) => '' + esc(n) + \" \" + Math.round(b * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n const overhead = Math.max(0, st.databaseBytes - st.eventBytes);\n $(\"#s-totals\").innerHTML = [[\"events\", st.events.toLocaleString() + \" \" + fmtBytes(st.eventBytes) + \"\"], [\"index and overhead\", fmtBytes(overhead)], [\"files\", st.blobs.toLocaleString() + \" \" + fmtBytes(st.mediaBytes) + \"\"]]\n .map(([k, v]) => \"
                                                                                                                                                                                                              \" + k + \"\" + v + \"
                                                                                                                                                                                                              \").join(\"\");\n const any = st.retention.find((r) => r.kind === null);\n const own = (kind) => { const r = st.retention.find((x) => x.kind === kind); return r ? r.days : \"\"; };\n const row = (k) => {\n const key = k.kind === null ? \"\" : String(k.kind);\n const placeholder = k.kind === null ? \"forever\" : k.replaceable || !any ? \"forever\" : any.days + \" (everything else)\";\n const pill = k.kind === null ? \"everything else\" : '' + k.kind + \" \" + esc(kindName(k.kind));\n const cells = k.protected\n ? 'required'\n : ' days' + ib(\"check\", \"Save keep-for rule\", \"saveretention\", key) + ib(\"trash\", \"Purge\", \"purgekind\", key, \"danger\") + \"\";\n return '' + pill + '' + (k.n === undefined ? \"\" : k.n.toLocaleString()) + '' + (k.bytes === undefined ? \"\" : fmtBytes(k.bytes)) + '' + (k.oldest ? esc(fmtDay(k.oldest)) : \"\") + \"\" + cells + \"\";\n };\n $(\"#kinds tbody\").innerHTML = [...st.kinds, { kind: null, days: any ? any.days : 0 }].map(row).join(\"\");\n loadListHistory().catch(() => {});\n }\n\n async function loadListHistory() {\n const rows = await rpc(\"listlisthistory\");\n const labels = { 3: \"follows\", 10002: \"relay list\", 10003: \"bookmarks\", 30003: \"bookmark list\" };\n $(\"#listhistory tbody\").innerHTML = rows.length ? rows.map((r) => '' + esc((labels[r.kind] || (\"kind \" + r.kind)) + (r.d ? \" / \" + r.d : \"\")) + '' + esc(fmtTime(r.created_at)) + '' + esc(fmtTime(r.saved_at)) + '' + ib(\"undo\", \"Restore this version\", \"restorelist\", r.event_id) + '').join(\"\") : 'no older list versions yet';\n }\n\n let searchQuery = \"\";\n async function loadEvents(reset) {\n const list = searchQuery ? await rpc(\"searchevents\", searchQuery, 200) : await rpc(\"listrecentevents\", reset ? 50 : 200);\n $(\"#events tbody\").innerHTML = list.length ? list.map((e) => '' + esc(fmtTime(e.created_at)) + '' + e.kind + '' + av(e.pubkey) + key(e.pubkey) + '' + esc(e.content) + '' + ib(\"pin\", \"Pin\", \"pinevent\", e.id) + ib(\"trash\", \"Delete event\", \"deleteevent\", e.id) + ib(\"ban\", \"Ban event\", \"banevent\", e.id, \"danger\") + ib(\"banuser\", \"Ban author\", \"banpubkey\", e.pubkey, \"danger\") + \"\").join(\"\") : 'no events yet';\n $(\"#more\").classList.toggle(\"hidden\", searchQuery || list.length < 50);\n }\n $(\"#searchform\").onsubmit = guard(async (ev) => {\n searchQuery = ev.target.q.value.trim();\n $(\"#searchclear\").classList.toggle(\"hidden\", !searchQuery);\n await loadEvents(true);\n });\n $(\"#searchclear\").onclick = guard(async () => {\n searchQuery = \"\"; $(\"#searchform\").reset(); $(\"#searchclear\").classList.add(\"hidden\");\n await loadEvents(true);\n });\n async function loadPins() {\n const pins = await rpc(\"listpins\");\n $(\"#pins-count\").textContent = pins.length || \"\";\n $(\"#pins\").innerHTML = pins.length ? pins.map((t) => '
                                                                                                                                                                                                            • ' + (t[0] === \"e\" ? \"event\" : \"address\") + '' + esc(t[1].length > 40 ? t[1].slice(0, 16) + \"\\u2026\" + t[1].slice(-8) : t[1]) + \"\" + ib(\"copy\", \"Copy\", \"copy\", t[1]) + ib(\"x\", \"Unpin\", \"unpinevent\", t[1]) + \"
                                                                                                                                                                                                            • \").join(\"\") : '
                                                                                                                                                                                                            • nothing pinned
                                                                                                                                                                                                            • ';\n }\n $(\"#pinform\").onsubmit = guard(async (ev) => {\n await rpc(\"pinevent\", ev.target.id.value.trim()); ev.target.reset(); toast(\"Pinned\"); await loadPins();\n });\n\n // ---- actions ----\n const refresh = () => Promise.all([loadLists(), loadEvents(true), loadStorage(), loadPins()]);\n\n $(\"#copy\").onclick = async () => { await navigator.clipboard.writeText(wsURL); toast(\"copied \" + wsURL); };\n $(\"#signin\").onclick = guard(async () => {\n if (!window.nostr) throw new Error(\"No nostr extension found. Install one (Alby, nos2x, …) and reload, or use a remote signer.\");\n me = await window.nostr.getPublicKey();\n localStorage.setItem(\"me\", me);\n renderHeader(); await loadAdmin(); await loadPeople();\n });\n $(\"#signin46\").onclick = () => showRemote(null);\n $$(\".remote\").forEach((b) => { b.onclick = () => showRemote($(\"#\" + b.dataset.note)); });\n $(\"#nccopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#nclink\").href); toast(\"Link copied; paste it into your signer app\"); };\n $(\"#bunkerform\").onsubmit = guard(async (ev) => {\n $(\"#remotenote\").textContent = \"Connecting…\";\n try { await connectBunker(ev.target.url.value); } catch (e) { $(\"#remotenote\").textContent = e.message; throw e; }\n $(\"#remotenote\").textContent = \"\"; ev.target.reset();\n await remoteDone();\n });\n $(\"#signout\").onclick = async () => {\n if (remote) { const r = remote; remote = null; try { await withTimeout(r.logout(), 5000, \"The signer\"); } catch { /* the session is gone either way */ } }\n localStorage.removeItem(\"nip46\");\n me = null; localStorage.removeItem(\"me\"); renderHeader(); await loadAdmin(); await loadPeople();\n };\n const claimNow = async (note) => {\n if (!signer.ready()) { note.textContent = \"Sign the claim with a nostr extension or a remote signer.\"; showRemote(note); return; }\n me = await signer.getPublicKey();\n const r = await rpc(\"claim\");\n if (!r.claimed) throw new Error(\"Somebody else claimed it first.\");\n localStorage.setItem(\"me\", me);\n toast(\"It's yours.\");\n await loadInfo(); await loadFuel(); await loadAdmin(); await loadPeople();\n if (r.converted && confirm(\"This relay began as a temporary one: anyone can write, and everything is deleted after 14 days. Switch to the default rules and keep everything from now on? Each rule can be changed later on the Rules and Storage tabs.\")) {\n await rpc(\"resetrules\"); toast(\"Rules reset\"); await loadInfo(); await loadAdmin();\n }\n };\n $(\"#claim\").onclick = guard(() => claimNow($(\"#claimnote\")));\n $(\"#claimlease\").onclick = guard(() => claimNow($(\"#leasenote\")));\n // Jobs run in the background; the table follows them while the tab is open.\n let jobsTimer = 0;\n const fmtJob = (j) => {\n const what = j.kind === \"mirror\" ? \"mirror site\" : j.kind === \"import\" ? \"import\" : j.label === \"backfill\" ? \"fetch my history\" : j.kind === \"pull\" ? \"pull\" : \"rebroadcast\";\n const f = [];\n if (j.filter.authors) f.push(j.filter.authors.length === 1 && j.filter.authors[0] === me ? \"my events\" : j.filter.authors.length + \" authors\");\n if (j.filter.kinds) f.push(\"kinds \" + j.filter.kinds.join(\", \"));\n if (j.filter.since) f.push(\"since \" + fmtDay(j.filter.since));\n const when = j.every ? \"every \" + (j.every === 24 ? \"day\" : j.every + \" h\") + (j.nextRun ? \", next \" + fmtTime(j.nextRun) : \"\") : \"once\";\n const l = j.last;\n const count = (stored, blobs, sent, refused) => (j.kind === \"mirror\" ? blobs.toLocaleString() + \" files mirrored\" : j.kind === \"import\" ? stored.toLocaleString() + \" events\" + ((j.last ? j.last.duplicates : j.duplicates) ? \", \" + (j.last ? j.last.duplicates : j.duplicates) + \" already here\" : \"\") : j.kind === \"pull\" ? stored.toLocaleString() + \" events\" + (blobs ? \", \" + blobs + \" files\" : \"\") : sent.toLocaleString() + \" sent\" + (refused ? \", \" + refused + \" refused\" : \"\"));\n const res = j.running ? \"running: \" + count(j.stored, j.blobs, j.sent, j.refused) + \"...\" : !l ? \"waiting\" : l.error ? \"failed: \" + l.error : count(l.stored, l.blobs, l.sent, l.refused) + (l.skipped ? \", \" + l.skipped + \" skipped\" : \"\") + \", \" + fmtTime(l.finishedAt);\n const sources = j.running ? j.pullSources : l?.sources;\n const details = sources?.length ? '
                                                                                                                                                                                                              Source results' + sources.map((s) => '

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

                                                                                                                                                                                                              ').join('') + '
                                                                                                                                                                                                              ' : '';\n const targets = j.kind === \"push\" && j.targetStatus ? \"
                                                                                                                                                                                                              \" + Object.entries(j.targetStatus).map(([u, s]) => esc(u) + \": \" + esc(s.status)).join(\"
                                                                                                                                                                                                              \") + \"
                                                                                                                                                                                                              \" : \"\";\n return \"\" + what + \"\" + j.relays.map(esc).join(\"
                                                                                                                                                                                                              \") + targets + \"\" + (f.join(\", \") || \"everything\") + \"\" + when + \"\" + esc(res) + details + \"\" + (j.running ? \"\" : ib(\"undo\", \"Run now\", \"runjob\", j.id)) + ib(\"x\", \"Remove\", \"removejob\", j.id) + \"\";\n };\n async function pollJobs() {\n clearTimeout(jobsTimer);\n let jobs;\n try { jobs = await rpc(\"listjobs\"); } catch { return; }\n $(\"#jobs tbody\").innerHTML = jobs.length ? jobs.map(fmtJob).join(\"\") : 'no jobs yet';\n if (isOwner) {\n try {\n const deliveries = await rpc(\"deliverystatus\");\n $(\"#deliveries tbody\").innerHTML = deliveries.length ? deliveries.map((d) => '' + esc(d.event_id.slice(0, 12)) + '' + esc(d.target) + '' + esc(d.status) + '' + d.attempts + '' + esc(d.error || \"\") + '').join(\"\") : 'no automatic deliveries yet';\n } catch { /* unavailable to non-owners */ }\n }\n if (jobs.some((j) => j.running || (j.nextRun && j.nextRun <= Math.floor(Date.now() / 1000) + 1))) jobsTimer = setTimeout(pollJobs, 3000);\n }\n const urls = (s) => s.split(/[\\s,]+/).map((u) => u.trim()).filter(Boolean);\n const kindsOf = (s) => s.split(/[\\s,]+/).map((k) => parseInt(k, 10)).filter((k) => Number.isInteger(k) && k >= 0);\n $(\"#pullform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"addjob\", { kind: \"pull\", relays: [f.url.value.trim()], every: +f.every.value });\n toast(+f.every.value ? \"Mirror scheduled\" : \"Pull started\"); f.reset(); await pollJobs();\n });\n $(\"#backfillform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"backfill\", urls(f.relays.value));\n toast(\"Fetching your history\"); f.reset(); await pollJobs();\n });\n $(\"#pushform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const filter = {};\n const kinds = kindsOf(f.kinds.value); if (kinds.length) filter.kinds = kinds;\n const days = +f.days.value; if (days > 0) filter.since = Math.floor(Date.now() / 1000) - days * 86400;\n await rpc(\"addjob\", { kind: \"push\", relays: urls(f.targets.value), filter, every: +f.every.value });\n toast(\"Rebroadcast started\"); f.reset(); await pollJobs();\n });\n // ---- presets: writes, reads, kinds and keep-for rules in one click ----\n let presets = null;\n async function renderPresets() {\n if (!presets) { try { presets = await rpc(\"listpresets\"); } catch { presets = []; } }\n $(\"#presets\").innerHTML = presets.map((p) => '\").join(\"\");\n $(\"#presetsourcerow\").classList.toggle(\"hidden\", !presets.some((p) => p.source));\n }\n $(\"#presets\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-preset]\"); if (!b) return;\n const p = (presets || []).find((x) => x.name === b.dataset.preset); if (!p) return;\n if (!confirm(p.title + \": \" + p.about + \"\\n\\nThis replaces the writes and reads rules, the directory setting, the kind rules and the keep-for rules. Limits, identity, people and bans stay.\")) return;\n const source = $(\"#presetsource\").value.trim();\n if (p.source === \"required\" && !source) { $(\"#presetnote\").textContent = p.title + \" needs a source relay to mirror; enter its wss:// URL first.\"; return; }\n b.disabled = true;\n try { policy = await rpc(\"applypreset\", p.name, p.source && source ? { source } : undefined); $(\"#presetnote\").textContent = \"Now: \" + p.about + (policy.job ? \" Mirroring \" + source + \" every \" + policy.job.every + \" h.\" : \"\"); toast(p.title + \" applied\"); await loadInfo(); await loadAdmin(); }\n catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // ---- wire me in: this relay in the owner's own lists ----\n // The lists are replaceable, so a fresh list with only this relay would\n // clobber the real one once it spread. Every publish starts from the\n // newest copy found here or on the indexers, verified when the signer\n // library is around, and adds or removes this relay in it.\n const INDEXERS = [\"wss://purplepag.es\", \"wss://relay.nostr.band\", \"wss://relay.damus.io\", \"wss://nos.lol\"];\n const LISTS = [\n { kind: 10002, tag: \"r\", title: \"Relay list\", nip: \"NIP-65\", about: \"where clients read your notes and send you mentions\" },\n { kind: 10050, tag: \"relay\", title: \"DM inbox\", nip: \"NIP-17\", about: \"where people send you private messages\" },\n { kind: 10007, tag: \"relay\", title: \"Search relays\", nip: \"NIP-51\", about: \"where clients run your searches\" },\n { kind: 10063, tag: \"server\", title: \"Blossom servers\", nip: \"BUD-03\", about: \"where clients upload and look for your files\" },\n ];\n const mineFor = (l) => (l.tag === \"server\" ? location.origin : wsURL);\n const normURL = (u) => { try { const x = new URL(String(u).trim()); return (x.host + x.pathname).replace(/\\/+$/, \"\").toLowerCase(); } catch { return String(u).trim().toLowerCase(); } };\n const isMine = (l, t) => t[0] === l.tag && normURL(t[1] || \"\") === normURL(mineFor(l));\n const listHas = (list, l) => !!list && list.tags.some((t) => isMine(l, t));\n // mergeList is pure: the newest list with this relay put first or taken out, every other tag kept.\n function mergeList(list, l, include) {\n const tags = (list ? list.tags : []).filter((t) => !isMine(l, t));\n if (include) tags.unshift([l.tag, mineFor(l)]);\n return { kind: l.kind, created_at: Math.floor(Date.now() / 1000), content: list ? list.content : \"\", tags };\n }\n const relaysIn = (list) => list.tags.filter((t) => (t[0] === \"r\" || t[0] === \"relay\") && /^wss?:\\/\\//i.test(t[1] || \"\")).map((t) => t[1].trim());\n // overWS opens one socket, sends one message, feeds answers to onMessage until it says done or time runs out.\n function overWS(url, ms, first, onMessage) {\n return new Promise((res) => {\n let ws = null, done = false, out = null;\n const finish = () => { if (done) return; done = true; clearTimeout(timer); try { if (ws) ws.close(); } catch { /* closed */ } res(out); };\n const timer = setTimeout(finish, ms);\n try { ws = new WebSocket(url); } catch { return finish(); }\n ws.onopen = () => ws.send(JSON.stringify(first));\n ws.onmessage = (m) => { let d; try { d = JSON.parse(m.data); } catch { return; } if (!Array.isArray(d)) return; if (onMessage(d, (v) => { out = v; })) finish(); };\n ws.onerror = finish; ws.onclose = finish;\n });\n }\n function fetchNewest(url, filter, ms) {\n let best = null;\n return overWS(url, ms, [\"REQ\", \"w\", filter], (d, set) => {\n if (d[0] === \"EVENT\" && d[1] === \"w\" && d[2] && (!best || d[2].created_at > best.created_at)) { best = d[2]; set(best); }\n return (d[0] === \"EOSE\" || d[0] === \"CLOSED\") && d[1] === \"w\";\n });\n }\n function publishTo(url, event, ms) {\n return overWS(url, ms, [\"EVENT\", event], (d, set) => { if (d[0] === \"OK\" && d[1] === event.id) { set({ url, ok: !!d[2], msg: d[3] || \"\" }); return true; } return false; })\n .then((r) => r || { url, ok: false, msg: \"no answer\" });\n }\n // bridge signs a NIP-98 request to this relay's HTTP door, so the owner's own reads and writes pass any read rule.\n async function bridge(path, body) {\n const url = location.origin + path, raw = JSON.stringify(body);\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"POST\"], [\"payload\", await sha256hex(raw)]] });\n const resp = await fetch(url, { method: \"POST\", headers: { \"content-type\": \"application/json\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body: raw });\n const json = await resp.json();\n if (!resp.ok) throw new Error(json.error || \"HTTP \" + resp.status);\n return json;\n }\n const wire = {};\n function renderWire() {\n const pill = (state) => {\n const cls = state === \"listed\" ? \"on\" : state === \"failed\" ? \"bad\" : state === \"not listed\" ? \"off\" : \"\";\n const label = state === \"checking\" || state === \"publishing\" ? state + \"\\u2026\" : state;\n return '' + esc(label) + \"\";\n };\n $(\"#wire\").innerHTML = LISTS.map((l) => {\n const w = wire[l.kind] || { state: \"\", results: [], list: null };\n const busy = w.state === \"checking\" || w.state === \"publishing\";\n const btn = (act, cls, label) => '\";\n const buttons = w.state === \"listed\" ? btn(\"remove\", \"\", \"Remove this relay\") : btn(\"add\", \"pri\", \"Add this relay\") + btn(\"check\", \"\", w.state ? \"Check again\" : \"Check\");\n const n = w.list ? w.list.tags.filter((t) => t[0] === l.tag).length : 0;\n const from = w.list ? \"\" + (n === 1 ? \"1 entry\" : n + \" entries\") + \", \" + fmtTime(w.list.created_at) + \"\" : w.state && !busy ? \"no list found\" : \"\";\n const sent = (w.results || []).map((r) => '' + esc(r.url.replace(/^wss?:\\/\\//, \"\")) + (r.ok ? \"\" : \" failed\") + \"\").join(\"\");\n const meta = (w.state ? pill(w.state) : \"\") + from + sent;\n return '
                                                                                                                                                                                                              ' + l.title + ' ' + l.nip + \"
                                                                                                                                                                                                              \" + l.about + '
                                                                                                                                                                                                              ' + buttons + '
                                                                                                                                                                                                              ' + meta + \"
                                                                                                                                                                                                              \";\n }).join(\"\");\n }\n async function wireCheck(l) {\n const w = (wire[l.kind] = { state: \"checking\", list: null, results: [] }); renderWire();\n const filter = { kinds: [l.kind], authors: [me], limit: 1 };\n const found = [];\n try { found.push(...(await bridge(\"/query\", [filter]))); } catch { /* the relay may hold none */ }\n const remote = await Promise.all(INDEXERS.map((u) => fetchNewest(u, filter, 4000)));\n let lib = null; try { lib = await signerLib(); } catch { /* unverified lists are still the owner's own, by pubkey */ }\n for (const e of remote) if (e && e.pubkey === me && e.kind === l.kind && Array.isArray(e.tags) && (!lib || lib.verifyEvent(e))) found.push(e);\n w.list = found.sort((a, b) => b.created_at - a.created_at)[0] || null;\n w.state = listHas(w.list, l) ? \"listed\" : \"not listed\"; renderWire();\n }\n async function wirePublish(l, include) {\n if (!wire[l.kind] || !wire[l.kind].state || wire[l.kind].state === \"failed\") await wireCheck(l);\n const w = wire[l.kind]; w.state = \"publishing\"; w.results = []; renderWire();\n try {\n const signed = await signer.signEvent(mergeList(w.list, l, include));\n const here = await bridge(\"/events\", signed).then((r) => ({ url: wsURL, ok: !!r.accepted, msg: r.message || \"\" })).catch((e) => ({ url: wsURL, ok: false, msg: e.message }));\n const targets = [...new Set([...relaysIn(signed), ...INDEXERS])].filter((u) => normURL(u) !== normURL(wsURL));\n const rest = await Promise.all(targets.map((u) => publishTo(u, signed, 6000)));\n w.results = [here, ...rest]; w.list = signed; w.state = include ? \"listed\" : \"not listed\";\n toast((include ? \"Added to your \" : \"Removed from your \") + l.title.toLowerCase());\n } catch (e) { w.state = \"failed\"; w.results = [{ url: wsURL, ok: false, msg: e.message }]; toast(e.message); }\n renderWire();\n }\n $(\"#wire\").addEventListener(\"click\", (ev) => {\n const b = ev.target.closest(\"button[data-wire]\"); if (!b) return;\n const l = LISTS.find((x) => String(x.kind) === b.dataset.wire); if (!l) return;\n if (b.dataset.do === \"check\") wireCheck(l); else wirePublish(l, b.dataset.do === \"add\");\n });\n\n // ---- custom domains: this relay under a hostname the owner controls ----\n let domainSites = [];\n const domainOptions = (current = \"\") => [{ label: \"\", title: \"This relay\" }, ...domainSites.map((s) => ({ label: s.label, title: (s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + \" / \" + key(s.author) })), ...(current && !domainSites.some((s) => s.label === current) ? [{ label: current, title: \"Site \" + current }] : [])].map((s) => '\").join(\"\");\n let domains = null; // null: not enabled on this host\n function renderDomains() {\n $(\"#domains\").innerHTML = (domains || []).map((d) => {\n const state = d.ready ? \"active\" : \"hostname \" + d.status.replace(/_/g, \" \") + \", certificate \" + d.sslStatus.replace(/_/g, \" \");\n const btn = (act, cls, label) => '\";\n const rows = d.ready ? \"\" : '
                                                                                                                                                                                                              ' + d.records.map((r) => \"\").join(\"\") + \"
                                                                                                                                                                                                              TypeNameValue
                                                                                                                                                                                                              \" + esc(r.type) + '' + esc(r.name) + '' + esc(r.value) + \"\" + esc(r.note) + \"
                                                                                                                                                                                                              \";\n return '
                                                                                                                                                                                                              ' + esc(d.host) + '' + esc(state) + \"\" + btn(\"check\", \"\", \"Check\") + btn(\"remove\", \"danger\", \"Remove\") + '
                                                                                                                                                                                                              \" + rows;\n }).join(\"\");\n }\n async function loadDomains() {\n try { [domains, domainSites] = await Promise.all([rpc(\"listdomains\"), rpc(\"listsites\")]); $(\"#adddomain select[name=site]\").innerHTML = domainOptions(); $(\"#domainnote\").textContent = domains.length ? \"\" : \"No custom domain yet.\"; $(\"#adddomain\").classList.remove(\"hidden\"); }\n catch (e) { domains = null; $(\"#domainnote\").textContent = /^unsupported/.test(e.message) ? \"Custom domains are not enabled on this host.\" : e.message; $(\"#adddomain\").classList.add(\"hidden\"); }\n renderDomains();\n }\n $(\"#adddomain\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"adddomain\", f.host.value.trim(), f.site.value); f.reset(); toast(\"Domain added; now create the CNAME\"); await loadDomains();\n });\n $(\"#domains\").addEventListener(\"change\", guard(async (ev) => {\n const select = ev.target.closest(\"select[data-domain-site]\"); if (!select) return;\n await rpc(\"setdomainsite\", select.dataset.domainSite, select.value);\n toast(\"Domain destination saved\"); await loadDomains();\n }));\n $(\"#domains\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-domain]\"); if (!b) return;\n const host = b.dataset.domain;\n if (b.dataset.do === \"remove\" && !confirm(\"Remove \" + host + \"? Its certificate goes with it and the name stops answering.\")) return;\n b.disabled = true;\n try {\n if (b.dataset.do === \"remove\") { await rpc(\"removedomain\", host); toast(\"Removed\"); }\n else { const d = await rpc(\"checkdomain\", host); toast(d.ready ? host + \" is live\" : \"Not yet: \" + (d.status === \"active\" ? \"certificate pending\" : \"waiting for the CNAME\")); }\n await loadDomains();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n $(\"#access\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const openKinds = f.openKinds.value.split(/[\\s,]+/).filter(Boolean).map(Number);\n if (openKinds.some((k) => !Number.isInteger(k) || k < 0 || k > 65535)) throw new Error(\"Open kinds must be whole numbers.\");\n policy = await rpc(\"setpolicy\", { writes: f.writes.value, reads: f.reads.value, openKinds, guestReplies: f.guestReplies.checked, minPow: +f.minPow.value, maxFuture: +f.maxFuture.value, maxLimit: +f.maxLimit.value, maxSubs: +f.maxSubs.value, maxMessageKB: +f.maxMessageKB.value, eventsPerMinute: +f.eventsPerMinute.value, reqsPerMinute: +f.reqsPerMinute.value, maxBlobMB: +f.maxBlobMB.value });\n toast(\"Rules saved\"); await loadInfo();\n });\n $(\"#notify\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { notify: { reports: f.reports.checked, fuel: f.fuel.checked, jobs: f.jobs.checked, succession: f.succession.checked, digest: f.digest.checked } });\n toast(\"Notifications saved\");\n });\n $(\"#notifytest\").onclick = guard(async () => {\n const r = await rpc(\"notifytest\");\n toast(r.sent ? \"Sent. Look for a message from the relay in your DMs.\" : \"Could not send.\");\n });\n $(\"#identity\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const list = (v) => v.split(\",\").map((s) => s.trim()).filter(Boolean);\n policy = await rpc(\"setpolicy\", { name: f.name.value, contact: f.contact.value, description: f.description.value, icon: f.icon.value,\n banner: f.banner.value, postingPolicy: f.postingPolicy.value, privacyPolicy: f.privacyPolicy.value, tags: list(f.tags.value), languageTags: list(f.languageTags.value), relayCountries: list(f.relayCountries.value) });\n toast(\"Identity saved\"); await loadInfo(); await loadPeople(); loadCard();\n });\n $(\"#addmember\").onsubmit = guard(async (ev) => {\n const f = ev.target; const pk = npubToHex(f.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"setmember\", pk, { name: f.name.value.trim() || null, note: f.note.value }); f.reset(); toast(\"Member added\"); await loadLists();\n });\n $(\"#addblock\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"blockip\", f.ip.value.trim(), f.reason.value); f.reset(); toast(\"Blocked\"); await loadLists();\n });\n $(\"#wordsform\").onsubmit = guard(async (ev) => {\n const words = ev.target.words.value.split(\"\\n\").map((s) => s.trim()).filter(Boolean);\n const kept = await rpc(\"setblockedwords\", words);\n policy = await rpc(\"setpolicy\", { blockedWordsInTags: ev.target.inTags.checked });\n ev.target.words.value = kept.join(\"\\n\"); toast(kept.length ? kept.length + \" words blocked\" : \"No words blocked\");\n });\n $(\"#thresholdform\").onsubmit = guard(async (ev) => {\n policy = await rpc(\"setpolicy\", { reportThreshold: +ev.target.reportThreshold.value });\n toast(policy.reportThreshold ? \"Hidden after \" + policy.reportThreshold + \" reports\" : \"Never hidden by reports\"); await loadLists();\n });\n $(\"#addban\").onsubmit = guard(async (ev) => {\n const pk = npubToHex(ev.target.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"banpubkey\", pk, ev.target.reason.value, ev.target.erase.checked); ev.target.reset(); toast(ev.target.erase.checked ? \"Banned and erased\" : \"Banned\"); await loadLists();\n });\n $(\"#mintinvite\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const inv = await rpc(\"createinvite\", +f.ttl.value, +f.max.value, f.note.value);\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n f.note.value = \"\"; toast(\"Invite created and copied\"); await loadLists();\n });\n $(\"#kindform\").onsubmit = guard(async (ev) => {\n const rule = ev.submitter.value; const k = +ev.target.kind.value;\n await rpc(rule === \"allow\" ? \"allowkind\" : \"disallowkind\", k); ev.target.reset(); toast((rule === \"allow\" ? \"Allowed kind \" : \"Blocked kind \") + k); await loadLists();\n });\n $(\"#more\").onclick = guard(() => loadEvents(false));\n $(\"#dumpform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { dumps: f.dumps.value, dumpsKeep: Math.max(1, Math.min(60, Math.floor(+f.keep.value || 7))) });\n toast(policy.dumps === \"off\" ? \"Dumps off\" : \"Dumping \" + policy.dumps); await loadDumps();\n });\n $(\"#importform\").onsubmit = guard(async (ev) => {\n const file = ev.target.file.files[0]; if (!file) return;\n if (!signer.ready()) throw new Error(NO_SIGNER);\n if (file.size > 64 * 1024 * 1024) throw new Error(\"At most 64 MB per import.\");\n const body = await file.text();\n const url = location.origin + \"/import?name=\" + encodeURIComponent(file.name);\n const token = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"PUT\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(url, { method: \"PUT\", headers: { authorization: \"Nostr \" + btoa(JSON.stringify(token)), \"content-type\": \"application/x-ndjson\" }, body });\n const r = await resp.json();\n if (!resp.ok) throw new Error(r.error || \"import failed\");\n ev.target.reset(); toast(\"Importing \" + fmtBytes(r.bytes)); await pollJobs();\n });\n $(\"#dumpnow\").onclick = guard(async () => { const d = await rpc(\"dumpnow\"); toast(\"Dumped \" + d.events.toLocaleString() + \" events\"); await loadStorage(); });\n async function backupRequest(path, method, body) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const hash = await sha256hex(body || \"\");\n const token = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", location.origin + path], [\"method\", method], [\"payload\", hash]] });\n return fetch(path, { method, headers: { authorization: \"Nostr \" + btoa(JSON.stringify(token)), ...(body ? { \"content-type\": \"application/json\" } : {}) }, body });\n }\n $(\"#backupnow\").onclick = guard(async () => { const id = $(\"#backupid\").value.trim() || \"backup\"; const r = await rpc(\"backupnow\", id); const resp = await backupRequest(\"/backups/\" + id, \"GET\"); if (!resp.ok) throw new Error(\"backup download failed\"); const a = document.createElement(\"a\"); a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(\".\")[0] + \"-\" + id + \".json\"; a.click(); URL.revokeObjectURL(a.href); toast(\"Backup downloaded\"); await loadStorage(); void r; });\n async function selectedBackup() { const f = $(\"#backupfile\").files[0]; if (!f) throw new Error(\"Choose a backup archive first.\"); if (f.size > 8 * 1024 * 1024) throw new Error(\"Backups are limited to 8 MB.\"); return f.text(); }\n $(\"#backuppreview\").onclick = guard(async () => { const body = await selectedBackup(); const r = await (await backupRequest(\"/backups/preview\", \"POST\", body)).json(); if (r.error) throw new Error(r.error); alert(JSON.stringify(r.result, null, 2)); });\n $(\"#backuprestore\").onclick = guard(async () => { const body = await selectedBackup(); if (!confirm(\"Restore this archive onto this fresh relay? This cannot be undone.\")) return; const r = await (await backupRequest(\"/backups/restore\", \"POST\", body)).json(); if (r.error) throw new Error(r.error); toast(\"Backup restored\"); await loadInfo(); await loadAdmin(); });\n $(\"#treeform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { memberInvites: { depth: Math.max(0, Math.floor(+f.depth.value || 0)), quota: Math.max(0, Math.floor(+f.quota.value || 0)) } });\n toast(policy.memberInvites.depth ? \"Members may invite\" : \"Only you and moderators invite\");\n });\n $(\"#mintmine\").onclick = guard(async () => {\n const inv = await rpc(\"createinvite\", 259200, 1, \"\");\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n toast(\"Invite created and copied\"); await loadMine();\n });\n $(\"#exportcfg\").onclick = guard(async () => {\n const cfg = await rpc(\"exportconfig\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(new Blob([JSON.stringify(cfg, null, 2)], { type: \"application/json\" }));\n a.download = host.split(\".\")[0] + \".bind.ws.config.json\";\n a.click(); URL.revokeObjectURL(a.href);\n });\n $(\"#importcfg\").onclick = () => $(\"#cfgfile\").click();\n $(\"#cfgfile\").onchange = guard(async (ev) => {\n const file = ev.target.files[0]; if (!file) return;\n const cfg = JSON.parse(await file.text());\n const check = await rpc(\"importconfig\", cfg, { dryRun: true });\n const lines = check.changes.summary.length ? check.changes.summary.join(\"\\n\") : \"Nothing would change.\";\n const dropped = check.warnings.length ? \"\\n\\nNot taken:\\n\" + check.warnings.join(\"\\n\") : \"\";\n if (!confirm(\"Apply \" + file.name + \" to this relay?\\n\\n\" + lines + dropped)) { ev.target.value = \"\"; return; }\n await rpc(\"importconfig\", cfg); ev.target.value = \"\"; toast(\"Configuration imported\"); await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // Succession: the status line under the heir form, from successionstatus.\n async function renderSuccession() {\n const el = $(\"#successionnote\"), f = $(\"#succession\");\n let st;\n try { st = await rpc(\"successionstatus\"); } catch { el.textContent = \"\"; return; }\n const sc = st.succession;\n $(\"#clearsuccession\").classList.toggle(\"hidden\", !sc);\n f.querySelector(\"button.btn:not(#clearsuccession)\").textContent = sc ? \"Change heir\" : \"Set heir\";\n if (!sc) { el.textContent = \"No heir named. Last signed in \" + fmtTime(st.ownerSeenAt) + \".\"; return; }\n if (f.heir.querySelector('[value=\"' + sc.heir + '\"]')) f.heir.value = sc.heir;\n f.afterDays.value = String(sc.afterDays);\n const who = (window.__members || []).find((m) => m.pubkey === sc.heir);\n const heir = who && who.name ? who.name + \"@\" + host : short(sc.heir);\n el.textContent = \"Heir: \" + heir + \". Last signed in \" + fmtTime(st.ownerSeenAt) + (st.silentDays ? \" (\" + st.silentDays + \" days ago)\" : \"\") + \". \" +\n (st.warning ? \"The warning month is running: the relay goes to \" + heir + \" on \" + fmtDay(st.handoverAt) + \" unless you sign in.\" : \"If you stay away, it goes to \" + heir + \" around \" + fmtDay(st.handoverAt) + \".\") +\n (st.log && st.log.length ? \" Handed over before: \" + st.log.map((l) => fmtDay(l.at) + \" to \" + short(l.to)).join(\", \") + \".\" : \"\");\n }\n $(\"#succession\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"setsuccession\", { heir: f.heir.value, afterDays: +f.afterDays.value });\n toast(\"Heir set\"); await loadAdmin();\n });\n $(\"#clearsuccession\").onclick = guard(async () => {\n await rpc(\"clearsuccession\");\n toast(\"Heir cleared\"); await loadAdmin();\n });\n $(\"#transfer\").onsubmit = guard(async (ev) => {\n const pk = ev.target.pubkey.value; if (!pk) return;\n const name = host.split(\".\")[0];\n const typed = prompt(\"This hands \" + host + \" to \" + short(pk) + \" for good. You stay on as a moderator. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing changed.\"); return; }\n await rpc(\"transferowner\", pk);\n toast(\"Transferred\");\n await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // ---- fork: a new name with this relay's events, claim reserved for a key ----\n $(\"#forkform\").scope.onchange = (ev) => $(\"#forkkinds\").classList.toggle(\"hidden\", ev.target.value !== \"kinds\");\n $(\"#joinform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { joinTerms: f.joinTerms.value, directoryPublic: f.directoryPublic.checked });\n toast(\"Saved\"); await loadInfo(); await loadPeople();\n });\n $(\"#forkform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const opts = { people: f.people.checked };\n if (f.name.value.trim()) opts.name = f.name.value.trim().toLowerCase();\n if (f.holder.value.trim()) { const pk = npubToHex(f.holder.value); if (!pk) throw new Error(\"That is not a pubkey.\"); opts.holder = pk; }\n if (f.scope.value === \"mine\") opts.filter = { authors: [me] };\n if (f.scope.value === \"kinds\") { const kinds = f.kinds.value.split(/[\\s,]+/).filter(Boolean).map(Number); if (!kinds.length || kinds.some((k) => !Number.isInteger(k) || k < 0)) throw new Error(\"Give kinds as numbers.\"); opts.filter = { kinds }; }\n if (!confirm(\"Fork this relay into a new name\" + (opts.name ? \" (\" + opts.name + \")\" : \"\") + \"? It pulls \" + (f.scope.value === \"all\" ? \"everything\" : f.scope.value === \"mine\" ? \"your events\" : \"the chosen kinds\") + (opts.people ? \" and the people\" : \"\") + \", and only \" + (opts.holder ? \"that key\" : \"you\") + \" can claim it.\")) return;\n const r = await rpc(\"forkrelay\", opts);\n $(\"#forknote\").textContent = r.handover + \" Expires \" + fmtTime(r.expires_at) + \".\";\n $(\"#forkurl\").textContent = r.console;\n $(\"#forkresult\").classList.remove(\"hidden\");\n toast(\"Forked to \" + r.name);\n });\n $(\"#forkcopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#forkurl\").textContent); toast(\"copied\"); };\n $(\"#deleterelay\").onclick = guard(async () => {\n const name = host.split(\".\")[0];\n const typed = prompt(\"This deletes everything on \" + host + \" and gives the name up. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing was deleted.\"); return; }\n await rpc(\"deleterelay\", name);\n localStorage.removeItem(\"me\");\n location.href = \"/\";\n });\n $(\"#topup\").onsubmit = guard((ev) => topUp(+ev.target.sats.value));\n $(\"#inv-copy\").onclick = async () => { await navigator.clipboard.writeText($(\"#inv-text\").value); toast(\"invoice copied\"); };\n $$(\"button[data-copy]\").forEach((b) => { b.onclick = async () => { await navigator.clipboard.writeText(b.dataset.copy === \"ws\" ? wsURL : location.origin); toast(\"copied\"); }; });\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-act]\"); if (!b) return;\n const act = b.dataset.act, id = b.dataset.id;\n if (act === \"copy\") { await navigator.clipboard.writeText(id); toast(\"copied\"); return; }\n if ((act === \"banpubkey\" || act === \"banevent\" || act === \"resolve:ban\") && !confirm(act === \"banevent\" ? \"Delete this event and refuse it forever?\" : \"Ban this author and refuse everything they post?\")) return;\n const erase = (act === \"banpubkey\" || act === \"resolve:ban\") && confirm(\"Also erase everything they wrote and uploaded here?\");\n if (act === \"deleteblob\" && !confirm(\"Delete this file for good?\")) return;\n if (act === \"deletedump\" && !confirm(\"Delete this dump?\")) return;\n if (act === \"downloaddump\") { b.disabled = true; try { await downloadDump(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }\n if (act === \"restorelist\") {\n if (!signer.ready()) { toast(NO_SIGNER); return; }\n try {\n const preview = await rpc(\"restorelist\", id);\n const d = preview.diff || {};\n const added = (d.addedTags || []).map((t) => \"+ \" + JSON.stringify(t)).join(\"\\n\");\n const removed = (d.removedTags || []).map((t) => \"- \" + JSON.stringify(t)).join(\"\\n\");\n const changes = [added, removed, d.contentChanged ? \"content changed\" : \"content unchanged\"].filter(Boolean).join(\"\\n\");\n if (!confirm(\"Restore this list version?\\n\\n\" + (changes || \"No tag or content changes\") + \"\\n\\nIt will be signed and published as the newest version.\")) return;\n const signed = await signer.signEvent(preview.draft);\n const result = await bridge(\"/events\", signed);\n if (!result.accepted) throw new Error(result.message || \"The relay refused the restored list.\");\n toast(\"List restored\"); await loadListHistory(); await loadStorage();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if ((act === \"removemember\" || act === \"banpubkey\") && (window.__members || []).some((m) => m.invited_by === id) && confirm(\"Also remove everyone this member invited, and everyone they invited in turn?\")) {\n b.disabled = true;\n try { const r = await rpc(\"removesubtree\", id); if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase); toast(\"Removed \" + r.removed.length); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"removejob\" && !confirm(\"Remove this job?\")) return;\n if (act === \"purgekind\") {\n const kind = id === \"\" ? null : +id;\n const label = kind === null ? \"everything without its own rule\" : kindName(kind) + \" (kind \" + kind + \")\";\n const typed = prompt(\"Purge \" + label + \" older than how many days? 0 purges all of them. This cannot be undone.\", \"30\");\n if (typed === null) return;\n const days = Math.max(0, Math.floor(+typed || 0));\n b.disabled = true;\n try { const r = await rpc(\"purgekind\", kind, days); toast(\"Purged \" + r.deleted.toLocaleString() + \" events\"); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"saveretention\") {\n const kind = id === \"\" ? null : +id;\n const days = Math.max(0, Math.floor(+b.closest(\"tr\").querySelector(\".days\").value || 0));\n b.disabled = true;\n try { await rpc(\"setretention\", kind, days); toast(days ? \"Kept for \" + days + \" days\" : \"Kept forever\"); await loadStorage(); await loadInfo(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n b.disabled = true;\n try {\n if (act === \"savemember\") { const tr = b.closest(\"tr\"), role = tr.querySelector(\"select.role\"), keep = tr.querySelector(\".keep\"), cap = tr.querySelector(\".cap\"); await rpc(\"setmember\", id, { name: tr.querySelector(\".name\").value.trim() || null, note: tr.querySelector(\".note\").value, ...(role ? { role: role.value } : {}), ...(keep ? { keepDays: Math.max(0, Math.floor(+keep.value || 0)), maxBytes: Math.max(0, Math.floor(+cap.value || 0)) * 1024 } : {}) }); }\n else if (act.startsWith(\"resolve:\")) await rpc(\"resolvereport\", id, act.slice(8), erase);\n else if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase);\n else await rpc(act, act === \"unrulekind\" ? +id : id, \"\");\n toast(\"Done\"); await (myRole ? refresh() : loadMine());\n if (act === \"pinevent\" || act === \"unpinevent\") await loadPins();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // The share block: the card picture, the group naddr and its QR.\n let card = null;\n async function loadCard() {\n try {\n card = await (await fetch(\"/card.json\", { cache: \"no-store\" })).json();\n $(\"#cardimg\").src = \"/card.svg?t=\" + Date.now();\n $(\"#naddr\").textContent = card.naddr || \"\";\n const q = $(\"#naddrqr\");\n if (card.naddr) { q.src = \"/qr.svg?text=\" + encodeURIComponent(card.naddr); q.classList.remove(\"hidden\"); } else q.classList.add(\"hidden\");\n } catch { /* the card is decoration */ }\n renderApps();\n }\n // The app rows on the Connect section: one per client people actually use,\n // with the link that lands on this relay in it. Relay apps take the relay\n // or the group address; feed apps take the owner's profile with this relay\n // as the hint, since they have no notion of opening a relay.\n function renderApps() {\n const el = $(\"#apps\"); if (!el) return;\n const enc = encodeURIComponent;\n const nprofile = card && card.nprofile ? card.nprofile : \"\";\n const naddr = card && card.naddr ? card.naddr : \"\";\n const whose = me && owner && me === owner ? \"your\" : \"the owner's\";\n const link = (label, href) => '' + label + \"\";\n const app = (label, uri) => '' + label + \"\";\n const copy = (label, text) => '\";\n const row = (name, where, note, acts) => '
                                                                                                                                                                                                              ' + name + \"\" + where + \"

                                                                                                                                                                                                              \" + note + '

                                                                                                                                                                                                              ' + acts.filter(Boolean).join(\"\") + \"
                                                                                                                                                                                                              \";\n const profileNote = \"Opens \" + whose + \" profile with this relay attached.\";\n const groups = [\n [\"As a place\", \"These open the relay itself: its feed, its people, its group.\", [\n row(\"Jumble\", \"web\", \"A feed of everything on this relay.\", [link(\"Open\", \"https://jumble.social/?r=\" + enc(wsURL))]),\n row(\"Coracle\", \"web\", \"The relay's page: its feed and its people.\", [link(\"Open\", \"https://coracle.social/relays/\" + enc(host))]),\n row(\"Flotilla\", \"web, phone\", \"The relay as a space, with the group as a room.\", [link(\"Open\", \"https://app.flotilla.social/spaces/\" + enc(host)), naddr && app(\"Open group\", \"nostr:\" + naddr)]),\n row(\"0xchat\", \"phone\", \"The group, in a chat app.\", [naddr && app(\"Open group\", \"nostr:\" + naddr), naddr && copy(\"Copy naddr\", naddr)]),\n row(\"noStrudel\", \"web\", \"Relays, add this one, then open its page.\", [copy(\"Copy relay URL\", wsURL)]),\n ]],\n [\"Find me here\", \"Feed apps have no relay pages. They meet this relay through a profile link that names it, then keep it once it is in the relay settings.\", [\n row(\"Primal\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://primal.net/p/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"YakiHonne\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://yakihonne.com/profile/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"Damus\", \"iPhone\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Amethyst\", \"Android\", profileNote + \" Then Relays in the drawer.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Nostur\", \"iPhone, Mac\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n ]],\n ];\n if (info?.supported_grasps?.includes(\"GRASP-01\")) groups.push([\n \"Git repositories\", \"Use this relay with a Git client.\", [\n row(\"GitWorkshop\", \"web\", \"Browse this relay's Git repositories.\", [link(\"Open in app\", \"https://gitworkshop.dev/relay/\" + enc((wsURL.startsWith(\"ws://\") ? \"ws:\" : \"\") + host)), copy(\"Copy relay URL\", wsURL)]),\n ],\n ]);\n el.innerHTML = groups.map(([h, note, rows]) => '

                                                                                                                                                                                                              ' + h + '

                                                                                                                                                                                                              ' + note + '

                                                                                                                                                                                                              ' + rows.join(\"\") + \"
                                                                                                                                                                                                              \").join(\"\");\n $(\"#apps-ws\").textContent = wsURL;\n const tile = (label, text) => '
                                                                                                                                                                                                              ' + label + '\"QR
                                                                                                                                                                                                              ';\n $(\"#phones\").innerHTML = [nprofile && tile(\"Find me here, for a phone\", \"nostr:\" + nprofile), naddr && tile(\"The group, for a phone\", \"nostr:\" + naddr)].filter(Boolean).join(\"\");\n }\n // The folds are a group: opening one closes the rest. Browsers with the\n // details name attribute do this themselves; this covers the others.\n $$(\".folds details\").forEach((d) => d.addEventListener(\"toggle\", () => { if (d.open) $$(\".folds details\").forEach((o) => { if (o !== d && o.open) o.open = false; }); }));\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-copytext]\"); if (!b) return;\n try { await navigator.clipboard.writeText(b.dataset.copytext); toast(\"copied\"); } catch { /* no clipboard */ }\n });\n $(\"#copynaddr\").onclick = async () => { if (!card || !card.naddr) return; await navigator.clipboard.writeText(card.naddr); toast(\"copied naddr\"); };\n $(\"#copyembed\").onclick = async () => { await navigator.clipboard.writeText('\"''); toast(\"copied embed\"); };\n\n // A click on a key copies it; a double-click selects the whole key so the usual copy shortcut takes it too.\n document.addEventListener(\"dblclick\", (ev) => {\n const k = ev.target.closest(\".key\"); if (!k) return;\n const s = window.getSelection(), r = document.createRange(); r.selectNodeContents(k.querySelector(\".full\")); s.removeAllRanges(); s.addRange(r);\n });\n document.addEventListener(\"click\", async (ev) => {\n const k = ev.target.closest(\".key\"); if (!k || ev.detail > 1 || String(window.getSelection())) return;\n try { await navigator.clipboard.writeText(k.title); toast(\"copied \" + k.dataset.short); } catch { /* no clipboard */ }\n });\n // ---- boot ----\n try { await loadInfo(); } catch { renderHeader(); }\n await loadFuel();\n await loadCard();\n me = localStorage.getItem(\"me\");\n if (me && await resumeRemote()) { /* a remote session answers for itself when first used */ }\n else if (me && window.nostr) { try { const pk = await window.nostr.getPublicKey(); if (pk !== me) me = null; } catch { me = null; } }\n else if (me) me = null;\n renderHeader();\n renderApps();\n await loadAdmin();\n await loadPeople();\n})();\n"; +export const CONSOLE_JS = "(async () => {\n const $ = (s) => document.querySelector(s);\n const $$ = (s) => document.querySelectorAll(s);\n const host = location.host;\n const wsURL = (location.protocol === \"https:\" ? \"wss://\" : \"ws://\") + host;\n const rpcURL = location.origin + \"/\";\n let info = null, me = null, owner = \"\", policy = null, fuel = null, people = null, myRole = \"\";\n\n const toast = (msg) => { const t = $(\"#toast\"); t.textContent = msg; t.classList.add(\"show\"); clearTimeout(t._t); t._t = setTimeout(() => t.classList.remove(\"show\"), 2600); };\n // guard wraps a handler: disables the button, shows errors as toasts. Defined first, since handlers below use it.\n const guard = (fn) => async (ev) => { ev.preventDefault(); const b = ev.submitter || ev.target; if (b) b.disabled = true; try { await fn(ev); } catch (e) { toast(e.message); } finally { if (b) b.disabled = false; } };\n const short = (hex) => hex ? hex.slice(0, 8) + \"…\" + hex.slice(-4) : \"\";\n // key shows a key short but keeps the whole of it in the DOM: selecting, double-clicking or clicking it copies the full hex.\n const key = (hex) => hex ? '' + hex + '' : '';\n const hue = (hex) => (parseInt(hex.slice(0, 2), 16) * 360 / 256).toFixed(0) + \"deg\";\n const av = (hex) => '';\n const fmtBytes = (n) => n < 1e6 ? (n / 1e3).toFixed(0) + \" KB\" : n < 1e9 ? (n / 1e6).toFixed(1) + \" MB\" : (n / 1e9).toFixed(2) + \" GB\";\n const fmtHours = (ms) => ms < 3600e3 ? Math.round(ms / 60e3) + \" min\" : (ms / 3600e3).toFixed(ms < 36e6 ? 1 : 0) + \" h\";\n const fuelOver = () => !!fuel && (fuel.eventBytes > fuel.freeEventBytes || fuel.mediaBytes > fuel.freeMediaBytes || fuel.activeMs > fuel.freeActiveMs || fuel.rowsWritten > fuel.freeRowsWritten);\n const fmtTime = (t) => t ? new Date(t * 1000).toLocaleString(undefined, { month: \"short\", day: \"numeric\", hour: \"2-digit\", minute: \"2-digit\" }) : \"–\";\n const fmtDay = (t) => t ? new Date(t * 1000).toLocaleDateString(undefined, { month: \"short\", day: \"numeric\" }) : \"–\";\n const ago = (t) => { const s = Math.max(0, Math.floor(Date.now() / 1000) - t); return s < 60 ? \"just now\" : s < 3600 ? Math.floor(s / 60) + \" min\" : s < 86400 ? Math.floor(s / 3600) + \" h\" : Math.floor(s / 86400) + \" d\"; };\n const esc = (s) => String(s ?? \"\").replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c]);\n\n const IC = {\n x: '',\n undo: '',\n check: '',\n trash: '',\n ban: '',\n banuser: '',\n eye: '',\n lock: '',\n pen: '',\n people: '',\n person: '',\n gauge: '',\n bolt: '',\n copy: '',\n pin: '',\n };\n const ib = (icon, label, act, id, extra) => '\";\n\n const CH = \"qpzry9x8gf2tvdw0s3jn54khce6mua7l\";\n function npubToHex(s) {\n s = s.trim().toLowerCase();\n if (/^[0-9a-f]{64}$/.test(s)) return s;\n if (!s.startsWith(\"npub1\")) return null;\n const data = s.slice(5, -6).split(\"\").map((c) => CH.indexOf(c));\n if (data.some((d) => d < 0)) return null;\n let bits = 0, acc = 0, out = [];\n for (const d of data) { acc = (acc << 5) | d; bits += 5; if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 255); } }\n return out.length === 32 ? out.map((b) => b.toString(16).padStart(2, \"0\")).join(\"\") : null;\n }\n\n async function sha256hex(s) {\n const b = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(s));\n return [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, \"0\")).join(\"\");\n }\n // ---- signing: a NIP-07 extension, or a NIP-46 remote signer ----\n // The remote path loads the bundled library on first use; its session\n // (client key, signer pubkey, relays) lives in localStorage until sign-out.\n let remote = null, lib = null, pendingNote = null;\n const NO_SIGNER = \"Install a nostr extension (Alby, nos2x, …) or connect a remote signer.\";\n const withTimeout = (p, ms, what) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(what + \" did not answer; is the signer app open?\")), ms))]);\n const signer = {\n ready: () => !!remote || !!window.nostr,\n async getPublicKey() { if (remote) return withTimeout(remote.getPublicKey(), 20000, \"The remote signer\"); if (window.nostr) return window.nostr.getPublicKey(); throw new Error(NO_SIGNER); },\n async signEvent(ev) { if (remote) return withTimeout(remote.signEvent(ev), 60000, \"The remote signer\"); if (window.nostr) return window.nostr.signEvent(ev); throw new Error(NO_SIGNER); },\n };\n async function signerLib() {\n if (lib) return lib;\n if (!window.NostrSigner) await new Promise((res, rej) => { const s = document.createElement(\"script\"); s.src = window.SIGNER_URL || \"/signer.js\"; s.onload = res; s.onerror = () => rej(new Error(\"Could not load the signer library.\")); document.head.appendChild(s); });\n return (lib = window.NostrSigner);\n }\n const onauth = (u) => window.open(u, \"_blank\");\n // The signer conversation rides this relay, and a relay may ask the socket to\n // authenticate before it delivers kind 24133 to its parties; answer with the\n // session's own key.\n const authPool = (L, sk) => new L.SimplePool({ automaticallyAuth: () => (evt) => Promise.resolve(L.finalizeEvent(evt, sk)) });\n const saveSession = (sk, s, secret) => localStorage.setItem(\"nip46\", JSON.stringify({ sk: lib.bytesToHex(sk), pubkey: s.bp.pubkey, relays: s.bp.relays, secret: secret || null }));\n async function resumeRemote() {\n const raw = localStorage.getItem(\"nip46\");\n if (!raw) return false;\n try {\n const s = JSON.parse(raw); const L = await signerLib();\n remote = L.BunkerSigner.fromBunker(L.hexToBytes(s.sk), { pubkey: s.pubkey, relays: s.relays, secret: s.secret }, { onauth, pool: authPool(L, L.hexToBytes(s.sk)) });\n return true;\n } catch { localStorage.removeItem(\"nip46\"); return false; }\n }\n async function connectBunker(input) {\n const L = await signerLib();\n const bp = await L.parseBunkerInput(input.trim());\n if (!bp) throw new Error(\"That is not a bunker:// URL.\");\n if (!bp.relays.length) throw new Error(\"The bunker URL names no relay.\");\n const sk = L.generateSecretKey();\n const s = L.BunkerSigner.fromBunker(sk, bp, { onauth, pool: authPool(L, sk) });\n await withTimeout(s.connect({ name: host, url: location.origin }), 60000, \"The signer\");\n await Promise.race([s.switchRelays(), new Promise((r) => setTimeout(r, 3000))]);\n remote = s; saveSession(sk, s, bp.secret);\n }\n // The nostrconnect:// flow: this relay carries the traffic, so no third relay is involved.\n let ncPending = null;\n async function offerNostrConnect() {\n if (ncPending) return ncPending;\n const L = await signerLib();\n const sk = L.generateSecretKey();\n const secret = L.bytesToHex(crypto.getRandomValues(new Uint8Array(8)));\n const uri = L.createNostrConnectURI({ clientPubkey: L.getPublicKey(sk), relays: [wsURL], secret, name: host, url: location.origin, perms: [\"sign_event:27235\", \"sign_event:9734\"] });\n $(\"#nclink\").href = uri; $(\"#ncnote\").textContent = \"\";\n $(\"#ncqr\").src = \"/qr.svg?text=\" + encodeURIComponent(uri); $(\"#ncqr\").classList.remove(\"hidden\");\n ncPending = L.BunkerSigner.fromURI(sk, uri, { onauth, pool: authPool(L, sk) }, 600000).then((s) => { remote = s; saveSession(sk, s, secret); ncPending = null; return s; }, (e) => { ncPending = null; throw e; });\n return ncPending;\n }\n async function remoteDone() {\n me = await signer.getPublicKey();\n localStorage.setItem(\"me\", me);\n $(\"#remotesec\").classList.add(\"hidden\");\n toast(\"Remote signer connected\");\n if (pendingNote && !owner) { const n = pendingNote; pendingNote = null; await claimNow(n); return; }\n pendingNote = null;\n renderHeader(); await loadAdmin(); await loadPeople();\n }\n function showRemote(note) {\n pendingNote = note || null;\n $(\"#remotesec\").classList.remove(\"hidden\");\n $(\"#remotesec\").scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n offerNostrConnect().then(remoteDone).catch((e) => { $(\"#ncnote\").textContent = e.message; });\n }\n\n async function rpc(method, ...params) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const body = JSON.stringify({ method, params });\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", rpcURL], [\"method\", \"POST\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(rpcURL, { method: \"POST\", headers: { \"content-type\": \"application/nostr+json+rpc\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body });\n const json = await resp.json();\n if (json.error) throw new Error(json.error);\n return json.result;\n }\n\n // ---- front of house ----\n // urlish shows a URL as a short link: the host and the start of the path, the whole thing on hover and as the target.\n class Html { constructor(s) { this.s = s; } }\n const urlish = (u) => {\n if (!u) return \"\";\n let label = u; try { const x = new URL(u); label = x.host + (x.pathname.length > 1 ? x.pathname.slice(0, 18) + (x.pathname.length > 18 ? \"\\u2026\" : \"\") : \"\"); } catch { /* not a URL */ }\n if (!/^https?:\\/\\//.test(u)) return u;\n return new Html('' + esc(label) + \"\");\n };\n function renderHeader() {\n const name = (info && info.name) || host.split(\".\")[0];\n $(\"#title\").textContent = name;\n document.title = name + \" - relay\";\n $(\"#url\").textContent = wsURL;\n $(\"#d-ws\").textContent = wsURL; $(\"#d-blossom\").textContent = location.origin; $(\"#d-nip05\").textContent = \"you@\" + host;\n $(\"#nip05-example\").textContent = \"alice@\" + host;\n const desc = (info && info.description) || \"\";\n $(\"#desc\").textContent = desc; $(\"#desc\").classList.toggle(\"hidden\", !desc);\n // What the owner declared about the relay, on the page, not only in the document clients read.\n const img = (id, url) => { const el = $(id); if (url) el.src = url; el.classList.toggle(\"hidden\", !url); };\n img(\"#bannerimg\", info?.banner); $(\"#banner\").classList.toggle(\"hidden\", !info?.banner);\n img(\"#iconimg\", info?.icon);\n const meta = [];\n for (const t of info?.tags || []) meta.push('' + esc(t) + \"\");\n const where = [...(info?.language_tags || []), ...(info?.relay_countries || [])];\n if (where.length) meta.push(\"\" + esc(where.join(\", \")) + \"\");\n const link = (label, href) => href ? '' + label + \"\" : \"\";\n const contact = info?.contact ? (/^(mailto:|https?:\\/\\/)/.test(info.contact) ? link(\"Contact\", info.contact) : \"\" + esc(info.contact) + \"\") : \"\";\n for (const x of [contact, link(\"Terms\", info?.terms_of_service), link(\"Posting policy\", info?.posting_policy), link(\"Privacy\", info?.privacy_policy)]) if (x) meta.push(x);\n $(\"#metarow\").innerHTML = meta.join('|'); $(\"#metarow\").classList.toggle(\"hidden\", meta.length === 0);\n $(\"#frontsec\").classList.toggle(\"hidden\", !desc && meta.length === 0);\n owner = (info && info.pubkey) || \"\";\n const isOwner = !!me && me === owner;\n const lease = !owner && info && info.lease ? info.lease : null;\n $(\"#ownerline\").innerHTML = owner ? 'run by ' + key(owner) + \"\" : lease ? \"temporary until \" + fmtDay(lease.expires_at) : \"unclaimed\";\n $(\"#owner-av\").classList.toggle(\"hidden\", !owner);\n if (owner) $(\"#owner-av\").style.setProperty(\"--h\", hue(owner));\n $(\"#who\").textContent = me ? (isOwner ? \"(that's you)\" : \"\") : \"\";\n $(\"#unclaimed\").classList.toggle(\"hidden\", !!owner || !!lease);\n $(\"#leased\").classList.toggle(\"hidden\", !lease);\n if (lease) {\n $(\"#lease-until\").textContent = fmtTime(lease.expires_at);\n $(\"#leasenote\").textContent = lease.holder ? \"Reserved for the key that asked for it; sign with that key.\" : \"\";\n }\n $(\"#signin\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signin46\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signout\").classList.toggle(\"hidden\", !me);\n $(\"#about\").innerHTML = [\n [\"Name\", info?.name], [\"Description\", info?.description], [\"Contact\", info?.contact], [\"Owner\", owner ? short(owner) : \"\"], [\"Relay key\", info?.self ? short(info.self) : \"\"], [\"Software\", urlish(info?.software)], [\"Version\", info?.version],\n [\"Max query\", info?.limitation?.max_limit], [\"Auth required\", String(!!info?.limitation?.auth_required)], [\"Restricted writes\", String(!!info?.limitation?.restricted_writes)], [\"Min PoW\", info?.limitation?.min_pow_difficulty || 0],\n [\"Tags\", info?.tags?.join(\", \")], [\"Languages\", info?.language_tags?.join(\", \")], [\"Countries\", info?.relay_countries?.join(\", \")],\n [\"Terms\", urlish(info?.terms_of_service)], [\"Posting policy\", urlish(info?.posting_policy)], [\"Privacy policy\", urlish(info?.privacy_policy)], [\"Icon\", urlish(info?.icon)], [\"Banner\", urlish(info?.banner)],\n [\"NIPs\", info?.supported_nips?.join(\" \")],\n ].map(([k, v]) => \"
                                                                                                                                                                                                              \" + k + \"\" + (v === undefined || v === \"\" ? '' : v instanceof Html ? v.s : esc(v)) + \"
                                                                                                                                                                                                              \").join(\"\");\n renderCare();\n }\n\n function renderCare() {\n const g = (icon, label, off) => '' + IC[icon] + \"\" + label + \"\";\n const writes = policy ? policy.writes : (info?.limitation?.restricted_writes ? \"allowlist\" : \"open\");\n const reads = policy ? policy.reads : (info?.limitation?.auth_required ? \"auth\" : \"open\");\n let out = writes === \"owner\" ? g(\"person\", \"only owner writes\") : writes === \"allowlist\" ? g(\"people\", \"members write\") : writes === \"wot\" ? g(\"people\", \"members and follows write\") : g(\"pen\", \"anyone writes\");\n out += reads === \"members\" ? g(\"people\", \"members read\") : reads === \"auth\" ? g(\"lock\", \"sign in to read\") : g(\"eye\", \"anyone reads\");\n if (fuel) {\n const over = fuelOver();\n out += fuel.outOfFuel ? g(\"gauge\", \"out of fuel\", true) : over ? g(\"bolt\", \"burning sats\") : g(\"gauge\", \"on free allowance\");\n }\n $(\"#care\").innerHTML = out;\n }\n\n async function loadPeople() {\n try { people = await (await fetch(\"/people\")).json(); } catch { return; }\n const isOwner = !!me && me === owner;\n let list = people.people || [];\n if (!people.public && isOwner && policy) list = (window.__members || []);\n $(\"#peoplesec\").classList.toggle(\"hidden\", !owner || list.length === 0);\n $(\"#people-note\").classList.toggle(\"hidden\", people.public);\n $(\"#people-count\").textContent = list.length || \"\";\n $(\"#dir\").innerHTML = list.map((m) => '' + av(m.pubkey) + '' + (m.name ? esc(m.name) + \"@\" + host : key(m.pubkey)) + \"\" + (m.role === \"owner\" ? 'owner' : \"\") + \"\").join(\"\");\n }\n\n async function loadInfo() {\n info = await (await fetch(\"/\", { headers: { accept: \"application/nostr+json\" } })).json();\n renderHeader();\n const git = info.supported_grasps?.includes(\"GRASP-01\");\n $(\"#git-connect\").classList.toggle(\"hidden\", !git);\n const clone = \"git clone '\" + location.origin + \"//.git'\";\n $(\"#git-clone\").textContent = clone;\n $(\"#git-copy\").dataset.copytext = clone;\n renderApps();\n }\n\n async function loadFuel() {\n try { fuel = await (await fetch(\"/fuel\")).json(); } catch { return; }\n $(\"#fuelsec\").classList.toggle(\"hidden\", !owner);\n const pct = (used, free) => Math.min(100, Math.round((used / Math.max(free, 1)) * 100));\n const gauge = (id, used, free, text) => { const g = $(\"#g-\" + id), share = free ? used / free : 0; g.style.width = pct(used, free) + \"%\"; g.classList.toggle(\"over\", share > 1); g.classList.toggle(\"warm\", share > 0.75 && share <= 1); $(\"#p-\" + id).textContent = share > 1 ? \"over\" : Math.round(share * 100) + \"%\"; $(\"#t-\" + id).textContent = text; };\n gauge(\"events\", fuel.eventBytes, fuel.freeEventBytes, fmtBytes(fuel.eventBytes) + \" of \" + fmtBytes(fuel.freeEventBytes) + \" free\");\n gauge(\"media\", fuel.mediaBytes, fuel.freeMediaBytes, fmtBytes(fuel.mediaBytes) + \" of \" + fmtBytes(fuel.freeMediaBytes) + \" free\");\n gauge(\"active\", fuel.activeMs, fuel.freeActiveMs, fmtHours(fuel.activeMs) + \" of \" + fmtHours(fuel.freeActiveMs) + \" free\");\n gauge(\"rows\", fuel.rowsWritten, fuel.freeRowsWritten, fuel.rowsWritten.toLocaleString() + \" of \" + fuel.freeRowsWritten.toLocaleString() + \" free\");\n const sats = Math.floor(fuel.balanceMsats / 1000);\n const r = fuel.rates;\n $(\"#fuel-balance\").innerHTML = fuel.outOfFuel\n ? 'out of fuel Writes are paused until someone tops up.'\n : \"Balance \" + sats.toLocaleString() + \" sats. Past the allowances, prices track what the hosting costs: \" + r.satsPerGBMonthEvents.toLocaleString() + \" sats per GB-month of events, \" + r.satsPerGBMonthMedia.toLocaleString() + \" per GB-month of files, \" + r.satsPerActiveHour.toLocaleString() + \" per hour awake, \" + r.satsPerMillionRows.toLocaleString() + \" per million rows written. Traffic is free.\";\n $(\"#topup\").classList.toggle(\"hidden\", !fuel.enabled);\n // Who paid is the owner's to see: it comes with the signed stats call.\n let credits = [];\n if (owner && signer.ready()) { try { credits = (await rpc(\"stats\")).credits || []; } catch { credits = []; } }\n $(\"#credits tbody\").innerHTML = credits.length ? credits.map((c) => '' + esc(fmtTime(c.at)) + '' + av(c.payer) + key(c.payer) + '' + Math.floor(c.msats / 1000).toLocaleString() + \"\").join(\"\") : 'no zaps yet';\n if (!fuel.enabled) $(\"#topup-note\").textContent = \"Top-ups are not enabled on this service yet.\";\n renderCare();\n renderFuelTile();\n }\n\n async function topUp(sats) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const msats = Math.round(sats * 1000);\n const zapRequest = await signer.signEvent({ kind: 9734, created_at: Math.floor(Date.now() / 1000), content: \"fuel for \" + host, tags: [[\"p\", fuel.servicePubkey], [\"amount\", String(msats)], [\"relays\", wsURL]] });\n const r = await (await fetch(\"/fuel/invoice\", { method: \"POST\", body: JSON.stringify({ zapRequest }) })).json();\n if (r.error) throw new Error(r.error);\n const inv = r.invoice;\n $(\"#invoice\").classList.remove(\"hidden\");\n $(\"#inv-text\").value = inv; $(\"#inv-link\").href = \"lightning:\" + inv; $(\"#inv-state\").textContent = \"waiting for payment…\";\n if (window.webln) { try { await window.webln.enable(); await window.webln.sendPayment(inv); } catch (e) { toast(e.message || \"wallet declined\"); } }\n const before = fuel.creditedMsats;\n for (let i = 0; i < 120; i++) {\n await new Promise((r) => setTimeout(r, 2500));\n await loadFuel();\n if (fuel.creditedMsats > before) { $(\"#inv-state\").textContent = \"paid, thank you\"; toast(\"Fuel credited\"); return; }\n }\n $(\"#inv-state\").textContent = \"no receipt yet; it can take a minute after paying\";\n }\n\n // ---- console ----\n function showTab(name) {\n if (name === \"content\" || name === \"storage\") name = \"data\";\n const known = myRole === \"moderator\" ? [\"people\", \"moderation\"] : [\"people\", \"moderation\", \"rules\", \"identity\", \"data\", \"sync\", \"views\", \"health\", \"owner\"];\n if (!known.includes(name)) name = \"people\";\n $$(\".tabs a\").forEach((a) => a.classList.toggle(\"on\", a.dataset.tab === name));\n $$(\".panel\").forEach((p) => p.classList.toggle(\"on\", p.dataset.panel === name));\n }\n // Tabs switch in place. A fragment still opens a tab when someone arrives with one,\n // but clicking never writes one, and any fragment already there is dropped.\n $$(\".tabs a\").forEach((a) => a.addEventListener(\"click\", (ev) => { ev.preventDefault(); showTab(a.dataset.tab); if (location.hash) history.replaceState(null, \"\", location.pathname + location.search); }));\n window.addEventListener(\"hashchange\", () => showTab(location.hash.slice(1)));\n // The strip fades on whichever side has more tabs off screen.\n const tabsFade = () => { const t = $(\"#tabs\"); t.classList.toggle(\"fade-l\", t.scrollLeft > 4); t.classList.toggle(\"fade-r\", t.scrollLeft + t.clientWidth < t.scrollWidth - 4); };\n $(\"#tabs\").addEventListener(\"scroll\", tabsFade); window.addEventListener(\"resize\", tabsFade); new ResizeObserver(tabsFade).observe($(\"#tabs\"));\n\n function renderFuelTile() {\n if (!fuel) return;\n const over = fuelOver();\n const sats = Math.max(0, Math.floor(fuel.balanceMsats / 1000));\n const d = new Date(), end = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1), days = Math.max(1, Math.ceil((end - Date.now()) / 86400000));\n if (fuel.outOfFuel) { $(\"#h-fuel\").textContent = \"0 sats\"; $(\"#h-fuel-label\").textContent = \"out of fuel\"; $(\"#h-fuel-sub\").textContent = \"writes are paused\"; }\n else if (over) { $(\"#h-fuel\").textContent = sats.toLocaleString() + \" sats\"; $(\"#h-fuel-label\").textContent = \"fuel left\"; $(\"#h-fuel-sub\").textContent = \"past the free allowance\"; }\n else { $(\"#h-fuel\").textContent = days + (days === 1 ? \" day\" : \" days\"); $(\"#h-fuel-label\").textContent = \"free allowance left\"; $(\"#h-fuel-sub\").textContent = sats ? \"then \" + sats.toLocaleString() + \" sats\" : \"then a top-up is needed\"; }\n $(\"#usage\").innerHTML = [[\"events stored\", fmtBytes(fuel.eventBytes)], [\"files stored\", fmtBytes(fuel.mediaBytes)], [\"awake this month\", fmtHours(fuel.activeMs)], [\"rows written\", fuel.rowsWritten.toLocaleString()], [\"rows read\", fuel.rowsRead.toLocaleString()], [\"received this month\", fmtBytes(fuel.bytesIn)], [\"served this month\", fmtBytes(fuel.bytesOut)], [\"charged\", Math.floor(fuel.chargedMsats / 1000).toLocaleString() + \" sats\"]]\n .map(([k, v]) => \"
                                                                                                                                                                                                              \" + k + \"\" + v + \"
                                                                                                                                                                                                              \").join(\"\");\n }\n\n function renderHealth(stats) {\n $(\"#h-last\").textContent = stats.newest ? ago(stats.newest) : \"none\";\n $(\"#h-last-sub\").textContent = stats.newest ? fmtTime(stats.newest) : \"no events yet\";\n $(\"#h-conns\").textContent = stats.connections;\n const buckets = [[\"notes\", \"k1\", [1]], [\"reactions\", \"k2\", [7]], [\"DMs\", \"k3\", [4, 14, 1059]], [\"long-form\", \"k4\", [30023]], [\"other\", \"k5\", null]];\n const sums = buckets.map(() => 0); let total = 0;\n for (const { kind, n } of stats.kinds || []) { total += n; const i = buckets.findIndex((b) => b[2] && b[2].includes(kind)); sums[i < 0 ? 4 : i] += n; }\n const parts = buckets.map((b, i) => [b[0], b[1], sums[i]]).filter((p) => p[2] > 0);\n $(\"#h-kinds\").innerHTML = total ? parts.map(([name, cls, n]) => '').join(\"\") : '';\n $(\"#h-kinds-legend\").innerHTML = total ? parts.map(([name, cls, n]) => '' + name + \" \" + Math.round(n * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n renderFuelTile();\n }\n\n async function loadAdmin() {\n const isOwner = !!me && me === owner;\n myRole = isOwner ? \"owner\" : \"\";\n let stats = null, p = null;\n // A signed-in member may be a moderator; the relay says by answering stats.\n if (me && owner && !isOwner) { try { [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]); myRole = \"moderator\"; } catch { myRole = \"\"; } }\n $(\"#console\").classList.toggle(\"hidden\", !myRole);\n loadMine();\n $(\"#console\").classList.toggle(\"mod\", myRole === \"moderator\");\n if (!myRole) { policy = null; renderCare(); return; }\n if (isOwner) [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]);\n policy = p;\n renderHealth(stats);\n renderCare();\n const fa = $(\"#access\"), fi = $(\"#identity\");\n fa.writes.value = p.writes; fa.reads.value = p.reads; fa.openKinds.value = (p.openKinds || []).join(\", \"); fa.guestReplies.checked = !!p.guestReplies;\n $(\"#wordsform\").words.value = (p.blockedWords || []).join(\"\\n\"); $(\"#wordsform\").inTags.checked = !!p.blockedWordsInTags; $(\"#thresholdform\").reportThreshold.value = p.reportThreshold || 0; fa.minPow.value = p.minPow; fa.maxFuture.value = p.maxFuture; fa.maxLimit.value = p.maxLimit; fa.maxSubs.value = p.maxSubs; fa.maxMessageKB.value = p.maxMessageKB;\n fa.eventsPerMinute.value = p.eventsPerMinute; fa.reqsPerMinute.value = p.reqsPerMinute; fa.maxBlobMB.value = p.maxBlobMB;\n renderFeatures(p.features || {});\n $(\"#push-policy-form\").elements.origins.value = (p.pushCallbacks || []).join(\"\\n\");\n $(\"#push-policy-form\").elements.lettered.checked = !!p.letteredNips;\n $(\"#push-policy-form\").elements.delivery.checked = !!p.delivery?.enabled;\n $(\"#push-policy-form\").elements.deliveryMax.value = p.delivery?.maxTargets || 8;\n fi.name.value = p.name; fi.contact.value = p.contact; fi.description.value = p.description; fi.icon.value = p.icon;\n const fj = $(\"#joinform\"); fj.joinTerms.value = p.joinTerms; fj.directoryPublic.checked = !!p.directoryPublic;\n loadCard();\n fi.banner.value = p.banner || \"\"; fi.postingPolicy.value = p.postingPolicy || \"\"; fi.privacyPolicy.value = p.privacyPolicy || \"\";\n fi.tags.value = (p.tags || []).join(\", \"); fi.languageTags.value = (p.languageTags || []).join(\", \"); fi.relayCountries.value = (p.relayCountries || []).join(\", \");\n const fn = $(\"#notify\"), nt = p.notify || {};\n fn.reports.checked = !!nt.reports; fn.fuel.checked = !!nt.fuel; fn.jobs.checked = !!nt.jobs; fn.succession.checked = !!nt.succession; fn.digest.checked = !!nt.digest;\n showTab(location.hash.slice(1));\n if (isOwner) { await renderPresets(); renderWire(); renderSuccession(); loadDomains(); }\n await Promise.all([loadLists(), loadEvents(true), loadPins(), ...(isOwner ? [loadStorage()] : [])]);\n }\n\n async function loadAudit(before) {\n const rows = await rpc(\"listaudit\", before || 0);\n const html = rows.map((r) => '' + esc(fmtTime(r.at)) + '' + av(r.actor) + key(r.actor) + '' + esc(r.action) + '' + (/^[0-9a-f]{64}$/.test(r.target) ? key(r.target) : esc(r.target)) + '' + esc(r.detail) + '').join(\"\");\n const tb = $(\"#audit tbody\");\n if (before) tb.insertAdjacentHTML(\"beforeend\", html); else tb.innerHTML = html || 'Nothing yet';\n $(\"#audit-more\").style.display = rows.length < 100 ? \"none\" : \"\";\n }\n $(\"#audit-more\").onclick = guard(async () => { const last = $(\"#audit tbody tr:last-child\"); await loadAudit(last ? +last.dataset.seq : 0); });\n async function loadLists() {\n loadAudit(0);\n const [mem, bans, bannedEvents, allow, block, invites, reports, blobs, blocks, sites] = await Promise.all([rpc(\"listmembers\"), rpc(\"listbannedpubkeys\"), rpc(\"listbannedevents\"), rpc(\"listallowedkinds\"), rpc(\"listblockedkinds\"), rpc(\"listinvites\"), rpc(\"listreports\"), rpc(\"listblobs\", 100), rpc(\"listblockedips\"), rpc(\"listsites\")]);\n const members = mem.members;\n window.__members = members;\n const roleCell = (m) => m.role === \"owner\" ? ' owner' : myRole === \"owner\" ? ' ' : m.role === \"moderator\" ? ' moderator' : \"\";\n const untouchable = (m) => m.role === \"owner\" || (myRole !== \"owner\" && m.role === \"moderator\");\n // The tree: everyone under whoever invited them, the owner and the owner's own additions at the root.\n const known = new Set(members.map((m) => m.pubkey));\n const byInviter = new Map();\n for (const m of members) { const k = m.role !== \"owner\" && known.has(m.invited_by) ? m.invited_by : \"\"; if (!byInviter.has(k)) byInviter.set(k, []); byInviter.get(k).push(m); }\n const ordered = [], placed = new Set();\n const walk = (k, depth) => { for (const m of byInviter.get(k) || []) { if (placed.has(m.pubkey)) continue; placed.add(m.pubkey); m.depth = depth; ordered.push(m); walk(m.pubkey, depth + 1); } };\n walk(\"\", 0);\n for (const m of members) if (!placed.has(m.pubkey)) { m.depth = 0; ordered.push(m); }\n const nameOf = (pk) => { const x = members.find((m) => m.pubkey === pk); return x && x.name ? x.name : short(pk); };\n const limits = (m) => m.role === \"owner\" ? \"\" : myRole === \"owner\"\n ? ''\n : '' + (m.keep_days ? m.keep_days + \" d\" : \"\") + (m.max_bytes ? \" \" + fmtBytes(m.max_bytes) : \"\") + \"\";\n $(\"#members tbody\").innerHTML = ordered.map((m) => '' + av(m.pubkey) + key(m.pubkey) + roleCell(m) + (m.depth ? ' via ' + esc(nameOf(m.invited_by)) + \"\" : \"\") + (m.invites ? ' ' + m.invites + \" inv\" : \"\") + '' + limits(m) + '' + esc(fmtDay(m.joined_at)) + \", \" + esc(m.via) + '' + ib(\"check\", \"Save\", \"savemember\", m.pubkey) + (untouchable(m) ? \"\" : ib(\"x\", \"Remove\", \"removemember\", m.pubkey) + ib(\"banuser\", \"Ban\", \"banpubkey\", m.pubkey, \"danger\")) + \"\").join(\"\");\n const tf = $(\"#treeform\");\n tf.classList.toggle(\"hidden\", myRole !== \"owner\");\n if (policy && policy.memberInvites) { tf.depth.value = policy.memberInvites.depth; tf.quota.value = policy.memberInvites.quota; }\n $(\"#transfer [name=pubkey]\").innerHTML = members.filter((m) => m.role !== \"owner\").map((m) => '\").join(\"\");\n $(\"#succession [name=heir]\").innerHTML = $(\"#transfer [name=pubkey]\").innerHTML;\n $(\"#tc-people\").textContent = members.length;\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#invites\").innerHTML = invites.length ? invites.map((i) => '
                                                                                                                                                                                                            • inv…' + i.code.slice(-8) + \" \" + esc(i.note || \"\") + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
                                                                                                                                                                                                            • \").join(\"\") : '
                                                                                                                                                                                                            • no invites
                                                                                                                                                                                                            • ';\n const person = (r, icon, label, act) => \"
                                                                                                                                                                                                            • \" + av(r.pubkey) + '' + short(r.pubkey) + \" \" + esc(r.reason || \"\") + \"\" + ib(icon, label, act, r.pubkey) + \"
                                                                                                                                                                                                            • \";\n $(\"#bans\").innerHTML = bans.length ? bans.map((r) => person(r, \"undo\", \"Unban\", \"unrulepubkey\")).join(\"\") : '
                                                                                                                                                                                                            • nobody banned
                                                                                                                                                                                                            • ';\n $(\"#blocks\").innerHTML = blocks.length ? blocks.map((r) => '
                                                                                                                                                                                                            • ip' + esc(r.ip) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"undo\", \"Unblock\", \"unblockip\", r.ip) + \"
                                                                                                                                                                                                            • \").join(\"\") : '
                                                                                                                                                                                                            • no addresses blocked
                                                                                                                                                                                                            • ';\n $(\"#banned-events\").innerHTML = bannedEvents.map((r) => '
                                                                                                                                                                                                            • ev' + key(r.id) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"check\", \"Allow again\", \"allowevent\", r.id) + \"
                                                                                                                                                                                                            • \").join(\"\");\n $(\"#reports tbody\").innerHTML = reports.length ? reports.map((r) => '' + esc(fmtTime(r.at)) + '' + esc(r.type || \"report\") + '' + av(r.target_pubkey) + key(r.target_pubkey) + (r.target_event ? (r.blob ? ' file ' + key(r.target_event) + \"\" : ' ev ' + key(r.target_event) + (r.hidden ? ' hidden' : \"\") + \"\") : \"\") + '' + esc(r.content) + '' + ib(\"check\", \"Dismiss\", \"resolve:dismiss\", r.id) + ib(\"trash\", r.blob ? \"Delete the file\" : \"Delete the event\", \"resolve:delete\", r.id) + ib(\"banuser\", \"Ban the author\", \"resolve:ban\", r.id, \"danger\") + \"\").join(\"\") : 'nothing reported';\n $(\"#reports-count\").textContent = reports.length || \"\"; $(\"#tc-reports\").textContent = reports.length || \"\";\n $(\"#blobs tbody\").innerHTML = blobs.length ? blobs.map((b) => '' + esc(fmtTime(b.uploaded)) + '' + key(b.sha256) + ' ' + esc(b.type) + '' + fmtBytes(b.size) + '' + av(b.uploader) + key(b.uploader) + '' + ib(\"trash\", \"Delete file\", \"deleteblob\", b.sha256, \"danger\") + \"\").join(\"\") : 'no uploads';\n $(\"#blobs-count\").textContent = blobs.length || \"\";\n $(\"#sites tbody\").innerHTML = sites.length ? sites.map((s) => '' + av(s.author) + key(s.author) + '' + esc(s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + '' + esc(s.url) + '' + esc(s.paths) + (s.missing ? ' ' + esc(s.missing) + ' missing' : '') + '' + fmtBytes(s.size) + '' + esc(s.expires_at ? fmtTime(s.expires_at) : \"never\") + '' + ib(\"trash\", \"Delete site\", \"deleteevent\", s.id, \"danger\") + '').join(\"\") : 'no sites yet';\n $(\"#sites-count\").textContent = sites.length || \"\";\n const tag = (k, cls) => '' + k + ib(\"x\", \"Remove rule\", \"unrulekind\", String(k)) + \"\";\n $(\"#kinds-allow\").innerHTML = allow.length ? allow.map((k) => tag(k, \"ok\")).join(\"\") : 'all';\n $(\"#kinds-block\").innerHTML = block.length ? block.map((k) => tag(k, \"blk\")).join(\"\") : 'none';\n await loadPeople();\n }\n\n const KIND_NAMES = { 0: \"profiles\", 1: \"notes\", 3: \"contacts\", 4: \"DMs\", 5: \"deletions\", 6: \"reposts\", 7: \"reactions\", 16: \"reposts\", 1059: \"gift wraps\", 1063: \"file headers\", 1111: \"comments\", 1984: \"reports\", 9734: \"zap requests\", 9735: \"zap receipts\", 9802: \"highlights\", 10002: \"relay lists\", 13534: \"roster\", 9000: \"group adds\", 9001: \"group removals\", 9021: \"join requests\", 9022: \"leave requests\", 39000: \"group info\", 39001: \"group admins\", 39002: \"group members\", 39003: \"group roles\", 30023: \"articles\", 30024: \"drafts\", 30078: \"app data\", 30311: \"live events\", 30818: \"wiki\" };\n const kindName = (k) => KIND_NAMES[k] || (k >= 20000 && k < 30000 ? \"ephemeral\" : k >= 30000 && k < 40000 ? \"addressable\" : k >= 10000 && k < 20000 ? \"replaceable\" : \"kind \" + k);\n const SYS_KINDS = new Set([0, 3, 10002, 9735, 13534, 8000, 8001, 9000, 9001, 39000, 39001, 39002, 39003]);\n let storage = null;\n\n // Features (settings.ts): a select each; search has three modes, the rest on or off.\n const FEATURES = [\n [\"search\", \"Search\", \"NIP-50. Prose indexes notes, threads, comments, highlights, articles and wiki pages; full indexes every public kind with content. A change applies to events from then on.\", [\"prose:prose\", \"full:full\", \"off:off\"]],\n [\"sync\", \"Sync\", \"NIP-77 reconciliation, which reads the whole matching set per sync.\"],\n [\"count\", \"Counts\", \"NIP-45 COUNT, with HLL sketches.\"],\n [\"discovery\", \"Discovery record\", \"NIP-66: the record the relay signs about itself, for crawlers.\"],\n [\"names\", \"Names\", \"NIP-05 addresses under this relay's domain.\"],\n [\"files\", \"Files\", \"Blossom and NIP-96: uploads, downloads and listings.\"],\n [\"pages\", \"Pages and feed\", \"Notes and articles as pages, and the Atom feed.\"],\n [\"sites\", \"Static websites\", \"NIP-5A sites on their own hostnames. Mirroring copies missing files into this relay and costs fuel.\", [\"mirror:on, mirror files\", \"proxy:on, fetch as needed\", \"off:off\"]],\n [\"marmot\", \"Marmot transport\", \"Signed KeyPackages and encrypted group messages, with account admission for ephemeral authors.\"],\n [\"grasp\", \"Git repositories\", \"GRASP Git hosting with admitted repository state. The prototype backend has bounded storage and compute limits.\"],\n [\"push\", \"Relay push\", \"NIP-9a callback delivery for members and the owner. Requires approved callback origins; advertises lettered NIP identifiers.\"],\n [\"signer\", \"Signer traffic\", \"NIP-46 remote signing carried for anyone, never stored.\"],\n ];\n function renderFeatures(f) {\n $(\"#features\").innerHTML = FEATURES.map(([k, title, about, modes]) => {\n const cur = k === \"sites\" ? (f.sites?.enabled === false ? \"off\" : f.sites?.mirror === false ? \"proxy\" : \"mirror\") : modes ? String(f[k] || \"prose\") : String(f[k] !== false);\n const opts = (modes || [\"true:on\", \"false:off\"]).map((m) => { const [v, l] = m.split(\":\"); return '\"; }).join(\"\");\n return \"\";\n }).join(\"\");\n }\n $(\"#features\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-feature]\"); if (!sel) return;\n const k = sel.dataset.feature, v = k === \"search\" || k === \"sites\" ? sel.value : sel.value === \"true\";\n policy = await rpc(\"setpolicy\", { features: { [k]: k === \"sites\" ? { enabled: v !== \"off\", mirror: v === \"mirror\" } : v } });\n toast(k === \"search\" ? \"Search: \" + v : (v ? \"Switched on \" : \"Switched off \") + k); await loadInfo();\n }));\n\n $(\"#push-policy-form\").addEventListener(\"submit\", guard(async (ev) => {\n ev.preventDefault();\n const form = ev.target;\n const origins = form.elements.origins.value.split(/\\s+/).filter(Boolean);\n const updated = await rpc(\"setpolicy\", { pushCallbacks: origins, letteredNips: form.elements.lettered.checked, delivery: { enabled: form.elements.delivery.checked, maxTargets: Math.max(1, Math.min(16, Math.floor(+form.elements.deliveryMax.value || 8))) } });\n if (JSON.stringify(updated.pushCallbacks) !== JSON.stringify([...new Set(origins.map((s) => s.replace(/\\/$/, \"\")))])) throw new Error(\"Use up to sixteen exact HTTPS origins, with no path or credentials.\");\n policy = updated;\n toast(\"Saved delivery policy\"); await loadInfo();\n }));\n\n async function loadViews() {\n let views;\n try { views = await rpc(\"listviews\"); } catch { return; }\n $(\"#views\").innerHTML = views.map((v) => {\n const runs = v.trigger === \"off\" ? \"off\" : v.trigger === \"live\" ? \"live, from memory\" : v.trigger === \"write\" ? \"on write and daily\" : v.trigger;\n const label = (c) => (c === \"off\" ? \"off\" : c === \"write\" ? \"on write\" : c === \"live\" ? \"on\" : c);\n const choices = [...v.choices, v.default].filter((c, i, a) => a.indexOf(c) === i);\n const pick = '\";\n const who = v.audience === \"members\" ? \"members\" + (v.stored ? \"\" : \", on request\") : \"anyone\";\n const last = v.trigger === \"live\" ? \"\" : v.last ? \"last run \" + fmtTime(v.last.at) : \"not run yet\";\n const rows = v.trigger === \"live\" ? \"no rows\" : v.last ? v.last.rows.toLocaleString() + \" rows\" : \"\";\n const meta = [runs, who, last, rows].filter(Boolean).map((x) => \"\" + esc(x) + \"\").join('|');\n return '
                                                                                                                                                                                                              ' + esc(v.name) + '
                                                                                                                                                                                                              ' + esc(v.about) + '
                                                                                                                                                                                                              ' + pick + (v.on ? 'Open' : \"\") + '
                                                                                                                                                                                                              ' + meta + \"
                                                                                                                                                                                                              \";\n }).join(\"\");\n }\n $(\"#views\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-view]\"); if (!sel) return;\n const value = sel.value === \"live\" ? true : sel.value;\n await rpc(\"setpolicy\", { views: { [sel.dataset.view]: value } });\n toast(sel.value === \"off\" ? \"Took down \" + sel.dataset.view : sel.dataset.view + \": \" + (sel.value === \"live\" ? \"on\" : sel.value === \"write\" ? \"on write\" : sel.value)); await loadViews(); await loadInfo();\n }));\n async function loadDumps() {\n const list = await rpc(\"listdumps\");\n const f = $(\"#dumpform\");\n if (policy) { f.dumps.value = policy.dumps || \"off\"; f.keep.value = policy.dumpsKeep || 7; }\n $(\"#dumps-count\").textContent = list.length || \"\";\n $(\"#dumps\").innerHTML = list.length ? list.map((d) => '
                                                                                                                                                                                                            • jsonl' + esc(d.name) + ' ' + d.events.toLocaleString() + \" events, \" + fmtBytes(d.bytes) + \"\" + ib(\"copy\", \"Download\", \"downloaddump\", d.name) + ib(\"trash\", \"Delete\", \"deletedump\", d.name, \"danger\") + \"
                                                                                                                                                                                                            • \").join(\"\") : '
                                                                                                                                                                                                            • no dumps yet
                                                                                                                                                                                                            • ';\n }\n async function loadBackups() {\n const list = await rpc(\"listbackups\");\n $(\"#backups-count\").textContent = list.length || \"\";\n $(\"#backups\").innerHTML = list.length ? list.map((b) => '
                                                                                                                                                                                                            • backup' + esc(b.id) + ' ' + fmtBytes(b.bytes) + '' + ib(\"copy\", \"Download\", \"downloadbackup\", b.id) + ib(\"trash\", \"Delete\", \"deletebackup\", b.id, \"danger\") + '
                                                                                                                                                                                                            • ').join(\"\") : '
                                                                                                                                                                                                            • no backups yet
                                                                                                                                                                                                            • ';\n }\n // A dump is fetched with a signed request and handed to the browser as a file.\n async function downloadDump(name) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const url = location.origin + \"/dumps/\" + name;\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"GET\"]] });\n const resp = await fetch(url, { headers: { authorization: \"Nostr \" + btoa(JSON.stringify(ev)) } });\n if (!resp.ok) throw new Error((await resp.json()).error || \"download failed\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(\".\")[0] + \"-\" + name; a.click(); URL.revokeObjectURL(a.href);\n }\n async function downloadBackup(id) {\n const resp = await backupRequest(\"/backups/\" + encodeURIComponent(id), \"GET\");\n if (!resp.ok) throw new Error(\"backup download failed\");\n const a = document.createElement(\"a\"); a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(\".\")[0] + \"-\" + id + \".json\"; a.click(); URL.revokeObjectURL(a.href);\n }\n // A plain member's own invites, when the owner lets members invite.\n async function loadMine() {\n const sec = $(\"#myinvites\");\n if (!me || !owner || myRole || !signer.ready()) { sec.classList.add(\"hidden\"); return; }\n let mine;\n try { mine = await rpc(\"listinvites\"); } catch { sec.classList.add(\"hidden\"); return; }\n sec.classList.remove(\"hidden\");\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#mine\").innerHTML = mine.length ? mine.map((i) => '
                                                                                                                                                                                                            • inv…' + i.code.slice(-8) + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
                                                                                                                                                                                                            • \").join(\"\") : '
                                                                                                                                                                                                            • no invites yet
                                                                                                                                                                                                            • ';\n }\n async function loadStorage() {\n storage = await rpc(\"storagestats\");\n pollJobs();\n loadViews();\n loadDumps().catch(() => {});\n loadBackups().catch(() => {});\n const st = storage;\n const top = st.kinds.slice(0, 4), rest = st.kinds.slice(4).reduce((a, k) => a + k.bytes, 0);\n const parts = [...top.map((k, i) => [k.kind + \" \" + kindName(k.kind), \"k\" + (i + 1), k.bytes]), ...(rest ? [[\"other\", \"k5\", rest]] : [])];\n const total = st.eventBytes || 1;\n $(\"#s-bar\").innerHTML = parts.length ? parts.map(([n, c, b]) => '').join(\"\") : '';\n $(\"#s-legend\").innerHTML = parts.length ? parts.map(([n, c, b]) => '' + esc(n) + \" \" + Math.round(b * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n const overhead = Math.max(0, st.databaseBytes - st.eventBytes);\n $(\"#s-totals\").innerHTML = [[\"events\", st.events.toLocaleString() + \" \" + fmtBytes(st.eventBytes) + \"\"], [\"index and overhead\", fmtBytes(overhead)], [\"files\", st.blobs.toLocaleString() + \" \" + fmtBytes(st.mediaBytes) + \"\"]]\n .map(([k, v]) => \"
                                                                                                                                                                                                              \" + k + \"\" + v + \"
                                                                                                                                                                                                              \").join(\"\");\n const any = st.retention.find((r) => r.kind === null);\n const own = (kind) => { const r = st.retention.find((x) => x.kind === kind); return r ? r.days : \"\"; };\n const row = (k) => {\n const key = k.kind === null ? \"\" : String(k.kind);\n const placeholder = k.kind === null ? \"forever\" : k.replaceable || !any ? \"forever\" : any.days + \" (everything else)\";\n const pill = k.kind === null ? \"everything else\" : '' + k.kind + \" \" + esc(kindName(k.kind));\n const cells = k.protected\n ? 'required'\n : ' days' + ib(\"check\", \"Save keep-for rule\", \"saveretention\", key) + ib(\"trash\", \"Purge\", \"purgekind\", key, \"danger\") + \"\";\n return '' + pill + '' + (k.n === undefined ? \"\" : k.n.toLocaleString()) + '' + (k.bytes === undefined ? \"\" : fmtBytes(k.bytes)) + '' + (k.oldest ? esc(fmtDay(k.oldest)) : \"\") + \"\" + cells + \"\";\n };\n $(\"#kinds tbody\").innerHTML = [...st.kinds, { kind: null, days: any ? any.days : 0 }].map(row).join(\"\");\n loadListHistory().catch(() => {});\n }\n\n async function loadListHistory() {\n const rows = await rpc(\"listlisthistory\");\n const labels = { 3: \"follows\", 10002: \"relay list\", 10003: \"bookmarks\", 30003: \"bookmark list\" };\n $(\"#listhistory tbody\").innerHTML = rows.length ? rows.map((r) => '' + esc((labels[r.kind] || (\"kind \" + r.kind)) + (r.d ? \" / \" + r.d : \"\")) + '' + esc(fmtTime(r.created_at)) + '' + esc(fmtTime(r.saved_at)) + '' + ib(\"undo\", \"Restore this version\", \"restorelist\", r.event_id) + '').join(\"\") : 'no older list versions yet';\n }\n\n let searchQuery = \"\";\n async function loadEvents(reset) {\n const list = searchQuery ? await rpc(\"searchevents\", searchQuery, 200) : await rpc(\"listrecentevents\", reset ? 50 : 200);\n $(\"#events tbody\").innerHTML = list.length ? list.map((e) => '' + esc(fmtTime(e.created_at)) + '' + e.kind + '' + av(e.pubkey) + key(e.pubkey) + '' + esc(e.content) + '' + ib(\"pin\", \"Pin\", \"pinevent\", e.id) + ib(\"trash\", \"Delete event\", \"deleteevent\", e.id) + ib(\"ban\", \"Ban event\", \"banevent\", e.id, \"danger\") + ib(\"banuser\", \"Ban author\", \"banpubkey\", e.pubkey, \"danger\") + \"\").join(\"\") : 'no events yet';\n $(\"#more\").classList.toggle(\"hidden\", searchQuery || list.length < 50);\n }\n $(\"#searchform\").onsubmit = guard(async (ev) => {\n searchQuery = ev.target.q.value.trim();\n $(\"#searchclear\").classList.toggle(\"hidden\", !searchQuery);\n await loadEvents(true);\n });\n $(\"#searchclear\").onclick = guard(async () => {\n searchQuery = \"\"; $(\"#searchform\").reset(); $(\"#searchclear\").classList.add(\"hidden\");\n await loadEvents(true);\n });\n async function loadPins() {\n const pins = await rpc(\"listpins\");\n $(\"#pins-count\").textContent = pins.length || \"\";\n $(\"#pins\").innerHTML = pins.length ? pins.map((t) => '
                                                                                                                                                                                                            • ' + (t[0] === \"e\" ? \"event\" : \"address\") + '' + esc(t[1].length > 40 ? t[1].slice(0, 16) + \"\\u2026\" + t[1].slice(-8) : t[1]) + \"\" + ib(\"copy\", \"Copy\", \"copy\", t[1]) + ib(\"x\", \"Unpin\", \"unpinevent\", t[1]) + \"
                                                                                                                                                                                                            • \").join(\"\") : '
                                                                                                                                                                                                            • nothing pinned
                                                                                                                                                                                                            • ';\n }\n $(\"#pinform\").onsubmit = guard(async (ev) => {\n await rpc(\"pinevent\", ev.target.id.value.trim()); ev.target.reset(); toast(\"Pinned\"); await loadPins();\n });\n\n // ---- actions ----\n const refresh = () => Promise.all([loadLists(), loadEvents(true), loadStorage(), loadPins()]);\n\n $(\"#copy\").onclick = async () => { await navigator.clipboard.writeText(wsURL); toast(\"copied \" + wsURL); };\n $(\"#signin\").onclick = guard(async () => {\n if (!window.nostr) throw new Error(\"No nostr extension found. Install one (Alby, nos2x, …) and reload, or use a remote signer.\");\n me = await window.nostr.getPublicKey();\n localStorage.setItem(\"me\", me);\n renderHeader(); await loadAdmin(); await loadPeople();\n });\n $(\"#signin46\").onclick = () => showRemote(null);\n $$(\".remote\").forEach((b) => { b.onclick = () => showRemote($(\"#\" + b.dataset.note)); });\n $(\"#nccopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#nclink\").href); toast(\"Link copied; paste it into your signer app\"); };\n $(\"#bunkerform\").onsubmit = guard(async (ev) => {\n $(\"#remotenote\").textContent = \"Connecting…\";\n try { await connectBunker(ev.target.url.value); } catch (e) { $(\"#remotenote\").textContent = e.message; throw e; }\n $(\"#remotenote\").textContent = \"\"; ev.target.reset();\n await remoteDone();\n });\n $(\"#signout\").onclick = async () => {\n if (remote) { const r = remote; remote = null; try { await withTimeout(r.logout(), 5000, \"The signer\"); } catch { /* the session is gone either way */ } }\n localStorage.removeItem(\"nip46\");\n me = null; localStorage.removeItem(\"me\"); renderHeader(); await loadAdmin(); await loadPeople();\n };\n const claimNow = async (note) => {\n if (!signer.ready()) { note.textContent = \"Sign the claim with a nostr extension or a remote signer.\"; showRemote(note); return; }\n me = await signer.getPublicKey();\n const r = await rpc(\"claim\");\n if (!r.claimed) throw new Error(\"Somebody else claimed it first.\");\n localStorage.setItem(\"me\", me);\n toast(\"It's yours.\");\n await loadInfo(); await loadFuel(); await loadAdmin(); await loadPeople();\n if (r.converted && confirm(\"This relay began as a temporary one: anyone can write, and everything is deleted after 14 days. Switch to the default rules and keep everything from now on? Each rule can be changed later on the Rules and Storage tabs.\")) {\n await rpc(\"resetrules\"); toast(\"Rules reset\"); await loadInfo(); await loadAdmin();\n }\n };\n $(\"#claim\").onclick = guard(() => claimNow($(\"#claimnote\")));\n $(\"#claimlease\").onclick = guard(() => claimNow($(\"#leasenote\")));\n // Jobs run in the background; the table follows them while the tab is open.\n let jobsTimer = 0;\n const fmtJob = (j) => {\n const what = j.kind === \"mirror\" ? \"mirror site\" : j.kind === \"import\" ? \"import\" : j.label === \"backfill\" ? \"fetch my history\" : j.kind === \"pull\" ? \"pull\" : \"rebroadcast\";\n const f = [];\n if (j.filter.authors) f.push(j.filter.authors.length === 1 && j.filter.authors[0] === me ? \"my events\" : j.filter.authors.length + \" authors\");\n if (j.filter.kinds) f.push(\"kinds \" + j.filter.kinds.join(\", \"));\n if (j.filter.since) f.push(\"since \" + fmtDay(j.filter.since));\n const when = j.every ? \"every \" + (j.every === 24 ? \"day\" : j.every + \" h\") + (j.nextRun ? \", next \" + fmtTime(j.nextRun) : \"\") : \"once\";\n const l = j.last;\n const count = (stored, blobs, sent, refused) => (j.kind === \"mirror\" ? blobs.toLocaleString() + \" files mirrored\" : j.kind === \"import\" ? stored.toLocaleString() + \" events\" + ((j.last ? j.last.duplicates : j.duplicates) ? \", \" + (j.last ? j.last.duplicates : j.duplicates) + \" already here\" : \"\") : j.kind === \"pull\" ? stored.toLocaleString() + \" events\" + (blobs ? \", \" + blobs + \" files\" : \"\") : sent.toLocaleString() + \" sent\" + (refused ? \", \" + refused + \" refused\" : \"\"));\n const res = j.running ? \"running: \" + count(j.stored, j.blobs, j.sent, j.refused) + \"...\" : !l ? \"waiting\" : l.error ? \"failed: \" + l.error : count(l.stored, l.blobs, l.sent, l.refused) + (l.skipped ? \", \" + l.skipped + \" skipped\" : \"\") + \", \" + fmtTime(l.finishedAt);\n const sources = j.running ? j.pullSources : l?.sources;\n const details = sources?.length ? '
                                                                                                                                                                                                              Source results' + sources.map((s) => '

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

                                                                                                                                                                                                              ').join('') + '
                                                                                                                                                                                                              ' : '';\n const targets = j.kind === \"push\" && j.targetStatus ? \"
                                                                                                                                                                                                              \" + Object.entries(j.targetStatus).map(([u, s]) => esc(u) + \": \" + esc(s.status)).join(\"
                                                                                                                                                                                                              \") + \"
                                                                                                                                                                                                              \" : \"\";\n return \"\" + what + \"\" + j.relays.map(esc).join(\"
                                                                                                                                                                                                              \") + targets + \"\" + (f.join(\", \") || \"everything\") + \"\" + when + \"\" + esc(res) + details + \"\" + (j.running ? \"\" : ib(\"undo\", \"Run now\", \"runjob\", j.id)) + ib(\"x\", \"Remove\", \"removejob\", j.id) + \"\";\n };\n async function pollJobs() {\n clearTimeout(jobsTimer);\n let jobs;\n try { jobs = await rpc(\"listjobs\"); } catch { return; }\n $(\"#jobs tbody\").innerHTML = jobs.length ? jobs.map(fmtJob).join(\"\") : 'no jobs yet';\n if (myRole === \"owner\") {\n try {\n const deliveries = await rpc(\"deliverystatus\");\n $(\"#deliveries tbody\").innerHTML = deliveries.length ? deliveries.map((d) => '' + esc(d.event_id.slice(0, 12)) + '' + esc(d.target) + '' + esc(d.status) + '' + d.attempts + '' + esc(d.error || \"\") + '').join(\"\") : 'no automatic deliveries yet';\n } catch { /* unavailable to non-owners */ }\n }\n if (jobs.some((j) => j.running || (j.nextRun && j.nextRun <= Math.floor(Date.now() / 1000) + 1))) jobsTimer = setTimeout(pollJobs, 3000);\n }\n const urls = (s) => s.split(/[\\s,]+/).map((u) => u.trim()).filter(Boolean);\n const kindsOf = (s) => s.split(/[\\s,]+/).map((k) => parseInt(k, 10)).filter((k) => Number.isInteger(k) && k >= 0);\n $(\"#pullform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"addjob\", { kind: \"pull\", relays: [f.url.value.trim()], every: +f.every.value });\n toast(+f.every.value ? \"Mirror scheduled\" : \"Pull started\"); f.reset(); await pollJobs();\n });\n $(\"#backfillform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"backfill\", urls(f.relays.value));\n toast(\"Fetching your history\"); f.reset(); await pollJobs();\n });\n $(\"#pushform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const filter = {};\n const kinds = kindsOf(f.kinds.value); if (kinds.length) filter.kinds = kinds;\n const days = +f.days.value; if (days > 0) filter.since = Math.floor(Date.now() / 1000) - days * 86400;\n await rpc(\"addjob\", { kind: \"push\", relays: urls(f.targets.value), filter, every: +f.every.value });\n toast(\"Rebroadcast started\"); f.reset(); await pollJobs();\n });\n // ---- presets: writes, reads, kinds and keep-for rules in one click ----\n let presets = null;\n async function renderPresets() {\n if (!presets) { try { presets = await rpc(\"listpresets\"); } catch { presets = []; } }\n $(\"#presets\").innerHTML = presets.map((p) => '\").join(\"\");\n $(\"#presetsourcerow\").classList.toggle(\"hidden\", !presets.some((p) => p.source));\n }\n $(\"#presets\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-preset]\"); if (!b) return;\n const p = (presets || []).find((x) => x.name === b.dataset.preset); if (!p) return;\n if (!confirm(p.title + \": \" + p.about + \"\\n\\nThis replaces the writes and reads rules, the directory setting, the kind rules and the keep-for rules. Limits, identity, people and bans stay.\")) return;\n const source = $(\"#presetsource\").value.trim();\n if (p.source === \"required\" && !source) { $(\"#presetnote\").textContent = p.title + \" needs a source relay to mirror; enter its wss:// URL first.\"; return; }\n b.disabled = true;\n try { policy = await rpc(\"applypreset\", p.name, p.source && source ? { source } : undefined); $(\"#presetnote\").textContent = \"Now: \" + p.about + (policy.job ? \" Mirroring \" + source + \" every \" + policy.job.every + \" h.\" : \"\"); toast(p.title + \" applied\"); await loadInfo(); await loadAdmin(); }\n catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // ---- wire me in: this relay in the owner's own lists ----\n // The lists are replaceable, so a fresh list with only this relay would\n // clobber the real one once it spread. Every publish starts from the\n // newest copy found here or on the indexers, verified when the signer\n // library is around, and adds or removes this relay in it.\n const INDEXERS = [\"wss://purplepag.es\", \"wss://relay.nostr.band\", \"wss://relay.damus.io\", \"wss://nos.lol\"];\n const LISTS = [\n { kind: 10002, tag: \"r\", title: \"Relay list\", nip: \"NIP-65\", about: \"where clients read your notes and send you mentions\" },\n { kind: 10050, tag: \"relay\", title: \"DM inbox\", nip: \"NIP-17\", about: \"where people send you private messages\" },\n { kind: 10007, tag: \"relay\", title: \"Search relays\", nip: \"NIP-51\", about: \"where clients run your searches\" },\n { kind: 10063, tag: \"server\", title: \"Blossom servers\", nip: \"BUD-03\", about: \"where clients upload and look for your files\" },\n ];\n const mineFor = (l) => (l.tag === \"server\" ? location.origin : wsURL);\n const normURL = (u) => { try { const x = new URL(String(u).trim()); return (x.host + x.pathname).replace(/\\/+$/, \"\").toLowerCase(); } catch { return String(u).trim().toLowerCase(); } };\n const isMine = (l, t) => t[0] === l.tag && normURL(t[1] || \"\") === normURL(mineFor(l));\n const listHas = (list, l) => !!list && list.tags.some((t) => isMine(l, t));\n // mergeList is pure: the newest list with this relay put first or taken out, every other tag kept.\n function mergeList(list, l, include) {\n const tags = (list ? list.tags : []).filter((t) => !isMine(l, t));\n if (include) tags.unshift([l.tag, mineFor(l)]);\n return { kind: l.kind, created_at: Math.floor(Date.now() / 1000), content: list ? list.content : \"\", tags };\n }\n const relaysIn = (list) => list.tags.filter((t) => (t[0] === \"r\" || t[0] === \"relay\") && /^wss?:\\/\\//i.test(t[1] || \"\")).map((t) => t[1].trim());\n // overWS opens one socket, sends one message, feeds answers to onMessage until it says done or time runs out.\n function overWS(url, ms, first, onMessage) {\n return new Promise((res) => {\n let ws = null, done = false, out = null;\n const finish = () => { if (done) return; done = true; clearTimeout(timer); try { if (ws) ws.close(); } catch { /* closed */ } res(out); };\n const timer = setTimeout(finish, ms);\n try { ws = new WebSocket(url); } catch { return finish(); }\n ws.onopen = () => ws.send(JSON.stringify(first));\n ws.onmessage = (m) => { let d; try { d = JSON.parse(m.data); } catch { return; } if (!Array.isArray(d)) return; if (onMessage(d, (v) => { out = v; })) finish(); };\n ws.onerror = finish; ws.onclose = finish;\n });\n }\n function fetchNewest(url, filter, ms) {\n let best = null;\n return overWS(url, ms, [\"REQ\", \"w\", filter], (d, set) => {\n if (d[0] === \"EVENT\" && d[1] === \"w\" && d[2] && (!best || d[2].created_at > best.created_at)) { best = d[2]; set(best); }\n return (d[0] === \"EOSE\" || d[0] === \"CLOSED\") && d[1] === \"w\";\n });\n }\n function publishTo(url, event, ms) {\n return overWS(url, ms, [\"EVENT\", event], (d, set) => { if (d[0] === \"OK\" && d[1] === event.id) { set({ url, ok: !!d[2], msg: d[3] || \"\" }); return true; } return false; })\n .then((r) => r || { url, ok: false, msg: \"no answer\" });\n }\n // bridge signs a NIP-98 request to this relay's HTTP door, so the owner's own reads and writes pass any read rule.\n async function bridge(path, body) {\n const url = location.origin + path, raw = JSON.stringify(body);\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"POST\"], [\"payload\", await sha256hex(raw)]] });\n const resp = await fetch(url, { method: \"POST\", headers: { \"content-type\": \"application/json\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body: raw });\n const json = await resp.json();\n if (!resp.ok) throw new Error(json.error || \"HTTP \" + resp.status);\n return json;\n }\n const wire = {};\n function renderWire() {\n const pill = (state) => {\n const cls = state === \"listed\" ? \"on\" : state === \"failed\" ? \"bad\" : state === \"not listed\" ? \"off\" : \"\";\n const label = state === \"checking\" || state === \"publishing\" ? state + \"\\u2026\" : state;\n return '' + esc(label) + \"\";\n };\n $(\"#wire\").innerHTML = LISTS.map((l) => {\n const w = wire[l.kind] || { state: \"\", results: [], list: null };\n const busy = w.state === \"checking\" || w.state === \"publishing\";\n const btn = (act, cls, label) => '\";\n const buttons = w.state === \"listed\" ? btn(\"remove\", \"\", \"Remove this relay\") : btn(\"add\", \"pri\", \"Add this relay\") + btn(\"check\", \"\", w.state ? \"Check again\" : \"Check\");\n const n = w.list ? w.list.tags.filter((t) => t[0] === l.tag).length : 0;\n const from = w.list ? \"\" + (n === 1 ? \"1 entry\" : n + \" entries\") + \", \" + fmtTime(w.list.created_at) + \"\" : w.state && !busy ? \"no list found\" : \"\";\n const sent = (w.results || []).map((r) => '' + esc(r.url.replace(/^wss?:\\/\\//, \"\")) + (r.ok ? \"\" : \" failed\") + \"\").join(\"\");\n const meta = (w.state ? pill(w.state) : \"\") + from + sent;\n return '
                                                                                                                                                                                                              ' + l.title + ' ' + l.nip + \"
                                                                                                                                                                                                              \" + l.about + '
                                                                                                                                                                                                              ' + buttons + '
                                                                                                                                                                                                              ' + meta + \"
                                                                                                                                                                                                              \";\n }).join(\"\");\n }\n async function wireCheck(l) {\n const w = (wire[l.kind] = { state: \"checking\", list: null, results: [] }); renderWire();\n const filter = { kinds: [l.kind], authors: [me], limit: 1 };\n const found = [];\n try { found.push(...(await bridge(\"/query\", [filter]))); } catch { /* the relay may hold none */ }\n const remote = await Promise.all(INDEXERS.map((u) => fetchNewest(u, filter, 4000)));\n let lib = null; try { lib = await signerLib(); } catch { /* unverified lists are still the owner's own, by pubkey */ }\n for (const e of remote) if (e && e.pubkey === me && e.kind === l.kind && Array.isArray(e.tags) && (!lib || lib.verifyEvent(e))) found.push(e);\n w.list = found.sort((a, b) => b.created_at - a.created_at)[0] || null;\n w.state = listHas(w.list, l) ? \"listed\" : \"not listed\"; renderWire();\n }\n async function wirePublish(l, include) {\n if (!wire[l.kind] || !wire[l.kind].state || wire[l.kind].state === \"failed\") await wireCheck(l);\n const w = wire[l.kind]; w.state = \"publishing\"; w.results = []; renderWire();\n try {\n const signed = await signer.signEvent(mergeList(w.list, l, include));\n const here = await bridge(\"/events\", signed).then((r) => ({ url: wsURL, ok: !!r.accepted, msg: r.message || \"\" })).catch((e) => ({ url: wsURL, ok: false, msg: e.message }));\n const targets = [...new Set([...relaysIn(signed), ...INDEXERS])].filter((u) => normURL(u) !== normURL(wsURL));\n const rest = await Promise.all(targets.map((u) => publishTo(u, signed, 6000)));\n w.results = [here, ...rest]; w.list = signed; w.state = include ? \"listed\" : \"not listed\";\n toast((include ? \"Added to your \" : \"Removed from your \") + l.title.toLowerCase());\n } catch (e) { w.state = \"failed\"; w.results = [{ url: wsURL, ok: false, msg: e.message }]; toast(e.message); }\n renderWire();\n }\n $(\"#wire\").addEventListener(\"click\", (ev) => {\n const b = ev.target.closest(\"button[data-wire]\"); if (!b) return;\n const l = LISTS.find((x) => String(x.kind) === b.dataset.wire); if (!l) return;\n if (b.dataset.do === \"check\") wireCheck(l); else wirePublish(l, b.dataset.do === \"add\");\n });\n\n // ---- custom domains: this relay under a hostname the owner controls ----\n let domainSites = [];\n const domainOptions = (current = \"\") => [{ label: \"\", title: \"This relay\" }, ...domainSites.map((s) => ({ label: s.label, title: (s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + \" / \" + key(s.author) })), ...(current && !domainSites.some((s) => s.label === current) ? [{ label: current, title: \"Site \" + current }] : [])].map((s) => '\").join(\"\");\n let domains = null; // null: not enabled on this host\n function renderDomains() {\n $(\"#domains\").innerHTML = (domains || []).map((d) => {\n const state = d.ready ? \"active\" : \"hostname \" + d.status.replace(/_/g, \" \") + \", certificate \" + d.sslStatus.replace(/_/g, \" \");\n const btn = (act, cls, label) => '\";\n const rows = d.ready ? \"\" : '
                                                                                                                                                                                                              ' + d.records.map((r) => \"\").join(\"\") + \"
                                                                                                                                                                                                              TypeNameValue
                                                                                                                                                                                                              \" + esc(r.type) + '' + esc(r.name) + '' + esc(r.value) + \"\" + esc(r.note) + \"
                                                                                                                                                                                                              \";\n return '
                                                                                                                                                                                                              ' + esc(d.host) + '' + esc(state) + \"\" + btn(\"check\", \"\", \"Check\") + btn(\"remove\", \"danger\", \"Remove\") + '
                                                                                                                                                                                                              \" + rows;\n }).join(\"\");\n }\n async function loadDomains() {\n try { [domains, domainSites] = await Promise.all([rpc(\"listdomains\"), rpc(\"listsites\")]); $(\"#adddomain select[name=site]\").innerHTML = domainOptions(); $(\"#domainnote\").textContent = domains.length ? \"\" : \"No custom domain yet.\"; $(\"#adddomain\").classList.remove(\"hidden\"); }\n catch (e) { domains = null; $(\"#domainnote\").textContent = /^unsupported/.test(e.message) ? \"Custom domains are not enabled on this host.\" : e.message; $(\"#adddomain\").classList.add(\"hidden\"); }\n renderDomains();\n }\n $(\"#adddomain\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"adddomain\", f.host.value.trim(), f.site.value); f.reset(); toast(\"Domain added; now create the CNAME\"); await loadDomains();\n });\n $(\"#domains\").addEventListener(\"change\", guard(async (ev) => {\n const select = ev.target.closest(\"select[data-domain-site]\"); if (!select) return;\n await rpc(\"setdomainsite\", select.dataset.domainSite, select.value);\n toast(\"Domain destination saved\"); await loadDomains();\n }));\n $(\"#domains\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-domain]\"); if (!b) return;\n const host = b.dataset.domain;\n if (b.dataset.do === \"remove\" && !confirm(\"Remove \" + host + \"? Its certificate goes with it and the name stops answering.\")) return;\n b.disabled = true;\n try {\n if (b.dataset.do === \"remove\") { await rpc(\"removedomain\", host); toast(\"Removed\"); }\n else { const d = await rpc(\"checkdomain\", host); toast(d.ready ? host + \" is live\" : \"Not yet: \" + (d.status === \"active\" ? \"certificate pending\" : \"waiting for the CNAME\")); }\n await loadDomains();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n $(\"#access\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const openKinds = f.openKinds.value.split(/[\\s,]+/).filter(Boolean).map(Number);\n if (openKinds.some((k) => !Number.isInteger(k) || k < 0 || k > 65535)) throw new Error(\"Open kinds must be whole numbers.\");\n policy = await rpc(\"setpolicy\", { writes: f.writes.value, reads: f.reads.value, openKinds, guestReplies: f.guestReplies.checked, minPow: +f.minPow.value, maxFuture: +f.maxFuture.value, maxLimit: +f.maxLimit.value, maxSubs: +f.maxSubs.value, maxMessageKB: +f.maxMessageKB.value, eventsPerMinute: +f.eventsPerMinute.value, reqsPerMinute: +f.reqsPerMinute.value, maxBlobMB: +f.maxBlobMB.value });\n toast(\"Rules saved\"); await loadInfo();\n });\n $(\"#notify\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { notify: { reports: f.reports.checked, fuel: f.fuel.checked, jobs: f.jobs.checked, succession: f.succession.checked, digest: f.digest.checked } });\n toast(\"Notifications saved\");\n });\n $(\"#notifytest\").onclick = guard(async () => {\n const r = await rpc(\"notifytest\");\n toast(r.sent ? \"Sent. Look for a message from the relay in your DMs.\" : \"Could not send.\");\n });\n $(\"#identity\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const list = (v) => v.split(\",\").map((s) => s.trim()).filter(Boolean);\n policy = await rpc(\"setpolicy\", { name: f.name.value, contact: f.contact.value, description: f.description.value, icon: f.icon.value,\n banner: f.banner.value, postingPolicy: f.postingPolicy.value, privacyPolicy: f.privacyPolicy.value, tags: list(f.tags.value), languageTags: list(f.languageTags.value), relayCountries: list(f.relayCountries.value) });\n toast(\"Identity saved\"); await loadInfo(); await loadPeople(); loadCard();\n });\n $(\"#addmember\").onsubmit = guard(async (ev) => {\n const f = ev.target; const pk = npubToHex(f.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"setmember\", pk, { name: f.name.value.trim() || null, note: f.note.value }); f.reset(); toast(\"Member added\"); await loadLists();\n });\n $(\"#addblock\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"blockip\", f.ip.value.trim(), f.reason.value); f.reset(); toast(\"Blocked\"); await loadLists();\n });\n $(\"#wordsform\").onsubmit = guard(async (ev) => {\n const words = ev.target.words.value.split(\"\\n\").map((s) => s.trim()).filter(Boolean);\n const kept = await rpc(\"setblockedwords\", words);\n policy = await rpc(\"setpolicy\", { blockedWordsInTags: ev.target.inTags.checked });\n ev.target.words.value = kept.join(\"\\n\"); toast(kept.length ? kept.length + \" words blocked\" : \"No words blocked\");\n });\n $(\"#thresholdform\").onsubmit = guard(async (ev) => {\n policy = await rpc(\"setpolicy\", { reportThreshold: +ev.target.reportThreshold.value });\n toast(policy.reportThreshold ? \"Hidden after \" + policy.reportThreshold + \" reports\" : \"Never hidden by reports\"); await loadLists();\n });\n $(\"#addban\").onsubmit = guard(async (ev) => {\n const pk = npubToHex(ev.target.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"banpubkey\", pk, ev.target.reason.value, ev.target.erase.checked); ev.target.reset(); toast(ev.target.erase.checked ? \"Banned and erased\" : \"Banned\"); await loadLists();\n });\n $(\"#mintinvite\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const inv = await rpc(\"createinvite\", +f.ttl.value, +f.max.value, f.note.value);\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n f.note.value = \"\"; toast(\"Invite created and copied\"); await loadLists();\n });\n $(\"#kindform\").onsubmit = guard(async (ev) => {\n const rule = ev.submitter.value; const k = +ev.target.kind.value;\n await rpc(rule === \"allow\" ? \"allowkind\" : \"disallowkind\", k); ev.target.reset(); toast((rule === \"allow\" ? \"Allowed kind \" : \"Blocked kind \") + k); await loadLists();\n });\n $(\"#more\").onclick = guard(() => loadEvents(false));\n $(\"#dumpform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { dumps: f.dumps.value, dumpsKeep: Math.max(1, Math.min(60, Math.floor(+f.keep.value || 7))) });\n toast(policy.dumps === \"off\" ? \"Dumps off\" : \"Dumping \" + policy.dumps); await loadDumps();\n });\n $(\"#importform\").onsubmit = guard(async (ev) => {\n const file = ev.target.file.files[0]; if (!file) return;\n if (!signer.ready()) throw new Error(NO_SIGNER);\n if (file.size > 64 * 1024 * 1024) throw new Error(\"At most 64 MB per import.\");\n const body = await file.text();\n const url = location.origin + \"/import?name=\" + encodeURIComponent(file.name);\n const token = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"PUT\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(url, { method: \"PUT\", headers: { authorization: \"Nostr \" + btoa(JSON.stringify(token)), \"content-type\": \"application/x-ndjson\" }, body });\n const r = await resp.json();\n if (!resp.ok) throw new Error(r.error || \"import failed\");\n ev.target.reset(); toast(\"Importing \" + fmtBytes(r.bytes)); await pollJobs();\n });\n $(\"#dumpnow\").onclick = guard(async () => { const d = await rpc(\"dumpnow\"); toast(\"Dumped \" + d.events.toLocaleString() + \" events\"); await loadStorage(); });\n async function backupRequest(path, method, body) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const hash = await sha256hex(body || \"\");\n const token = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", location.origin + path], [\"method\", method], [\"payload\", hash]] });\n return fetch(path, { method, headers: { authorization: \"Nostr \" + btoa(JSON.stringify(token)), ...(body ? { \"content-type\": \"application/json\" } : {}) }, body });\n }\n $(\"#backupnow\").onclick = guard(async () => { const id = $(\"#backupid\").value.trim() || \"backup\"; const r = await rpc(\"backupnow\", id); const resp = await backupRequest(\"/backups/\" + id, \"GET\"); if (!resp.ok) throw new Error(\"backup download failed\"); const a = document.createElement(\"a\"); a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(\".\")[0] + \"-\" + id + \".json\"; a.click(); URL.revokeObjectURL(a.href); toast(\"Backup downloaded\"); await loadStorage(); void r; });\n async function selectedBackup() { const f = $(\"#backupfile\").files[0]; if (!f) throw new Error(\"Choose a backup archive first.\"); if (f.size > 8 * 1024 * 1024) throw new Error(\"Backups are limited to 8 MB.\"); return { file: f, body: await f.text() }; }\n let backupPreview = null;\n $(\"#backupfile\").onchange = () => { backupPreview = null; $(\"#backup-preview\").textContent = \"\"; $(\"#backuprestore\").disabled = true; };\n $(\"#backupremote\").onclick = () => showRemote(null);\n $(\"#backuppreview\").onclick = guard(async () => { const selected = await selectedBackup(); const r = await (await backupRequest(\"/backups/preview\", \"POST\", selected.body)).json(); if (r.error) throw new Error(r.error); backupPreview = { file: selected.file, body: selected.body }; $(\"#backuprestore\").disabled = false; const s = r.result.source; $(\"#backup-preview\").innerHTML = '

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

                                                                                                                                                                                                              Source relay identity: ' + esc(s.relayIdentity || \"unknown\") + '

                                                                                                                                                                                                              ' + esc(JSON.stringify(r.result.config, null, 2)) + '
                                                                                                                                                                                                              '; });\n $(\"#backuprestore\").onclick = guard(async () => { const selected = await selectedBackup(); if (!backupPreview || backupPreview.file !== selected.file || backupPreview.body !== selected.body) throw new Error(\"Preview this exact archive before restoring.\"); if (!confirm(\"Restore this archive onto this fresh relay? This cannot be undone.\")) return; const r = await (await backupRequest(\"/backups/restore\", \"POST\", selected.body)).json(); if (r.error) throw new Error(r.error); me = await signer.getPublicKey(); localStorage.setItem(\"me\", me); toast(\"Backup restored\"); await loadInfo(); await loadFuel(); await loadAdmin(); await loadPeople(); });\n $(\"#treeform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { memberInvites: { depth: Math.max(0, Math.floor(+f.depth.value || 0)), quota: Math.max(0, Math.floor(+f.quota.value || 0)) } });\n toast(policy.memberInvites.depth ? \"Members may invite\" : \"Only you and moderators invite\");\n });\n $(\"#mintmine\").onclick = guard(async () => {\n const inv = await rpc(\"createinvite\", 259200, 1, \"\");\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n toast(\"Invite created and copied\"); await loadMine();\n });\n $(\"#exportcfg\").onclick = guard(async () => {\n const cfg = await rpc(\"exportconfig\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(new Blob([JSON.stringify(cfg, null, 2)], { type: \"application/json\" }));\n a.download = host.split(\".\")[0] + \".bind.ws.config.json\";\n a.click(); URL.revokeObjectURL(a.href);\n });\n $(\"#importcfg\").onclick = () => $(\"#cfgfile\").click();\n $(\"#cfgfile\").onchange = guard(async (ev) => {\n const file = ev.target.files[0]; if (!file) return;\n const cfg = JSON.parse(await file.text());\n const check = await rpc(\"importconfig\", cfg, { dryRun: true });\n const lines = check.changes.summary.length ? check.changes.summary.join(\"\\n\") : \"Nothing would change.\";\n const dropped = check.warnings.length ? \"\\n\\nNot taken:\\n\" + check.warnings.join(\"\\n\") : \"\";\n if (!confirm(\"Apply \" + file.name + \" to this relay?\\n\\n\" + lines + dropped)) { ev.target.value = \"\"; return; }\n await rpc(\"importconfig\", cfg); ev.target.value = \"\"; toast(\"Configuration imported\"); await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // Succession: the status line under the heir form, from successionstatus.\n async function renderSuccession() {\n const el = $(\"#successionnote\"), f = $(\"#succession\");\n let st;\n try { st = await rpc(\"successionstatus\"); } catch { el.textContent = \"\"; return; }\n const sc = st.succession;\n $(\"#clearsuccession\").classList.toggle(\"hidden\", !sc);\n f.querySelector(\"button.btn:not(#clearsuccession)\").textContent = sc ? \"Change heir\" : \"Set heir\";\n if (!sc) { el.textContent = \"No heir named. Last signed in \" + fmtTime(st.ownerSeenAt) + \".\"; return; }\n if (f.heir.querySelector('[value=\"' + sc.heir + '\"]')) f.heir.value = sc.heir;\n f.afterDays.value = String(sc.afterDays);\n const who = (window.__members || []).find((m) => m.pubkey === sc.heir);\n const heir = who && who.name ? who.name + \"@\" + host : short(sc.heir);\n el.textContent = \"Heir: \" + heir + \". Last signed in \" + fmtTime(st.ownerSeenAt) + (st.silentDays ? \" (\" + st.silentDays + \" days ago)\" : \"\") + \". \" +\n (st.warning ? \"The warning month is running: the relay goes to \" + heir + \" on \" + fmtDay(st.handoverAt) + \" unless you sign in.\" : \"If you stay away, it goes to \" + heir + \" around \" + fmtDay(st.handoverAt) + \".\") +\n (st.log && st.log.length ? \" Handed over before: \" + st.log.map((l) => fmtDay(l.at) + \" to \" + short(l.to)).join(\", \") + \".\" : \"\");\n }\n $(\"#succession\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"setsuccession\", { heir: f.heir.value, afterDays: +f.afterDays.value });\n toast(\"Heir set\"); await loadAdmin();\n });\n $(\"#clearsuccession\").onclick = guard(async () => {\n await rpc(\"clearsuccession\");\n toast(\"Heir cleared\"); await loadAdmin();\n });\n $(\"#transfer\").onsubmit = guard(async (ev) => {\n const pk = ev.target.pubkey.value; if (!pk) return;\n const name = host.split(\".\")[0];\n const typed = prompt(\"This hands \" + host + \" to \" + short(pk) + \" for good. You stay on as a moderator. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing changed.\"); return; }\n await rpc(\"transferowner\", pk);\n toast(\"Transferred\");\n await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // ---- fork: a new name with this relay's events, claim reserved for a key ----\n $(\"#forkform\").scope.onchange = (ev) => $(\"#forkkinds\").classList.toggle(\"hidden\", ev.target.value !== \"kinds\");\n $(\"#joinform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { joinTerms: f.joinTerms.value, directoryPublic: f.directoryPublic.checked });\n toast(\"Saved\"); await loadInfo(); await loadPeople();\n });\n $(\"#forkform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const opts = { people: f.people.checked };\n if (f.name.value.trim()) opts.name = f.name.value.trim().toLowerCase();\n if (f.holder.value.trim()) { const pk = npubToHex(f.holder.value); if (!pk) throw new Error(\"That is not a pubkey.\"); opts.holder = pk; }\n if (f.scope.value === \"mine\") opts.filter = { authors: [me] };\n if (f.scope.value === \"kinds\") { const kinds = f.kinds.value.split(/[\\s,]+/).filter(Boolean).map(Number); if (!kinds.length || kinds.some((k) => !Number.isInteger(k) || k < 0)) throw new Error(\"Give kinds as numbers.\"); opts.filter = { kinds }; }\n if (!confirm(\"Fork this relay into a new name\" + (opts.name ? \" (\" + opts.name + \")\" : \"\") + \"? It pulls \" + (f.scope.value === \"all\" ? \"everything\" : f.scope.value === \"mine\" ? \"your events\" : \"the chosen kinds\") + (opts.people ? \" and the people\" : \"\") + \", and only \" + (opts.holder ? \"that key\" : \"you\") + \" can claim it.\")) return;\n const r = await rpc(\"forkrelay\", opts);\n $(\"#forknote\").textContent = r.handover + \" Expires \" + fmtTime(r.expires_at) + \".\";\n $(\"#forkurl\").textContent = r.console;\n $(\"#forkresult\").classList.remove(\"hidden\");\n toast(\"Forked to \" + r.name);\n });\n $(\"#forkcopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#forkurl\").textContent); toast(\"copied\"); };\n $(\"#deleterelay\").onclick = guard(async () => {\n const name = host.split(\".\")[0];\n const typed = prompt(\"This deletes everything on \" + host + \" and gives the name up. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing was deleted.\"); return; }\n await rpc(\"deleterelay\", name);\n localStorage.removeItem(\"me\");\n location.href = \"/\";\n });\n $(\"#topup\").onsubmit = guard((ev) => topUp(+ev.target.sats.value));\n $(\"#inv-copy\").onclick = async () => { await navigator.clipboard.writeText($(\"#inv-text\").value); toast(\"invoice copied\"); };\n $$(\"button[data-copy]\").forEach((b) => { b.onclick = async () => { await navigator.clipboard.writeText(b.dataset.copy === \"ws\" ? wsURL : location.origin); toast(\"copied\"); }; });\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-act]\"); if (!b) return;\n const act = b.dataset.act, id = b.dataset.id;\n if (act === \"copy\") { await navigator.clipboard.writeText(id); toast(\"copied\"); return; }\n if ((act === \"banpubkey\" || act === \"banevent\" || act === \"resolve:ban\") && !confirm(act === \"banevent\" ? \"Delete this event and refuse it forever?\" : \"Ban this author and refuse everything they post?\")) return;\n const erase = (act === \"banpubkey\" || act === \"resolve:ban\") && confirm(\"Also erase everything they wrote and uploaded here?\");\n if (act === \"deleteblob\" && !confirm(\"Delete this file for good?\")) return;\n if (act === \"deletedump\" && !confirm(\"Delete this dump?\")) return;\n if (act === \"downloaddump\") { b.disabled = true; try { await downloadDump(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }\n if (act === \"deletebackup\" && !confirm(\"Delete this backup?\")) return;\n if (act === \"downloadbackup\") { b.disabled = true; try { await downloadBackup(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }\n if (act === \"deletebackup\") { b.disabled = true; try { await rpc(\"deletebackup\", id); toast(\"Backup deleted\"); await loadBackups(); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }\n if (act === \"restorelist\") {\n if (!signer.ready()) { toast(NO_SIGNER); return; }\n try {\n const preview = await rpc(\"restorelist\", id);\n const d = preview.diff || {};\n const added = (d.addedTags || []).map((t) => \"+ \" + JSON.stringify(t)).join(\"\\n\");\n const removed = (d.removedTags || []).map((t) => \"- \" + JSON.stringify(t)).join(\"\\n\");\n const changes = [added, removed, d.contentChanged ? \"content changed\" : \"content unchanged\"].filter(Boolean).join(\"\\n\");\n if (!confirm(\"Restore this list version?\\n\\n\" + (changes || \"No tag or content changes\") + \"\\n\\nIt will be signed and published as the newest version.\")) return;\n const signed = await signer.signEvent(preview.draft);\n const result = await bridge(\"/events\", signed);\n if (!result.accepted) throw new Error(result.message || \"The relay refused the restored list.\");\n toast(\"List restored\"); await loadListHistory(); await loadStorage();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if ((act === \"removemember\" || act === \"banpubkey\") && (window.__members || []).some((m) => m.invited_by === id) && confirm(\"Also remove everyone this member invited, and everyone they invited in turn?\")) {\n b.disabled = true;\n try { const r = await rpc(\"removesubtree\", id); if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase); toast(\"Removed \" + r.removed.length); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"removejob\" && !confirm(\"Remove this job?\")) return;\n if (act === \"purgekind\") {\n const kind = id === \"\" ? null : +id;\n const label = kind === null ? \"everything without its own rule\" : kindName(kind) + \" (kind \" + kind + \")\";\n const typed = prompt(\"Purge \" + label + \" older than how many days? 0 purges all of them. This cannot be undone.\", \"30\");\n if (typed === null) return;\n const days = Math.max(0, Math.floor(+typed || 0));\n b.disabled = true;\n try { const r = await rpc(\"purgekind\", kind, days); toast(\"Purged \" + r.deleted.toLocaleString() + \" events\"); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"saveretention\") {\n const kind = id === \"\" ? null : +id;\n const days = Math.max(0, Math.floor(+b.closest(\"tr\").querySelector(\".days\").value || 0));\n b.disabled = true;\n try { await rpc(\"setretention\", kind, days); toast(days ? \"Kept for \" + days + \" days\" : \"Kept forever\"); await loadStorage(); await loadInfo(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n b.disabled = true;\n try {\n if (act === \"savemember\") { const tr = b.closest(\"tr\"), role = tr.querySelector(\"select.role\"), keep = tr.querySelector(\".keep\"), cap = tr.querySelector(\".cap\"); await rpc(\"setmember\", id, { name: tr.querySelector(\".name\").value.trim() || null, note: tr.querySelector(\".note\").value, ...(role ? { role: role.value } : {}), ...(keep ? { keepDays: Math.max(0, Math.floor(+keep.value || 0)), maxBytes: Math.max(0, Math.floor(+cap.value || 0)) * 1024 } : {}) }); }\n else if (act.startsWith(\"resolve:\")) await rpc(\"resolvereport\", id, act.slice(8), erase);\n else if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase);\n else await rpc(act, act === \"unrulekind\" ? +id : id, \"\");\n toast(\"Done\"); await (myRole ? refresh() : loadMine());\n if (act === \"pinevent\" || act === \"unpinevent\") await loadPins();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // The share block: the card picture, the group naddr and its QR.\n let card = null;\n async function loadCard() {\n try {\n card = await (await fetch(\"/card.json\", { cache: \"no-store\" })).json();\n $(\"#cardimg\").src = \"/card.svg?t=\" + Date.now();\n $(\"#naddr\").textContent = card.naddr || \"\";\n const q = $(\"#naddrqr\");\n if (card.naddr) { q.src = \"/qr.svg?text=\" + encodeURIComponent(card.naddr); q.classList.remove(\"hidden\"); } else q.classList.add(\"hidden\");\n } catch { /* the card is decoration */ }\n renderApps();\n }\n // The app rows on the Connect section: one per client people actually use,\n // with the link that lands on this relay in it. Relay apps take the relay\n // or the group address; feed apps take the owner's profile with this relay\n // as the hint, since they have no notion of opening a relay.\n function renderApps() {\n const el = $(\"#apps\"); if (!el) return;\n const enc = encodeURIComponent;\n const nprofile = card && card.nprofile ? card.nprofile : \"\";\n const naddr = card && card.naddr ? card.naddr : \"\";\n const whose = me && owner && me === owner ? \"your\" : \"the owner's\";\n const link = (label, href) => '' + label + \"\";\n const app = (label, uri) => '' + label + \"\";\n const copy = (label, text) => '\";\n const row = (name, where, note, acts) => '
                                                                                                                                                                                                              ' + name + \"\" + where + \"

                                                                                                                                                                                                              \" + note + '

                                                                                                                                                                                                              ' + acts.filter(Boolean).join(\"\") + \"
                                                                                                                                                                                                              \";\n const profileNote = \"Opens \" + whose + \" profile with this relay attached.\";\n const groups = [\n [\"As a place\", \"These open the relay itself: its feed, its people, its group.\", [\n row(\"Jumble\", \"web\", \"A feed of everything on this relay.\", [link(\"Open\", \"https://jumble.social/?r=\" + enc(wsURL))]),\n row(\"Coracle\", \"web\", \"The relay's page: its feed and its people.\", [link(\"Open\", \"https://coracle.social/relays/\" + enc(host))]),\n row(\"Flotilla\", \"web, phone\", \"The relay as a space, with the group as a room.\", [link(\"Open\", \"https://app.flotilla.social/spaces/\" + enc(host)), naddr && app(\"Open group\", \"nostr:\" + naddr)]),\n row(\"0xchat\", \"phone\", \"The group, in a chat app.\", [naddr && app(\"Open group\", \"nostr:\" + naddr), naddr && copy(\"Copy naddr\", naddr)]),\n row(\"noStrudel\", \"web\", \"Relays, add this one, then open its page.\", [copy(\"Copy relay URL\", wsURL)]),\n ]],\n [\"Find me here\", \"Feed apps have no relay pages. They meet this relay through a profile link that names it, then keep it once it is in the relay settings.\", [\n row(\"Primal\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://primal.net/p/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"YakiHonne\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://yakihonne.com/profile/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"Damus\", \"iPhone\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Amethyst\", \"Android\", profileNote + \" Then Relays in the drawer.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Nostur\", \"iPhone, Mac\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n ]],\n ];\n if (info?.supported_grasps?.includes(\"GRASP-01\")) groups.push([\n \"Git repositories\", \"Use this relay with a Git client.\", [\n row(\"GitWorkshop\", \"web\", \"Browse this relay's Git repositories.\", [link(\"Open in app\", \"https://gitworkshop.dev/relay/\" + enc((wsURL.startsWith(\"ws://\") ? \"ws:\" : \"\") + host)), copy(\"Copy relay URL\", wsURL)]),\n ],\n ]);\n el.innerHTML = groups.map(([h, note, rows]) => '

                                                                                                                                                                                                              ' + h + '

                                                                                                                                                                                                              ' + note + '

                                                                                                                                                                                                              ' + rows.join(\"\") + \"
                                                                                                                                                                                                              \").join(\"\");\n $(\"#apps-ws\").textContent = wsURL;\n const tile = (label, text) => '
                                                                                                                                                                                                              ' + label + '\"QR
                                                                                                                                                                                                              ';\n $(\"#phones\").innerHTML = [nprofile && tile(\"Find me here, for a phone\", \"nostr:\" + nprofile), naddr && tile(\"The group, for a phone\", \"nostr:\" + naddr)].filter(Boolean).join(\"\");\n }\n // The folds are a group: opening one closes the rest. Browsers with the\n // details name attribute do this themselves; this covers the others.\n $$(\".folds details\").forEach((d) => d.addEventListener(\"toggle\", () => { if (d.open) $$(\".folds details\").forEach((o) => { if (o !== d && o.open) o.open = false; }); }));\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-copytext]\"); if (!b) return;\n try { await navigator.clipboard.writeText(b.dataset.copytext); toast(\"copied\"); } catch { /* no clipboard */ }\n });\n $(\"#copynaddr\").onclick = async () => { if (!card || !card.naddr) return; await navigator.clipboard.writeText(card.naddr); toast(\"copied naddr\"); };\n $(\"#copyembed\").onclick = async () => { await navigator.clipboard.writeText('\"''); toast(\"copied embed\"); };\n\n // A click on a key copies it; a double-click selects the whole key so the usual copy shortcut takes it too.\n document.addEventListener(\"dblclick\", (ev) => {\n const k = ev.target.closest(\".key\"); if (!k) return;\n const s = window.getSelection(), r = document.createRange(); r.selectNodeContents(k.querySelector(\".full\")); s.removeAllRanges(); s.addRange(r);\n });\n document.addEventListener(\"click\", async (ev) => {\n const k = ev.target.closest(\".key\"); if (!k || ev.detail > 1 || String(window.getSelection())) return;\n try { await navigator.clipboard.writeText(k.title); toast(\"copied \" + k.dataset.short); } catch { /* no clipboard */ }\n });\n // ---- boot ----\n try { await loadInfo(); } catch { renderHeader(); }\n await loadFuel();\n await loadCard();\n me = localStorage.getItem(\"me\");\n if (me && await resumeRemote()) { /* a remote session answers for itself when first used */ }\n else if (me && window.nostr) { try { const pk = await window.nostr.getPublicKey(); if (pk !== me) me = null; } catch { me = null; } }\n else if (me) me = null;\n renderHeader();\n renderApps();\n await loadAdmin();\n await loadPeople();\n})();\n"; diff --git a/src/jobs.ts b/src/jobs.ts index 6d923f8..6820ad3 100644 --- a/src/jobs.ts +++ b/src/jobs.ts @@ -187,6 +187,7 @@ async function runPushRound(relay: Relay, job: Job): Promise<{ more: boolean; er // the legacy cursor. This is the compatibility bridge for old jobs. for (const url of job.relays) if (job.targetCursors[url] === undefined) job.targetCursors[url] = legacyCursor; for (const url of job.relays) { + if ((job.targetAttempts[url] ?? 0) >= 3) continue; const cursor = job.targetCursors[url] ?? 0; const rows = relay.store.after(cursor, f, PUSH_BATCH, now()); if (rows.length === 0) { job.targetStatus[url] = { status: "accepted", error: "", at: now() }; continue; } @@ -198,15 +199,17 @@ async function runPushRound(relay: Relay, job: Job): Promise<{ more: boolean; er events.push(e); } try { + const refusedBefore = job.refused; await pushTo(relay, url, events, job); reached++; job.targetCursors[url] = rows[rows.length - 1].seq; - job.targetStatus[url] = { status: "accepted", error: "", at: now() }; + job.targetStatus[url] = { status: job.refused > refusedBefore ? "rejected" : "accepted", error: job.refused > refusedBefore ? "Some events were explicitly refused." : "", at: now() }; + job.targetAttempts[url] = 0; job.cursor = Math.max(job.cursor, rows[rows.length - 1].seq); } catch (err) { failed = url + ": " + (err instanceof Error ? err.message : String(err)); job.targetAttempts[url] = (job.targetAttempts[url] ?? 0) + 1; - job.targetStatus[url] = { status: "pending", error: failed, at: now() }; + job.targetStatus[url] = { status: job.targetAttempts[url] >= 3 ? "rejected" : "pending", error: failed, at: now() }; more = true; } } @@ -225,7 +228,7 @@ async function pushTo(relay: Relay, url: string, events: Event[], job: Job): Pro for (const e of batch) sock.send("EVENT", e); const deadline = Date.now() + PUSH_TIMEOUT_MS; while (pending.size && Date.now() < deadline) { - const m = await sock.recv(); + const m = await sock.recv(deadline - Date.now()); if (m[0] !== "OK" || typeof m[1] !== "string" || !pending.has(m[1])) continue; pending.delete(m[1]); const msg = String(m[3] ?? ""); @@ -262,11 +265,13 @@ export function startRun(job: Job, t: number) { job.sent = 0; job.refused = 0; job.duplicates = 0; + if (job.kind === "push") job.targetAttempts = {}; if (job.kind === "pull") job.pullSources = job.relays.map((url) => ({ url, ...newPullProgress(), stored: 0, skipped: 0, blobs: 0 })); } // finishRun closes the current run and schedules the next one. export function finishRun(job: Job, error: string, t: number) { + if (!error && job.kind === "push" && Object.values(job.targetStatus ?? {}).some((s) => s.status === "rejected")) error = "Some delivery targets refused events or exhausted retries; inspect target results."; const incomplete = job.pullSources?.filter((s) => ["partial", "refused", "failed"].includes(s.status)); if (!error && incomplete?.length) error = `${incomplete.length} import source(s) incomplete; inspect Source results for details.`; job.last = { finishedAt: t, error, rounds: job.rounds, stored: job.stored, skipped: job.skipped, blobs: job.blobs, sent: job.sent, refused: job.refused, duplicates: job.duplicates ?? 0, ...(job.pullSources ? { sources: structuredClone(job.pullSources) } : {}) }; diff --git a/src/relay.ts b/src/relay.ts index 44f05e7..0f109c0 100644 --- a/src/relay.ts +++ b/src/relay.ts @@ -3,6 +3,7 @@ // relay.go; policy is per relay and owner-managed (see manage.ts). import { callbackOrigins } from "./push-policy.ts"; import { pushTick, queuePush, nextPush, PUSH_SCHEMA } from "./push.ts"; +import { BACKUP_SCHEMA, backupBytes } from "./backups.ts"; import { DELIVERY_SCHEMA, deliveryTick, queueDelivery } from "./delivery.ts"; import { graspTick, isGitPath, graspCORS } from "./grasp.ts"; import { graspBytes, holdGrasp, graspVisible } from "./grasp-state.ts"; @@ -158,7 +159,8 @@ export class Relay extends DurableObject { this.settings.load(); this.sql.exec(PUSH_SCHEMA); this.sql.exec(DELIVERY_SCHEMA); - this.store.hidden = this.settings.hiddenEvents; + this.sql.exec(BACKUP_SCHEMA); + this.store.hidden = this.settings.hiddenEvents; this.store.searchMode = () => this.settings.policy.features.search; this.fuel.init(); this.slug = (await ctx.storage.get("slug")) ?? ""; @@ -501,6 +503,7 @@ export class Relay extends DurableObject { this.settings.load(); this.sql.exec(PUSH_SCHEMA); this.sql.exec(DELIVERY_SCHEMA); + this.sql.exec(BACKUP_SCHEMA); this.store.hidden = this.settings.hiddenEvents; this.store.searchMode = () => this.settings.policy.features.search; this.fuel.init(); @@ -768,7 +771,7 @@ export class Relay extends DurableObject { // Files and dumps both live in R2 and both cost media storage. mediaBytes(): number { - return blobBytes(this.sql) + dumpBytes(this.sql) + importBytes(this.sql) + graspBytes(this); + return blobBytes(this.sql) + dumpBytes(this.sql) + importBytes(this.sql) + graspBytes(this) + backupBytes(this); } fuelStatus() { diff --git a/test/object/backups.test.ts b/test/object/backups.test.ts index b9771b1..931dea6 100644 --- a/test/object/backups.test.ts +++ b/test/object/backups.test.ts @@ -1,15 +1,83 @@ // Portable backup archives cover configuration, events, site media and Git // bytes, while refusing tampering, the wrong signer and non-fresh targets. -import { env, runInDurableObject } from "cloudflare:test"; +import { SELF, env, runInDurableObject } from "cloudflare:test"; import { describe, expect, it } from "vitest"; import { generateSecretKey } from "nostr-tools/pure"; -import { createBackup, restoreBackup } from "../../src/backups.ts"; +import { BACKUP_MAX_BYTES, createBackup, restoreBackup } from "../../src/backups.ts"; import { storeBlob } from "../../src/blossom.ts"; import type { Relay } from "../../src/relay.ts"; -import { ev, pk, rpc } from "../helpers/relay.ts"; +import { ev, nip98, pk, rpc } from "../helpers/relay.ts"; import { WS } from "../helpers/ws.ts"; describe("portable backups", () => { + it("serves owner-only download and keeps signed preview read-only", async () => { + const source = "backup-http-source.bind.ws"; + const target = "backup-http-target.bind.ws"; + const owner = generateSecretKey(); + const stranger = generateSecretKey(); + await rpc(source, owner, "claim"); + const archive = await runInDurableObject(env.RELAY.getByName("backup-http-source"), async (relay: Relay) => { + await relay.store.save(ev(owner, 1, "http backup"), 1); + expect(typeof await createBackup(relay, "http-test")).not.toBe("string"); + const object = await relay.media.get("backup-http-source/backups/http-test.json"); + return new Uint8Array(await object!.arrayBuffer()); + }); + const downloadURL = `http://${source}/backups/http-test`; + expect((await SELF.fetch(downloadURL)).status).toBe(401); + expect((await SELF.fetch(downloadURL, { headers: { authorization: await nip98(stranger, downloadURL) } })).status).toBe(403); + const downloaded = await SELF.fetch(downloadURL, { headers: { authorization: await nip98(owner, downloadURL) } }); + expect(downloaded.status).toBe(200); + expect(new Uint8Array(await downloaded.arrayBuffer())).toEqual(archive); + + const previewURL = `http://${target}/backups/preview`; + const before = await SELF.fetch(`http://${target}/`, { headers: { accept: "application/nostr+json" } }); + const preview = await SELF.fetch(previewURL, { method: "POST", headers: { authorization: await nip98(owner, previewURL, "POST", JSON.parse(new TextDecoder().decode(archive))) }, body: new TextDecoder().decode(archive) }); + expect(preview.status).toBe(200); + expect((await preview.clone().json()).result).toMatchObject({ preview: true, targetIsFresh: true }); + expect((await preview.clone().json()).result.events).toBeGreaterThanOrEqual(1); + const after = await SELF.fetch(`http://${target}/`, { headers: { accept: "application/nostr+json" } }); + expect(await before.text()).toBe(await after.text()); + // The preview response is metadata, not a restore capability or archive. + const previewBody = await preview.text(); + const restoreWithPreview = await SELF.fetch(`http://${target}/backups/restore`, { method: "POST", headers: { authorization: await nip98(owner, `http://${target}/backups/restore`, "POST", JSON.parse(previewBody)) }, body: previewBody }); + expect(restoreWithPreview.status).toBe(400); + const restoreURL = `http://${target}/backups/restore`; + const body = new TextDecoder().decode(archive); + const previewToken = await nip98(owner, previewURL, "POST", JSON.parse(body)); + expect((await SELF.fetch(restoreURL, { method: "POST", headers: { authorization: previewToken }, body })).status).toBe(401); + const restored = await SELF.fetch(restoreURL, { method: "POST", headers: { authorization: await nip98(owner, restoreURL, "POST", JSON.parse(body)) }, body }); + expect(restored.status, await restored.clone().text()).toBe(200); + expect((await restored.json()).result.restored).toBe(true); + }); + + it("rejects a tampered signed body and a valid body signed by the wrong owner", async () => { + const source = "backup-http-tamper-source.bind.ws"; + const target = "backup-http-tamper-target.bind.ws"; + const owner = generateSecretKey(); + const wrong = generateSecretKey(); + await rpc(source, owner, "claim"); + const archive = await runInDurableObject(env.RELAY.getByName("backup-http-tamper-source"), async (relay: Relay) => { + await relay.store.save(ev(owner, 1, "tamper"), 1); + await createBackup(relay, "tamper-http"); + const object = await relay.media.get("backup-http-tamper-source/backups/tamper-http.json"); + return new TextDecoder().decode(await object!.arrayBuffer()); + }); + const restoreURL = `http://${target}/backups/restore`; + const changed = archive.slice(0, -2) + (archive.endsWith("}") ? " ]" : " }"); + const badAuth = await nip98(owner, restoreURL, "POST", JSON.parse(archive)); + expect((await SELF.fetch(restoreURL, { method: "POST", headers: { authorization: badAuth }, body: changed })).status).toBe(401); + const wrongAuth = await nip98(wrong, restoreURL, "POST", JSON.parse(archive)); + const denied = await SELF.fetch(restoreURL, { method: "POST", headers: { authorization: wrongAuth }, body: archive }); + expect(denied.status).toBe(403); + expect(await denied.text()).toContain("backup owner"); + }); + + it("caps chunked restore uploads without a Content-Length", async () => { + const url = "http://backup-stream-cap.bind.ws/backups/restore"; + const response = await SELF.fetch(url, { method: "POST", headers: { authorization: await nip98(generateSecretKey(), url, "POST") }, body: new ReadableStream({ start(controller) { controller.enqueue(new Uint8Array(BACKUP_MAX_BYTES)); controller.enqueue(new Uint8Array(1)); controller.close(); } }) }); + expect(response.status).toBe(413); + }); + it("round trips configuration, events, a media blob and a Git object, with preview and exclusions", async () => { const source = "backup-source.bind.ws"; const owner = generateSecretKey(), writer = generateSecretKey(); @@ -18,6 +86,13 @@ describe("portable backups", () => { const c = await WS.connect(source); await c.ok(ev(writer, 1, "retained")); const archive = await runInDurableObject(env.RELAY.getByName("backup-source"), async (relay: Relay) => { + const oldList = ev(owner, 3, "older", [], 10); + relay.store.save(oldList, 10); + relay.store.save(ev(owner, 3, "latest", [], 11), 11); + const held = ev(writer, 1, "hidden state"); + relay.store.save(held, 12); + relay.settings.setEvent(held.id, "hide"); + relay.sql.exec(`INSERT INTO grasp_pending(id,until) VALUES(?,?)`, held.id, 9999999999); await storeBlob(relay, new TextEncoder().encode("site bytes"), "text/plain", pk(writer), 10); await relay.media.put("backup-source/git/test-object", new TextEncoder().encode("git bytes")); await relay.store.save(ev(owner, 30390, '{"callback":"https://secret.invalid"}'), 10); @@ -40,6 +115,9 @@ describe("portable backups", () => { expect(preview.git).toBe(1); await runInDurableObject(env.RELAY.getByName("backup-target"), async (relay: Relay) => { expect(relay.settings.policy.name).toBe("Recovered"); + expect(relay.store.listHistory(pk(owner), Math.floor(Date.now() / 1000))).toHaveLength(1); + expect(relay.settings.hiddenEvents.size).toBe(1); + expect(relay.sql.exec(`SELECT count(*) n FROM grasp_pending`).one().n).toBe(1); expect((await relay.media.get("backup-target/" + ""))).toBeNull(); expect((await relay.media.get("backup-target/" + "git/test-object"))?.size).toBe(9); }); diff --git a/test/object/delivery.test.ts b/test/object/delivery.test.ts index 9191210..21d5db7 100644 --- a/test/object/delivery.test.ts +++ b/test/object/delivery.test.ts @@ -1,9 +1,32 @@ +import { env, runInDurableObject } from "cloudflare:test"; +import { deliveryTick, queueDelivery } from "../../src/delivery.ts"; +import type { Relay } from "../../src/relay.ts"; import { describe, expect, it } from "vitest"; import { generateSecretKey } from "nostr-tools/pure"; import { ev, pk, rpc, alarm } from "../helpers/relay.ts"; import { WS } from "../helpers/ws.ts"; describe("NIP-65 automatic delivery", () => { + it("retries a refused target to its finite limit and stops on private relay policy", async () => { + const owner = generateSecretKey(), targetOwner = generateSecretKey(); + const source = "retry-source.bind.ws", target = "retry-target.bind.ws"; + await rpc(source, owner, "claim"); await rpc(target, targetOwner, "claim"); + await rpc(target, targetOwner, "setpolicy", { writes: "owner" }); + await rpc(source, owner, "setpolicy", { delivery: { enabled: true, maxTargets: 1 } }); + await runInDurableObject(env.RELAY.getByName("retry-source"), async (relay: Relay) => { + relay.store.save(ev(owner, 10002, "", [["r", "wss://" + target]]), 1); + const note = ev(owner, 1, "retry me"); relay.store.save(note, 1); + expect(queueDelivery(relay, note)).toBe(true); + for (let i = 0; i < 4; i++) { + relay.sql.exec(`UPDATE delivery_queue SET due=0`); + await deliveryTick(relay); + } + expect(relay.sql.exec(`SELECT status,attempts FROM delivery_queue WHERE event_id=?`, note.id).one()).toMatchObject({ status: "rejected", attempts: 4 }); + relay.settings.update({ reads: "members" }); + expect(queueDelivery(relay, ev(owner, 1, "private relay note"))).toBe(false); + }); + }); + it("routes public events to the author's write relay and exposes target status", async () => { const owner = generateSecretKey(); const source = "auto-source.bind.ws", target = "auto-target.bind.ws"; diff --git a/test/object/exposure.test.ts b/test/object/exposure.test.ts index 53a127f..4e614b5 100644 --- a/test/object/exposure.test.ts +++ b/test/object/exposure.test.ts @@ -79,6 +79,9 @@ function doors(f: Fixture): { path: string; host?: string; method?: string; gate { path: "/terms", gated: false }, { path: "/api/join-policy", gated: false }, { path: "/.well-known/nostr/nip96.json", gated: false }, + { path: "/backups/private", gated: true }, + { path: "/backups/preview", method: "POST", gated: true }, + { path: "/backups/restore", method: "POST", gated: true }, ]; } diff --git a/test/object/jobs.test.ts b/test/object/jobs.test.ts index 995246d..ea5e2e5 100644 --- a/test/object/jobs.test.ts +++ b/test/object/jobs.test.ts @@ -126,6 +126,26 @@ describe("rebroadcast", () => { }); describe("rebroadcast cursor safety", () => { + it("preserves a failed target independently and resumes it after a successful target", async () => { + const owner = generateSecretKey(), targetOwner = generateSecretKey(); + const src = "mixed-source.bind.ws", good = "mixed-good.bind.ws", bad = "mixed-bad.bind.ws"; + const events = Array.from({ length: 6 }, (_, i) => ev(owner, 1, "mixed " + i)); + await relay(src, owner, events); + await relay(good, targetOwner); + const blocked = await relay(bad, targetOwner); + await rpc(bad, targetOwner, "setpolicy", { writes: "owner" }); + const added = await rpc(src, owner, "addjob", { kind: "push", relays: ["wss://" + good, "wss://" + bad], filter: { kinds: [1] } }); + let job = (await drive(src, owner)).find((j) => j.id === added.result.id)!; + expect(job.targetCursors?.["wss://" + good]).toBeGreaterThan(0); + expect(job.targetCursors?.["wss://" + bad]).toBe(0); + expect(job.last?.error).toBeTruthy(); + await rpc(bad, targetOwner, "setpolicy", { writes: "open" }); + await rpc(src, owner, "runjob", job.id); + job = (await drive(src, owner)).find((j) => j.id === added.result.id)!; + expect(job.last?.sent).toBe(6); + expect((await blocked.req({ kinds: [1] })).map((e) => e.id).sort()).toEqual(events.map((e) => e.id).sort()); + }); + it("does not advance a target past an unprocessed refusal tail", async () => { const owner = generateSecretKey(); const src = "cursor-tail.bind.ws", target = "cursor-tail-target.bind.ws"; diff --git a/test/object/lease.test.ts b/test/object/lease.test.ts index f68f5ed..24b5e1f 100644 --- a/test/object/lease.test.ts +++ b/test/object/lease.test.ts @@ -192,7 +192,7 @@ describe("pull from another relay", () => { expect(last.skipped).toBe(1); }); - it("gives up on a source that refuses to sync, and says why", async () => { + it("falls back from sync and records a source that also refuses ordinary queries", async () => { const owner = generateSecretKey(); const closedHost = "closed.bind.ws"; await rpc(closedHost, owner, "claim"); @@ -200,7 +200,9 @@ describe("pull from another relay", () => { const host = "puller.bind.ws"; await rpc(host, owner, "claim"); const last = await pull(host, owner, "wss://" + closedHost); - expect(last.error).toMatch(/^sync refused: auth-required/); - expect(last.rounds).toBe(3); + expect(last.error).toMatch(/import source.*incomplete/); + expect(last.rounds).toBe(2); + const jobs = (await rpc(host, owner, "listjobs")).result; + expect(jobs[0].last.sources[0]).toMatchObject({ mode: "query", status: "refused", error: expect.stringContaining("auth-required") }); }); }); From 00b6cfa654c15df0c46bb8833422d200999ccc02 Mon Sep 17 00:00:00 2001 From: Dami Date: Fri, 4 Sep 2026 18:54:57 -0600 Subject: [PATCH 14/14] test: checkpoint migration allows hosted CI time for 260 sequential commits Scopes a 90-second timeout to the migration fixtures after the growing-ref case completed in about 41 seconds on CI against a 30-second deadline. Keeps the workload and assertions intact. --- test/object/grasp-checkpoint.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/object/grasp-checkpoint.test.ts b/test/object/grasp-checkpoint.test.ts index c85f736..353d685 100644 --- a/test/object/grasp-checkpoint.test.ts +++ b/test/object/grasp-checkpoint.test.ts @@ -40,6 +40,8 @@ const announcement = (sk: Uint8Array, host: string, identifier: string) => ev(sk ]); describe("GRASP checkpoint integration", () => { + // Each migration performs 260 sequential commits and retained-object scans. + // Hosted CI takes over 30 seconds for the growing-ref fixture. it.each(["growing", "fixed"])("migrates an isolated %s-ref repository and reconciles retained storage", async (workload) => { const host = `grasp-checkpoint-${workload}.bind.ws`; const owner = generateSecretKey(); @@ -139,7 +141,7 @@ describe("GRASP checkpoint integration", () => { expect(measurements.retained.currentBytes).toBeLessThan(measurements.retained.physicalBytes); expect(measurements.retained.categories["unreferenced/records"]).toBeUndefined(); expect(measurements.retained.categories["unreferenced/packs"]).toBeUndefined(); - }); + }, 90_000); it("retains a reservation when an immutable checkpoint write is ambiguous", async () => { const host = "grasp-checkpoint-ambiguous.bind.ws";