pyrtan / sourcev0.14.0 · main

Merge pull request #22 from b-i-t-8/claude/sleepy-mendel-h22689

b-i-t-8 · 2026-10-27 · 5bf0c30f1af4d51289fef2c23caa398d32e1bc81 · parents 0fe012d f0218b8

Public lobbies (--chat-public, people only) and no seeds: pyrtan.com is GitHub Pages and a hub list; release 0.13.0

Changes

 .github/workflows/pages.yml              |  7 ++---
 README.md                                | 31 ++++++++++++----------
 _meta/chat.md                            | 28 +++++++++++++++++---
 _meta/design.md                          |  3 ++-
 _meta/federation.md                      | 52 +++++++++++++++++++------------------
 _meta/platform.md                        | 17 +++++++------
 deploy/README.md                         |  3 ++-
 deploy/openmediavault/docker-compose.yml |  3 ++-
 pyproject.toml                           |  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/__init__.py                   |  2 +-
 src/pyrtan/chat.py                       | 34 +++++++++++++++++++------
 src/pyrtan/cli.py                        | 19 +++++++-------
 src/pyrtan/federation.py                 | 40 +++++++++++++++--------------
 src/pyrtan/hub.py                        | 35 +++++++++++++++----------
 src/pyrtan/page.py                       | 18 ++++++++++---
 src/pyrtan/service.py                    | 11 ++++----
 tests/test_chat.py                       | 54 +++++++++++++++++++++++++++++++++++++++
 tests/test_federation.py                 | 72 ++++++++++++++++++++++++++--------------------------
 uv.lock                                  |  2 +-
 26 files changed, 351 insertions(+), 213 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 list
permissions: contents: read diff --git a/README.md b/README.md index 9500c1d..a57992e 100644 --- a/README.md +++ b/README.md
@@ -160,7 +160,7 @@ hosted box is not lent out. The design is in [`_meta/io.md`](_meta/io.md).
| `pyrtan run --tree NAME --input k=v` | Runs a named tree. | | `pyrtan match "…" --trees DIR` | How a sentence reads, without running anything: the rewrites, every trigger's verdict, the winner. The tool for writing triggers. | | `pyrtan serve DIR --port 8701` | Hosts the trees as a service: `/health`, `/capabilities`, `/intent`, `/run`, `/resume`, `/gaps`, `/unanswered`. Reloads trees when their files change. `--gaps FILE` keeps gaps across restarts; `--egress public` refuses private addresses. |
-| `pyrtan hub --port 8700` | Routes between services. Services register with `--hub URL`; peers carry a `link` of `up`, `stale` or `down`. `--trees DIR` holds trees in the hub itself; `--federate` lets other hubs follow it and it follow them; `--listed` puts it in the directory; `--chat` keeps a lobby where people meet strangers and bots (`--no-chat-typing` turns its typing indicator off). |
+| `pyrtan hub --port 8700` | Routes between services. Services register with `--hub URL`; peers carry a `link` of `up`, `stale` or `down`. `--trees DIR` holds trees in the hub itself; `--federate` lets other hubs follow it and it follow them; `--listed` puts it in the directory; `--chat` keeps a lobby where people meet strangers and bots (`--no-chat-typing` turns its typing indicator off, `--chat-public` lets callers without the token in, to meet people only). |
| `pyrtan keep KEY [FILE] --at HUB` | Gives your hub a blob to keep; its keepers get copies. `kept` shows who holds what; `fetch KEY` gets a blob back from your hub or whoever holds it. | | `pyrtan follow [URL] --at HUB` | Makes your hub follow another: it then routes sentences to that hub's trees too. With no URL, `pyrtan join`. `--network NAME` follows within a network of yours; `--listed` also puts your hub in the directory. `unfollow` undoes it; `peers` lists services, hubs followed and followers; `directory` lists every hub that asked to be found, as the swarm knows it. | | `pyrtan join [INVITE] --at HUB` | Puts your hub in a network and follows a hub in it: the default network pyrtan.com publishes, or the one an invite (`NAME:SECRET@HUB`) names. |
@@ -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
@@ -452,7 +452,12 @@ secret; the outbox logs a `Say` without its text and leaves `Typing` out. A hub
hub that follows it, and never passes one on. The lobby follows the hub's token: with one, only callers who have it
-chat (a bot runs trees, so a lobby must not be a way round it). It is
+chat (a bot runs trees, so a lobby must not be a way round it). To let
+strangers 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 hub of pyrtan.com's: every hub started `--chat` is a lobby, and hubs
+that 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 moderation but leaving. The design is in [`_meta/chat.md`](_meta/chat.md).
@@ -569,14 +574,14 @@ _meta/ the design record, the tree format reference, and the
design 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 catalog
viewer, the source browser (files, releases, branches and history), and the build the pages workflow publishes ``` ## Status
-0.12.0. The core, the service, the hub, the command, the console, the
+0.13.0. The core, the service, the hub, the command, the console, the
catalog (namespaces, publishing by pull request through the gate, gap reports gossiped back to authors), the language rules, federation between hubs, networks (any hub starts one; pyrtan.com publishes the
@@ -587,7 +592,7 @@ part of the swarm), the blobs they keep and the trees they run
behind an allow-list, watches (a tree that runs when a clock, a file, a topic or a serial line speaks), `pyrtan grow` (the branch a gap asks for, drafted by rules), and the chat lobby (strangers on the hub and the
-hubs it follows, bots made of trees, and a typing indicator) are built and tested; a swarm can require a
+hubs it follows, bots made of trees, a typing indicator, and lobbies open to the public) are built and tested; a swarm can require a
token, keep its gaps and unanswered sentences, refuse private addresses and its own box, and follow other swarms. Not yet built: hosting. See [`_meta/design.md`](_meta/design.md) for the runtime's design and diff --git a/_meta/chat.md b/_meta/chat.md index ee60171..f3c7214 100644 --- a/_meta/chat.md +++ b/_meta/chat.md
@@ -37,8 +37,27 @@ partner told, which is how a closed tab ends a conversation.
The lobby follows the hub's access rule: with a token, only callers with the token chat. That is deliberate. A bot runs trees, so a lobby open to callers the hub would refuse at `/intent` would be a way round the token.
-A hub that wants strangers from the internet runs without a token, as
-every hub that serves strangers already does.
+
+**A public lobby (v0.13).** A hub that wants strangers from the
+internet in its lobby, and its token on everything else, starts
+`--chat-public` (`$PYRTAN_CHAT_PUBLIC`). `/chat` then answers without
+the token, and a caller without it is a *guest*: a guest meets people,
+on this hub or on a hub it follows, and never a bot. `want: bot` from a
+guest is refused (`bots-need-token`), `anyone` is read as `people`, and
+`/chat` counts no bots for them. `/api` says `chat_public` and, for the
+caller asking, `chat_bots`; the console offers a guest "people only" and
+says why. The rest of the hub (`/intent`, `/run`, `/gaps`, `/follow`,
+`/keep`) still needs the token, so opening the lobby lends strangers
+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 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
+waiting on two hubs to meet; the network has as many lobbies as it has
+hubs that chat. A
+person runs one, or opens the Chat tab of any hub that is public.
## 2. Bots
@@ -151,7 +170,8 @@ for that conversation.
- **Pretending to moderate.** Omegle closed in 2023 over abuse it could not moderate at scale; this lobby is not built to do better. What it does: text only, no links rendered, opt-in per hub (`--chat`), behind
- the hub's token when it has one, a line at most 1,000 characters, five
+ the hub's token when it has one unless the operator opens it
+ (`--chat-public`, people only), a line at most 1,000 characters, five
lines in a burst and one a second after, at most 500 seats, and either side can leave at any moment. An operator who opens a lobby to the internet should know that is all.
@@ -178,5 +198,7 @@ for that conversation.
- **v0.11, built:** the lobby, bots, strangers across a follow, the Chat tab, `pyrtan chat`, `small-talk`, `trigger_inputs`. - **v0.12, built:** the typing indicator, per person and per hub (§4).
+- **v0.13, built:** public lobbies: `--chat-public`, guests meet people
+ only (§1).
- **Later:** a member's lobby that works offline between two phones of the same hub. 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 hold
trees 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 nobody
chose. **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 an
operator'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 last
snapshot, 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 empty
is 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, with
the 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`; the
catalog 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 5539a5c..6e502cc 100644 --- a/deploy/openmediavault/docker-compose.yml +++ b/deploy/openmediavault/docker-compose.yml
@@ -35,7 +35,7 @@ x-pyrtan: &pyrtan
PYRTAN_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
@@ -46,6 +46,7 @@ x-pyrtan: &pyrtan
# PYRTAN_REPORT_GAPS: "true" # opt in: the gaps your catalog trees meet (tree, node, value; never inputs) reach their authors via pyrtan.com # PYRTAN_WATCH: "true" # run the trees' [[watches]]: a clock, a file, a topic or a serial line starts a run # PYRTAN_CHAT: "true" # a lobby at /chat: people meet a random stranger here or on a hub this one follows, or a bot made of its trees
+ # PYRTAN_CHAT_PUBLIC: "true" # open the lobby to people without the token (they meet people, never a bot); everything else keeps the token
# PYRTAN_CHAT_TYPING: "0" # turn the lobby's "Stranger is typing…" off for everyone (each person can also turn it off for themselves) PYRTAN_UNANSWERED: /state/unanswered.jsonl # sentences nobody answered, kept across restarts (the hub's; services with a hub leave the record to it) PYRTAN_STATE: /state/federation.json diff --git a/pyproject.toml b/pyproject.toml index 8b7a991..7b1a867 100644 --- a/pyproject.toml +++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project] name = "pyrtan"
-version = "0.12.0"
+version = "0.13.0"
description = "Open-source total automation: a core set of expandable services that act on all forms of I/O through inspectable decision trees." readme = "README.md" license = "AGPL-3.0-or-later" 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` with
no 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 that
provisions 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 th
const esc = (s) => String(s).replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[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 => ({"&":"&amp;","<":"&lt;",">
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. It
merges 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 writes
of 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 Any
TTL = 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 included
PARALLEL = 16
@@ -113,11 +115,11 @@ def merge_reports(into: dict[str, dict[str, Any]], rows: Any, now: float) -> int
def 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/__init__.py b/src/pyrtan/__init__.py index eeb064c..ec3238a 100644 --- a/src/pyrtan/__init__.py +++ b/src/pyrtan/__init__.py
@@ -33,4 +33,4 @@ __all__ = [
"Response", ]
-__version__ = "0.12.0"
+__version__ = "0.13.0"
diff --git a/src/pyrtan/chat.py b/src/pyrtan/chat.py index f076bf3..3f7927c 100644 --- a/src/pyrtan/chat.py +++ b/src/pyrtan/chat.py
@@ -37,6 +37,14 @@ else's, and a hub started ``--no-chat-typing`` has it off for everyone.
Across hubs it travels as ``Typing``, at most every two seconds, and the logs leave it out.
+**Public lobbies.** The lobby follows the hub's token: a bot runs trees,
+and the token is what guards them. A hub started ``--chat-public`` keeps
+its token for everything else and opens ``/chat`` to callers without it,
+as *guests*: a guest meets people (here, or on a hub this one follows)
+and never a bot. ``want: bot`` from a guest is refused, and ``anyone`` is
+read as ``people``. A hub with no token has no guests: anyone may meet a
+bot there, as anyone may ask it at ``/intent``.
+
Endpoints, all POST with a JSON body except the first: ``GET /chat`` how many are waiting and talking; whether bots, other hubs and typing are in reach
@@ -89,6 +97,10 @@ REF = re.compile(r"^[A-Za-z0-9_-]{8,64}$")
INTEREST = re.compile(r"^[a-z0-9][a-z0-9-]{0,23}$")
+def is_chat_path(path: str) -> bool:
+ return path == "/chat" or path.startswith("/chat/")
+
+
def interests_of(value: Any) -> list[str]: """At most five lowercase words, from a list or a comma-separated string.""" words = value.replace(",", " ").split() if isinstance(value, str) else value if isinstance(value, list) else []
@@ -173,6 +185,7 @@ class Seat:
ref: str # what another hub calls it; never the id want: str interests: list[str]
+ bots: bool = True # may meet a bot: false for a guest of a public lobby
since: float = field(default_factory=time.time) seen: float = field(default_factory=time.time) state: str = "waiting" # waiting | talking | ended | left
@@ -208,7 +221,7 @@ class Lobby:
# ------------------------------------------------------------ views
- def stats(self) -> dict[str, Any]:
+ def stats(self, guest: bool = False) -> dict[str, Any]:
with self.cond: self._sweep() seats = list(self.seats.values())
@@ -217,14 +230,14 @@ class Lobby:
"status": "ok", "waiting": sum(1 for s in seats if s.state == "waiting"), "talking": sum(1 for s in seats if s.state == "talking"),
- "bots": sum(1 for h in self.hub.holders() if h.link != "down" and h.trees),
+ "bots": 0 if guest else sum(1 for h in self.hub.holders() if h.link != "down" and h.trees),
"hubs": sum(1 for r in fed.following.values() if r.link != "down") if fed is not None else 0, "typing": self.typing, } def _view(self, seat: Seat, after: int) -> dict[str, Any]: events = [e for e in seat.events if e["n"] > after]
- return {"status": "ok", "seat": seat.id, **seat.view(), "typing_indicator": self._shows(seat), "partner_typing": self._partner_typing(seat), "events": events, "after": seat.n}
+ return {"status": "ok", "seat": seat.id, **seat.view(), "bots": seat.bots, "typing_indicator": self._shows(seat), "partner_typing": self._partner_typing(seat), "events": events, "after": seat.n}
# ------------------------------------------------------------ typing
@@ -422,15 +435,19 @@ class Lobby:
seat.seen = time.time() return seat
- def start(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:
+ def start(self, body: dict[str, Any], guest: bool = False) -> tuple[int, dict[str, Any]]:
want = body.get("want", "anyone") if want not in WANTS: return 400, {"status": "error", "reason": "bad-request", "say": f"want is one of {', '.join(WANTS)}."}
+ if guest and want == "bot":
+ return 403, {"status": "error", "reason": "bots-need-token", "say": "Without this hub's token you meet people only: a bot runs the hub's trees."}
+ if guest:
+ want = "people"
with self.cond: self._sweep() if len(self.seats) >= SEATS_MAX: return 503, {"status": "error", "reason": "lobby-full", "say": f"The lobby is full ({SEATS_MAX} seats); try again in a minute."}
- seat = Seat(secrets.token_urlsafe(18), secrets.token_urlsafe(12), want, interests_of(body.get("interests")), typing=body.get("typing_indicator") is not False)
+ seat = Seat(secrets.token_urlsafe(18), secrets.token_urlsafe(12), want, interests_of(body.get("interests")), typing=body.get("typing_indicator") is not False, bots=not guest)
self.seats[seat.id] = seat self.refs[seat.ref] = seat self._add(seat, "system", "Looking for a bot…" if want == "bot" else "Looking for someone you can chat with…")
@@ -669,16 +686,17 @@ class Lobby:
# ------------------------------------------------------------ routing
- def route(self, method: str, path: str, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:
+ def route(self, method: str, path: str, body: dict[str, Any], *, guest: bool = False) -> tuple[int, dict[str, Any]]:
+ """``guest``: the caller lacks the hub's token, and came in because the lobby is public."""
if method == "GET" and path == "/chat": try:
- return 200, self.stats()
+ return 200, self.stats(guest)
finally: self._flush() handlers = {"/chat/start": self.start, "/chat/poll": self.poll, "/chat/say": self.say, "/chat/typing": self.typed, "/chat/settings": self.settings, "/chat/next": self.next, "/chat/leave": self.leave} if method == "POST" and path in handlers: try:
- return handlers[path](body)
+ return self.start(body, guest) if path == "/chat/start" else handlers[path](body)
finally: self._flush() # the Parts an ending owes another hub, sent once no lock is held return 404, {"status": "error", "reason": "not-found", "say": f"No {method} {path} here."} diff --git a/src/pyrtan/cli.py b/src/pyrtan/cli.py index 55e682a..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 network
pyrtan 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)")
@@ -172,6 +172,7 @@ def main(argv: list[str] | None = None) -> int:
p.add_argument("--store", metavar="DIR", default=os.environ.get("PYRTAN_STORE"), help="where this hub keeps blobs, its own and others' (default $PYRTAN_STORE, else next to --state)") p.add_argument("--replicas", type=int, default=int(os.environ.get("PYRTAN_REPLICAS", "2")), help="how many members or hubs should keep each of this hub's blobs (default $PYRTAN_REPLICAS or 2)") p.add_argument("--chat", action="store_true", default=os.environ.get("PYRTAN_CHAT", "").lower() in ("1", "true", "yes"), help="keep a lobby at /chat where people meet a random stranger here or at a hub this one follows, or a bot made of the trees it routes to (default $PYRTAN_CHAT)")
+ p.add_argument("--chat-public", action="store_true", default=os.environ.get("PYRTAN_CHAT_PUBLIC", "").lower() in ("1", "true", "yes"), help="open the lobby to callers without the token (implies --chat): they meet people only, never a bot, and everything else still needs the token (default $PYRTAN_CHAT_PUBLIC)")
p.add_argument("--chat-typing", action=argparse.BooleanOptionalAction, default=os.environ.get("PYRTAN_CHAT_TYPING", "").lower() not in ("0", "false", "no", "off"), help="the lobby's typing indicator: \"Stranger is typing…\" for anyone who keeps theirs on (default on; $PYRTAN_CHAT_TYPING=0 turns it off)") p.add_argument("--report-gaps", action="store_true", default=os.environ.get("PYRTAN_REPORT_GAPS", "").lower() in ("1", "true", "yes"), help="put the gaps this swarm's catalog trees meet (tree id, node, value; never inputs) into its announces, so they reach the tree's author through the federation and pyrtan.com (default $PYRTAN_REPORT_GAPS)") p.add_argument("--quiet", action="store_true")
@@ -220,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")
@@ -786,14 +787,14 @@ 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, state=args.state, open_follows=not args.closed, follow=follow, join=join, listed=args.listed, trees=[Path(t) for t in args.trees], handout=[Path(t) for t in args.handout], env=_kv(args.env), gaps=args.gaps, egress=args.egress,
- store=args.store, replicas=args.replicas, report_gaps=args.report_gaps, unanswered=args.unanswered, watch=args.watch, chat=args.chat, chat_typing=args.chat_typing,
+ store=args.store, replicas=args.replicas, report_gaps=args.report_gaps, unanswered=args.unanswered, watch=args.watch, chat=args.chat or args.chat_public, chat_typing=args.chat_typing, chat_public=args.chat_public,
) fed = f", federated as {hub.federation.id}" + (f" following {len(hub.federation.following)}" if hub.federation.following else "") + (", listed" if hub.federation.listed else "") if hub.federation else "" local = f", holding {len(hub.local.trees)} tree(s)" if hub.local else ""
@@ -802,7 +803,7 @@ def cmd_hub(args: argparse.Namespace) -> int:
if hub.handout_registry is not None: local += f", handing out {len(hub.handout_registry.trees())} tree(s) to trusted members" if hub.lobby is not None:
- local += ", chatting at /chat"
+ local += ", chatting at /chat" + (" (open to callers without the token, people only)" if hub.chat_public and args.token else "")
print(f"pyrtan hub at {server.url}: {len(hub.peers)} peer(s){local}{fed}" + _access_note(args), flush=True) try: server.wait()
@@ -891,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
@@ -1006,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 it
holds, 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. An
entry 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 coun
SIGNED_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_LIST
try: 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 fe163d2..3cf3f75 100644 --- a/src/pyrtan/hub.py +++ b/src/pyrtan/hub.py
@@ -21,7 +21,9 @@ Endpoints:
With ``--chat``, a hub also keeps a lobby at ``/chat``, where people meet a stranger here or at a hub it follows, or a bot made of the trees it routes
-to; see :mod:`pyrtan.chat`.
+to; see :mod:`pyrtan.chat`. ``--chat-public`` opens the lobby to callers
+without the hub's token, who meet people only: a bot runs trees, and the
+token is what guards them.
Federated (``--federate``), a hub also answers ``GET /actor``, ``POST /inbox``, ``POST /follow`` and ``/unfollow`` (operator), ``GET /following``,
@@ -47,8 +49,8 @@ from dataclasses import dataclass, field
from typing import Any from . import __version__
-from .chat import Lobby
-from .federation import MAX_HOPS, Federation, Remote, bootstrap_candidates, default_network, first_live_seed, parse_invite, sign
+from .chat import Lobby, is_chat_path
+from .federation import MAX_HOPS, Federation, Remote, bootstrap_candidates, default_network, first_live_hub, parse_invite, sign
from .grow import Unanswered, no_match_outcome from .intent import Trigger, read from .page import MANIFEST, PAGE, PYRTAN_JS, SERVICE_WORKER
@@ -125,9 +127,10 @@ class _HubResolver:
class Hub:
- def __init__(self, *, name: str = "hub", poll_every: float = 10.0, token: str | None = None, local: Service | None = None, handout: Registry | None = None, unanswered: str | Path | None = None, chat: bool = False, chat_typing: bool = True) -> None:
+ def __init__(self, *, name: str = "hub", poll_every: float = 10.0, token: str | None = None, local: Service | None = None, handout: Registry | None = None, unanswered: str | Path | None = None, chat: bool = False, chat_typing: bool = True, chat_public: bool = False) -> None:
self.name = name
- self.lobby = Lobby(self, typing=chat_typing) if chat else None
+ self.lobby = Lobby(self, typing=chat_typing) if chat or chat_public else None
+ self.chat_public = chat_public # callers without the token may chat, with people only
self.handout_registry = handout # trees for trusted members only: this hub hands them out and does not run them self.poll_every = poll_every self.token = token or None
@@ -477,6 +480,10 @@ class Hub:
out.extend({**u, "peer": peer.url} for u in body.get("unanswered", [])) return {"unanswered": out}
+ def _trusted(self, headers: dict[str, str]) -> bool:
+ """Whether the caller holds this hub's token (always, when it has none)."""
+ return not self.token or bearer_ok(headers.get("Authorization") or headers.get("authorization"), self.token)
+
def route(self, method: str, path: str, body: dict[str, Any] | None, headers: dict[str, str] | None = None) -> tuple[int, Any]: body = body or {} headers = headers or {}
@@ -582,10 +589,10 @@ class Hub:
return 200, self.unanswered_all() if method == "GET" and path == "/watches": return 200, self.watches()
- if path == "/chat" or path.startswith("/chat/"):
+ if is_chat_path(path):
if self.lobby is None: return 404, {"status": "error", "reason": "no-chat", "say": "This hub keeps no lobby; start it with --chat."}
- return self.lobby.route(method, path, body)
+ return self.lobby.route(method, path, body, guest=not self._trusted(headers))
if method == "GET" and path in ("/", "/index.html", "/console"): return 200, PAGE if method == "GET" and path == "/manifest.webmanifest":
@@ -600,7 +607,7 @@ class Hub:
endpoints += ["/chat", "/chat/start", "/chat/poll", "/chat/say", "/chat/typing", "/chat/settings", "/chat/next", "/chat/leave"] if fed is not None: endpoints += ["/actor", "/inbox", "/mailbox", "/directory", "/reports", "/follow", "/unfollow", "/networks", "/join", "/leave", "/listed", "/following", "/followers", "/outbox", "/keep", "/fetch", "/kept/{key}"]
- return 200, {"pyrtan": "hub", "name": self.name, "version": __version__, "auth": "bearer" if self.token else "none", "federation": fed.id if fed else None, "chat": self.lobby is not None, "endpoints": endpoints}
+ return 200, {"pyrtan": "hub", "name": self.name, "version": __version__, "auth": "bearer" if self.token else "none", "federation": fed.id if fed else None, "chat": self.lobby is not None, "chat_public": self.lobby is not None and self.chat_public, "chat_bots": self.lobby is not None and self._trusted(headers), "endpoints": endpoints}
if method == "POST" and path == "/register": url = body.get("url") if not isinstance(url, str) or not url.startswith(("http://", "https://")):
@@ -626,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:
@@ -690,10 +698,11 @@ def serve_hub(
watch: bool = False, chat: bool = False, chat_typing: bool = True,
+ chat_public: bool = False,
) -> tuple[Hub, Server]: local = Service(Registry.from_paths(trees), name=f"{name}", env=env, gaps=gaps, egress=egress, watch=watch) if trees else None
- hub = Hub(name=name, poll_every=poll_every, token=token, local=local, handout=Registry.from_paths(handout) if handout else None, unanswered=unanswered, chat=chat, chat_typing=chat_typing)
- server = Server(hub.route, host, port, quiet, token=token, cors=cors, federated=federate or bool(advertise)).start()
+ hub = Hub(name=name, poll_every=poll_every, token=token, local=local, handout=Registry.from_paths(handout) if handout else None, unanswered=unanswered, chat=chat, chat_typing=chat_typing, chat_public=chat_public)
+ server = Server(hub.route, host, port, quiet, token=token, cors=cors, federated=federate or bool(advertise), public=is_chat_path if chat_public else None).start()
if federate or advertise or join: hub.federate(advertise or server.url, secret=federation_secret, state=state, open_follows=open_follows, listed=listed, store=store or (str(Path(state).with_name("store")) if state else None), replicas=replicas, report_gaps=report_gaps) for url in peers or []: diff --git a/src/pyrtan/page.py b/src/pyrtan/page.py index 31ab967..525ecf1 100644 --- a/src/pyrtan/page.py +++ b/src/pyrtan/page.py
@@ -221,6 +221,7 @@ PAGE = r"""<!doctype html>
<section id="tab-chat" hidden> <h2>Talk to a stranger</h2> <p class="mute" style="font-size:14px;margin-top:0">You are paired at random: with someone else on this hub or on a hub it follows, or with a bot. A bot is a set of trees on the network, not a person and not a model; each of its lines carries the path that produced it, and what it cannot answer it says so, with the tree that would. Nobody has a name and nothing is kept: the conversation is gone when either of you leaves. <span id="chat-count"></span></p>
+ <div class="notice" id="chat-guest" hidden>This hub's lobby is open to anyone, and you are here without its token: you meet people, not bots. A bot runs the hub's trees, and the token is what guards them.</div>
<div class="card" id="chat-off"> <form class="row" id="chat-start" style="margin:0"> <input type="text" id="chat-interests" placeholder="interests, optional: weather, news, music" autocomplete="off">
@@ -313,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)));
@@ -338,7 +339,7 @@ async function detect() {
$("#connect-lede").textContent = "This Pyrtan needs a token:"; $("#connect-help").innerHTML = "Kept in this browser only. The token is the one the hub or service was started with (<code>--token</code> or <code>$PYRTAN_TOKEN</code>)."; $("#hub-token").value = token;
- if (!token) problem("Enter the token to see what this Pyrtan can answer.");
+ if (!token) problem(a.chat_public ? "Enter the token to see what this Pyrtan can answer, or open Chat: its lobby is open to anyone." : "Enter the token to see what this Pyrtan can answer.");
} return a; }
@@ -367,6 +368,14 @@ async function load() {
$("#directory-tab").hidden = !(isHub && a.federation); $("#member-tab").hidden = !(isHub && a.federation); $("#chat-tab").hidden = !(isHub && a.chat);
+ if (a.chat && a.chat_bots === false && token) {
+ try { a.chat_bots = (await json("api")).chat_bots; } catch (e) {} // the first ask went without the token
+ }
+ if (a.chat && a.chat_bots === false) {
+ // A public lobby, and no token here: people only, since a bot runs the hub's trees.
+ $("#chat-want").innerHTML = `<option value="people">people only</option>`;
+ $("#chat-guest").hidden = false;
+ }
member.hub = a.federation || null; if (member.hub) member.start(); $("#where").textContent = (base || location.origin) + (a.auth === "bearer" ? " · token" : "");
@@ -375,7 +384,10 @@ async function load() {
const h = await json("health"); $("#who").textContent = (h.name ? h.name + " · " : "") + (h.peers != null ? `${h.up}/${h.peers} peers up` + (h.trees != null ? `, ${h.trees} trees` : "") : (h.trees ? `${h.trees.length} trees` : "")); } catch (e) { $("#who").textContent = "unreachable"; }
- if (a.auth === "bearer" && !token) return;
+ if (a.auth === "bearer" && !token) {
+ if (a.chat_public && location.hash === "#chat") showTab("chat"); // the one tab open without the token
+ return;
+ }
let c; try { c = await json("capabilities"); } catch (e) { problem(e.message + (a.auth === "bearer" ? " (is it the right one?)" : ""), true); return; } problem(""); diff --git a/src/pyrtan/service.py b/src/pyrtan/service.py index cfdbb03..4dcea81 100644 --- a/src/pyrtan/service.py +++ b/src/pyrtan/service.py
@@ -279,7 +279,7 @@ def bearer_ok(header: str | None, token: str) -> bool:
return scheme.lower() == "bearer" and hmac.compare_digest(value.strip().encode("utf-8"), token.encode("utf-8"))
-def make_handler(route: Route, quiet: bool = False, token: str | None = None, cors: list[str] | None = None, federated: bool = False):
+def make_handler(route: Route, quiet: bool = False, token: str | None = None, cors: list[str] | None = None, federated: bool = False, public: Callable[[str], bool] | None = None):
token = token or None origins = [o.rstrip("/") for o in (cors or []) if o] any_origin = "*" in origins
@@ -319,7 +319,7 @@ def make_handler(route: Route, quiet: bool = False, token: str | None = None, co
def _authorised(self, path: str) -> bool: """True if the request may proceed; otherwise a 401 has been sent."""
- if token is None or path in PUBLIC_PATHS or bearer_ok(self.headers.get("Authorization"), token):
+ if token is None or path in PUBLIC_PATHS or bearer_ok(self.headers.get("Authorization"), token) or (public is not None and public(path)):
return True if federated and signed_path(path) and self.headers.get("X-Pyrtan-Signature"): return True # the route verifies the signature
@@ -391,10 +391,11 @@ class Server:
"""A running HTTP server around a route function, for a service or a hub. ``token`` requires ``Authorization: Bearer`` on everything but the public
- paths; ``cors`` lists the browser origins allowed to call (``*`` for any)."""
+ paths, and any path ``public`` says yes to (a hub's open lobby); ``cors``
+ lists the browser origins allowed to call (``*`` for any)."""
- def __init__(self, route: Route, host: str = "127.0.0.1", port: int = 0, quiet: bool = False, *, token: str | None = None, cors: list[str] | None = None, federated: bool = False) -> None:
- self.httpd = ThreadingHTTPServer((host, port), make_handler(route, quiet, token, cors, federated))
+ def __init__(self, route: Route, host: str = "127.0.0.1", port: int = 0, quiet: bool = False, *, token: str | None = None, cors: list[str] | None = None, federated: bool = False, public: Callable[[str], bool] | None = None) -> None:
+ self.httpd = ThreadingHTTPServer((host, port), make_handler(route, quiet, token, cors, federated, public))
self.httpd.daemon_threads = True self.thread: threading.Thread | None = None diff --git a/tests/test_chat.py b/tests/test_chat.py index c89e4f3..2808758 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py
@@ -440,3 +440,57 @@ def test_cli_shows_the_indicator_unless_told_not_to(examples_hub, capsys, monkey
assert "thinking" not in out and "Bot: Deterministic" in out finally: srv.stop()
+
+
+def test_a_public_lobby_lets_strangers_in_without_the_token_and_without_bots(tmp_path):
+ from pyrtan.hub import serve_hub
+
+ (tmp_path / "greeter.toml").write_text(GREETER)
+ hub, srv = serve_hub(port=0, quiet=True, name="pub", token="sekrit", trees=[tmp_path], chat_public=True)
+ call = lambda method, path, body=None, token=None: http_json(method, f"{srv.url}{path}", body, token=token)
+ try:
+ # The lobby answers without the token; nothing else does.
+ status, stats = call("GET", "/chat")
+ assert status == 200 and stats["bots"] == 0
+ assert call("GET", "/chat", token="sekrit")[1]["bots"] == 1
+ assert call("POST", "/intent", {"text": "greet me"})[0] == 401
+ assert call("GET", "/gaps")[0] == 401
+ _, api = call("GET", "/api")
+ assert api["chat"] is True and api["chat_public"] is True and api["chat_bots"] is False
+ assert call("GET", "/api", token="sekrit")[1]["chat_bots"] is True
+
+ # A guest meets people only: no bot on asking, and none after waiting.
+ status, out = call("POST", "/chat/start", {"want": "bot"})
+ assert status == 403 and out["reason"] == "bots-need-token"
+ hub.lobby.bot_after = 0
+ _, guest = call("POST", "/chat/start", {"want": "anyone"})
+ assert guest["want"] == "people" and guest["bots"] is False
+ _, view = call("POST", "/chat/poll", {"seat": guest["seat"], "after": guest["after"], "wait": 0.3})
+ assert view["state"] == "waiting"
+ _, other = call("POST", "/chat/start", {"want": "people"})
+ assert other["state"] == "talking"
+ assert call("POST", "/chat/say", {"seat": other["seat"], "text": "hi from outside"})[0] == 200
+ _, view = call("POST", "/chat/poll", {"seat": guest["seat"], "after": view["after"]})
+ assert texts(view["events"], "stranger") == ["hi from outside"]
+
+ # With the token, the same lobby has bots.
+ _, member = call("POST", "/chat/start", {"want": "bot"}, token="sekrit")
+ assert member["state"] == "talking" and member["partner"]["kind"] == "bot" and member["bots"] is True
+ finally:
+ hub.stop()
+ srv.stop()
+
+
+def test_a_lobby_that_is_not_public_follows_the_token(tmp_path):
+ from pyrtan.hub import serve_hub
+
+ hub, srv = serve_hub(port=0, quiet=True, name="closed", token="sekrit", chat=True)
+ try:
+ assert http_json("GET", f"{srv.url}/chat")[0] == 401
+ assert http_json("POST", f"{srv.url}/chat/start", {"want": "people"})[0] == 401
+ _, api = http_json("GET", f"{srv.url}/api")
+ assert api["chat_public"] is False and api["chat_bots"] is False
+ assert http_json("GET", f"{srv.url}/chat", token="sekrit")[0] == 200
+ finally:
+ hub.stop()
+ srv.stop()
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_hubs
def 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 skipped
monkeypatch.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 answers
assert 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 list
code, 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 err
def 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.url
def 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.url
code, 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 diff --git a/uv.lock b/uv.lock index 2cc270f..f9dfa38 100644 --- a/uv.lock +++ b/uv.lock
@@ -147,7 +147,7 @@ wheels = [
[[package]] name = "pyrtan"
-version = "0.12.0"
+version = "0.13.0"
source = { editable = "." } [package.optional-dependencies]