- commit
- e23883ef529c17d332d8bf672e0a73842b39a6b6
- parent
- a76d7fdb786c9d77a0ca76ed1746955d36ffd0ba
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 121 | ++++++++++++++++++------------ |
@@ -9,107 +9,132 @@ One Cloudflare Worker, one Durable Object per relay name, SQLite inside each obj
99 1010 ```1111 client ──wss/https──▶ worker ──host→object──▶ durable object "<name>"12- ├ sqlite: events, tags, search, members, rules, usage13- ├ sockets: live subscriptions (hibernating)14- ├ alarm: daily sweep, retention, fuel15- └ r2: blobs by sha25612+ │ KV: custom host → name ├ sqlite: events, tags, search, members, rules, jobs, usage13+ │ ├ sockets: live subscriptions (hibernating)14+ │ ├ alarm: sweep, retention, fuel, dumps, jobs, succession15+ │ ├ r2: blobs and dumps under <name>/16+ │ └ outbound sockets: pulls, pushes, notifications1617 ```1718 18-## Routing19+## Routing and states1920 20-`src/index.ts` maps the hostname to a name. `<name>.bind.ws` goes to the object `getByName(name)`. The apex serves the landing page. Reserved names and invalid names redirect to the apex. In `wrangler dev`, `<name>.localhost` plays the same role.21+`src/index.ts` maps the hostname to a name. `<name>.<domain>` goes to the object `getByName(name)`. The apex serves the landing page and `POST /lease`. Reserved names and invalid names redirect to the apex. Any other hostname is looked up in the `HOSTS` KV namespace, which maps a custom hostname to a relay name, with a one-minute cache per isolate; a miss is cached too. In `wrangler dev`, `<name>.localhost` plays the same role. The worker stamps every forwarded request with `x-relay-name` and with `x-relay-ip` from Cloudflare's `cf-connecting-ip`, overwriting anything a client sent.22+23+A relay is in one of three states:24+25+| State | Owner | Writes | Management |26+|---|---|---|---|27+| unclaimed | none | refused | `claim` only |28+| leased | none, `policy.lease` holds an expiry and an optional holder | open to all, kept 14 days | `claim` only, by the holder if one is set |29+| claimed | a pubkey | by the rules | by role |30+31+A lease comes from `POST /lease`, which tries memorable names from `names.ts` until one is unclaimed and calls the object's `lease` method over RPC. A claim on a leased relay converts it in place and clears the lease; the events, files and the 14-day retention rule stay until the owner resets the rules. A lease past its expiry is torn down whole by the alarm, which returns the name to unclaimed.32+33+Custom hostnames (`domains.ts`) are registered with Cloudflare for SaaS from the object, through the `fetcher` seam the lightning code also uses, and written to KV only after Cloudflare accepted them. Up to three per relay, never two relays for one hostname. They survive transfer and die with the relay.2134 2235 ## The object2336 2437 `src/relay.ts` is the `Relay` class. It owns:2538 2639 - **Store** (`store.ts`): the event tables and queries.27-- **Settings** (`settings.ts`): policy, members, bans, kind rules, retention, invites, reports.40+- **Settings** (`settings.ts`): policy, members, bans, kind rules, retention, invites, reports, blocked addresses, succession.2841 - **Fuel** (`fuel.ts`): usage meters, credits, prices.29-- **Identity** (`identity.ts`): the relay's own keypair and the NIP-43 roster.42+- **Identity** (`identity.ts`): the relay's own keypair and everything it signs.3043 31-A relay is in one of three states. Unclaimed: no owner, no lease, writes refused. Leased: no owner yet, `policy.lease` holds an expiry and, optionally, the one pubkey that may claim; reads and writes are open, management is refused except `claim`. Claimed: an owner. A lease is handed out by `POST /lease` on the apex (`index.ts`), which tries memorable names from `names.ts` until one is unclaimed and calls the object's `lease` method over RPC. A claim on a leased relay converts it in place and clears the lease; the events, files and the 14-day retention rule stay until the owner resets the rules.44+The constructor runs schema setup inside `blockConcurrencyWhile`, then handles fetches. Websockets use the hibernation API. Per-connection state (NIP-42 challenge, authenticated pubkeys, open subscriptions, the client's address) is stored on the socket with `serializeAttachment`, so it survives hibernation. Negentropy sessions are in memory only and end with `closed:` when the object sleeps.3245 33-The constructor runs schema setup inside `blockConcurrencyWhile`, then handles fetches. Websockets use the hibernation API. Per-connection state (NIP-42 challenge, authenticated pubkeys, open subscriptions) is stored on the socket with `serializeAttachment`, so it survives hibernation. Negentropy sessions are in memory only and end with `closed:` when the object sleeps.46+The write path is one gate plus the client policy. The gate refuses malformed, expired, banned and out-of-context events (an `h` tag for another group) and, for client writes, the unclaimed and expired-lease states, fuel and NIP-70. The client policy adds kind rules, retention windows, the write rule, proof of work and per-member caps. Host-side writes, such as pulls and the relay's own records, pass the gate only. Kind 24133, NIP-46 signer traffic, passes everything but bans and rate limits: it is ephemeral, never stored and encrypted end to end, so the relay can carry a remote signer's session for the console, including for someone about to claim it.3447 35-## Storage48+## Storage and media3649 37-SQLite tables: `events`, `tags` (single-letter tags only), an FTS5 `search` table keyed by row id, `vanished` tombstones, plus settings tables. Two platform details shape the SQL:50+SQLite tables: `events`, `tags` (single-letter tags only), an FTS5 `search` table keyed by row id, `vanished` tombstones, plus the settings tables and `jobs`, `dumps` and `usage`. Two platform details shape the SQL:3851 3952 - Statements take at most 100 bound parameters, so id and author lists bind as one JSON parameter expanded with `json_each`.4053 - There are no PRAGMAs, so cascading deletes are triggers.4154 4255 Only kinds that carry prose go into the search index: profiles, notes, threads, comments, highlights, articles and wiki pages.4356 44-Blobs live in R2 under `<name>/<sha256>`. Their descriptors live in the relay's database, so they count toward storage and appear in the console. Two doors lead to the same bucket and table: Blossom (`blossom.ts`) and NIP-96 (`nip96.ts`), a thin adapter that reuses the Blossom store, gate and URLs. Descriptors and NIP-96 answers carry the NIP-94 tags the relay can vouch for without decoding the file: `url`, `m`, `x`, `ox` and `size`. Blob reports (BUD-09) go into the `reports` table with the sha256 in `target_event` and the uploader in `target_pubkey`; resolving one with delete removes the blob and puts its hash on the banned id list, which every upload door checks.57+Blobs live in R2 under `<name>/<sha256>`. Their descriptors live in the relay's database, so they count toward storage and appear in the console. Two doors lead to the same bucket and table: Blossom (`blossom.ts`, BUD-01, 02, 04, 06, 08 and 09) and NIP-96 (`nip96.ts`), a thin adapter that reuses the Blossom store, gate and URLs. Descriptors and NIP-96 answers carry the NIP-94 tags the relay can vouch for without decoding the file: `url`, `m`, `x`, `ox` and `size`. Blob reports go into the `reports` table with the sha256 in `target_event` and the uploader in `target_pubkey`; resolving one with delete removes the blob and puts its hash on the banned id list, which every upload door checks.4558 46-## Alarm59+Per-member caps are checked on the write path from a per-author byte count the store caches; anything that deletes clears the cache.4760 48-One alarm per object, at most a day out, sooner if a NIP-40 expiry is due or a lease expires. It flushes usage counters, charges storage, applies retention rules (the kind rules, then each member's own keep-for), sweeps expired events and, when the owner turned dumps on, writes a JSONL of every event to R2 (`dumps.ts`): streamed by sequence into a multipart upload, recorded in the `dumps` table, counted as media, the newest `dumpsKeep` kept. A lease past its expiry is torn down whole, which returns the name to unclaimed.61+## The alarm4962 50-Per-member caps are checked on the write path from a per-author byte count the store caches; anything that deletes clears the cache.63+One alarm per object, at most a day out, sooner if a NIP-40 expiry is due, a lease expires or a job is due. Each tick, in order:5164 52-NIP-46 traffic (kind 24133) is ephemeral, never stored and encrypted end to end, so it passes the ownership and write gates, and a subscription to it alone passes the read gate. That lets the relay carry a remote signer's session for the console, including for someone about to claim it. Bans and the per-connection rate limit still apply.65+1. Tear down a lease past its expiry, and stop.66+2. Run one round of the running or due job, and come back a quarter second later while there is more (see below).67+3. Flush usage counters and charge storage past the allowances, pro rata by day.68+4. Apply retention: the kind rules, then each member's own keep-for. Sweep NIP-40 expiries.69+5. Write a dump when one is due (`dumps.ts`): every event streamed by sequence into a multipart upload under `<name>/dumps/`, recorded in the `dumps` table, counted as media, the newest `dumpsKeep` kept.70+6. Notify the owner when fuel turns low, once, then daily while it stays low.71+7. Run the succession check.5372 54-The alarm also drives jobs (`jobs.ts`), a persisted list run one round at a time. `addjob` records one and wakes the alarm; the alarm runs the job that is running or due, saves it, and comes back a quarter second later while there is more. Three failed rounds in a row end a run with the reason. A once job is done after its run; a standing job is scheduled again at its interval, and the daily alarm is never set past the next run.73+Succession is the dead-man's switch. `ownerSeenAt` in storage is refreshed by owner-signed actions (management calls, websocket AUTH, bridge requests, stored events), at most hourly. With an heir named, silence past the chosen delay puts a warning record in storage, mirrored in memory so NIP-11 can report `succession_pending`; the owner is notified weekly for 30 days; then `transferOwner` runs, both parties are notified and the handover is logged. Any owner action clears the warning.5574 56-A pull round (`pull.ts`) opens one websocket to the source, reconciles with NIP-77 as the initiator over the job's filter (authors, kinds, since), fetches a bounded batch of the missing ids, verifies signatures, and stores them through the normal write path minus the client policy. Sources on this host are dialled through their object stub and, on a whole-relay pull, their files copied in R2 by prefix. A pull job syncs its sources in turn, so a backfill is a pull with the owner as author from the relays in their kind 10002. A push round takes the next batch past a sequence cursor kept on the job, offers it to every target, counts OKs (a `duplicate:` counts as sent), and leaves a target alone for the round after several refusals in a row; the cursor survives sleeps and runs, so a standing push forwards only what arrived since. NIP-70 protected events are never pushed, nor private kinds from a members-only relay.75+## Jobs5776 58-The alarm also runs succession, the dead-man's switch. `ownerSeenAt` in storage is refreshed by owner-signed actions (management calls, websocket AUTH, bridge requests, stored events), at most hourly. With an heir named, silence past the chosen delay puts a warning record in storage, mirrored in memory so NIP-11 can report `succession_pending`; the owner is notified weekly for thirty days; then `transferOwner` runs, both parties are notified and the handover is logged. Any owner action clears the warning.77+`jobs.ts` is a persisted list the alarm runs one round at a time, so work survives the object sleeping. `addjob` records a job and wakes the alarm. Three failed rounds in a row end a run with the reason. A once job is done after its run; a standing job runs again every one, six or 24 hours, and the daily alarm is never set past the next run. Five standing jobs and 20 in all, 10 relays per job.5978 60-## Fuel79+A pull round (`pull.ts`) opens one websocket to the source, reconciles with NIP-77 as the initiator over the job's filter (authors, kinds, since), fetches a bounded batch of the missing ids, verifies signatures and stores them through the gate. Sources on this host are dialled through their object stub and, on a whole-relay pull, their files copied in R2 by prefix. A pull job syncs its sources in turn, so a backfill is a pull with the owner as author from the relays in their kind 10002.6180 62-Meters accumulate in memory and flush to a monthly `usage` row in batches. Time awake is estimated the way Cloudflare bills it: a wake costs the idle window (about 10 seconds) once, and each later message costs the gap since the previous one, capped at that window.81+A push round takes the next batch past a sequence cursor kept on the job, offers it to every target, counts OKs (a `duplicate:` counts as sent) and leaves a target alone for the round after five refusals in a row. The cursor survives sleeps and runs, so a standing push forwards only what arrived since. NIP-70 protected events are never pushed, nor private kinds from a members-only relay.6382 64-Credits come from NIP-57 receipts. A receipt is validated (provider signature, service pubkey, embedded zap request naming this relay, invoice amount) and recorded once in `credits`. Receipts are stored as ordinary events, so the ledger is the relay's own data.83+A fork (`forkrelay`) is built from three things that exist: it leases a name through the same `lease` method the apex uses, reserved for the holder; calls `adoptFrom` on the new object, an RPC method HTTP cannot reach, which copies the plain members and adds a once pull from this relay's public URL with the optional filter; and returns the console URL to hand over. One fork an hour per relay.6584 66-## Identity85+## Identity and signed records6786 68-On claim, the object generates a keypair, advertised as `self` in NIP-11. Everything the relay vouches for is signed with it and derived from one place, the `members` table and the policy, by one function, `publishMembership`: the NIP-43 roster (13534, roles named only when there is one) and its deltas (8000, 8001), the NIP-29 put-user and remove-user records (9000, 9001), the group's state (39000-39003), the NIP-43 role definitions (33534) and the relay's own kind 0 profile, re-signed only when name, description or icon changed. Every path that touches membership or roles goes through that function, so the roster and the group cannot drift. All but the profile are NIP-70 protected, with strictly increasing timestamps.87+On claim, the object generates a keypair, advertised as `self` in NIP-11. Everything the relay vouches for is signed with it and derived from one place, the `members` table and the policy, by one function, `publishMembership`:6988 70-NIP-43's own join and leave requests (28934 with a `claim` tag, 28936) are handled next to their NIP-29 counterparts; they are ephemeral, so the answer is the OK message and nothing is stored.89+| Record | Kind | When |90+|---|---|---|91+| NIP-43 roster and its deltas | 13534, 8000, 8001 | membership change |92+| NIP-29 put-user, remove-user | 9000, 9001 | membership change |93+| group metadata, admins, members, roles | 39000 to 39003 | membership, role or rule change |94+| NIP-43 role definitions | 33534 | same |95+| the relay's own profile | 0 | name, description or icon changed |7196 72-The card (`card.ts`) is the public face of a name: `/card.json` with name, owner, member count when the directory is public, rules, fuel state and the group naddr, `/card.nostr` as the same facts in a kind 30078 signed by the relay key, `/card.svg` as an open graph sized picture, and `/qr.svg` for the console. The naddr comes from nostr-tools' nip19; the QR encoder is `qr.ts`, byte mode at level M up to version 20, with a test that reads symbols back and checks the Reed-Solomon syndromes.97+Every path that touches membership or roles goes through that function, so the roster and the group cannot drift. All but the profile are NIP-70 protected, with strictly increasing timestamps, and all are protected from retention. NIP-43's join and leave requests (28934 with a `claim` tag, 28936) are handled next to their NIP-29 counterparts; they are ephemeral, so the answer is the OK message and nothing is stored.7398 74-## Groups and roles99+The card (`card.ts`) is the public face of a name: `/card.json` with name, owner, member count when the directory is public, rules, fuel state and the group naddr; `/card.nostr` as the same facts in a kind 30078 signed by the relay key; `/card.svg` as an Open Graph sized picture; and `/qr.svg` for the console. The naddr comes from nostr-tools' nip19. The QR encoder is `qr.ts`, byte mode at level M up to version 20, with a test that reads symbols back and checks the Reed-Solomon syndromes.75100 76-Every relay is one NIP-29 group (`src/groups.ts`). The group id is the relay's name. An event with an `h` tag for any other id is refused; events without one are ordinary relay events. The group's flags are derived from the rules: `private` is reads set to members, `restricted` and `closed` are writes not open. The identity signs the group's state as addressable events, kind 39000 metadata, 39001 admins, 39002 members (only while the directory is public), 39003 roles, on the same strictly increasing clock as the roster, and republishes them on every membership, role or rule change. Membership changes also produce relay-signed 9000 put-user and 9001 remove-user records next to the NIP-43 deltas. All of these are protected kinds.101+## Groups and roles77102 78-Joins (9021, with an optional invite `code`), leaves (9022) and the moderation kinds 9000, 9001, 9002, 9005 and 9009 are applied before the write policy and gated by role instead. Unsupported moderation kinds are refused with `unsupported:`. Timeline `previous` references, pins, subgroups and livekit are not implemented.103+Every relay is one NIP-29 group (`groups.ts`). The group id is the relay's name. An event with an `h` tag for any other id is refused; events without one are ordinary relay events. The group's flags are derived from the rules: `private` is reads set to members, `restricted` and `closed` are writes not open. Joins (9021, with an optional invite `code`), leaves (9022) and the moderation kinds 9000, 9001, 9002, 9005 and 9009 are applied before the write policy and gated by role instead. Unsupported moderation kinds are refused with `unsupported:`. Timeline `previous` references, pins, subgroups and livekit are not implemented.79104 80-`src/roles.ts` is the one permission table, used by the NIP-86 methods and the NIP-29 events alike:105+`roles.ts` is the one permission table, used by the NIP-86 methods and the NIP-29 events alike:81106 82107 | Role | May |83108 |---|---|84-| owner | everything, including rules, identity, storage, config, jobs, transfer, delete |85-| moderator | read stats and lists, add and edit plain members, ban, delete events, invites, reports |109+| owner | everything, including rules, identity, storage, config, jobs, transfer, fork, delete |110+| moderator | read stats and lists, add and edit plain members, ban, block addresses, delete events, invites, reports |86111 87-Moderators cannot act on the owner or on each other; only the owner appoints or removes moderators. `transferowner` hands the relay to a member and leaves the old owner as a moderator; the identity key and fuel do not change.112+Moderators cannot act on the owner or on each other; only the owner appoints or removes moderators. `transferowner` hands the relay to a member and leaves the old owner as a moderator; the identity key, hostnames and fuel do not change.88113 89-## Pages, feed and notifications114+Members inviting members is `invited_by` on the member row, set from the invite's minter on both join doors, and a `memberInvites` rule in policy. `Settings.inviteDepth` walks the chain to the owner; `Settings.subtree` collects a member and everyone under them, stopping at moderators; `removesubtree` removes them all and publishes membership once.90115 91-`src/pages.ts` renders kind 1 at `/e/<id>` and kind 30023 at `/a/<d>` (the owner's) or `/a/<author>/<d>` with Open Graph tags, and `/feed.xml` as Atom, from the same store query the console uses, with an access of no pubkeys so private kinds can never appear. Every page path answers 404 unless reads are open. `nostr:` references become links here when the target is on this relay.116+## Management and rate limits92117 93-`src/notify.ts` lets the relay write its owner: a kind 14 rumour sealed and gift wrapped with the identity key (NIP-59, NIP-44), stored as the owner's inbox and pushed best effort to the relays in the owner's kind 10050 through the pull code's `dial`. Hooks: a report filed (either door), the daily alarm when fuel turns low (once, then daily while low), a pull finishing, and the test button. Switched off by default in `policy.notify`. The catch-all retention rule skips kind 1059 so the inbox survives it.118+`manage.ts` implements NIP-86 over NIP-98. The console is a client of it. `verifyNIP98` is a local implementation: kind 27235, 60-second window, `u` matches host and path, method matches, payload hash matches when there is a body. Every method except `claim` and `supportedmethods` names an action in `roles.ts`; a method without one is refused for everyone.94119 95-## Management120+`listeventsneedingmoderation` is a view over the reports queue: one entry per open reported thing, an event id or a blob hash, with the report's type and words as the reason. `blockip`, `unblockip` and `listblockedips` work on the address the worker stamped. A blocked address gets no socket and no write, read or upload door, while the page, NIP-11 and management stay reachable so an owner can undo a block on their own address.96121 97-`src/manage.ts` implements NIP-86 over NIP-98. The console is a client of it. `verifyNIP98` is a local implementation: kind 27235, 60-second window, `u` matches host and path, method matches, payload hash matches.122+Rate limits are token buckets per connection (`ratelimit.ts`) and, beside them, per address at four times the allowance. The address buckets are the HTTP bridge's only limit, live in memory and reset when the object sleeps. The apex caps leases with two rate limiting bindings, per address and overall.98123 99-`listeventsneedingmoderation` is a view over the reports queue: one entry per open reported thing, an event id or a blob hash, with the report's type and words as the reason. `blockip`, `unblockip` and `listblockedips` work on the client's address, which the worker stamps on every forwarded request as `x-relay-ip` from Cloudflare's `cf-connecting-ip`, never read from a client header. The address is kept in the socket's state and in the HTTP doors' virtual connection. Per-address token buckets sit beside the per-connection ones at four times the allowance, and are the bridge's only rate limit.124+## Presets100125 101-## Presets and lists126+`presets.ts` holds the rule bundles: writes, reads, the directory, kind rules and keep-for rules in one go, applied through the same Settings methods the console uses one at a time. Limits, identity, people and bans stay. The `search` and `articles` presets also keep a standing pull labelled `replica` of their kinds from a source relay; applying any preset removes the earlier replica job first, so a name holds at most one. The owner's own replaceable kinds pass the kind rules, so a relay can always hold its owner's lists.102127 103-`src/presets.ts` holds the rule bundles (`applypreset`, `listpresets` over NIP-86), applied through the same Settings methods the console uses one at a time. The owner's own replaceable kinds pass the kind rules, so a relay can always hold its owner's lists. Wiring the relay into those lists happens in the console: it fetches the newest copy from this relay over the NIP-98 bridge and from a few indexers over websockets, merges, signs once, and publishes to every relay the list names.128+Wiring the relay into the owner's lists (kinds 10002, 10050, 10007, 10063) happens in the console: it fetches the newest copy from this relay over the NIP-98 bridge and from a few indexers over websockets, merges, signs once and publishes to every relay the list names.104129 105-Members inviting members is `invited_by` on the member row, set from the invite's minter on both join doors, and a `memberInvites` rule in policy. `Settings.inviteDepth` walks the chain to the owner; `Settings.subtree` collects a member and everyone under them, stopping at moderators; `removesubtree` removes them all and publishes membership once.130+## The web surface106131 107-## Protocol surface132+`pages.ts` renders kind 1 at `/e/<id>` and kind 30023 at `/a/<d>` (the owner's) or `/a/<author>/<d>` with Open Graph tags, and `/feed.xml` as Atom, newest 50, from the same store query the console uses, with an access of no pubkeys so private kinds can never appear. Every page path answers 404 unless reads are open; an unclaimed relay answers 404 too, a leased one has pages. `nostr:` references become links here when the target is on this relay. Answers are cacheable for five minutes.108133 109-NIP-01, 09, 11, 13, 17/59 private kinds, 29, 40, 42, 45 with HLL, 50, 56, 62, 67, 70, 77, 86, 98, plus NIP-05, NIP-43, NIP-57, NIP-96 with NIP-94, and Blossom BUD-01/02/04/06/08/09. `test/conformance` is a black-box suite that runs against any relay URL.134+`notify.ts` lets the relay write its owner: a kind 14 rumour sealed and gift wrapped with the identity key (NIP-59, NIP-44), stored as the owner's inbox and pushed best effort to up to three relays from the owner's kind 10050 through the pull code's `dial`. Hooks: a report filed through either door, fuel turning low, a job finishing, the succession clock and the test button. Off by default in `policy.notify`; naming an heir switches the succession kind on. The catch-all retention rule skips kind 1059 so the inbox survives it.110135 111-## Forks and replicas136+The console (`dashboard.ts`) is one page, no build step: markup, styles and script as three string literals. Its signer is NIP-07 when present or a NIP-46 session over the relay itself, using a bundle of nostr-tools served at `/signer.js` and loaded only when someone picks remote signing.112137 113-`forkrelay` builds a new relay from three things that already exist: it leases a name through the same `lease` method the apex uses, reserved for the holder; calls `adoptFrom` on the new object, an RPC method HTTP cannot reach, which copies the plain members and adds a once pull job from this relay's public URL with the optional filter; and returns the console URL to hand over. The new object pulls on its own alarm. One fork an hour per relay, recorded in storage.138+## Protocol surface114139 115-Replica presets (`search`, `articles`) are rule bundles plus a standing pull labelled `replica` of the preset's kinds from a source relay. Applying any preset removes the earlier replica job first, so a name holds at most one.140+NIP-01, 05, 09, 11, 13, 17 and 59 private kinds, 29, 40, 42, 43, 45 with HLL counts, 46 as transport, 50, 56, 57, 62, 67, 70, 77, 86, 94, 96, 98, and Blossom BUD-01, 02, 03 by way of the lists, 04, 06, 08 and 09. `test/conformance` is a black-box suite that runs against any relay URL.