No seeds: pyrtan.com is GitHub Pages and a hub list, nothing more
Claude · 2026-10-27 · f0218b81137814815d6b4269e54d2e2fbc890d87 · parents 895f92c
pyrtan.com runs no hub, and the Fly.io seed is gone, but the word and the machinery around it stayed. The list of hubs operators add by pull request stays, since it is the only way the static site learns that a hub exists, and is now what it is: rendezvous/hubs.json, "the hub list".
- site/rendezvous/seeds.json -> hubs.json; site/snapshot.py takes it as HUBS and reports what it asked under "listed"; build.sh and the pages workflow follow. - pyrtan.federation: seed_urls -> rendezvous_hubs, first_live_seed -> first_live_hub, $PYRTAN_SEEDS -> $PYRTAN_HUB_LIST. `pyrtan hub --follow seed` is gone; --join default does what it did. - The landing page fell silent ("No seeds listed yet") when the list was empty, which it is; it now falls through to the hourly snapshot. - README, site/README, the federation, platform, design and chat notes, and the deploy docs say "hub list" and that pyrtan.com runs no hub; the federation note keeps one line of history.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rD7jd9JrpLd1HKE8P1h3G
Changes
.github/workflows/pages.yml | 7 ++--- README.md | 20 +++++++-------- _meta/chat.md | 2 +- _meta/design.md | 3 ++- _meta/federation.md | 52 +++++++++++++++++++------------------ _meta/platform.md | 17 +++++++------ deploy/README.md | 3 ++- deploy/openmediavault/docker-compose.yml | 2 +- site/README.md | 46 +++++++++++++++++---------------- site/build.sh | 8 +++--- site/index.html | 25 +++++++++--------- site/rendezvous/hubs.json | 5 ++++ site/rendezvous/network.json | 2 +- site/rendezvous/seeds.json | 5 ---- site/snapshot.py | 40 +++++++++++++++-------------- src/pyrtan/cli.py | 14 +++++----- src/pyrtan/federation.py | 40 +++++++++++++++-------------- src/pyrtan/hub.py | 9 ++++--- src/pyrtan/page.py | 2 +- tests/test_federation.py | 72 ++++++++++++++++++++++++++-------------------------- 20 files changed, 195 insertions(+), 179 deletions(-)
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index f8454fe..977a587 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml@@ -2,8 +2,9 @@# and once an hour, build the landing page, the console (from this # checkout's pyrtan), the source browser (files, releases, branches and # history from git), the catalog (from examples/ and catalog/, through the-# gate), the seed list and a snapshot of the directory and of the gap-# reports as the seeds know them, and publish them. Set the+# gate), the hub list and a snapshot of the directory and of the gap+# reports as the hubs on it, and every hub they know of, know them, and+# publish them. pyrtan.com runs no hub: these static files are all of it. Set the# repository's Pages source to "GitHub Actions" and its custom domain to # pyrtan.com; site/CNAME carries the domain.@@ -14,7 +15,7 @@ on:branches: [main] workflow_dispatch: schedule:- - cron: "23 * * * *" # hourly: refresh rendezvous/directory.json from the seeds+ - cron: "23 * * * *" # hourly: refresh rendezvous/directory.json from the hub listpermissions: contents: read diff --git a/README.md b/README.md index 8cccb02..a57992e 100644 --- a/README.md +++ b/README.md@@ -265,16 +265,16 @@ purpose. It names the network rather than guarding it: anyone may join,and what keeps a stranger's hub harmless is that following is still the one act of trust. `pyrtan join` (or `pyrtan hub --join default`, or `$PYRTAN_JOIN=default`) puts a hub in it and follows the first hub in-`rendezvous/seeds.json` that answers, then the first from the snapshot;+`rendezvous/hubs.json` that answers, then the first from the snapshot;when none does, the hub is the network so far, and others join through-it. No hub has to be always up: every hour pyrtan.com's build asks-every hub it knows of for its directory (the seeds, then everyone the-last snapshot or a hub names), keeps the default network's part, and-publishes it as `rendezvous/directory.json`, so the directory stays-current while any listed hub is up, and pyrtan.com itself is static-files on GitHub Pages. To be a seed, a hub in the default network,+it. pyrtan.com runs no hub: it is static files on GitHub Pages. Every+hour its build asks every hub it knows of for its directory (the hub+list, then everyone the last snapshot or a hub names), keeps the default+network's part, and publishes it as `rendezvous/directory.json`, so the+directory stays current while any hub it knows of is up, and no hub has+to be always up. To be on the hub list, a hub in the default network,started `--listed` and `--cors https://pyrtan.com`, goes into-`seeds.json` by pull request.+`site/rendezvous/hubs.json` by pull request.## Members, and what they keep@@ -456,7 +456,7 @@ chat (a bot runs trees, so a lobby must not be a way round it). To letstrangers in and keep the token on everything else, start the hub `--chat-public`: callers without the token are guests, who meet people (here, or on a hub this one follows) and never a bot. There is no chat-server and no seed: every hub started `--chat` is a lobby, and hubs+server and no hub of pyrtan.com's: every hub started `--chat` is a lobby, and hubsthat follow each other share their strangers. It is text only, a line is at most 1,000 characters, five in a burst and one a second after; a seat nobody polls for a minute is let go. There is no@@ -574,7 +574,7 @@ _meta/ the design record, the tree format reference, and thedesign for pyrtan.com deploy/ Dockerfile at the root; a compose stack for OpenMediaVault and a systemd unit-site/ pyrtan.com: the landing page, the seed list, the catalog+site/ pyrtan.com: the landing page, the hub list, the catalogviewer, the source browser (files, releases, branches and history), and the build the pages workflow publishes ``` diff --git a/_meta/chat.md b/_meta/chat.md index 51f0845..f3c7214 100644 --- a/_meta/chat.md +++ b/_meta/chat.md@@ -51,7 +51,7 @@ says why. The rest of the hub (`/intent`, `/run`, `/gaps`, `/follow`,nothing but each other. A hub with no token has no guests: there, anyone may meet a bot, as anyone may ask it at `/intent`.-**No seed, no centre.** There is no chat server. Every hub started+**No centre.** There is no chat server, and pyrtan.com runs no hub. Every hub started`--chat` is a lobby, and a seat that finds nobody at home asks the hubs its hub follows (§3). A hub that joins the default network (`pyrtan join`) follows a hub in it, and each follow is a way for the people diff --git a/_meta/design.md b/_meta/design.md index 9792584..b794e0d 100644 --- a/_meta/design.md +++ b/_meta/design.md@@ -284,7 +284,8 @@ Five trees, all against real public APIs, all tested offline:as the only trust. Design in [`federation.md`](federation.md). 4. **v0.4, built: no server anywhere.** The directory hubs find each other by is kept and gossiped by every federated hub, with a static- seed list at pyrtan.com in place of a rendezvous hub; a hub can hold+ list of hubs at pyrtan.com in place of a rendezvous hub (then+ `seeds.json`, now `hubs.json`); a hub can holdtrees itself (`--trees`), so a federated swarm can be one process. 5. **v0.5, built: members and what they keep.** A browser or a phone joins a hub through a mailbox, carries the directory, and keeps the diff --git a/_meta/federation.md b/_meta/federation.md index cc12cea..8f5aef4 100644 --- a/_meta/federation.md +++ b/_meta/federation.md@@ -137,7 +137,7 @@ then answers for all of them. Rejected: it would route to hubs nobodychose. **A rendezvous hub that holds the directory** (v0.3) was built and then replaced: it was a server, and the point of a swarm is that there is none. What stands is §7: a directory every hub keeps and-gossips, a static seed list, and routing that only ever follows an+gossips, a static hub list, and routing that only ever follows anoperator's follow. **Multi-hop routing.** Announcing transitive capabilities with a hop@@ -206,34 +206,38 @@ Each entry remembers `via`, the hub it was last heard from.**Reading it.** `GET /directory` (public) on any federated hub lists what that hub knows, each entry with `link` computed from `seen`: `up` within ten minutes, `stale` within a day, `down` after. `pyrtan directory` asks-your own hub first and falls back to the seeds; the console shows it on-every federated hub; the page at pyrtan.com asks the seeds from the-visitor's browser.--**Seeds.** pyrtan.com publishes `rendezvous/seeds.json`, a static list-of hub URLs maintained by pull request, the role of a torrent's bootstrap-nodes. `pyrtan join` (and `pyrtan follow` with no URL) puts a hub in the-default network (§7a) and follows the first seed whose `/actor` answers;-`pyrtan hub --join default` (or `--follow seed`) does the same on start.-A seed is an ordinary federated hub in the default network, up when its-operator's box is; nothing needs it to be up always (below). `$PYRTAN_SEEDS` points at another list (a URL or a file),-`$PYRTAN_RENDEZVOUS` at one hub.--**The snapshot.** Seeds are home boxes, and home boxes go off. So the-site's build, which GitHub runs on every push and once an hour, asks each-seed's `/directory`, then every other hub it knows of (from the last+your own hub first and falls back to the hub list; the console shows it+on every federated hub; the page at pyrtan.com asks the listed hubs from+the visitor's browser.++**The hub list.** pyrtan.com runs no hub. It publishes+`rendezvous/hubs.json`, a static list of hub URLs maintained by pull+request, the role of a torrent's bootstrap nodes, and it is the only way+pyrtan.com learns that a hub exists. `pyrtan join` (and `pyrtan follow`+with no URL) puts a hub in the default network (§7a) and follows the+first listed hub whose `/actor` answers; `pyrtan hub --join default`+does the same on start. A listed hub is an ordinary federated hub in+the default network, up when its operator's box is; nothing needs it to+be up always (below). `$PYRTAN_HUB_LIST` points at another list (a URL+or a file), `$PYRTAN_RENDEZVOUS` at one hub. (Until v0.13 this was+`seeds.json`, and pyrtan.com briefly ran a hub of its own on Fly.io; both+are gone.)++**The snapshot.** Listed hubs are home boxes, and home boxes go off. So+the site's build, which GitHub runs on every push and once an hour, asks+each listed hub's `/directory`, then every other hub it knows of (from the lastsnapshot, and from what the hubs it asked named), in parallel rounds up to a bound, and bakes the merged answer into `rendezvous/directory.json` with the time, keeping the default network's part of each directory (and what was heard outside any network), keeping the last snapshot's entries for hubs that did not answer and dropping anything a week old-(`site/snapshot.py`, standard library, no pyrtan). The build is the-seed's directory work, done by GitHub: the directory stays current while-any listed hub is up, whether or not a seed is. The landing page-shows it when no seed answers the visitor's browser; `pyrtan directory`-falls back to it; `pyrtan follow` with no URL tries its hubs after the-seeds (`$PYRTAN_SNAPSHOT` overrides). A new hub can therefore bootstrap-from any listed hub that is up, and the only way the directory is empty+(`site/snapshot.py`, standard library, no pyrtan). The build does the+directory work a central hub would, done by GitHub: the directory stays+current while any hub it knows of is up, listed or not. The landing page+shows it when no listed hub answers the visitor's browser; `pyrtan+directory` falls back to it; `pyrtan follow` with no URL tries its hubs+after the listed ones (`$PYRTAN_SNAPSHOT` overrides). A new hub can+therefore bootstrap from any hub the snapshot knows of that is up, and the only way the directory is emptyis if nobody has been up for a week. Nothing of ours polls: GitHub does. **What an entry is not.** A hub two follows away cannot verify who made diff --git a/_meta/platform.md b/_meta/platform.md index 1024a7f..5edc863 100644 --- a/_meta/platform.md +++ b/_meta/platform.md@@ -134,7 +134,7 @@ federated hub keeps what it hears, one row per reporter and gap, withthe merge rules the directory uses (newer wins, nothing from the future, thirty days, two thousand rows), passes it on, and serves `/reports` publicly with counts and reporters summed. The site's snapshot asks the-seeds for `/reports` hourly and bakes `rendezvous/reports.json`; the+listed hubs, and every hub they know of, for `/reports` hourly and bakes `rendezvous/reports.json`; thecatalog page shows each tree's gaps beside it; `pyrtan reports NAMESPACE/NAME` reads a hub or the baked file. A tree without a namespace reports nothing: there is nobody to report to. Opting in is the@@ -159,13 +159,14 @@ says so and points at the hub's own `/`, which is the same console.### 2.6 The rendezvous (built; nothing runs)-pyrtan.com runs nothing. Hubs find each other through the directory-every federated hub keeps and gossips ([`federation.md`](federation.md)-§7); the site publishes `rendezvous/seeds.json`, a static list of hubs-to follow first, and its page reads the live directory from those seeds-in the visitor's browser. The first seed is the author's own NAS,-reachable through a tunnel. A hosted hub, when there is one, is just-another seed.+pyrtan.com runs nothing: it is static files on GitHub Pages, and runs no+hub of its own. Hubs find each other through the directory every+federated hub keeps and gossips ([`federation.md`](federation.md) §7);+the site publishes `rendezvous/hubs.json`, a static list of hubs their+operators added by pull request, and its page reads the live directory+from them in the visitor's browser, falling back to the snapshot GitHub+bakes hourly. A hosted hub, when there is one, goes on the list like+any other.### 2.7 Hosting (designed) diff --git a/deploy/README.md b/deploy/README.md index 0a0c7ca..73b5956 100644 --- a/deploy/README.md +++ b/deploy/README.md@@ -70,7 +70,8 @@ file's environment `PYRTAN_ADVERTISE` (that address), `PYRTAN_JOIN:default` and `PYRTAN_LISTED: "true"`, with `PYRTAN_TOKEN` set and `PYRTAN_EGRESS: public` (hubs that follow yours may ask it to run trees, and `any` would let them point a tree at your LAN). The hub then joins-the default network pyrtan.com publishes, follows the first seed in it,+the default network pyrtan.com publishes, follows the first hub in it+that answers (from pyrtan.com's hub list, then its snapshot),routes to it, and is listed in the directory every hub gossips; `pyrtan follow URL --at <your hub> --token …` follows any other. For a network of your own, `pyrtan network new home --at <your hub> --token …` prints diff --git a/deploy/openmediavault/docker-compose.yml b/deploy/openmediavault/docker-compose.yml index 52098c2..6e502cc 100644 --- a/deploy/openmediavault/docker-compose.yml +++ b/deploy/openmediavault/docker-compose.yml@@ -35,7 +35,7 @@ x-pyrtan: &pyrtanPYRTAN_EGRESS: any # Federation: once the hub is reachable from outside (a tunnel or a # port-forward with TLS), give it its public URL and it joins the- # default network pyrtan.com publishes, follows a seed in it, and lists+ # default network pyrtan.com publishes, follows a hub in it, and lists# itself in the directory. Needs the token, and PYRTAN_EGRESS: public # above: hubs that follow this one may ask it to run trees. # PYRTAN_JOIN may also hold invites to networks of your own diff --git a/site/README.md b/site/README.md index 9e68a8e..3f20c74 100644 --- a/site/README.md +++ b/site/README.md@@ -6,14 +6,14 @@ static directory and publishes it.| Path | What | Built from | | :-- | :-- | :-- |-| `/` | the landing page, with the live directory read from the seeds in the visitor's browser | `site/index.html` |+| `/` | the landing page, with the live directory read from the listed hubs in the visitor's browser | `site/index.html` || `/console/` | the console (installable: manifest, service worker and `pyrtan.js`, the tree runtime, beside it); point it at any hub started with `--cors https://pyrtan.com`; its Member tab joins that hub, and runs its trees when joined with the token | `pyrtan console --out` | | `/catalog/` | the trees people can `pyrtan pull`, as `namespace/name.toml`, and `index.json` the command reads | `pyrtan index examples catalog --copy`, through `site/gate.py` | | `/theme.js` | the wordmark's colour picker the landing page and the catalog load (the console carries its own copy inline) | `src/pyrtan/js/theme.js` | | `/rendezvous/network.json` | the default network: its name, and a secret that is public on purpose | `site/rendezvous/network.json` |-| `/rendezvous/seeds.json` | hubs to follow first | `site/rendezvous/seeds.json` |+| `/rendezvous/hubs.json` | the hub list: hubs operators added by pull request, which the snapshot asks first and `pyrtan join` tries first | `site/rendezvous/hubs.json` || `/rendezvous/directory.json` | a snapshot of the default network's directory, from every hub the build can reach, refreshed hourly | `site/snapshot.py` |-| `/rendezvous/reports.json` | the gap reports the seeds carry for catalog trees, refreshed hourly | `site/snapshot.py --reports` |+| `/rendezvous/reports.json` | the gap reports the hubs carry for catalog trees, refreshed hourly | `site/snapshot.py --reports` |`site/build.sh out` builds the same thing locally; open `out/index.html`.@@ -54,11 +54,11 @@ always passes. See [`catalog/README.md`](../catalog/README.md) and[`CONTRIBUTING.md`](../CONTRIBUTING.md). `rendezvous/reports.json` is baked beside the directory snapshot: the gap-reports the seeds carry (from hubs that opted in with `--report-gaps`),+reports the hubs carry (from hubs that opted in with `--report-gaps`),merged hourly, which the catalog page shows beside each tree and `pyrtan reports` reads when given no hub.-## The default network and the seeds+## The default network and the hub list`site/rendezvous/network.json` is the default network: the name `pyrtan` and a secret that is public on purpose (it names the network,@@ -66,22 +66,24 @@ it guards nothing; see `_meta/federation.md` §7a). `pyrtan join` withno invite, `pyrtan hub --join default` and `$PYRTAN_JOIN=default` put a hub in it.-`site/rendezvous/seeds.json` lists hubs to follow first. A seed is a-federated hub in the default network (`--join default`), started with-`--listed` and `--cors https://pyrtan.com`, reachable from outside (a-tunnel or a port forward behind TLS; [`deploy/README.md`](../deploy/README.md)-says how). It need not be up always. Put yours in by pull request. The-landing page asks each seed's `/directory` from the visitor's browser,-so the directory on pyrtan.com is live and needs no backend; the-console link on each row opens `/console/?hub=<url>`. When no seed-answers, the page shows the snapshot instead, marked with its time:-`site/snapshot.py` asks the seeds on every build, hourly on the-schedule, then every other hub it knows of, keeps the default network's-part of what they say, keeps the last published snapshot's entries for-any hub that is down, and drops entries unheard of for a week. `pyrtan-join` tries the snapshot's hubs after the seeds, and `pyrtan directory`-falls back to it, so a new hub can join through any listed hub that-happens to be up, not only a seed.+pyrtan.com runs no hub of its own; it is these static files, and the+hub list is how it learns that a hub exists. `site/rendezvous/hubs.json`+lists hubs operators put in by pull request: each a federated hub in the+default network (`--join default`), started with `--listed` and `--cors+https://pyrtan.com`, reachable from outside (a tunnel or a port forward+behind TLS; [`deploy/README.md`](../deploy/README.md) says how). None+need be up always. The landing page asks each listed hub's `/directory`+from the visitor's browser, so the directory on pyrtan.com is live and+needs no backend; the console link on each row opens+`/console/?hub=<url>`. When none answers, or the list is empty, the page+shows the snapshot instead, marked with its time: `site/snapshot.py`+asks the listed hubs on every build, hourly on the schedule, then every+other hub it knows of, keeps the default network's part of what they+say, keeps the last published snapshot's entries for any hub that is+down, and drops entries unheard of for a week. `pyrtan join` tries the+snapshot's hubs after the listed ones, and `pyrtan directory` falls back+to it, so a new hub can join through any hub the snapshot knows of that+happens to be up.## Later: hosting@@ -90,6 +92,6 @@ with `--token`, `--cors https://pyrtan.com`, `--gaps` on a volume,`--egress public` and `--federate --advertise https://<tenant>.pyrtan.com --listed --state <volume>/federation.json`, behind TLS; the console connects to it like to any other, a self-hosted hub can follow it or be-followed by it, and it is one more seed. The control plane that+followed by it, and it can go on the hub list like any other. The control plane thatprovisions it is a separate, private repository, per `_meta/platform.md`. diff --git a/site/build.sh b/site/build.sh index 18f0bc3..d187c20 100755 --- a/site/build.sh +++ b/site/build.sh@@ -1,6 +1,6 @@#!/bin/sh # Assemble pyrtan.com into one static directory: the landing page, the-# console, the catalog (built from examples/ and catalog/), the seed list, the source. Run from the+# console, the catalog (built from examples/ and catalog/), the hub list, the source. Run from the# repository root with pyrtan installed (`uv run site/build.sh`); the Pages # workflow runs the same thing. set -eu@@ -16,12 +16,12 @@ version="$(pyrtan --version | cut -d' ' -f2)"sed "s/__VERSION__/$version/g" site/index.html > "$out/index.html" # The wordmark's colour picker, shared by the landing page and the catalog (the console inlines it). cp src/pyrtan/js/theme.js "$out/"-cp site/rendezvous/seeds.json site/rendezvous/network.json "$out/rendezvous/"-# The directory as the hubs know it now (the seeds, then every hub the+cp site/rendezvous/hubs.json site/rendezvous/network.json "$out/rendezvous/"+# The directory as the hubs know it now (the hub list, then every hub the# last snapshot or a hub names), the default network's part only, keeping # the last published snapshot's entries for hubs that are down # (SNAPSHOT_PREVIOUS may add more).-python3 site/snapshot.py site/rendezvous/seeds.json "$out/rendezvous/directory.json" \+python3 site/snapshot.py site/rendezvous/hubs.json "$out/rendezvous/directory.json" \--network site/rendezvous/network.json \ --previous https://pyrtan.com/rendezvous/directory.json \ --previous https://b-i-t-8.github.io/pyrtan/rendezvous/directory.json \ diff --git a/site/index.html b/site/index.html index a64c3b5..ba6af81 100644 --- a/site/index.html +++ b/site/index.html@@ -80,13 +80,13 @@ pyrtan serve trees --port 8701 # then open http://127<p>Every hub and service serves the console at <code>/</code>. The same console is at <a href="console/">pyrtan.com/console</a>: point it at your own hub, started with <code>--token</code> and <code>--cors https://pyrtan.com</code>, and ask it from anywhere. Nothing about your swarm passes through this site; the page talks to your hub directly.</p> <h2 id="directory">The directory</h2>- <p>Hubs find each other without a server. A hub started <code>--listed</code> announces itself to the hubs it follows and that follow it; every hub keeps what it hears and passes it on. This is the <a href="rendezvous/network.json">default network</a>, the one <code>pyrtan join</code> puts a hub in; any hub can also start a network of its own and share its invite, and a private network's hubs never show here. This page asks the <a href="rendezvous/seeds.json">seed hubs</a> from your browser and shows what they know; when none answers, it shows the <a href="rendezvous/directory.json">snapshot</a> pyrtan.com takes every hour from every hub it can reach. An entry is a hint, not a route: to use a hub, follow it from your own.</p>- <div class="card"><table id="hubs"><thead><tr><th>Heard</th><th>Hub</th><th>Holds</th><th></th></tr></thead><tbody><tr><td colspan="4" class="mute" id="hubs-note">Asking the seeds…</td></tr></tbody></table></div>+ <p>Hubs find each other without a server. A hub started <code>--listed</code> announces itself to the hubs it follows and that follow it; every hub keeps what it hears and passes it on. This is the <a href="rendezvous/network.json">default network</a>, the one <code>pyrtan join</code> puts a hub in; any hub can also start a network of its own and share its invite, and a private network's hubs never show here. pyrtan.com runs no hub of its own: it is static pages. This page asks the hubs on its <a href="rendezvous/hubs.json">hub list</a> from your browser and shows what they know; when none answers, it shows the <a href="rendezvous/directory.json">snapshot</a> pyrtan.com takes every hour from every hub it can reach. An entry is a hint, not a route: to use a hub, follow it from your own.</p>+ <div class="card"><table id="hubs"><thead><tr><th>Heard</th><th>Hub</th><th>Holds</th><th></th></tr></thead><tbody><tr><td colspan="4" class="mute" id="hubs-note">Asking the hubs…</td></tr></tbody></table></div><pre>pyrtan hub --federate --advertise https://your-hub --listed --token … # your hub, behind a tunnel or TLS pyrtan join --at https://your-hub --token … # be in the default network pyrtan network new home --at https://your-hub --token … # or start your own: prints its invite pyrtan directory # who the swarm knows</pre>- <p>To become a seed, add your hub's URL to <code>rendezvous/seeds.json</code> by pull request. It need not be up all the time: the hourly snapshot keeps the directory from every listed hub that is.</p>+ <p>To be on the hub list, add your hub's URL to <code>site/rendezvous/hubs.json</code> by pull request. It need not be up all the time: the hourly snapshot keeps the directory from every hub it can reach.</p><h2>Hosting</h2> <p>Self-hosting is the product. A hosted option, running the same open-source hub per customer with secrets, retention, scheduled re-runs and alerts around it, is planned; it will speak the same API, so a swarm can move either way without changing a caller.</p>@@ -97,27 +97,28 @@ pyrtan directory # who thconst esc = (s) => String(s).replace(/[&<>"]/g, c => ({"&":"&","<":"<",">":">",'"':"""}[c])); (async () => { const note = document.getElementById("hubs-note");- let seeds = [];- try { seeds = (await (await fetch("rendezvous/seeds.json")).json()).hubs || []; } catch (e) {}- if (!seeds.length) { note.textContent = "No seeds listed yet."; return; }+ let listed = [];+ try { listed = (await (await fetch("rendezvous/hubs.json")).json()).hubs || []; } catch (e) {}const seen = new Map(); let answered = 0;- for (const seed of seeds) {+ for (const hub of listed) {try {- const r = await fetch(seed.replace(/\/+$/, "") + "/directory", {headers: {"Accept": "application/json"}});+ const r = await fetch(hub.replace(/\/+$/, "") + "/directory", {headers: {"Accept": "application/json"}});if (!r.ok) continue; const d = await r.json(); answered++; for (const h of d.hubs || []) if (!seen.has(h.id) || seen.get(h.id).seen < h.seen) seen.set(h.id, h); } catch (e) {} }- let source = answered ? `live from ${answered} seed${answered > 1 ? "s" : ""}` : "";+ let source = answered ? `live from ${answered} listed hub${answered > 1 ? "s" : ""}` : "";if (!answered) {+ // Nobody on the list answered (or the list is empty): the hourly snapshot.+ const why = listed.length ? "no listed hub answered from this browser" : "no hubs listed yet";try { const snap = await (await fetch("rendezvous/directory.json")).json(); for (const h of snap.hubs || []) if (!seen.has(h.id) || seen.get(h.id).seen < h.seen) seen.set(h.id, h);- source = `no seed answered from this browser; snapshot as of ${snap.as_of || "?"}`;- } catch (e) { note.textContent = "No seed answered from this browser, and there is no snapshot yet."; return; }+ source = `${why}; snapshot as of ${snap.as_of || "?"}`;+ } catch (e) { note.textContent = `${why[0].toUpperCase() + why.slice(1)}, and there is no snapshot yet.`; return; }} const now = Date.now() / 1000; for (const h of seen.values()) { const age = now - (h.seen || 0); h.link = age < 600 ? "up" : age < 86400 ? "stale" : "down"; }@@ -129,7 +130,7 @@ const esc = (s) => String(s).replace(/[&<>"]/g, c => ({"&":"&","<":"<",">tr.innerHTML = `<td><span class="link ${esc(h.link)}"></span>${esc(h.link)}</td><td><code>${esc(h.name||"")}</code><div class="mute">${esc(h.id)}</div></td><td>${(h.trees||[]).map(n => `<code>${esc(n)}</code>`).join(" ")}</td><td><a href="console/?hub=${encodeURIComponent(h.id)}">open in console</a></td></tr>`; body.appendChild(tr); }- if (!hubs.length) body.innerHTML = `<tr><td colspan="4" class="mute">The seeds know of nobody yet.</td></tr>`;+ if (!hubs.length) body.innerHTML = `<tr><td colspan="4" class="mute">${esc(source)}: nobody heard of yet. Add your hub to the hub list.</td></tr>`;})(); </script> </body> diff --git a/site/rendezvous/hubs.json b/site/rendezvous/hubs.json new file mode 100644 index 0000000..794fe83 --- /dev/null +++ b/site/rendezvous/hubs.json@@ -0,0 +1,5 @@+{+ "pyrtan-hubs": 1,+ "about": "Hubs pyrtan.com asks every hour for the directory, and the ones `pyrtan join` with no invite tries first. pyrtan.com runs no hub: this list is how it learns that one exists. Add yours by pull request; it should be a federated hub in the default network, started with --listed and --cors https://pyrtan.com. It need not be up all the time: the hourly snapshot keeps what every hub it can reach knows.",+ "hubs": []+}diff --git a/site/rendezvous/network.json b/site/rendezvous/network.json index 84c8b5b..ac2765c 100644 --- a/site/rendezvous/network.json +++ b/site/rendezvous/network.json@@ -1,6 +1,6 @@{ "pyrtan-network": 1,- "about": "The default network: `pyrtan join` with no invite (or `pyrtan hub --join default`) puts a hub in it and follows the first seed in seeds.json that answers, then the snapshot's hubs. Its secret is public on purpose: it names the network rather than guarding it, so anyone may join, and following stays one-directional trust. A network of your own is `pyrtan network new NAME`; its invite is yours to share.",+ "about": "The default network: `pyrtan join` with no invite (or `pyrtan hub --join default`) puts a hub in it and follows the first hub in hubs.json that answers, then the snapshot's hubs. Its secret is public on purpose: it names the network rather than guarding it, so anyone may join, and following stays one-directional trust. A network of your own is `pyrtan network new NAME`; its invite is yours to share.","name": "pyrtan", "secret": "public-default-network-of-pyrtan-com" } diff --git a/site/rendezvous/seeds.json b/site/rendezvous/seeds.json deleted file mode 100644 index 0b17941..0000000 --- a/site/rendezvous/seeds.json +++ /dev/null@@ -1,5 +0,0 @@-{- "pyrtan-seeds": 1,- "about": "Hubs to follow first. `pyrtan follow` with no URL takes the first that answers /actor. Add yours by pull request to the site repository; a seed is any federated hub whose operator keeps it up and started it with --listed and --cors https://pyrtan.com.",- "hubs": []-}diff --git a/site/snapshot.py b/site/snapshot.py index 3ff3327..da218d1 100644 --- a/site/snapshot.py +++ b/site/snapshot.py@@ -1,9 +1,11 @@"""Bake a snapshot of the directory into the site. GitHub Actions runs this on every build and on a schedule: it asks each-seed hub for its ``/directory``, then every other hub it knows of (from-the last snapshot and from what the hubs it asked said), so the directory-stays fresh while any listed hub is up, even with every seed down. It+hub in the hub list (``rendezvous/hubs.json``, which operators add to by+pull request) for its ``/directory``, then every other hub it knows of+(from the last snapshot and from what the hubs it asked said), so the+directory stays fresh while any hub it knows of is up. pyrtan.com runs no+hub of its own; this is the whole of its part. Itmerges what they say with the snapshot already published (so a build when nobody answers keeps the last good one rather than publishing an empty list), drops entries nobody has heard of for a week, and writes@@ -11,14 +13,14 @@ list), drops entries nobody has heard of for a week, and writesof each directory (``--network``, the ``network.json`` the site publishes) and what was heard outside any network; a private network's hubs never reach the snapshot. The landing page-shows the live directory when a seed answers the visitor's browser and-this snapshot, marked with its time, when none does; ``pyrtan follow``-with no URL tries the hubs in it after the seeds.+shows the live directory when a listed hub answers the visitor's browser+and this snapshot, marked with its time, when none does; ``pyrtan join``+with no invite tries the hubs in it after the listed ones.Standard library only; no pyrtan import, so it runs before anything is installed.- python3 site/snapshot.py SEEDS OUT [--previous URL_OR_FILE ...]+ python3 site/snapshot.py HUBS OUT [--previous URL_OR_FILE ...][--network URL_OR_FILE] [--max N] [--reports OUT --previous-reports URL_OR_FILE ...]@@ -44,7 +46,7 @@ from typing import AnyTTL = 7 * 86400 REPORT_TTL = 30 * 86400 TIMEOUT = 8.0-MAX_ASK = 200 # hubs asked per run, seeds included+MAX_ASK = 200 # hubs asked per run, listed ones includedPARALLEL = 16@@ -113,11 +115,11 @@ def merge_reports(into: dict[str, dict[str, Any]], rows: Any, now: float) -> intdef main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])- ap.add_argument("seeds", help="seeds.json, a path or URL")+ ap.add_argument("hub_list", help="hubs.json, a path or URL")ap.add_argument("out", help="where to write directory.json") ap.add_argument("--previous", action="append", default=[], help="an earlier directory.json to keep entries from (URL or path; repeatable, first that answers wins)") ap.add_argument("--network", default=None, help="the default network's network.json (URL or path): keep only its part of each directory")- ap.add_argument("--max", type=int, default=MAX_ASK, help=f"hubs to ask at most, seeds included (default {MAX_ASK})")+ ap.add_argument("--max", type=int, default=MAX_ASK, help=f"hubs to ask at most, listed ones included (default {MAX_ASK})")ap.add_argument("--reports", default=None, help="also write the gap reports the hubs carry to this reports.json") ap.add_argument("--previous-reports", action="append", default=[], help="an earlier reports.json to keep rows from (URL or path; repeatable, first that answers wins)") args = ap.parse_args(argv)@@ -153,10 +155,10 @@ def main(argv: list[str] | None = None) -> int:except Exception as e: print(f"snapshot: cannot read the default network from {args.network}: {e}; keeping every network's part", file=sys.stderr) try:- seeds = [h.rstrip("/") for h in fetch(args.seeds).get("hubs", []) if isinstance(h, str)]+ listed = [h.rstrip("/") for h in fetch(args.hub_list).get("hubs", []) if isinstance(h, str)]except Exception as e:- print(f"snapshot: cannot read seeds from {args.seeds}: {e}", file=sys.stderr)- seeds = []+ print(f"snapshot: cannot read the hub list from {args.hub_list}: {e}", file=sys.stderr)+ listed = []report["crawled"] = {"answered": [], "silent": []} def ask(hub: str) -> tuple[str, Any, Any, str | None]:@@ -172,11 +174,11 @@ def main(argv: list[str] | None = None) -> int:pass return hub, d, r, None- # The seeds first, then every hub the last snapshot heard of, most+ # The listed hubs first, then every hub the last snapshot heard of, most# recent first, then whoever the hubs asked name: rounds of parallel # asks until nobody new is named or --max is reached. asked: set[str] = set()- pending = list(dict.fromkeys(seeds + [h["id"] for h in sorted(hubs.values(), key=lambda h: -h["seen"])]))+ pending = list(dict.fromkeys(listed + [h["id"] for h in sorted(hubs.values(), key=lambda h: -h["seen"])]))while pending and len(asked) < args.max: batch = [h for h in pending if h not in asked][: args.max - len(asked)] pending = []@@ -184,7 +186,7 @@ def main(argv: list[str] | None = None) -> int:with ThreadPoolExecutor(max_workers=PARALLEL) as pool: results = list(pool.map(ask, batch)) for hub, d, r, error in results:- book = report if hub in seeds else report["crawled"]+ book = report if hub in listed else report["crawled"]if error is None and isinstance(d, dict) and isinstance(d.get("hubs"), list): merge(hubs, d["hubs"], hub, now, network) book["answered"].append(hub)@@ -192,19 +194,19 @@ def main(argv: list[str] | None = None) -> int:if isinstance(r, dict): merge_reports(reports, r.get("reports"), now) else:- book["silent"].append({"seed" if hub in seeds else "hub": hub, "error": error or "not a directory"})+ book["silent"].append({"hub": hub, "error": error or "not a directory"})pending = list(dict.fromkeys(pending)) out = { "pyrtan-directory": 1, "as_of": _dt.datetime.fromtimestamp(now, _dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),- "seeds": report,+ "listed": report,"hubs": sorted(hubs.values(), key=lambda h: (-h["seen"], h["id"])), } path = Path(args.out) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(out, indent=2) + "\n", encoding="utf-8")- print(f"snapshot: {len(out['hubs'])} hub(s); {len(report['answered'])} seed(s) answered, {len(report['silent'])} silent; {len(report['crawled']['answered'])} other hub(s) answered, {len(report['crawled']['silent'])} silent" + (f"; kept entries from {report['previous']}" if report["previous"] else ""))+ print(f"snapshot: {len(out['hubs'])} hub(s); {len(report['answered'])} listed hub(s) answered, {len(report['silent'])} silent; {len(report['crawled']['answered'])} other hub(s) answered, {len(report['crawled']['silent'])} silent" + (f"; kept entries from {report['previous']}" if report["previous"] else ""))if args.reports: rows = sorted(reports.values(), key=lambda r: (-r["count"], -r["last"], r["id"])) rpath = Path(args.reports) diff --git a/src/pyrtan/cli.py b/src/pyrtan/cli.py index 1245cc4..f83f467 100644 --- a/src/pyrtan/cli.py +++ b/src/pyrtan/cli.py@@ -12,7 +12,7 @@pyrtan hub --trees DIR and hold trees itself: a swarm in one process pyrtan hub --federate --advertise URL [--follow URL ...] [--listed] and between hubs pyrtan hub --chat and keep a lobby: strangers and bots, Omegle-style- pyrtan follow [URL] --at HUB [--listed] follow another hub (and route to it); default: a seed+ pyrtan follow [URL] --at HUB [--listed] follow another hub (and route to it); default: join the default networkpyrtan unfollow URL --at HUB pyrtan peers --at HUB services, and hubs followed and following pyrtan directory [--at HUB] every hub that asked to be found, as the swarm knows it@@ -163,7 +163,7 @@ def main(argv: list[str] | None = None) -> int:_access_args(p) p.add_argument("--federate", action="store_true", help="answer /actor and /inbox so other hubs can follow this one, and follow others") p.add_argument("--advertise", default=os.environ.get("PYRTAN_ADVERTISE"), help="the URL other hubs reach this hub at (implies --federate; default $PYRTAN_ADVERTISE, else this host and port)")- p.add_argument("--follow", action="append", default=[h for h in os.environ.get("PYRTAN_FOLLOW", "").replace(",", " ").split() if h], metavar="URL", help="a hub to follow on start (repeatable; default $PYRTAN_FOLLOW; 'seed' means join the default network, as --join default)")+ p.add_argument("--follow", action="append", default=[h for h in os.environ.get("PYRTAN_FOLLOW", "").replace(",", " ").split() if h], metavar="URL", help="a hub to follow on start (repeatable; default $PYRTAN_FOLLOW; to join the default network, --join default)")p.add_argument("--join", action="append", default=[h for h in os.environ.get("PYRTAN_JOIN", "").replace(",", " ").split() if h], metavar="INVITE", help="a network to be in on start: 'default' for the public one pyrtan.com publishes, or an invite NAME:SECRET[@HUB] (repeatable; default $PYRTAN_JOIN)") p.add_argument("--federation-secret", default=os.environ.get("PYRTAN_FEDERATION_SECRET"), help="only hubs that sign their Follow with this secret may follow (default $PYRTAN_FEDERATION_SECRET; without it, follows are open)") p.add_argument("--closed", action="store_true", help="take no follows at all (this hub can still follow others)")@@ -221,7 +221,7 @@ def main(argv: list[str] | None = None) -> int:_token_arg(p, "your hub's token, if any") p = sub.add_parser("directory", help="every hub that asked to be found, as a hub knows it")- p.add_argument("--at", default=None, help="a federated hub to ask (default $PYRTAN_AT, else the local hub, else the first live seed)")+ p.add_argument("--at", default=None, help="a federated hub to ask (default $PYRTAN_AT, else the local hub, else the first hub from pyrtan.com's hub list that answers)")p.add_argument("--json", action="store_true") p = sub.add_parser("peers", help="a hub's services, and the hubs it follows and is followed by")@@ -787,8 +787,8 @@ def _from_report(r: dict[str, Any]) -> dict[str, Any]:def cmd_hub(args: argparse.Namespace) -> int: from .hub import serve_hub- follow = [url for url in args.follow if url != "seed"]- join = list(args.join) + (["default"] if "seed" in args.follow and "default" not in args.join else [])+ follow = list(args.follow)+ join = list(args.join)hub, server = serve_hub( args.host, args.port, name=args.name, peers=args.peer, poll_every=args.poll, quiet=args.quiet, token=args.token, cors=args.cors, federate=args.federate or bool(args.advertise) or bool(follow) or bool(join) or args.listed, advertise=args.advertise, federation_secret=args.federation_secret,@@ -892,7 +892,7 @@ def cmd_join(args: argparse.Namespace) -> int:code = _hub_post(args, "/join", {"invite": args.invite, "listed": bool(args.listed)}, said) if code == 1 and args.invite == "default": tried = bootstrap_candidates()- print("pyrtan join: no seed or snapshot hub answers" + (f" (tried {', '.join(tried)})" if tried else " (none listed; set $PYRTAN_SEEDS or $PYRTAN_RENDEZVOUS)") + "; give an invite or a hub URL", file=sys.stderr)+ print("pyrtan join: no listed or snapshot hub answers" + (f" (tried {', '.join(tried)})" if tried else " (none listed; set $PYRTAN_HUB_LIST or $PYRTAN_RENDEZVOUS)") + "; give an invite or a hub URL", file=sys.stderr)return 3 return code@@ -1007,7 +1007,7 @@ def cmd_directory(args: argparse.Namespace) -> int:break body = None if body is None and not args.at:- # Nobody live: the snapshot pyrtan.com bakes from the seeds, with its age.+ # Nobody live: the snapshot pyrtan.com bakes from the hub list, with its age.source = os.environ.get("PYRTAN_SNAPSHOT") or "https://pyrtan.com/rendezvous/directory.json" try: if source.startswith(("http://", "https://")): diff --git a/src/pyrtan/federation.py b/src/pyrtan/federation.py index 857d1fa..90a1b10 100644 --- a/src/pyrtan/federation.py +++ b/src/pyrtan/federation.py@@ -42,8 +42,10 @@ started ``--listed`` puts an entry for itself (id, name, the trees itholds, when) in every ``Announce`` it sends; every hub keeps the entries it hears and passes them on in its own announces, in both directions of every follow. Nothing central holds the directory: every federated hub-does, and pyrtan.com publishes only static files: the default network,-a list of *seeds* (hubs to follow first), and an hourly snapshot. An+does, and pyrtan.com publishes only static files on GitHub Pages: the+default network, the *hub list* (hubs operators added by pull request,+which pyrtan.com asks every hour), and an hourly snapshot of what they+know. No hub is run for it, and none has to be up for it. Anentry is a hint (a hub two follows away cannot verify who said it), kept until its hub has not been heard of for a week, and routing never uses it: finding a hub in the directory and following it is still the one act@@ -94,7 +96,7 @@ from . import __version__from .service import http_json from .store import BlobStore, StoreError, check_key, digest as blob_digest-DEFAULT_SEEDS = "https://pyrtan.com/rendezvous/seeds.json"+DEFAULT_HUB_LIST = "https://pyrtan.com/rendezvous/hubs.json"DEFAULT_SNAPSHOT = "https://pyrtan.com/rendezvous/directory.json" DEFAULT_NETWORK = "https://pyrtan.com/rendezvous/network.json" NETWORK_NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,39}$")@@ -122,17 +124,17 @@ ASK_WINDOW = 600.0 # a member asked to keep within this counSIGNED_PATHS = ("/inbox", "/run", "/intent", "/resume", "/capabilities")-# ---------------------------------------------------------------- seeds+# ---------------------------------------------------------------- the hub list-def seed_urls() -> list[str]:- """Hubs to follow first, from ``$PYRTAN_RENDEZVOUS`` (one hub),- ``$PYRTAN_SEEDS`` (a URL or file holding ``{"hubs": [...]}``), or the- list at pyrtan.com. Never raises: no seeds is an empty list."""+def rendezvous_hubs() -> list[str]:+ """Hubs to try first, from ``$PYRTAN_RENDEZVOUS`` (one hub),+ ``$PYRTAN_HUB_LIST`` (a URL or file holding ``{"hubs": [...]}``), or+ the hub list at pyrtan.com. Never raises: no list is an empty list."""single = os.environ.get("PYRTAN_RENDEZVOUS") if single: return [single.rstrip("/")]- source = os.environ.get("PYRTAN_SEEDS") or DEFAULT_SEEDS+ source = os.environ.get("PYRTAN_HUB_LIST") or DEFAULT_HUB_LISTtry: if source.startswith(("http://", "https://")): status, body = http_json("GET", source, timeout=10.0)@@ -150,7 +152,7 @@ def seed_urls() -> list[str]:def snapshot_hubs() -> list[str]: """Hubs from the directory snapshot pyrtan.com bakes hourly (``$PYRTAN_SNAPSHOT`` overrides the URL or names a file), most recently- heard first. What a new hub can try when every seed is down."""+ heard first. What a new hub can try when no listed hub answers."""source = os.environ.get("PYRTAN_SNAPSHOT") or DEFAULT_SNAPSHOT try: if source.startswith(("http://", "https://")):@@ -238,16 +240,16 @@ def default_network() -> Network | None:def bootstrap_candidates() -> list[str]:- """Where to look for a first hub: the seeds, then the snapshot's hubs."""- seeds = seed_urls()- return seeds + [h for h in snapshot_hubs() if h not in seeds]+ """Where to look for a first hub: the hub list, then the snapshot's hubs."""+ listed = rendezvous_hubs()+ return listed + [h for h in snapshot_hubs() if h not in listed]-def first_live_seed(seeds: list[str] | None = None) -> str | None:- """The first seed whose ``/actor`` answers as a hub; when none does, the- first hub from the snapshot that answers; else None."""- tried = seeds if seeds is not None else seed_urls()- for batch in ([tried] if seeds is not None else [tried, lambda: [h for h in snapshot_hubs() if h not in tried]]):+def first_live_hub(hubs: list[str] | None = None) -> str | None:+ """The first listed hub whose ``/actor`` answers as a hub; when none+ does, the first hub from the snapshot that answers; else None."""+ tried = hubs if hubs is not None else rendezvous_hubs()+ for batch in ([tried] if hubs is not None else [tried, lambda: [h for h in snapshot_hubs() if h not in tried]]):for url in (batch() if callable(batch) else batch): try: status, actor = http_json("GET", f"{url}/actor", timeout=5.0)@@ -671,7 +673,7 @@ class Federation:# the path decided on and the value no branch covered, with a count; # never the inputs or the rest of the state. Every federated hub keeps # what it hears and passes it on, like the directory, so the reports- # reach the seeds and the snapshot pyrtan.com bakes, where an author+ # reach the listed hubs and the snapshot pyrtan.com bakes, where an author# reads them beside the tree. Nothing is reported for a tree without a # namespace: there is nobody to report to. diff --git a/src/pyrtan/hub.py b/src/pyrtan/hub.py index 4289387..3cf3f75 100644 --- a/src/pyrtan/hub.py +++ b/src/pyrtan/hub.py@@ -50,7 +50,7 @@ from typing import Anyfrom . import __version__ from .chat import Lobby, is_chat_path-from .federation import MAX_HOPS, Federation, Remote, bootstrap_candidates, default_network, first_live_seed, parse_invite, sign+from .federation import MAX_HOPS, Federation, Remote, bootstrap_candidates, default_network, first_live_hub, parse_invite, signfrom .grow import Unanswered, no_match_outcome from .intent import Trigger, read from .page import MANIFEST, PAGE, PYRTAN_JS, SERVICE_WORKER@@ -633,14 +633,15 @@ def join_network(fed: Federation, invite: Any) -> tuple[int, dict[str, Any]]:"""Join a network from an invite (``name:secret[@hub]``), or the default network pyrtan.com publishes when ``invite`` is empty or ``default``, and follow a hub in it. When the default network cannot be read, the- first live seed is followed outside any network, as before networks."""+ first listed hub that answers is followed outside any network, as+ before networks."""if invite in (None, "", "default"): net = default_network() hubs = bootstrap_candidates() if net is None:- url = first_live_seed()+ url = first_live_hub()if url is None:- return 200, {"status": "error", "reason": "no-network", "say": "Cannot read the default network, and no seed or snapshot hub answers; give an invite or a hub URL."}+ return 200, {"status": "error", "reason": "no-network", "say": "Cannot read the default network, and no listed or snapshot hub answers; give an invite or a hub URL."}try: remote = fed.follow(url) except ValueError as e: diff --git a/src/pyrtan/page.py b/src/pyrtan/page.py index 6176802..525ecf1 100644 --- a/src/pyrtan/page.py +++ b/src/pyrtan/page.py@@ -314,7 +314,7 @@ async function loadDirectory() {tr.innerHTML = `<td><span class="link ${esc(h.link)}"></span>${esc(h.link)}</td><td><code>${esc(h.name||"")}</code>${h.via === "self" ? ' <span class="mute">(this hub)</span>' : ""}${h.kind === "member" ? ' <span class="mute">(member)</span>' : ""}<div class="mute">${esc(h.id)}</div></td><td>${(h.trees||[]).map(n => `<code>${esc(n)}</code>`).join(" ")}</td><td class="mute">${h.seen ? new Date(h.seen * 1000).toLocaleString() : ""}</td>`; body.appendChild(tr); }- if (!(d.hubs || []).length) body.innerHTML = `<tr><td colspan="4" class="mute">Nobody heard of yet. Follow a seed, or start this hub with <code>--listed</code>.</td></tr>`;+ if (!(d.hubs || []).length) body.innerHTML = `<tr><td colspan="4" class="mute">Nobody heard of yet. Join the default network (<code>pyrtan join</code>), or start this hub with <code>--listed</code>.</td></tr>`;} window.addEventListener("hashchange", () => showTab((location.hash || "#ask").slice(1))); diff --git a/tests/test_federation.py b/tests/test_federation.py index 2463349..fb0211c 100644 --- a/tests/test_federation.py +++ b/tests/test_federation.py@@ -319,39 +319,39 @@ def test_directory_is_gossiped_in_both_directions_and_routes_to_nobody(two, tmp_c.stop()-def test_seeds_and_cli_defaults(two, capsys, monkeypatch, tmp_path):+def test_hub_list_and_cli_defaults(two, capsys, monkeypatch, tmp_path):a, b = two- from pyrtan.federation import first_live_seed, seed_urls+ from pyrtan.federation import first_live_hub, rendezvous_hubsdef run(*argv): code = main(list(argv)) out = capsys.readouterr() return code, out.out, out.err- seeds = tmp_path / "seeds.json"- seeds.write_text(json.dumps({"pyrtan-seeds": 1, "hubs": ["http://127.0.0.1:9", b.url, a.url]}))- monkeypatch.setenv("PYRTAN_SEEDS", str(seeds))+ listed = tmp_path / "hubs.json"+ listed.write_text(json.dumps({"pyrtan-hubs": 1, "hubs": ["http://127.0.0.1:9", b.url, a.url]}))+ monkeypatch.setenv("PYRTAN_HUB_LIST", str(listed))monkeypatch.setenv("PYRTAN_SNAPSHOT", str(tmp_path / "no-snapshot.json")) # never reach pyrtan.com from a test monkeypatch.delenv("PYRTAN_RENDEZVOUS", raising=False)- assert seed_urls() == ["http://127.0.0.1:9", b.url, a.url]- assert first_live_seed() == b.url # the dead one is skipped+ assert rendezvous_hubs() == ["http://127.0.0.1:9", b.url, a.url]+ assert first_live_hub() == b.url # the dead one is skippedmonkeypatch.setenv("PYRTAN_RENDEZVOUS", a.url)- assert seed_urls() == [a.url]+ assert rendezvous_hubs() == [a.url]monkeypatch.delenv("PYRTAN_RENDEZVOUS") b.fed.set_listed(True)- code, out, _ = run("follow", "--at", a.url, "--token", "token-a", "--listed") # no URL: the first live seed+ code, out, _ = run("follow", "--at", a.url, "--token", "token-a", "--listed") # no URL: the first listed hub that answersassert code == 0 and f"now follows {b.url}" in out and "listed in the directory" in out assert a.fed.listed and a.url in {h["id"] for h in b.fed.directory()["hubs"]}- monkeypatch.setenv("PYRTAN_AT", "http://127.0.0.1:9") # the local default is dead: fall through to the seeds+ monkeypatch.setenv("PYRTAN_AT", "http://127.0.0.1:9") # the local default is dead: fall through to the hub listcode, out, _ = run("directory") assert code == 0 and f"as b ({b.url})" in out and a.url in out and b.url in out code, out, _ = run("directory", "--at", a.url, "--json") assert {h["id"] for h in json.loads(out)["hubs"]} == {a.url, b.url} assert run("directory", "--at", b.svc_srv.url)[0] == 3- monkeypatch.setenv("PYRTAN_SEEDS", str(tmp_path / "missing.json"))- assert seed_urls() == []+ monkeypatch.setenv("PYRTAN_HUB_LIST", str(tmp_path / "missing.json"))+ assert rendezvous_hubs() == []code, _, err = run("follow", "--at", a.url, "--token", "token-a")- assert code == 3 and "no seed or snapshot hub answers" in err+ assert code == 3 and "no listed or snapshot hub answers" in errdef test_hub_holds_trees_itself(tmp_path):@@ -404,7 +404,7 @@ say = "picnic says {{ state.a.say }}"def test_snapshot_bakes_and_bootstraps(two, tmp_path, monkeypatch, capsys):- """site/snapshot.py merges the seeds' directories with the last snapshot,+ """site/snapshot.py merges the listed hubs' directories with the last snapshot,and the command falls back to the snapshot when nothing is live.""" import subprocess import sys as _sys@@ -412,36 +412,36 @@ def test_snapshot_bakes_and_bootstraps(two, tmp_path, monkeypatch, capsys):a, b = two b.fed.set_listed(True) a.post("/follow", {"url": b.url})- seeds = tmp_path / "seeds.json"- seeds.write_text(json.dumps({"pyrtan-seeds": 1, "hubs": ["http://127.0.0.1:9", a.url]}))+ listed = tmp_path / "hubs.json"+ listed.write_text(json.dumps({"pyrtan-hubs": 1, "hubs": ["http://127.0.0.1:9", a.url]}))old = tmp_path / "old.json" old.write_text(json.dumps({"pyrtan-directory": 1, "hubs": [{"id": "http://gone.example", "name": "gone", "trees": ["x"], "seen": time.time() - 3600}, {"id": "http://ancient.example", "trees": [], "seen": time.time() - 30 * 86400}]})) out = tmp_path / "directory.json"- run = subprocess.run([_sys.executable, "site/snapshot.py", str(seeds), str(out), "--previous", str(tmp_path / "missing.json"), "--previous", str(old)], capture_output=True, text=True, cwd=str(EXAMPLES.parent))+ run = subprocess.run([_sys.executable, "site/snapshot.py", str(listed), str(out), "--previous", str(tmp_path / "missing.json"), "--previous", str(old)], capture_output=True, text=True, cwd=str(EXAMPLES.parent))assert run.returncode == 0, run.stderr snap = json.loads(out.read_text())- assert snap["pyrtan-directory"] == 1 and snap["seeds"]["answered"] == [a.url] and snap["seeds"]["silent"][0]["seed"] == "http://127.0.0.1:9"- assert snap["seeds"]["previous"] == str(old)+ assert snap["pyrtan-directory"] == 1 and snap["listed"]["answered"] == [a.url] and snap["listed"]["silent"][0]["hub"] == "http://127.0.0.1:9"+ assert snap["listed"]["previous"] == str(old)ids = [h["id"] for h in snap["hubs"]]- assert b.url in ids and "http://gone.example" in ids and "http://ancient.example" not in ids # live from the seed, kept from the snapshot, expired+ assert b.url in ids and "http://gone.example" in ids and "http://ancient.example" not in ids # live from the listed hub, kept from the snapshot, expired# B was named by A, so it was asked too, and its own word on itself is the newest.- assert snap["seeds"]["crawled"]["answered"] == [b.url] and next(h for h in snap["hubs"] if h["id"] == b.url)["via"] == b.url+ assert snap["listed"]["crawled"]["answered"] == [b.url] and next(h for h in snap["hubs"] if h["id"] == b.url)["via"] == b.urldef cli(*argv): code = main(list(argv)) o = capsys.readouterr() return code, o.out, o.err- from pyrtan.federation import bootstrap_candidates, first_live_seed, snapshot_hubs+ from pyrtan.federation import bootstrap_candidates, first_live_hub, snapshot_hubs- # With every seed dead, the snapshot's hubs are the next place to look.- monkeypatch.setenv("PYRTAN_SEEDS", str(tmp_path / "dead-seeds.json"))- (tmp_path / "dead-seeds.json").write_text(json.dumps({"hubs": ["http://127.0.0.1:9"]}))+ # With every listed hub dead, the snapshot's hubs are the next place to look.+ monkeypatch.setenv("PYRTAN_HUB_LIST", str(tmp_path / "dead-hubs.json"))+ (tmp_path / "dead-hubs.json").write_text(json.dumps({"hubs": ["http://127.0.0.1:9"]}))monkeypatch.setenv("PYRTAN_SNAPSHOT", str(out)) monkeypatch.delenv("PYRTAN_RENDEZVOUS", raising=False) assert snapshot_hubs()[0] == b.url # most recently heard first assert bootstrap_candidates() == ["http://127.0.0.1:9", b.url, "http://gone.example"]- assert first_live_seed() == b.url+ assert first_live_hub() == b.urlcode, o, _ = cli("follow", "--at", a.url, "--token", "token-a") assert code == 0 and f"now follows {b.url}" in o monkeypatch.setenv("PYRTAN_AT", "http://127.0.0.1:9")@@ -839,12 +839,12 @@ def test_reports_command_and_snapshot(two, tmp_path, capsys, monkeypatch):assert "pyrtan/weather@1 code: state.code = 99 ×4" in capsys.readouterr().out assert main(["reports", "nobody/x", "--at", a.url]) == 0 and "no gap reports" in capsys.readouterr().out assert main(["reports", "--at", a.url, "--json"]) == 0 and json.loads(capsys.readouterr().out)["reports"][0]["id"] == "pyrtan/weather@1"- # The site's snapshot asks the seeds for /reports and bakes reports.json; the command reads that when no hub is given.+ # The site's snapshot asks the listed hubs for /reports and bakes reports.json; the command reads that when no hub is given.import subprocess, sys as _sys- seeds = tmp_path / "seeds.json"- seeds.write_text(json.dumps({"hubs": [a.url]}))- out = subprocess.run([_sys.executable, "site/snapshot.py", str(seeds), str(tmp_path / "directory.json"), "--reports", str(tmp_path / "reports.json")], capture_output=True, text=True, cwd=str(ROOT))+ listed = tmp_path / "hubs.json"+ listed.write_text(json.dumps({"hubs": [a.url]}))+ out = subprocess.run([_sys.executable, "site/snapshot.py", str(listed), str(tmp_path / "directory.json"), "--reports", str(tmp_path / "reports.json")], capture_output=True, text=True, cwd=str(ROOT))assert out.returncode == 0, out.stderr baked = json.loads((tmp_path / "reports.json").read_text()) assert baked["count"] == 1 and baked["reports"][0]["id"] == "pyrtan/weather@1" and baked["reports"][0]["count"] == 4@@ -985,9 +985,9 @@ def test_the_default_network_from_pyrtan_com(two, tmp_path, monkeypatch, capsys)network = ROOT / "site" / "rendezvous" / "network.json" monkeypatch.setenv("PYRTAN_NETWORK", str(network))- seeds = tmp_path / "seeds.json"- seeds.write_text(json.dumps({"hubs": ["http://127.0.0.1:9", b.url]}))- monkeypatch.setenv("PYRTAN_SEEDS", str(seeds))+ listed = tmp_path / "hubs.json"+ listed.write_text(json.dumps({"hubs": ["http://127.0.0.1:9", b.url]}))+ monkeypatch.setenv("PYRTAN_HUB_LIST", str(listed))monkeypatch.setenv("PYRTAN_SNAPSHOT", str(tmp_path / "no-snapshot.json")) monkeypatch.delenv("PYRTAN_RENDEZVOUS", raising=False) default = default_network()@@ -995,7 +995,7 @@ def test_the_default_network_from_pyrtan_com(two, tmp_path, monkeypatch, capsys)# The first hub in the default network is a network of one: nobody to follow yet. code, out, _ = run("join", "--at", b.url, "--token", "token-b", "--listed") assert code == 0 and "in pyrtan; no hub in it accepted a follow yet" in out and b.fed.following == {}- # `pyrtan follow` with no URL is `pyrtan join`: A is in the default network, following the seed in it.+ # `pyrtan follow` with no URL is `pyrtan join`: A is in the default network, following the listed hub in it.code, out, _ = run("follow", "--at", a.url, "--token", "token-a") assert code == 0 and f"in pyrtan, following {b.url}" in out and "invite others" not in out # a public network needs no invite assert a.fed.following[b.url].network == default.id@@ -1011,7 +1011,7 @@ def test_the_default_network_from_pyrtan_com(two, tmp_path, monkeypatch, capsys)code, out, _ = run("network", "leave", "home", "--at", a.url, "--token", "token-a") assert code == 0 and "left home" in out and "home" not in a.fed.networks assert run("network", "new", "--at", a.url, "--token", "token-a")[0] == 2- # A hub started with --join default (or --follow seed) is in it from the start.+ # A hub started with --join default is in it from the start.hub, srv = serve_hub(port=0, quiet=True, name="d", join=["default"]) try: assert hub.federation is not None and hub.federation.following[b.url].network == default.id@@ -1023,7 +1023,7 @@ def test_the_default_network_from_pyrtan_com(two, tmp_path, monkeypatch, capsys)b.fed.merge_directory([{"id": "http://public.example", "name": "q", "trees": [], "seen": time.time()}], via="x", network=default.id) b.hub.token = None # as anyone sees it, the private part included: the snapshot must still drop it out = tmp_path / "directory.json"- done = subprocess.run([_sys.executable, "site/snapshot.py", str(seeds), str(out), "--network", str(network)], capture_output=True, text=True, cwd=str(ROOT))+ done = subprocess.run([_sys.executable, "site/snapshot.py", str(listed), str(out), "--network", str(network)], capture_output=True, text=True, cwd=str(ROOT))assert done.returncode == 0, done.stderr ids = {h["id"] for h in json.loads(out.read_text())["hubs"]} assert "http://public.example" in ids and "http://private.example" not in ids and b.url in ids