diff --git a/README.md b/README.md index c9200c1..90d4dff 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ Site hosting and mirroring are on by default; Marmot and GRASP are off until ena - [NIP-AD web addresses](docs/23-nip-ad-web-addresses.md): resolve relay, article and site URLs to their Nostr counterparts. - [GRASP-01 Git hosting](docs/22-grasp-01-git-hosting.md): host bounded NIP-34 repositories through Git Smart HTTP. - [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. ### Protocol guides diff --git a/docs/01-relay-configuration.md b/docs/01-relay-configuration.md index d1de2af..790dda5 100644 --- a/docs/01-relay-configuration.md +++ b/docs/01-relay-configuration.md @@ -139,3 +139,13 @@ The relay as a thing you hold. ### If I lose my key Name a member as your heir and a delay of 90, 180 or 365 days of silence. Past it the relay warns you for 30 days, then hands itself to the heir. See [People and groups](03-people-and-groups.md#if-you-lose-your-key). + +## Relay push + +Relay push is off by default. Under Features, enable it and enter callback +origins approved by your host operator. Members register their own callback +paths using signed kind 30390 events; callback work costs fuel. The +`features.push`, `pushCallbacks` and `letteredNips` settings travel with relay +configurations. Push requires mixed numeric and lettered NIP advertisements, +which some older clients reject. See [NIP-9a relay push](25-nip-9a-relay-push.md) +and [NIP-11 identifier compatibility](26-nip11-compatibility.md). diff --git a/docs/20-nip-5a-static-websites.md b/docs/20-nip-5a-static-websites.md index 7fbc1c0..926f643 100644 --- a/docs/20-nip-5a-static-websites.md +++ b/docs/20-nip-5a-static-websites.md @@ -123,11 +123,12 @@ site paths to the live manifest under the same site authentication and read rules, with the hosting relay as its hint. Custom site domains retain their site origin; discovery does not open the hosting relay's other HTTP doors. -bind.ws does not advertise NIP-5A in `supported_nips`: NIP-11 still defines -that field as integer identifiers, while 5A is a lettered draft and does not -ask relays to advertise it. The relay advertises `nsites` instead. Follow -upstream [pull request 1621](https://github.com/nostr-protocol/nips/pull/1621) -for the eventual representation. +bind.ws advertises `5A` in `supported_nips` when sites are enabled and the +owner opts into lettered NIP identifiers (or enables relay push). Numeric +entries keep their JSON number type. `nsites` remains available while sites +are enabled, including in the default numeric-only mode. Some clients reject +lettered entries; see [NIP-11 identifier compatibility](26-nip11-compatibility.md). +`5A` is a literal identifier, never decimal 90. The implementation tracks upstream changes to the label grammar, aggregate hash, snapshot and copy tags, and the status of kind 34128. If those change, diff --git a/docs/25-nip-9a-relay-push.md b/docs/25-nip-9a-relay-push.md new file mode 100644 index 0000000..ba10770 --- /dev/null +++ b/docs/25-nip-9a-relay-push.md @@ -0,0 +1,161 @@ +--- +title: NIP-9a relay push +audience: developer +--- + +# NIP-9a relay push + +bind.ws implements opt-in relay-to-callback delivery for community and inbox +notifications, Git activity and automation hooks. A callback can bridge to a +mobile notification service. bind.ws does not provide device registration, +FCM, APNs or Web Push infrastructure. + +The implementation pins [proposal 2194](https://github.com/nostr-protocol/nips/pull/2194) +to [5a908b8dc5190a46eac47992cba5ba6d8e7fc094](https://github.com/coracle-social/nips/blob/5a908b8dc5190a46eac47992cba5ba6d8e7fc094/9a.md), +checked 2026-09-04. This is an unmerged draft; clients should periodically +resync their registrations and follow proposal changes. + +## Enable delivery + +The host operator sets `PUSH_CALLBACK_ORIGINS` to a JSON array of trusted, +public HTTPS origins in the selected Wrangler or celld environment. The +shipped value is `"[]"`, so no endpoint receives requests by default: + +```json +"PUSH_CALLBACK_ORIGINS": "[\"https://push.example.com\"]" +``` + +The relay owner enables push and selects origins using the console's +Features settings, `setpolicy`, or a relay configuration: + +```json +{ + "format": "bind.ws/relay-config/2", + "policy": { + "features": { "push": true }, + "pushCallbacks": ["https://push.example.com"] + } +} +``` + +Both origin lists must authorize a callback. Owners cannot expand the host's +list. Entries are exact HTTPS origins without paths, credentials or wildcard +hosts; callback registrations supply the path and query. Only public DNS +names on port 443 are accepted. IP literals, local names, fragments and +redirects are refused. The host operator must trust the endpoint's operator +and DNS control and keep it publicly routed: the origin allowlist is the +SSRF boundary, not a DNS preflight that could race a later resolution. A host +must not approve arbitrary tenant-controlled origins or callback services +that proxy arbitrary destinations. No credentials or cookies are attached. + +The existing templates keep push off. Templates with kind allowlists must +also allow kind `30390` before members can register. Outgoing traffic and +background work use the relay's fuel budget; queue storage is part of its +SQLite storage. + +## Register and receive + +The owner or a current member authenticates with NIP-42 and publishes a +signed addressable event. The HTTP bridge can instead authenticate the same +author using NIP-98. Ordinary relay write and kind restrictions still apply. + +```json +{ + "kind": 30390, + "content": "", + "tags": [ + ["d", "community-inbox"], + ["relay", "wss://community.bind.ws/"], + ["filter", "{\"kinds\":[1,1111],\"#p\":[\"\"]}"], + ["ignore", "{\"#t\":[\"muted-topic\"]}"], + ["callback", "https://push.example.com/opaque-secret-token"], + ["include_event"] + ] +} +``` + +For Git activity, filters can select accepted repository state or +collaboration events, for example `{"kinds":[30618,1617,1621]}`. Events held +pending Git admission do not trigger delivery until accepted. Filters match +new events only; registration does not backfill history. Multiple `filter` +tags are ORed, and any matching `ignore` suppresses delivery. + +The callback receives `POST`, `Content-Type: application/json`: + +```json +{ + "id": "", + "relay": "wss://community.bind.ws/", + "event": { "id": "", "pubkey": "...", "kind": 1 } +} +``` + +The actual `event` is the complete signed Nostr event and is present only +when the registration contains `include_event`. A receiver should verify +included signatures, treat URL tokens as secrets, and deduplicate by relay, +registration destination and event ID. The payload itself has no separate +relay signature. An ID-only notification still reveals an event's existence, +so it uses the same read authorization as full-event delivery. + +## Privacy and revocation + +Kind 30390 is author-only regardless of feature state. Authenticated authors +can read their own registrations through REQ, COUNT, NIP-77 and the HTTP +bridge, subject to the relay's current read rule. A `p` tag grants no access. +The relay owner has no special read override for another author's callbacks. +Registrations are excluded from shared dashboard views, dumps, forks and +rebroadcast jobs. Config exports contain origin policy, never registration +callback paths. Clients must export or resync their own registrations. + +Every callback attempt rechecks the current registration, membership, bans, +write/read rules, callback approvals and target event visibility. Removing a +member deletes their registrations. Changing callback approval or read/write +policy cancels queued work. Hiding/deleting an event, replacing or +deleting a registration, or disabling push prevents later delivery. An +already dispatched HTTP request cannot be recalled. A `404` removes the +registration. NIP-09 deletions and NIP-62 vanish requests remove it through the +normal event store. The queue holds event and registration IDs, not copies +that could outlive deletion. + +## Bounds and retries + +Delivery runs from the existing Durable Object alarm, after publication has +returned. Queue persistence precedes external I/O. Delivery is best effort: +finite queues and retries can lose notifications, and ambiguous responses +can produce duplicates. Receivers must not assume exactly-once delivery. + +| Limit | Value | +| --- | --- | +| Registrations | 32 per relay, 4 per author, 8 KiB UTF-8 each | +| Filters | 8 matching and 8 ignore filters; standard event fields/tag filters | +| Pending deliveries | 256 references, 24-hour expiry | +| Alarm batch | 4 attempts, 5 seconds per HTTP request | +| Retries | 4 total attempts; 30, 120 and 600 seconds between failures | +| Deduplication | 2,048 terminal outcomes, at most 7 days | +| Event input | Stored JSON up to 1 MiB characters; larger imported/HTTP events are skipped | +| Payload | 4 MiB plus 4 KiB envelope allowance; complete event when included | + +Timeouts, network failures, 429 and 5xx responses retry; other non-2xx +responses end that delivery. Redirects are never followed. Attempts are +reserved durably before HTTP starts, so a crash also consumes an attempt. +A `404` deletes the registration. Response bodies are canceled unread. +Queue saturation drops new work instead of delaying event acceptance. +No successful registration promises unlimited delivery or an indefinitely +retained event payload. `include_event` always carries the full event for +accepted deliveries; ID-only callbacks can reduce traffic. `search`, `limit` +and unknown filter fields are rejected rather than silently broadened. + +NIP-11 keeps existing identifiers numeric and includes literal `9a` while +push is enabled. The same mode includes enabled `5A` and implemented `AD`. +Some clients reject mixed arrays; see +[NIP-11 identifier compatibility](26-nip11-compatibility.md). Disable push +and `letteredNips` to restore numeric-only advertisement. + +## Validation + +The workerd object tests use a controlled callback receiver and injected +fetch boundary; they do not POST to public notification services. They cover +registration, asynchronous delivery, authorization changes, privacy across +read/export paths, callback failures, deletion and queue bounds. This proves +the relay payload and lifecycle contract, not interoperability with every +mobile push service. The callback service remains application infrastructure. diff --git a/docs/26-nip11-compatibility.md b/docs/26-nip11-compatibility.md new file mode 100644 index 0000000..e18d48a --- /dev/null +++ b/docs/26-nip11-compatibility.md @@ -0,0 +1,73 @@ +--- +title: NIP-11 identifier compatibility +audience: developer +--- + +# NIP-11 identifier compatibility + +## Wire contract + +bind.ws preserves the existing numeric `supported_nips` array by default. +`policy.letteredNips: true` opts into mixed numbers and lettered strings. +`features.push: true` also activates mixed output because NIP-9a requires +literal `9a` advertisement. Disabling both restores numeric-only output. +Existing numbers never become strings or hexadecimal conversions. + +| Capability | Numeric-only mode | Mixed mode | +| --- | --- | --- | +| Existing numeric NIPs | Existing numbers and feature gates | Same numbers and gates | +| NIP-43 | `43` when relay identity exists | Same | +| NIP-5A | `nsites` while sites are enabled | Also `"5A"` while sites are enabled | +| NIP-9a | Push is off | `"9a"` while push is enabled | +| NIP-AD | Discovery endpoint remains available | `"AD"`; homepage discovery has no feature toggle | + +Turning off sites removes `5A` and `nsites`, not `AD`: AD's page and site +mappings follow their feature/read gates, while its relay homepage mapping +remains implemented. NIP-AD is present in the main-branch baseline for this advertisement change. +GRASP keeps `supported_grasps`; Marmot and Blossom are separate protocol +families. None becomes an invented decimal NIP. In particular `5A` is not 90. + +The [current NIP-11](https://github.com/nostr-protocol/nips/blob/656cecc7c0a815b6a2b218d3b5d6f078b3f4dbab/11.md#supported-nips) +still specifies integers. [PR 2218](https://github.com/nostr-protocol/nips/pull/2218) +is open at the checked revision +[`b4bad62ad44a900d6800e83c1f83549f2f705c33`](https://github.com/coracle-social/nips/blob/b4bad62ad44a900d6800e83c1f83549f2f705c33/11.md#supported-nips) +(2026-09-04). It proposes string identifiers and normalization of historical +integers. Mixed output is a deliberate transitional choice, not a claim that +the current integer-only specification has already changed. + +## Concrete parser evidence + +The following source revisions were inspected on 2026-09-04. Source behavior +is distinguished from testing a complete released application. + +| Client/library | Evidence | Mixed vs all strings | +| --- | --- | --- | +| Installed nostr-tools 2.25.1 | [fetchRelayInformation](https://github.com/nbd-wtf/nostr-tools/blob/v2.25.1/nip11.ts) returns `response.json()` without validating or normalizing the array; its TypeScript declaration says `number[]`. An injected-response run of the installed implementation accepted both arrays. | Mixed `[1,11,77,"9a"]` preserves `.includes(77)`. All strings makes that numeric lookup false. Neither array is rejected by this fetch helper itself. | +| Amethyst | [Model at a22bc0d](https://github.com/vitorpamplona/amethyst/blob/a22bc0db14364a3192a69581d08631bfd4f82e04/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt) uses a custom [FlexibleIntListSerializer](https://github.com/vitorpamplona/amethyst/blob/a22bc0db14364a3192a69581d08631bfd4f82e04/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/FlexibleIntListSerializer.kt): it reads each JSON primitive as text. Its serializer emits numeric identifiers as numbers and nonnumeric identifiers as strings. | Explicit parser support for numbers, strings and mixed arrays. This is stronger evidence than the model's `List` declaration alone. | +| Damus | [RelayMetadata at 2ad6f02](https://github.com/damus-io/damus/blob/2ad6f02372b842def26ed193e027eebea0d5f111/damus/Core/Nostr/Relay.swift#L126) has `supported_nips: [Int]?`. [fetch_relay_metadata](https://github.com/damus-io/damus/blob/2ad6f02372b842def26ed193e027eebea0d5f111/damus/Features/Timeline/Models/HomeModel.swift#L1183) uses `JSONDecoder().decode(RelayMetadata.self, from: data)`. | Both mixed and all-string arrays fail this metadata decode. The integer-only Swift decoder shape was reproduced locally. This does not establish that WebSocket connectivity fails; the [negentropy path](https://github.com/damus-io/damus/blob/2ad6f02372b842def26ed193e027eebea0d5f111/damus/Core/Nostr/RelayConnection.swift#L306) tolerates a metadata fetch failure with `try?`. | +| Coracle / Welshman | [Coracle manifest at 544fe55](https://github.com/coracle-social/coracle/blob/544fe559e234942fec0e889cd6edcd8b7b1cf6e2/package.json) and [Welshman net 0.8.15 declaration](https://unpkg.com/@welshman/net@0.8.15/dist/util/src/Relay.d.ts) use string identifiers; the PR author reports support. | A declaration and maintainer report establish intent, not proof that every numeric lookup normalizes mixed input. No full-client run was performed. | +| Flotilla | [Fixtures at 801568d](https://gitea.coracle.social/coracle/flotilla/src/commit/801568d59091c05c87c2a49ff6ffa195bac1a0be/e2e/specs/settings.spec.ts) use string NIP arrays; the PR author reports support. | Evidence for string-array use, not an independent full-client mixed-array test. | +| Primal web | [Package at c96ee21](https://github.com/PrimalHQ/primal-web-app/blob/c96ee211043c6fee8a8b7c431746aab06392f765/package.json) uses nostr-tools 2.23.1. The inspected direct NIP-11 use reads a premium server version. | General relay capability handling remains unverified. A dependency version alone does not prove rejection or compatibility. | +| Rust nostr crate | [Pinned struct and decoder test](https://github.com/nostrdevkit/nostr/blob/0c6fad2ac8ce934747096953f6dba355e3532614/nostr/src/nips/nip11.rs) derives serde `Deserialize` with `Option>`. Its test rejects an array containing a string. | Both arrays containing `"9a"` and all-string arrays fail the integer decoder. This is a specific parser, not a claim about every Rust client. | +| Go fork inspected | [frnandu/go-nostr at fc34d8e](https://github.com/frnandu/go-nostr/blob/fc34d8e7a8c21647abe83c34a1bc731dae0a734e/nip11/types.go) uses `SupportedNIPs []any`. | The array field permits mixed values. Older `[]int` examples must not be used as evidence about this revision. Semantic numeric comparison still depends on the caller. | + +The PR author also reports Zooid support; its parser was not independently +verified. These are representative libraries and clients, including clients +linked by bind.ws, not a universal compatibility certification. TypeScript +`number[]`, fixtures, or an `.includes(number)` test alone are not evidence +that a JSON document will be rejected at runtime. + +## Why mixed output + +All-string output changes every existing numeric capability lookup and still +fails strict integer decoders. Mixed output keeps numeric consumers working +where they accept arbitrary JSON values, including the installed nostr-tools +fetch helper. It cannot protect Damus metadata or strict serde decoding from +a lettered entry. The explicit numeric-only default prevents the existing, +default-on sites feature from unexpectedly changing every relay's wire type. + +Operators who need strict-client metadata compatibility leave both push and +`letteredNips` off. Owners who need callback delivery accept the draft +advertisement tradeoff; other relay protocols remain usable according to each +client's handling of metadata failures. The console and +[NIP-9a relay push](25-nip-9a-relay-push.md) document this choice. diff --git a/relay-config.schema.json b/relay-config.schema.json index a33263b..c56f81a 100644 --- a/relay-config.schema.json +++ b/relay-config.schema.json @@ -63,6 +63,8 @@ "properties": { "depth": { "type": "integer", "minimum": 0 }, "quota": { "type": "integer", "minimum": 0 } } }, "views": { "type": "object", "additionalProperties": { "anyOf": [{ "type": "boolean" }, { "enum": ["off", "write", "hourly", "daily"] }] }, "description": "a view named here runs on that trigger, or is off; the rest run on their own trigger. true and false still mean the default and off" }, + "pushCallbacks": { "type": "array", "maxItems": 16, "items": { "type": "string", "maxLength": 2048 }, "description": "Exact HTTPS callback origins, also approved by the host operator" }, + "letteredNips": { "type": "boolean", "description": "Mixed numeric and lettered NIP-11 identifiers. Push also activates this representation." }, "features": { "type": "object", "additionalProperties": false, @@ -84,7 +86,8 @@ } } ] }, - "grasp": { "type": "boolean" } + "grasp": { "type": "boolean" }, + "push": { "type": "boolean" } } } } diff --git a/src/console/console.html b/src/console/console.html index 8990fec..98a7b81 100644 --- a/src/console/console.html +++ b/src/console/console.html @@ -232,6 +232,11 @@

Kinds

Features

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

+
+ + +
+

Blocked words

diff --git a/src/console/console.js b/src/console/console.js index 146d5b3..86c4f8a 100644 --- a/src/console/console.js +++ b/src/console/console.js @@ -333,6 +333,8 @@ $("#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; fa.eventsPerMinute.value = p.eventsPerMinute; fa.reqsPerMinute.value = p.reqsPerMinute; fa.maxBlobMB.value = p.maxBlobMB; renderFeatures(p.features || {}); + $("#push-policy-form").elements.origins.value = (p.pushCallbacks || []).join("\n"); + $("#push-policy-form").elements.lettered.checked = !!p.letteredNips; 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(); @@ -414,6 +416,7 @@ ["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"]], ["marmot", "Marmot transport", "Signed KeyPackages and encrypted group messages, with account admission for ephemeral authors."], ["grasp", "Git repositories", "GRASP Git hosting with admitted repository state. The prototype backend has bounded storage and compute limits."], + ["push", "Relay push", "NIP-9a callback delivery for members and the owner. Requires approved callback origins; advertises lettered NIP identifiers."], ["signer", "Signer traffic", "NIP-46 remote signing carried for anyone, never stored."], ]; function renderFeatures(f) { @@ -430,6 +433,16 @@ toast(k === "search" ? "Search: " + v : (v ? "Switched on " : "Switched off ") + k); await loadInfo(); })); + $("#push-policy-form").addEventListener("submit", guard(async (ev) => { + 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 }); + 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(); + })); + async function loadViews() { let views; try { views = await rpc("listviews"); } catch { return; } diff --git a/src/event.ts b/src/event.ts index 2bab632..7fd0bd4 100644 --- a/src/event.ts +++ b/src/event.ts @@ -1,5 +1,6 @@ // Event validation and helpers, mirroring event.go. import { verifyEvent, type Event } from "nostr-tools/pure"; +import { KIND_PUSH_REGISTRATION } from "./kinds.ts"; export type { Event }; @@ -7,8 +8,9 @@ export const isEphemeral = (k: number) => k >= 20000 && k < 30000; export const isReplaceable = (k: number) => k === 0 || k === 3 || (k >= 10000 && k < 20000); export const isAddressable = (k: number) => k >= 30000 && k < 40000; // Only served to the parties involved: NIP-04 DMs and NIP-59 gift wraps per -// NIP-17, and NIP-46 signer traffic, which is ephemeral and addressed by p. -export const isPrivate = (k: number) => k === 4 || k === 1059 || k === 21059 || k === 24133; +// NIP-17, NIP-46 signer traffic, which is ephemeral and addressed by p, and +// NIP-9a registrations, which are readable only by their author. +export const isPrivate = (k: number) => k === 4 || k === 1059 || k === 21059 || k === 24133 || k === KIND_PUSH_REGISTRATION; export const now = () => Math.floor(Date.now() / 1000); diff --git a/src/gates.ts b/src/gates.ts index 39f9017..405168d 100644 --- a/src/gates.ts +++ b/src/gates.ts @@ -4,6 +4,7 @@ // subscription's filters must clear for the socket that opened it: the // read rule, and the private kinds only their parties may see. Both answer // "" to let through, or the reason in NIP-01's prefix: form. +import { checkPush } from "./push.ts"; import { graspGate } from "./grasp-state.ts"; import { checkSite } from "./sites.ts"; import type { Relay, ConnState } from "./relay.ts"; @@ -18,6 +19,8 @@ import { marmotPrincipal, marmotShape } from "./marmot.ts"; // relay's state, fuel, and the one-group rule. "" lets it through. export function writeGate(relay: Relay, e: Event, conn: ConnState | null, t: number): string { if (relay.repositoryAccess.blocked) return "restricted: relay operation in progress; retry the event"; + const pushError = checkPush(relay, e, conn); + if (pushError) return pushError; const graspError = graspGate(relay, e, conn?.host ?? ""); if (graspError) return graspError; const siteError = checkSite(e); diff --git a/src/gen/console.ts b/src/gen/console.ts index 215a77f..1564f6e 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

              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
                            \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 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 [\"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 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 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"; diff --git a/src/kinds.ts b/src/kinds.ts index d786ff4..80daddd 100644 --- a/src/kinds.ts +++ b/src/kinds.ts @@ -69,3 +69,6 @@ export const KIND_GIT_PATCH = 1617; export const KIND_GIT_PR = 1618; export const KIND_GIT_PR_UPDATE = 1619; export const KIND_GIT_ISSUE = 1621; + +// NIP-9a relay push registrations, readable only by their author. +export const KIND_PUSH_REGISTRATION = 30390; diff --git a/src/nip11.ts b/src/nip11.ts index 3d2f3b0..95d17f6 100644 --- a/src/nip11.ts +++ b/src/nip11.ts @@ -16,12 +16,23 @@ import { nip11Views } from "./views.ts"; // (publishDiscovery); 77 is negentropy sync (handleSync). export const SUPPORTED_NIPS = [1, 5, 9, 11, 13, 17, 29, 40, 42, 45, 46, 50, 56, 59, 62, 66, 67, 70, 77, 86, 98]; // The numbers a switched-off feature takes with it (settings.ts, features). -export const FEATURE_NIPS: Record = { search: [50], sync: [77], count: [45], discovery: [66], names: [5], files: [], pages: [], signer: [46], sites: [], marmot: [], grasp: [] }; +export const FEATURE_NIPS: Record = { search: [50], sync: [77], count: [45], discovery: [66], names: [5], files: [], pages: [], signer: [46], sites: [], marmot: [], grasp: [], push: [] }; + +// Lettered identifiers retain their protocol spelling, never decimal values. +// New draft capabilities belong here only alongside their implementation. +export const LETTERED_NIPS: { id: string; enabled: (relay: Relay) => boolean }[] = [ + { id: "AD", enabled: () => true }, + { id: "5A", enabled: (relay) => featureOn(relay.settings.policy, "sites") }, + { id: "9a", enabled: (relay) => featureOn(relay.settings.policy, "push") }, +]; +export type NipIdentifier = number | string; // supportedNips is the list as it stands with the features that are on. -export function supportedNips(relay: Relay): number[] { +export function supportedNips(relay: Relay): NipIdentifier[] { const off = new Set(FEATURE_NAMES.filter((f) => !featureOn(relay.settings.policy, f)).flatMap((f) => FEATURE_NIPS[f])); - return SUPPORTED_NIPS.filter((n) => !off.has(n)); + const numeric = SUPPORTED_NIPS.filter((n) => !off.has(n)); + const mixed = relay.settings.policy.letteredNips || featureOn(relay.settings.policy, "push"); + return mixed ? [...numeric, ...LETTERED_NIPS.filter((n) => n.enabled(relay)).map((n) => n.id)] : numeric; } export const SOFTWARE = "https://bind.ws"; export const VERSION = "0.1.0"; diff --git a/src/nip66.ts b/src/nip66.ts index efd17a7..818092e 100644 --- a/src/nip66.ts +++ b/src/nip66.ts @@ -5,7 +5,7 @@ // content is the NIP-11 document. Nothing here is measured, so there are // no rtt or geohash tags: those are a monitor's to add. import type { Relay } from "./relay.ts"; -import { nip11 } from "./nip11.ts"; +import { nip11, type NipIdentifier } from "./nip11.ts"; export interface Discovery { url: string; @@ -35,7 +35,7 @@ export function discovery(relay: Relay, host: string): Discovery { const url = new URL(relay.relayURL(host)).toString(); const tags: string[][] = [["-"], ["d", url], ["n", "clearnet"]]; for (const t of relayTypes(p.writes, p.reads)) tags.push(["T", t]); - for (const n of doc.supported_nips as number[]) tags.push(["N", String(n)]); + for (const n of doc.supported_nips as NipIdentifier[]) tags.push(["N", String(n)]); const req = (key: string, on: boolean) => tags.push(["R", (on ? "" : "!") + key]); req("auth", lim.auth_required === true); req("writes", lim.restricted_writes === true); diff --git a/src/push-policy.ts b/src/push-policy.ts new file mode 100644 index 0000000..2164fc4 --- /dev/null +++ b/src/push-policy.ts @@ -0,0 +1,26 @@ +// Callback policy shared by configuration and delivery. The host's trusted +// origins and the relay owner's origins intersect; neither list is a URL proxy. +export function callbackOrigin(raw: unknown): string { + if (typeof raw !== "string" || raw.length > 2048) return ""; + try { + const u = new URL(raw); + if (u.protocol !== "https:" || u.username || u.password || u.hash || u.port) return ""; + const h = u.hostname; + // Only ordinary public DNS names. DNS ownership is trusted explicitly + // by the host operator, not delegated to relay members or tenants. + if (!/^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z][a-z0-9-]*$/.test(h)) return ""; + if (/\.(?:localhost|local|internal|lan|home|test|invalid|onion)$/.test(h)) return ""; + return u.origin; + } catch { return ""; } +} + +export function callbackOrigins(raw: unknown): string[] | null { + if (!Array.isArray(raw) || raw.length > 16) return null; + const out: string[] = []; + for (const value of raw) { + const origin = callbackOrigin(value); + if (!origin || (value !== origin && value !== origin + "/")) return null; + if (!out.includes(origin)) out.push(origin); + } + return out; +} diff --git a/src/push.ts b/src/push.ts new file mode 100644 index 0000000..1be867f --- /dev/null +++ b/src/push.ts @@ -0,0 +1,260 @@ +// NIP-9a relay push: bounded registrations, durable work and authenticated delivery. +// +// The queue keeps references rather than event payloads. A deleted event or +// registration therefore disappears at the next validation pass, and a +// callback never receives an event that the author can no longer read. +import { canonical, type Event } from "./event.ts"; +import { match, parseFilter, type Filter } from "./filter.ts"; +import { callbackOrigin } from "./push-policy.ts"; + +export const PUSH_KIND = KIND_PUSH_REGISTRATION; +export const PUSH_SCHEMA = ` +CREATE TABLE IF NOT EXISTS push_queue ( + registration_id TEXT NOT NULL, + event_id TEXT NOT NULL, + due INTEGER NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + expires INTEGER NOT NULL, + PRIMARY KEY (registration_id,event_id) +); +CREATE INDEX IF NOT EXISTS push_queue_due ON push_queue(due,expires); +CREATE TABLE IF NOT EXISTS push_delivered ( + registration_id TEXT NOT NULL, + event_id TEXT NOT NULL, + delivered_at INTEGER NOT NULL, + PRIMARY KEY (registration_id,event_id) +); +CREATE INDEX IF NOT EXISTS push_delivered_at ON push_delivered(delivered_at); +CREATE TRIGGER IF NOT EXISTS push_event_deleted AFTER DELETE ON events BEGIN + DELETE FROM push_queue WHERE registration_id=old.id OR event_id=old.id; + DELETE FROM push_delivered WHERE registration_id=old.id; +END; +CREATE TRIGGER IF NOT EXISTS push_member_removed AFTER DELETE ON members BEGIN + DELETE FROM events WHERE kind=30390 AND pubkey=old.pubkey; +END; +CREATE TRIGGER IF NOT EXISTS push_policy_changed AFTER UPDATE OF value ON settings +WHEN new.key='policy' AND ( + json_extract(new.value,'$.features.push') IS NOT 1 OR + json_extract(new.value,'$.owner') IS NOT json_extract(old.value,'$.owner') OR + json_extract(new.value,'$.pushCallbacks') IS NOT json_extract(old.value,'$.pushCallbacks') OR + json_extract(new.value,'$.reads') IS NOT json_extract(old.value,'$.reads') OR + json_extract(new.value,'$.writes') IS NOT json_extract(old.value,'$.writes') +) BEGIN + DELETE FROM push_queue; +END; +`; + +const MAX_REGISTRATIONS = 32; +const MAX_AUTHOR_REGISTRATIONS = 4; +const MAX_FILTERS = 8; +const MAX_REGISTRATION_BYTES = 8192; +const MAX_PENDING = 256; +// A NIP event can occupy up to the relay's one MiB message ceiling. The +// larger bound keeps include_event lossless while retaining a hard callback +// allocation limit and room for JSON envelope overhead. +const MAX_EVENT_TEXT = 1024 * 1024; +const MAX_PAYLOAD = 4 * MAX_EVENT_TEXT + 4096; +const MAX_DEDUP = 2048; +const MAX_ATTEMPTS = 4; +const RETRY_SECONDS = [30, 120, 600]; +const QUEUE_TTL = 24 * 60 * 60; +const DEDUP_TTL = 7 * 24 * 60 * 60; +const REQUEST_TIMEOUT = 5000; + +// All authorization uses the relay's current policy and event read path. +import type { Relay, ConnState } from "./relay.ts"; +import { KIND_PUSH_REGISTRATION } from "./kinds.ts"; +export type PushRelay = Relay; +export type PushConn = Pick; + +// rows consumes each SQL cursor before recording its billing units. +function rows>(r: Relay, query: string, ...args: SqlStorageValue[]): T[] { + const cursor = r.sql.exec(query, ...args); + const result = cursor.toArray(); + r.meterPush(cursor.rowsRead, cursor.rowsWritten); + return result; +} +const utf8Size = (s: string) => new TextEncoder().encode(s).byteLength; + +const tagValues = (e: Event, name: string) => e.tags.filter((t) => t[0] === name && t.length > 1).map((t) => t[1]); +const tag = (e: Event, name: string) => tagValues(e, name)[0] ?? ""; +const featureEnabled = (r: PushRelay) => r.settings.policy.features.push === true; +const ownRelay = (r: PushRelay) => { + try { return new URL(r.relayURL(r.slug + "." + r.domain)); } + catch { return null; } +}; + +function originAllowed(r: PushRelay, raw: string): string { + const origin = callbackOrigin(raw); + if (!origin) return ""; + const host = r.pushCallbackOrigins ?? []; + const owner = r.settings.policy.pushCallbacks ?? []; + return host.includes(origin) && owner.includes(origin) ? (() => { try { return new URL(raw).href; } catch { return ""; } })() : ""; +} + +function parseRegistration(e: Event, r: PushRelay): { relay: string; filters: Filter[]; ignores: Filter[]; callback: string; include: boolean } | string { + if (e.kind !== PUSH_KIND) return "invalid: not a push registration"; + if (utf8Size(canonical(e)) > MAX_REGISTRATION_BYTES) return "invalid: push registration is too large"; + const d = tagValues(e, "d"); + const relays = tagValues(e, "relay"); + const callbacks = tagValues(e, "callback"); + if (d.length !== 1 || !d[0] || d[0].length > 256) return "invalid: push registration needs one d tag"; + if (!relays.length || relays.length > 4 || callbacks.length !== 1) return "invalid: push registration needs relay and callback tags"; + const self = ownRelay(r); + const relayURL = relays.map((raw) => { try { return new URL(raw); } catch { return null; } }).find((u) => u && /^wss?:$/.test(u.protocol) && u.toString() === self?.toString())?.toString() ?? ""; + if (!self || !relayURL) return "invalid: registration does not name this relay"; + const callback = originAllowed(r, callbacks[0]); + if (!callback) return "restricted: callback origin is not approved by this relay"; + const read = (name: string): Filter[] | string => { + const vals = tagValues(e, name); + if (vals.length > MAX_FILTERS) return "invalid: too many push filters"; + const out: Filter[] = []; + for (const raw of vals) { + let parsed: unknown; + try { parsed = JSON.parse(raw); } catch { return "invalid: push filter is not JSON"; } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || Object.keys(parsed as Record).some((k) => !(["ids", "authors", "kinds", "since", "until"].includes(k) || (k.length === 2 && k[0] === "#")))) return "invalid: unknown push filter field"; + const f = parseFilter(parsed); + if (typeof f === "string") return "invalid: bad push filter: " + f; + if (f.kinds?.some((n) => n < 0 || n > 65535) || (f.since !== undefined && f.since < 0) || (f.until !== undefined && f.until < 0)) return "invalid: push filter value out of range"; + out.push(f); + } + return out; + }; + const filters = read("filter"); + const ignores = read("ignore"); + if (typeof filters === "string") return filters; + if (typeof ignores === "string") return ignores; + if (!filters.length) return "invalid: push registration needs a filter"; + return { relay: relayURL, filters, ignores, callback, include: e.tags.some((t) => t[0] === "include_event") }; +} + + +// checkPush validates the opt-in registration. Its caller still applies the +// ordinary write gate, while this gate requires the author's AUTH identity. +export function checkPush(r: PushRelay, e: Event, conn: PushConn | null): string { + if (e.kind !== PUSH_KIND) return ""; + if (!featureEnabled(r)) return "unsupported: relay push is switched off"; + if (!conn?.authed.includes(e.pubkey)) return "auth-required: push registration must be authenticated as its author"; + if (!r.settings.isAllowed(e.pubkey)) return "restricted: push registrations are limited to relay members"; + const parsed = parseRegistration(e, r); + if (typeof parsed === "string") return parsed; + const total = rows<{ n: number }>(r, `SELECT count(*) AS n FROM events WHERE kind=?`, PUSH_KIND)[0].n; + if (total >= MAX_REGISTRATIONS && !rows(r, `SELECT 1 FROM events WHERE pubkey=? AND kind=? AND d=?`, e.pubkey, PUSH_KIND, tag(e, "d")).length) return "restricted: relay push registration limit reached"; + const mine = rows<{ n: number }>(r, `SELECT count(*) AS n FROM events WHERE kind=? AND pubkey=?`, PUSH_KIND, e.pubkey)[0].n; + if (mine >= MAX_AUTHOR_REGISTRATIONS && !rows(r, `SELECT 1 FROM events WHERE pubkey=? AND kind=? AND d=?`, e.pubkey, PUSH_KIND, tag(e, "d")).length) return "restricted: author push registration limit reached"; + return ""; +} + +// authorized checks current membership and both read/write policy. Callback +// destinations never inherit the owner's broad export privileges. +function authorized(r: Relay, pubkey: string): boolean { + return !r.settings.isUnclaimed() && !r.settings.leaseExpired(Math.floor(Date.now() / 1000)) && r.settings.isAllowed(pubkey) && !r.settings.isBanned(pubkey) && !r.settings.mayRead([pubkey]) && !r.settings.mayWrite(pubkey); +} + +function visible(r: Relay, id: string, author: string, t: number): Event | null { + if (r.settings.isEventBanned(id)) return null; + // Imports and the HTTP bridge may hold events larger than websocket + // messages. Do not materialize those rows for callback fanout. + if (!rows(r, `SELECT 1 FROM events WHERE id=? AND length(raw)<=?`, id, MAX_EVENT_TEXT).length) return null; + const raw = r.store.query({ ids: [id], tags: {} }, { pubkeys: [author] }, 1, t).rows[0]; + return raw ? JSON.parse(raw) as Event : null; +} + +// queuePush persists bounded references without any callback I/O. A true +// return asks the caller to keep alarm scheduling alive beyond its response. +export function queuePush(r: Relay, e: Event): boolean { + if (!featureEnabled(r) || e.kind === PUSH_KIND) return false; + const t = Math.floor(Date.now() / 1000); + const registrations = rows<{ id: string; pubkey: string }>(r, `SELECT id,pubkey FROM events WHERE kind=? ORDER BY created_at DESC LIMIT ?`, PUSH_KIND, MAX_REGISTRATIONS); + let added = false; + for (const row of registrations) { + if (!authorized(r, row.pubkey)) continue; + const reg = visible(r, row.id, row.pubkey, t); + if (!reg || !visible(r, e.id, row.pubkey, t)) continue; + const p = parseRegistration(reg, r); + if (typeof p === "string" || !p.filters.some((f) => match(f, e)) || p.ignores.some((f) => match(f, e))) continue; + if (rows(r, `SELECT 1 FROM push_delivered WHERE registration_id=? AND event_id=?`, row.id, e.id).length) continue; + if (rows<{ n: number }>(r, `SELECT count(*) AS n FROM push_queue`)[0].n >= MAX_PENDING) break; + rows(r, `INSERT OR IGNORE INTO push_queue(registration_id,event_id,due,attempts,expires) VALUES(?,?,?,?,?)`, row.id, e.id, t, 0, t + QUEUE_TTL); + added = true; + } + return added; +} + +function payload(e: Event, relayURL: string, include: boolean): string { + return JSON.stringify({ id: e.id, relay: relayURL, ...(include ? { event: e } : {}) }); +} + +function finish(r: Relay, registration: string, event: string, t: number) { + rows(r, `DELETE FROM push_queue WHERE registration_id=? AND event_id=?`, registration, event); + rows(r, `INSERT OR REPLACE INTO push_delivered(registration_id,event_id,delivered_at) VALUES(?,?,?)`, registration, event, t); + rows(r, `DELETE FROM push_delivered WHERE rowid IN (SELECT rowid FROM push_delivered ORDER BY delivered_at ASC,rowid ASC LIMIT max(0,(SELECT count(*) FROM push_delivered)-?))`, MAX_DEDUP); +} + +// pushTick runs outside the repository admission lock, so slow callbacks +// cannot block event acceptance. Each attempt is checked again after prior I/O. +export async function pushTick(r: Relay): Promise { + const started = Date.now(); + let t = Math.floor(started / 1000); + rows(r, `DELETE FROM push_queue WHERE expires<=?`, t); + rows(r, `DELETE FROM push_delivered WHERE delivered_at(r, `SELECT registration_id,event_id,attempts FROM push_queue WHERE due<=? ORDER BY due,rowid LIMIT 4`, t); + for (const job of jobs) { + t = Math.floor(Date.now() / 1000); + // Rows can disappear during the previous callback (deletion, teardown, + // member removal). An alarm restart cannot exceed the reserved attempt cap. + if (r.repositoryAccess.busy) return t + 1; + const current = rows<{ attempts: number }>(r, `SELECT attempts FROM push_queue WHERE registration_id=? AND event_id=? AND expires>? AND due<=?`, job.registration_id, job.event_id, t, t)[0]; + if (!current) continue; + job.attempts = current.attempts; + if (!featureEnabled(r) || job.attempts >= MAX_ATTEMPTS) { finish(r, job.registration_id, job.event_id, t); continue; } + r.tally(); + if (r.fuelStatus().outOfFuel) { + rows(r, `UPDATE push_queue SET due=max(due,?)`, t + 60); + return t + 60; + } + const owner = rows<{ pubkey: string }>(r, `SELECT pubkey FROM events WHERE id=? AND kind=?`, job.registration_id, PUSH_KIND)[0]?.pubkey; + const reg = owner && authorized(r, owner) ? visible(r, job.registration_id, owner, t) : null; + const e = owner ? visible(r, job.event_id, owner, t) : null; + const p = reg ? parseRegistration(reg, r) : "missing registration"; + if (!reg || !e || typeof p === "string" || !p.filters.some((f) => match(f, e)) || p.ignores.some((f) => match(f, e))) { + finish(r, job.registration_id, job.event_id, t); continue; + } + const body = payload(e, p.relay, p.include); + const bytes = utf8Size(body); + if (bytes > MAX_PAYLOAD) { finish(r, job.registration_id, job.event_id, t); continue; } + // Reserve before external I/O. A crash consumes the attempt and waits + // longer than the request timeout before another alarm may retry it. + rows(r, `UPDATE push_queue SET attempts=attempts+1,due=? WHERE registration_id=? AND event_id=?`, t + 60, job.registration_id, job.event_id); + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT); + let response: Response | null = null; + const requestStarted = Date.now(); + r.meterBytes(0, bytes); + try { response = await r.fetcher(p.callback, { method: "POST", redirect: "manual", headers: { "content-type": "application/json" }, body, signal: ctrl.signal }); } + catch { response = null; } + finally { clearTimeout(timer); r.meterPush(0, 0, Date.now() - requestStarted); } + if (response?.body) await response.body.cancel().catch(() => {}); + // An obsolete in-flight response must not mutate a replacement row. + if (!rows(r, `SELECT 1 FROM push_queue WHERE registration_id=? AND event_id=?`, job.registration_id, job.event_id).length) continue; + if (response?.status === 404) { + r.store.deleteEvent(job.registration_id); + } else if ((response && response.status >= 200 && response.status < 300) || job.attempts + 1 >= MAX_ATTEMPTS || (response && response.status < 500 && response.status !== 429)) { + finish(r, job.registration_id, job.event_id, t); + } else { + rows(r, `UPDATE push_queue SET due=? WHERE registration_id=? AND event_id=?`, Math.floor(Date.now() / 1000) + RETRY_SECONDS[job.attempts], job.registration_id, job.event_id); + } + } + r.tally(); + const next = rows<{ next: number | null }>(r, `SELECT MIN(due) AS next FROM push_queue`)[0].next; + return next ? Math.max(next, Math.floor(Date.now() / 1000) + 1) : 0; +} + +// nextPush includes work enqueued by views or discovery after the push round. +export function nextPush(r: Relay): number { + return rows<{ next: number | null }>(r, `SELECT MIN(due) AS next FROM push_queue`)[0].next ?? 0; +} diff --git a/src/relay.ts b/src/relay.ts index 0d1290a..f4319ec 100644 --- a/src/relay.ts +++ b/src/relay.ts @@ -1,6 +1,8 @@ // The relay: one Durable Object per name, holding its SQLite database and // its live websockets (hibernating while idle). Protocol handling mirrors // 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 { graspTick, isGitPath, graspCORS } from "./grasp.ts"; import { graspBytes, holdGrasp, graspVisible } from "./grasp-state.ts"; import { queueMirrors } from "./site-mirror.ts"; @@ -33,7 +35,7 @@ import { Hostnames, forgetDomains } from "./domains.ts"; import { hostOf } from "./edge.ts"; import { groupFacts, handleGroupEvent, isGroupManagement, isNIP43Request } from "./groups.ts"; import { markView, notePresence, viewsTick } from "./views.ts"; -import { KIND_VANISH, KIND_AUTH, KIND_REPORT, KIND_NOSTR_CONNECT, KIND_GROUP_MEMBERS, KIND_GROUP_PINS, KIND_RELAY_DISCOVERY, KIND_MARMOT_GROUP } from "./kinds.ts"; +import { KIND_VANISH, KIND_AUTH, KIND_REPORT, KIND_NOSTR_CONNECT, KIND_GROUP_MEMBERS, KIND_GROUP_PINS, KIND_RELAY_DISCOVERY, KIND_MARMOT_GROUP, KIND_PUSH_REGISTRATION } from "./kinds.ts"; import { marmotPrincipal } from "./marmot.ts"; import { RepositoryAccess } from "./repository-access.ts"; @@ -42,6 +44,7 @@ export interface Env { MEDIA: R2Bucket; DOMAIN: string; DEV_RELAY: string; + PUSH_CALLBACK_ORIGINS?: string; // JSON array of operator-trusted public HTTPS origins LEASE_DAYS?: string; // how long a temporary relay lives; 14 by default // Cloudflare's rate limit bindings; without them (celld) edge.ts keeps // token buckets in memory instead. @@ -152,6 +155,7 @@ export class Relay extends DurableObject { ctx.blockConcurrencyWhile(async () => { this.store.init(); this.settings.load(); + this.sql.exec(PUSH_SCHEMA); this.store.hidden = this.settings.hiddenEvents; this.store.searchMode = () => this.settings.policy.features.search; this.fuel.init(); @@ -190,6 +194,18 @@ export class Relay extends DurableObject { return this.env.CNAME_TARGET || "customers." + this.env.DOMAIN; } + get pushCallbackOrigins(): string[] { + try { return callbackOrigins(JSON.parse(this.env.PUSH_CALLBACK_ORIGINS || "[]")) ?? []; } + catch { return []; } + } + + // meterPush accounts for bounded callback work, including failed attempts. + meterPush(rowsRead: number, rowsWritten: number, activeMs = 0) { + this.meter.rowsRead += rowsRead; + this.meter.rowsWritten += rowsWritten; + this.meter.activeMs += activeMs; + } + // relays reaches the other objects on this host, for pulls between them. get relays(): DurableObjectNamespace { return this.env.RELAY; @@ -481,6 +497,7 @@ export class Relay extends DurableObject { this.audit = new Audit(this.sql, () => this.slug); this.store.init(); this.settings.load(); + this.sql.exec(PUSH_SCHEMA); this.store.hidden = this.settings.hiddenEvents; this.store.searchMode = () => this.settings.policy.features.search; this.fuel.init(); @@ -1101,6 +1118,7 @@ export class Relay extends DurableObject { } async alarm() { + await pushTick(this); return this.repositoryAccess.run("alarm", async () => { this.touch(); const t = now(); @@ -1165,6 +1183,8 @@ export class Relay extends DurableObject { const run = await this.nextJobRun(); if (run > 0 && run < at) at = Math.max(run, t + 1); if (graspAt && graspAt < at) at = graspAt; + const pushAt = nextPush(this); + if (pushAt) at = Math.min(at, Math.max(pushAt, now() + 1)); await this.ctx.storage.setAlarm(at * 1000 + 500); }, async () => { await this.ctx.storage.setAlarm(Date.now() + 1000); }); } @@ -1198,6 +1218,7 @@ export class Relay extends DurableObject { // transport key it never AUTHs with. A bare subscription to the kind // still sees nothing. private canSee(s: ConnState, e: Event, f: Filter): boolean { + if (e.kind === KIND_PUSH_REGISTRATION) return s.authed.includes(e.pubkey); if (!isPrivate(e.kind)) return true; const parties = [e.pubkey, ...tagValues(e, "p")]; if (parties.some((p) => s.authed.includes(p))) return true; @@ -1207,11 +1228,12 @@ export class Relay extends DurableObject { broadcast(e: Event) { if (!graspVisible(this, e.id)) return; + if (queuePush(this, e)) this.ctx.waitUntil(this.ensureAlarm(now() + 1)); const raw = canonical(e); for (const ws of this.ctx.getWebSockets()) { const s = this.state(ws); for (const [id, filters] of Object.entries(s.subs)) { - if (filters.some((f) => match(f, e) && this.canSee(s, e, f))) { + if (!readGate(this, s, filters).reason && filters.some((f) => match(f, e) && this.canSee(s, e, f))) { this.raw(ws, `["EVENT",${JSON.stringify(id)},${raw}]`); break; } diff --git a/src/settings.ts b/src/settings.ts index abd03a5..49eb4b3 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,4 +1,5 @@ // Per-relay settings and policy, kept in the same SQLite database. +import { callbackOrigins } from "./push-policy.ts"; import { notifySettings, type NotifySettings } from "./notify.ts"; import type { CustomHost } from "./domains.ts"; import type { Role } from "./roles.ts"; @@ -74,6 +75,8 @@ export interface Policy { // Views (views.ts): a name maps to false when the owner switched it off. views: Record; 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 } export const VIEW_NAMES = ["profiles", "relays", "calendar", "moderation", "articles", "zaps", "presence"]; @@ -88,7 +91,7 @@ export type ViewSetting = (typeof VIEW_SETTINGS)[number]; // is not one: the console runs on it. What is off leaves supported_nips // (nip11.ts), answers 404 at its door (routes.ts) and is refused at the // socket (gates.ts, relay.ts). -export const FEATURE_NAMES = ["search", "sync", "count", "discovery", "names", "files", "pages", "signer", "sites", "marmot", "grasp"] as const; +export const FEATURE_NAMES = ["search", "sync", "count", "discovery", "names", "files", "pages", "signer", "sites", "marmot", "grasp", "push"] as const; export type Feature = (typeof FEATURE_NAMES)[number]; export type SearchMode = "full" | "prose" | "off"; export interface Features { @@ -102,9 +105,10 @@ export interface Features { sites: { enabled: boolean; mirror: boolean }; // NIP-5A hosting and automatic blob copies grasp: boolean; // GRASP Git repositories and accepted repository state signer: boolean; // NIP-46 traffic carried for anyone + push: boolean; // NIP-9a relay-to-callback delivery marmot: boolean; // Marmot's opaque MLS transport events } -export const DEFAULT_FEATURES: Features = { search: "prose", sync: true, count: true, discovery: true, names: true, files: true, pages: true, signer: true, sites: { enabled: true, mirror: true }, marmot: false, grasp: false }; +export const DEFAULT_FEATURES: Features = { search: "prose", sync: true, count: true, discovery: true, names: true, files: true, pages: true, signer: true, sites: { enabled: true, mirror: true }, marmot: false, grasp: false, push: false }; export const featureOn = (p: Policy, f: Feature): boolean => f === "sites" ? p.features.sites.enabled : p.features[f] !== false && p.features[f] !== "off"; export function featureFields(patch: Record, cur: Features): Partial { @@ -134,6 +138,9 @@ export function policyPatch(patch: Record, cur: Policy): Partia 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); if (typeof patch.directoryPublic === "boolean") clean.directoryPublic = patch.directoryPublic; + if (typeof patch.letteredNips === "boolean") clean.letteredNips = patch.letteredNips; + const callbacks = callbackOrigins(patch.pushCallbacks); + if (callbacks) clean.pushCallbacks = callbacks; const notifyPatch = notifySettings(patch.notify, cur.notify); if (notifyPatch) clean.notify = notifyPatch; Object.assign(clean, limitFields(patch)); @@ -192,6 +199,8 @@ export const DEFAULT_POLICY: Policy = { memberInvites: { depth: 0, quota: 0 }, views: {}, features: { ...DEFAULT_FEATURES }, + pushCallbacks: [], + letteredNips: false, }; export const SETTINGS_SCHEMA = ` diff --git a/src/store.ts b/src/store.ts index 5e239a6..c459fcf 100644 --- a/src/store.ts +++ b/src/store.ts @@ -2,6 +2,7 @@ // synchronous, so each call is one atomic write batch on the DO. import { GRASP_SCHEMA } from "./grasp-state.ts"; import { canonical, expiration, isAddressable, isEphemeral, isPrivate, isReplaceable, tag, tagValues, type Event } from "./event.ts"; +import { KIND_PUSH_REGISTRATION } from "./kinds.ts"; import { ftsQuery, searchTerms, type Filter } from "./filter.ts"; import { SITE_SCHEMA, SITE_KINDS } from "./sites.ts"; import { HLL } from "./hll.ts"; @@ -227,6 +228,15 @@ export class Store { conds.push(`id NOT IN (SELECT value FROM json_each(?))`); args.push(JSON.stringify([...this.hidden])); } + // Push registrations are private to their author even for internal callers + // that use all:true. A p tag never grants access to a registration, and an + // empty pubkey list therefore excludes every registration. + if (who.pubkeys.length === 0) { + conds.push(`kind <> ${KIND_PUSH_REGISTRATION}`); + } else { + conds.push(`(kind <> ${KIND_PUSH_REGISTRATION} OR ${inList("pubkey")})`); + args.push(JSON.stringify(who.pubkeys)); + } if (!who.all) { if (who.pubkeys.length === 0) conds.push("kind NOT IN (4,1059)"); else { @@ -287,18 +297,18 @@ export class Store { } stats(): { events: number; bytes: number; oldest: number; newest: number } { - const row = this.x<{ n: number; oldest: number | null; newest: number | null }>(`SELECT count(*) AS n, min(created_at) AS oldest, max(created_at) AS newest FROM events`).one(); + const row = this.x<{ n: number; oldest: number | null; newest: number | null }>(`SELECT count(*) AS n, min(created_at) AS oldest, max(created_at) AS newest FROM events WHERE kind <> ${KIND_PUSH_REGISTRATION}`).one(); return { events: row.n, bytes: this.sql.databaseSize, oldest: row.oldest ?? 0, newest: row.newest ?? 0 }; } // kinds counts events per kind since a timestamp, busiest first, for the dashboard. kinds(since: number): { kind: number; n: number }[] { - return this.x<{ kind: number; n: number }>(`SELECT kind, count(*) AS n FROM events WHERE created_at >= ? GROUP BY kind ORDER BY n DESC LIMIT 50`, since).toArray(); + return this.x<{ kind: number; n: number }>(`SELECT kind, count(*) AS n FROM events WHERE created_at >= ? AND kind <> ${KIND_PUSH_REGISTRATION} GROUP BY kind ORDER BY n DESC LIMIT 50`, since).toArray(); } // kindCounts groups events since a timestamp by kind, for the dashboard. kindCounts(since: number): { kind: number; n: number }[] { - return this.x<{ kind: number; n: number }>(`SELECT kind, count(*) AS n FROM events WHERE created_at >= ? GROUP BY kind ORDER BY n DESC LIMIT 50`, since).toArray(); + return this.x<{ kind: number; n: number }>(`SELECT kind, count(*) AS n FROM events WHERE created_at >= ? AND kind <> ${KIND_PUSH_REGISTRATION} GROUP BY kind ORDER BY n DESC LIMIT 50`, since).toArray(); } // recent returns the newest events for the dashboard, regardless of visibility. @@ -310,7 +320,7 @@ export class Store { } recent(limit: number, now: number): string[] { - return this.x<{ raw: string }>(`SELECT raw FROM events WHERE (expires = 0 OR expires > ?) ORDER BY created_at DESC, id ASC LIMIT ?`, now, limit).toArray().map((r) => r.raw); + return this.x<{ raw: string }>(`SELECT raw FROM events WHERE kind <> ${KIND_PUSH_REGISTRATION} AND (expires = 0 OR expires > ?) ORDER BY created_at DESC, id ASC LIMIT ?`, now, limit).toArray().map((r) => r.raw); } deleteEvent(id: string): boolean { @@ -371,14 +381,14 @@ export class Store { // 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>? ORDER BY seq LIMIT ?`, afterSeq, limit).toArray(); + 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(); } // kindStats sizes each kind present: how many, how many bytes of raw event, // and the span of timestamps. Heaviest first. kindStats(): { kind: number; n: number; bytes: number; oldest: number; newest: number }[] { return this.x<{ kind: number; n: number; bytes: number; oldest: number; newest: number }>( - `SELECT kind, count(*) AS n, sum(length(raw)) AS bytes, min(created_at) AS oldest, max(created_at) AS newest FROM events GROUP BY kind ORDER BY bytes DESC`, + `SELECT kind, count(*) AS n, sum(length(raw)) AS bytes, min(created_at) AS oldest, max(created_at) AS newest FROM events WHERE kind <> ${KIND_PUSH_REGISTRATION} GROUP BY kind ORDER BY bytes DESC`, ).toArray(); } diff --git a/test/object/features.test.ts b/test/object/features.test.ts index 7843a34..826f284 100644 --- a/test/object/features.test.ts +++ b/test/object/features.test.ts @@ -15,7 +15,7 @@ describe("features", () => { const owner = generateSecretKey(); await rpc(host, owner, "claim"); const p = (await rpc(host, owner, "getpolicy")).result; - expect(p.features).toEqual({ search: "prose", sync: true, count: true, discovery: true, names: true, files: true, pages: true, signer: true, sites: { enabled: true, mirror: true }, marmot: false, grasp: false }); + expect(p.features).toEqual({ search: "prose", sync: true, count: true, discovery: true, names: true, files: true, pages: true, signer: true, sites: { enabled: true, mirror: true }, marmot: false, grasp: false, push: false }); for (const n of [5, 45, 46, 50, 66, 77]) expect((await info(host)).supported_nips).toContain(n); const r = await rpc(host, owner, "setpolicy", { features: { search: "full", sync: false, bogus: true, count: "no" } }); expect(r.result.features).toMatchObject({ search: "full", sync: false, count: true }); diff --git a/test/object/nip11.test.ts b/test/object/nip11.test.ts index 09248c6..1eeeb22 100644 --- a/test/object/nip11.test.ts +++ b/test/object/nip11.test.ts @@ -104,3 +104,40 @@ describe("NIP-11 extras", () => { expect(doc.relay_countries).toEqual(["FR"]); }); }); + +describe("lettered capability identifiers", () => { + it("preserves numeric support by default and advertises enabled lettered capabilities together", async () => { + const host = "lettered-nips.bind.ws"; + const owner = generateSecretKey(); + await rpc(host, owner, "claim"); + const baseline = (await info(host)).supported_nips; + expect(baseline.every((n: unknown) => typeof n === "number")).toBe(true); + await rpc(host, owner, "setpolicy", { letteredNips: true }); + let nips = (await info(host)).supported_nips; + expect(nips.filter((n: unknown) => typeof n === "number")).toEqual(baseline); + expect(nips).toEqual(expect.arrayContaining(["AD", "5A"])); + expect(nips).not.toContain("9a"); + expect(nips).not.toContain(90); + await rpc(host, owner, "setpolicy", { letteredNips: false, features: { push: true, sites: false } }); + nips = (await info(host)).supported_nips; + expect(nips).toEqual(expect.arrayContaining([1, 11, 86, "AD", "9a"])); + expect(nips).not.toContain("5A"); + await rpc(host, owner, "setpolicy", { features: { push: false } }); + expect((await info(host)).supported_nips).toEqual(baseline); + }); + + it("exports the representation and callback policy and validates entire origin lists", async () => { + const host = "push-policy-config.bind.ws"; + const owner = generateSecretKey(); + await rpc(host, owner, "claim"); + await rpc(host, owner, "setpolicy", { letteredNips: true, pushCallbacks: ["https://push.example.com/"] }); + const config = (await rpc(host, owner, "exportconfig")).result; + expect(config.policy).toMatchObject({ letteredNips: true, pushCallbacks: ["https://push.example.com"] }); + const invalid = await rpc(host, owner, "setpolicy", { pushCallbacks: ["https://127.0.0.1", "https://push.example.com"] }); + expect(invalid.result.pushCallbacks).toEqual(["https://push.example.com"]); + const other = "push-policy-import.bind.ws"; + await rpc(other, owner, "claim"); + await rpc(other, owner, "importconfig", config); + expect((await rpc(other, owner, "getpolicy")).result).toMatchObject({ letteredNips: true, pushCallbacks: ["https://push.example.com"] }); + }); +}); diff --git a/test/object/push-privacy.test.ts b/test/object/push-privacy.test.ts new file mode 100644 index 0000000..ff93e5f --- /dev/null +++ b/test/object/push-privacy.test.ts @@ -0,0 +1,117 @@ +import { env, runInDurableObject } from "cloudflare:test"; +import { describe, expect, it } from "vitest"; +import { generateSecretKey } from "nostr-tools/pure"; +import type { Relay } from "../../src/relay.ts"; +import { KIND_PUSH_REGISTRATION } from "../../src/kinds.ts"; +import { now, ev, pk, post, rpc } from "../helpers/relay.ts"; +import { WS } from "../helpers/ws.ts"; + +describe("push registration privacy", () => { + it("keeps registrations author-only in queries, counts, filters and sync", async () => { + const owner = generateSecretKey(); + const other = generateSecretKey(); + const recipient = generateSecretKey(); + const host = "push-private.bind.ws"; + await rpc(host, owner, "claim"); + const mine = ev(owner, KIND_PUSH_REGISTRATION, "mine", [["d", "mine"], ["p", pk(recipient)]], now() - 10); + const theirs = ev(other, KIND_PUSH_REGISTRATION, "theirs", [["d", "theirs"], ["p", pk(recipient)]], now() - 5); + const note = ev(owner, 1, "public", [], now()); + + await runInDurableObject(env.RELAY.getByName("push-private"), async (r: Relay) => { + const before = r.store.stats(); + expect(r.store.save(mine, now())).toBe(""); + expect(r.store.save(theirs, now())).toBe(""); + expect(r.store.save(note, now())).toBe(""); + + const filter = { kinds: [KIND_PUSH_REGISTRATION], tags: {} }; + const hidden = r.store.query(filter, { pubkeys: [], all: true }, 20, now()); + expect(hidden.rows).toEqual([]); + expect(r.store.query(filter, { pubkeys: [pk(recipient)], all: true }, 20, now()).rows).toEqual([]); + expect(r.store.query(filter, { pubkeys: [pk(owner)], all: true }, 20, now()).rows.map((x) => JSON.parse(x).id)).toEqual([mine.id]); + expect(r.store.query({ ids: [theirs.id], tags: {} }, { pubkeys: [pk(owner)], all: true }, 20, now()).rows).toEqual([]); + expect(r.store.count([filter], { pubkeys: [], all: true }, now())).toBe(0); + expect(r.store.count([filter], { pubkeys: [pk(owner)], all: true }, now())).toBe(1); + expect(r.store.countHLL(filter, { pubkeys: [], all: true }, 0, now()).count).toBe(0); + expect(r.store.syncItems(filter, { pubkeys: [], all: true }, 20, now())).toEqual([]); + expect(r.store.syncItems(filter, { pubkeys: [pk(owner)], all: true }, 20, now())).toEqual([{ timestamp: mine.created_at, id: expect.any(Uint8Array) }]); + + // Internal jobs use all:true and sequence reads. Registrations stay out. + expect(r.store.after(0, filter, 20, now()).map((x) => JSON.parse(x.raw).id)).toEqual([]); + expect(r.store.recent(20, now()).map((x) => JSON.parse(x).id)).not.toContain(mine.id); + expect(r.store.recent(20, now()).map((x) => JSON.parse(x).id)).not.toContain(theirs.id); + expect(r.store.dumpPage(0, 20).map((x) => JSON.parse(x.raw).id)).not.toContain(mine.id); + expect(r.store.dumpPage(0, 20).map((x) => JSON.parse(x.raw).id)).not.toContain(theirs.id); + + expect(r.store.stats().events).toBe(before.events + 1); + expect(r.store.kinds(0).map((x) => x.kind)).not.toContain(KIND_PUSH_REGISTRATION); + expect(r.store.kindCounts(0).map((x) => x.kind)).not.toContain(KIND_PUSH_REGISTRATION); + expect(r.store.kindStats().map((x) => x.kind)).not.toContain(KIND_PUSH_REGISTRATION); + }); + }); + + it("keeps stored registrations private through the HTTP count and query bridge", async () => { + const owner = generateSecretKey(); + const other = generateSecretKey(); + const recipient = generateSecretKey(); + const bystander = generateSecretKey(); + const host = "push-bridge-private.bind.ws"; + await rpc(host, owner, "claim"); + const mine = ev(owner, KIND_PUSH_REGISTRATION, "mine", [["d", "mine"], ["p", pk(recipient)]], now() - 10); + const theirs = ev(other, KIND_PUSH_REGISTRATION, "theirs", [["d", "theirs"], ["p", pk(recipient)]], now() - 5); + await runInDurableObject(env.RELAY.getByName("push-bridge-private"), async (r: Relay) => { + // This bypasses feature and write gates so the read boundary is tested + // even when push is switched off. + expect(r.store.save(mine, now())).toBe(""); + expect(r.store.save(theirs, now())).toBe(""); + expect(r.store.count([{ kinds: [KIND_PUSH_REGISTRATION], tags: {} }], { pubkeys: [pk(other)] }, now())).toBe(1); + expect(r.store.query({ kinds: [KIND_PUSH_REGISTRATION], tags: {} }, { pubkeys: [pk(other)] }, 20, now()).rows.map((x) => JSON.parse(x).id)).toEqual([theirs.id]); + }); + + for (const [key, count] of [[owner, 1], [other, 1], [recipient, 0], [bystander, 0]] as const) { + const result = await post(host, key, "/count", [{ kinds: [KIND_PUSH_REGISTRATION] }]); + expect(result.status, JSON.stringify(result)).toBe(200); + expect(result.body.count, JSON.stringify({ key: pk(key), owner: pk(owner), other: pk(other), recipient: pk(recipient) })).toBe(count); + const query = await post(host, key, "/query", [{ kinds: [KIND_PUSH_REGISTRATION] }]); + expect(query.status, JSON.stringify(query)).toBe(200); + expect(query.body.map((e: { id: string }) => e.id)).toEqual(key === owner ? [mine.id] : key === other ? [theirs.id] : []); + } + }); + + it("keeps stored registrations private for authenticated websocket reads and live delivery", async () => { + const owner = generateSecretKey(); + const other = generateSecretKey(); + const recipient = generateSecretKey(); + const bystander = generateSecretKey(); + const host = "push-socket-private.bind.ws"; + await rpc(host, owner, "claim"); + const mine = ev(owner, KIND_PUSH_REGISTRATION, "mine", [["d", "mine"], ["p", pk(recipient)]], now() - 10); + const theirs = ev(other, KIND_PUSH_REGISTRATION, "theirs", [["d", "theirs"], ["p", pk(recipient)]], now() - 5); + await runInDurableObject(env.RELAY.getByName("push-socket-private"), async (r: Relay) => { + expect(r.store.save(mine, now())).toBe(""); + expect(r.store.save(theirs, now())).toBe(""); + }); + + const ownerSocket = await WS.connect(host); + const otherSocket = await WS.connect(host); + const recipientSocket = await WS.connect(host); + const bystanderSocket = await WS.connect(host); + await ownerSocket.auth(owner, host); + await otherSocket.auth(other, host); + await recipientSocket.auth(recipient, host); + await bystanderSocket.auth(bystander, host); + expect((await ownerSocket.open("mine", { kinds: [KIND_PUSH_REGISTRATION] })).events.map((e) => e.id)).toEqual([mine.id]); + expect((await otherSocket.open("other", { kinds: [KIND_PUSH_REGISTRATION] })).events.map((e) => e.id)).toEqual([theirs.id]); + expect((await recipientSocket.open("recipient", { kinds: [KIND_PUSH_REGISTRATION] })).events).toEqual([]); + expect((await bystanderSocket.open("bystander", { kinds: [KIND_PUSH_REGISTRATION] })).events).toEqual([]); + + const live = ev(owner, KIND_PUSH_REGISTRATION, "live", [["d", "live"]]); + await runInDurableObject(env.RELAY.getByName("push-socket-private"), async (r: Relay) => { + expect(r.store.save(live, now())).toBe(""); + r.broadcast(live); + }); + expect((await ownerSocket.recvOr(200))?.[2]?.id).toBe(live.id); + expect(await otherSocket.recvOr(200)).toBeNull(); + expect(await recipientSocket.recvOr(200)).toBeNull(); + expect(await bystanderSocket.recvOr(200)).toBeNull(); + }); +}); diff --git a/test/object/push.test.ts b/test/object/push.test.ts new file mode 100644 index 0000000..84ce0bb --- /dev/null +++ b/test/object/push.test.ts @@ -0,0 +1,294 @@ +import { env, runInDurableObject } from "cloudflare:test"; +import { describe, expect, it } from "vitest"; +import { generateSecretKey } from "nostr-tools/pure"; +import type { Relay } from "../../src/relay.ts"; +import { KIND_PUSH_REGISTRATION } from "../../src/kinds.ts"; +import { queuePush } from "../../src/push.ts"; +import { ev, now, pk, rpc } from "../helpers/relay.ts"; +import { WS } from "../helpers/ws.ts"; + +const callback = "https://push.example.com/hooks/device-1"; + +async function enable(host: string, owner: Uint8Array) { + await rpc(host, owner, "claim"); + await rpc(host, owner, "setpolicy", { features: { push: true }, pushCallbacks: ["https://push.example.com"] }); +} + +async function trustAndCapture(name: string, capture: (req: Request) => Response | Promise) { + await runInDurableObject(env.RELAY.getByName(name), async (r: Relay) => { + Object.defineProperty(r, "pushCallbackOrigins", { value: ["https://push.example.com"], configurable: true }); + r.fetcher = async (url, init) => capture(new Request(String(url), init)); + }); +} + +describe("NIP-9a push engine", () => { + it("delivers a filtered event with either an id-only or complete payload", async () => { + const host = "push-engine.bind.ws"; + const owner = generateSecretKey(); + await enable(host, owner); + const socket = await WS.connect(host); + await socket.auth(owner, host); + const seen: { url: string; body: string }[] = []; + await trustAndCapture("push-engine", async (req) => { seen.push({ url: req.url, body: await req.text() }); return new Response(null, { status: 204 }); }); + const registration = ev(owner, KIND_PUSH_REGISTRATION, "", [ + ["d", "one"], ["relay", "wss://" + host], ["filter", JSON.stringify({ kinds: [1] })], ["callback", callback], ["include_event"], + ]); + expect((await socket.ok(registration)).ok).toBe(true); + const note = ev(owner, 1, "hello"); + expect((await socket.ok(note)).ok).toBe(true); + await runInDurableObject(env.RELAY.getByName("push-engine"), async (r: Relay) => { await r.alarm(); }); + expect(seen).toHaveLength(1); + expect(seen[0].url).toBe(callback); + const body = JSON.parse(seen[0].body) as { id: string; relay: string; event: { id: string; content: string } }; + expect(body.id).toBe(note.id); + expect(body.relay).toBe("wss://" + host + "/"); + expect(body.event).toMatchObject({ id: note.id, content: "hello" }); + const idsOnly = ev(owner, KIND_PUSH_REGISTRATION, "", [ + ["d", "two"], ["relay", "wss://" + host], ["filter", JSON.stringify({ kinds: [1] })], ["callback", callback], + ]); + expect((await socket.ok(idsOnly)).ok).toBe(true); + const second = ev(owner, 1, "id only"); + await socket.ok(second); + await runInDurableObject(env.RELAY.getByName("push-engine"), async (r: Relay) => { await r.alarm(); }); + expect(seen).toHaveLength(3); + const idBody = JSON.parse(seen[2].body) as { id: string; event?: unknown }; + expect(idBody.id).toBe(second.id); + expect(idBody.event).toBeUndefined(); + }); + + it("does not deliver ignored events, duplicate broadcasts, or events after member removal", async () => { + const host = "push-auth.bind.ws"; + const owner = generateSecretKey(); + const member = generateSecretKey(); + await enable(host, owner); + await rpc(host, owner, "setmember", pk(member)); + const socket = await WS.connect(host); + const seen: Request[] = []; + await trustAndCapture("push-auth", (req) => { seen.push(req); return new Response(null, { status: 204 }); }); + const registration = ev(member, KIND_PUSH_REGISTRATION, "", [ + ["d", "member"], ["relay", "wss://" + host], ["filter", JSON.stringify({ kinds: [1] })], ["ignore", JSON.stringify({ "#t": ["skip"] })], ["callback", callback], + ]); + await socket.auth(member, host); + expect((await socket.ok(registration)).ok).toBe(true); + const ignored = ev(owner, 1, "skip", [["t", "skip"]]); + await socket.ok(ignored); + await runInDurableObject(env.RELAY.getByName("push-auth"), async (r: Relay) => { r.broadcast(ignored); await r.alarm(); }); + expect(seen).toHaveLength(0); + const queued = ev(owner, 1, "queued before removal"); + await socket.ok(queued); + await rpc(host, owner, "removemember", pk(member)); + const after = ev(owner, 1, "after"); + await socket.ok(after); + await rpc(host, owner, "setmember", pk(member)); + await runInDurableObject(env.RELAY.getByName("push-auth"), async (r: Relay) => { await r.alarm(); }); + expect(seen).toHaveLength(0); + }); + + it("retries transient failures, removes exhausted work, and deletes registrations on 404", async () => { + const host = "push-retry.bind.ws"; + const owner = generateSecretKey(); + await enable(host, owner); + const socket = await WS.connect(host); + await socket.auth(owner, host); + let status = 500; + const seen: Request[] = []; + await trustAndCapture("push-retry", (req) => { seen.push(req); return new Response(null, { status }); }); + const registration = ev(owner, KIND_PUSH_REGISTRATION, "", [ + ["d", "retry"], ["relay", "wss://" + host], ["filter", JSON.stringify({ kinds: [1] })], ["callback", callback], + ]); + expect((await socket.ok(registration)).ok).toBe(true); + const note = ev(owner, 1, "retry me"); + await socket.ok(note); + for (let i = 0; i < 4; i++) await runInDurableObject(env.RELAY.getByName("push-retry"), async (r: Relay) => { r.sql.exec("UPDATE push_queue SET due=0"); await r.alarm(); }); + expect(seen).toHaveLength(4); + await runInDurableObject(env.RELAY.getByName("push-retry"), async (r: Relay) => { expect(r.sql.exec("SELECT 1 FROM push_queue").toArray()).toHaveLength(0); }); + status = 404; + const second = ev(owner, KIND_PUSH_REGISTRATION, "", [ + ["d", "gone"], ["relay", "wss://" + host], ["filter", JSON.stringify({ kinds: [1] })], ["callback", callback], + ]); + expect((await socket.ok(second)).ok).toBe(true); + const last = ev(owner, 1, "gone"); + await socket.ok(last); + await runInDurableObject(env.RELAY.getByName("push-retry"), async (r: Relay) => { r.sql.exec("UPDATE push_queue SET due=0"); await r.alarm(); }); + await runInDurableObject(env.RELAY.getByName("push-retry"), async (r: Relay) => { expect(r.sql.exec("SELECT 1 FROM events WHERE id=?", second.id).toArray()).toHaveLength(0); }); + }); + + it("bounds queued work and drops queued references when policy, visibility, expiry, or registrations change", async () => { + const host = "push-bounds.bind.ws"; + const owner = generateSecretKey(); + await enable(host, owner); + const seen: string[] = []; + await trustAndCapture("push-bounds", async (req) => { seen.push(await req.text()); return new Response(null, { status: 204 }); }); + await runInDurableObject(env.RELAY.getByName("push-bounds"), async (r: Relay) => { + const registration = ev(owner, KIND_PUSH_REGISTRATION, "", [["d", "bounded"], ["relay", "wss://" + host], ["filter", JSON.stringify({ kinds: [1] })], ["callback", callback]]); + expect(r.store.save(registration, now())).toBe(""); + for (let i = 0; i < 300; i++) { + const note = ev(owner, 1, "queued " + i); + expect(r.store.save(note, now())).toBe(""); + queuePush(r, note); + } + expect(r.sql.exec("SELECT count(*) AS n FROM push_queue").one().n).toBe(256); + queuePush(r, ev(owner, 1, "overflow")); + expect(r.sql.exec("SELECT count(*) AS n FROM push_queue").one().n).toBe(256); + r.sql.exec("DELETE FROM push_queue"); + const disabled = ev(owner, 1, "disabled"); + r.store.save(disabled, now()); + queuePush(r, disabled); + r.settings.update({ features: { ...r.settings.policy.features, push: false } }); + await r.alarm(); + r.settings.update({ features: { ...r.settings.policy.features, push: true } }); + await r.alarm(); + expect(seen.some((x) => x.includes(disabled.id))).toBe(false); + // A hidden event is removed from the delivery set at tick time. + r.sql.exec("DELETE FROM push_queue"); + const hidden = ev(owner, 1, "hidden"); + r.store.save(hidden, now()); + queuePush(r, hidden); + r.settings.setEvent(hidden.id, "hide", "test", now()); + await r.alarm(); + expect(seen.some((x) => x.includes(hidden.id))).toBe(false); + }); + }); + + it("does not fetch after an exhausted attempt and handles a network failure", async () => { + const host = "push-attempts.bind.ws"; + const owner = generateSecretKey(); + await enable(host, owner); + let calls = 0; + await trustAndCapture("push-attempts", () => { calls++; throw new Error("network down"); }); + await runInDurableObject(env.RELAY.getByName("push-attempts"), async (r: Relay) => { + const registration = ev(owner, KIND_PUSH_REGISTRATION, "", [["d", "attempts"], ["relay", "wss://" + host], ["filter", JSON.stringify({ kinds: [1] })], ["callback", callback]]); + const note = ev(owner, 1, "network"); + r.store.save(registration, now()); + r.store.save(note, now()); + queuePush(r, note); + r.sql.exec("UPDATE push_queue SET attempts=3,due=0"); + await r.alarm(); + expect(calls).toBe(1); + r.sql.exec("INSERT OR IGNORE INTO push_queue(registration_id,event_id,due,attempts,expires) VALUES(?,?,?,?,?)", registration.id, note.id, 0, 4, now() + 100); + await r.alarm(); + expect(calls).toBe(1); + }); + }); + + it("delivers private events only when the registration author is a party", async () => { + const host = "push-private-events.bind.ws"; + const owner = generateSecretKey(); + const recipient = generateSecretKey(); + const stranger = generateSecretKey(); + await enable(host, owner); + const socket = await WS.connect(host); + await socket.auth(owner, host); + const seen: string[] = []; + await trustAndCapture("push-private-events", async (req) => { seen.push(await req.text()); return new Response(null, { status: 204 }); }); + // Sign the event first, then register an ID filter for the ID-only path. + const dm = ev(owner, 4, "secret", [["p", pk(recipient)]]); + const actual = ev(owner, KIND_PUSH_REGISTRATION, "", [["d", "private"], ["relay", "wss://" + host], ["filter", JSON.stringify({ ids: [dm.id] })], ["callback", callback]]); + expect((await socket.ok(actual)).ok).toBe(true); + await socket.ok(dm); + await runInDurableObject(env.RELAY.getByName("push-private-events"), async (r: Relay) => { await r.alarm(); }); + expect(seen).toHaveLength(1); + const unrelated = ev(owner, 4, "other", [["p", pk(stranger)]]); + await socket.ok(unrelated); + await runInDurableObject(env.RELAY.getByName("push-private-events"), async (r: Relay) => { await r.alarm(); }); + expect(seen).toHaveLength(1); + }); +}); + +describe("push admission and callback isolation", () => { + it("rejects unapproved and malformed registrations and permits replacement at the author cap", async () => { + const host = "push-admission.bind.ws"; + const owner = generateSecretKey(); + const outsider = generateSecretKey(); + await enable(host, owner); + const socket = await WS.connect(host); + const registration = (d: string, url = callback, filter = "{}", relay = "wss://" + host + "/", time = Math.floor(Date.now() / 1000)) => ev(owner, KIND_PUSH_REGISTRATION, "", [["d", d], ["relay", relay], ["filter", filter], ["callback", url]], time); + expect((await socket.ok(registration("no-auth"))).msg).toContain("auth-required:"); + await socket.auth(owner, host); + expect((await socket.ok(registration("no-host-approval"))).ok).toBe(false); + await trustAndCapture("push-admission", () => new Response(null, { status: 204 })); + for (const bad of [registration("path", callback, "{}", "wss://" + host + "/wrong"), registration("filter", callback, '{"unknown":true}'), registration("search", callback, '{"search":"topic"}'), registration("port", "https://push.example.com:444/path")]) expect((await socket.ok(bad)).ok).toBe(false); + const stranger = await WS.connect(host); + await stranger.auth(outsider, host); + expect((await stranger.ok(ev(outsider, KIND_PUSH_REGISTRATION, "", registration("stranger").tags))).msg).toContain("members"); + for (let i = 0; i < 4; i++) expect((await socket.ok(registration(String(i)))).ok).toBe(true); + expect((await socket.ok(registration("fifth"))).ok).toBe(false); + expect((await socket.ok(registration("0", callback + "-updated", "{}", "wss://" + host + "/", Math.floor(Date.now() / 1000) + 1))).ok).toBe(true); + socket.ws.close(); stranger.ws.close(); + }); + + it("accepts another event while a callback is still waiting", async () => { + const host = "push-slow.bind.ws"; + const owner = generateSecretKey(); + await enable(host, owner); + const socket = await WS.connect(host); + await socket.auth(owner, host); + await trustAndCapture("push-slow", () => new Response(null, { status: 204 })); + expect((await socket.ok(ev(owner, KIND_PUSH_REGISTRATION, "", [["d", "slow"], ["relay", "wss://" + host], ["filter", '{"kinds":[1]}'], ["callback", callback]]))).ok).toBe(true); + await runInDurableObject(env.RELAY.getByName("push-slow"), async (r: Relay) => { + let release!: () => void; + let started!: () => void; + const pending = new Promise((resolve) => { release = resolve; }); + const fetching = new Promise((resolve) => { started = resolve; }); + r.fetcher = async () => { started(); await pending; return new Response(null, { status: 204 }); }; + const conn = r.virtualConn(host, pk(owner)); + const first = ev(owner, 1, "first"); + expect((await r.acceptAny(first, conn)).ok).toBe(true); + r.broadcast(first); + const tick = r.alarm(); + await fetching; + try { expect((await r.acceptAny(ev(owner, 1, "during callback"), conn)).ok).toBe(true); } + finally { release(); await tick; } + }); + }); +}); + +describe("push alarm durability", () => { + it("aborts a stalled callback and persists a retry instead of waiting indefinitely", async () => { + const host = "push-timeout.bind.ws"; + const owner = generateSecretKey(); + await enable(host, owner); + await trustAndCapture("push-timeout", () => new Response(null, { status: 204 })); + const socket = await WS.connect(host); + await socket.auth(owner, host); + expect((await socket.ok(ev(owner, KIND_PUSH_REGISTRATION, "", [["d", "timeout"], ["relay", "wss://" + host], ["filter", '{"kinds":[1]}'], ["callback", callback]]))).ok).toBe(true); + await runInDurableObject(env.RELAY.getByName("push-timeout"), async (r: Relay) => { + let aborted = false; + r.fetcher = async (_url, init) => new Promise((_resolve, reject) => { + expect(init?.redirect).toBe("manual"); + init?.signal?.addEventListener("abort", () => { aborted = true; reject(new Error("aborted")); }, { once: true }); + }); + const note = ev(owner, 1, "stalled"); + r.accept(note, null); r.broadcast(note); + await r.alarm(); + expect(aborted).toBe(true); + const job = r.sql.exec<{ attempts: number; due: number }>("SELECT attempts,due FROM push_queue WHERE event_id=?", note.id).one(); + expect(job.attempts).toBe(1); + expect(job.due).toBeGreaterThan(Math.floor(Date.now() / 1000)); + }); + }); + + it("schedules work created later in an alarm and recreates callback tables after teardown", async () => { + const host = "push-late-alarm.bind.ws"; + const owner = generateSecretKey(); + await enable(host, owner); + await trustAndCapture("push-late-alarm", () => new Response(null, { status: 204 })); + const socket = await WS.connect(host); + await socket.auth(owner, host); + expect((await socket.ok(ev(owner, KIND_PUSH_REGISTRATION, "", [["d", "late"], ["relay", "wss://" + host], ["filter", '{"kinds":[1]}'], ["callback", callback]]))).ok).toBe(true); + await runInDurableObject(env.RELAY.getByName("push-late-alarm"), async (r: Relay) => { + const publish = r.publishDiscovery; + r.publishDiscovery = async () => { + const note = ev(owner, 1, "late alarm event"); + if (r.accept(note, null).stored) r.broadcast(note); + }; + try { await r.alarm(); } + finally { r.publishDiscovery = publish; } + expect(r.sql.exec("SELECT 1 FROM push_queue").toArray()).toHaveLength(1); + expect(await r.storage.getAlarm()).toBeLessThan(Date.now() + 3000); + await r.teardown(); + await r.alarm(); + expect(r.sql.exec("SELECT 1 FROM push_queue").toArray()).toHaveLength(0); + }); + }); +}); diff --git a/test/unit/push-policy.test.ts b/test/unit/push-policy.test.ts new file mode 100644 index 0000000..bb299fb --- /dev/null +++ b/test/unit/push-policy.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from "vitest"; +import { callbackOrigin, callbackOrigins } from "../../src/push-policy.ts"; + +describe("callback origins", () => { + it("accepts HTTPS public DNS origins and never broadens a path into a policy origin", () => { + expect(callbackOrigin("https://push.example.com/token?q=secret")).toBe("https://push.example.com"); + expect(callbackOrigins(["https://push.example.com/", "https://push.example.com"])).toEqual(["https://push.example.com"]); + expect(callbackOrigins(["https://push.example.com/token"])).toBeNull(); + expect(callbackOrigins([])).toEqual([]); + expect(callbackOrigins(Array(17).fill("https://push.example.com"))).toBeNull(); + }); + + it("rejects local targets, credentials, redirects disguised as origins and other protocols", () => { + for (const url of ["http://push.example.com", "https://127.0.0.1", "https://2130706433", "https://0x7f000001", "https://[::1]", "https://localhost", "https://service.local", "https://service.internal", "https://host.test", "https://user:secret@push.example.com", "https://push.example.com:8443", "https://push.example.com/#secret", "file:///etc/passwd", "https://push.example.com.", "https://*.example.com"]) expect(callbackOrigin(url), url).toBe(""); + }); +}); diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index f057c44..8a1524b 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,16 +1,26 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: f9195d554e0f2c31ce33d975c316e2cf) +// Generated by Wrangler by running `wrangler types` (hash: 1fd8b1adbc960eef3ef448f16b146ad9) // Runtime types generated with workerd@1.20260831.1 2026-08-01 nodejs_compat interface __BaseEnv_Env { + HOSTS: KVNamespace; MEDIA: R2Bucket; + LEASE_LIMIT_IP: RateLimit; + LEASE_LIMIT_ALL: RateLimit; DOMAIN: "bind.ws"; DEV_RELAY: "dev"; - LIGHTNING_ADDRESS: "" | "darkbeauty403@walletofsatoshi.com"; + PUSH_CALLBACK_ORIGINS: "[]"; + ZONE_ID: "" | "491aaf5e1c12976e9828199686a81733"; + CNAME_TARGET: "customers.bind.ws"; + LIGHTNING_ADDRESS: "" | "bindws@getalby.com"; SERVICE_PUBKEY: "" | "6b559c0816c3b9ec03c2f0cedb8a56efe83746f8c56e543e36e1973c04ef0384"; - FREE_STORAGE_MB: "50"; - FREE_EGRESS_MB: "2048"; - SATS_PER_GB_MONTH: "2000"; - SATS_PER_GB_EGRESS: "500"; + FREE_EVENTS_MB: "100"; + FREE_MEDIA_MB: "1024"; + FREE_ACTIVE_HOURS: "100"; + FREE_ROWS_WRITTEN: "250000"; + SATS_PER_GB_MONTH_EVENTS: "400"; + SATS_PER_GB_MONTH_MEDIA: "30"; + SATS_PER_ACTIVE_HOUR: "11"; + SATS_PER_MILLION_ROWS: "2000"; RELAY: DurableObjectNamespace; } declare namespace Cloudflare { @@ -19,15 +29,25 @@ declare namespace Cloudflare { durableNamespaces: "Relay"; } interface DevEnv { + HOSTS: KVNamespace; MEDIA: R2Bucket; + LEASE_LIMIT_IP: RateLimit; + LEASE_LIMIT_ALL: RateLimit; DOMAIN: "bind.ws"; DEV_RELAY: "dev"; + PUSH_CALLBACK_ORIGINS: "[]"; + ZONE_ID: ""; + CNAME_TARGET: "customers.bind.ws"; LIGHTNING_ADDRESS: ""; SERVICE_PUBKEY: ""; - FREE_STORAGE_MB: "50"; - FREE_EGRESS_MB: "2048"; - SATS_PER_GB_MONTH: "2000"; - SATS_PER_GB_EGRESS: "500"; + FREE_EVENTS_MB: "100"; + FREE_MEDIA_MB: "1024"; + FREE_ACTIVE_HOURS: "100"; + FREE_ROWS_WRITTEN: "250000"; + SATS_PER_GB_MONTH_EVENTS: "400"; + SATS_PER_GB_MONTH_MEDIA: "30"; + SATS_PER_ACTIVE_HOUR: "11"; + SATS_PER_MILLION_ROWS: "2000"; RELAY: DurableObjectNamespace; } interface Env extends __BaseEnv_Env {} @@ -37,7 +57,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } // Begin runtime types diff --git a/wrangler.celld.jsonc b/wrangler.celld.jsonc index 33e9aa1..2a72328 100644 --- a/wrangler.celld.jsonc +++ b/wrangler.celld.jsonc @@ -12,6 +12,7 @@ "vars": { "DOMAIN": "bind.ws", "DEV_RELAY": "dev", + "PUSH_CALLBACK_ORIGINS": "[]", // The header a trusted proxy puts the client's address in: x-forwarded-for // for Caddy, x-real-ip for nginx. Empty means no address is known, so // per-address limits are off. Set it only behind a proxy that overwrites diff --git a/wrangler.jsonc b/wrangler.jsonc index 8c2978d..718720e 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -34,6 +34,7 @@ // Requests for any other host fall back to the relay named DEV_RELAY. "DOMAIN": "bind.ws", "DEV_RELAY": "dev", + "PUSH_CALLBACK_ORIGINS": "[]", // Custom domains: the zone custom hostnames are created in, and the name // owners point their CNAME at. CF_API_TOKEN is a secret, set with // `wrangler secret put CF_API_TOKEN`; without it the feature is off. @@ -84,6 +85,7 @@ "vars": { "DOMAIN": "bind.ws", "DEV_RELAY": "dev", + "PUSH_CALLBACK_ORIGINS": "[]", "ZONE_ID": "", "CNAME_TARGET": "customers.bind.ws", "LIGHTNING_ADDRESS": "",