pyrtan / sourcev0.14.0 · main

Identity, step two: names, resolved and pinned, and names at pyrtan.com

Claude · 2026-10-27 · da7cb1970fae528b124098047feb1cd3c95c133f · parents c6fbeae

src/pyrtan/names.py resolves name@domain through /.well-known/pyrtan/name/<name>.json, checks the claim against the key's own signature, and pins the first answer: a changed answer is refused until --repin. build_wellknown writes /.well-known/pyrtan/ (claims, identity documents, names.json) from a directory of claims, for any domain; gate is pyrtan.com's rule for site/names/: a name is the author's GitHub login, one per account, owned by the account's numeric id, so a freed login cannot take a name over.

pyrtan id resolve, pins, wellknown, and name --into a checkout. The pages build serves site/names/ (and stays on upload-pages-artifact v3, which keeps dot-directories); the catalog workflow passes the author's numeric id to site/gate.py. The gate now diffs with --no-renames, so a file moved out of site/names/ or catalog/ is judged as a removal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rD7jgoLgxA3qCSMVWdoDv

Changes

 .github/workflows/catalog.yml |  11 ++-
 .github/workflows/pages.yml   |   2 +
 README.md                     |  27 ++++--
 _meta/identity.md             |  56 ++++++++----
 site/README.md                |   1 +
 site/build.sh                 |   6 +-
 site/gate.py                  |  22 ++++-
 site/names/README.md          |  46 ++++++++++
 src/pyrtan/cli.py             |  92 +++++++++++++++++--
 src/pyrtan/names.py           | 304 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 tests/test_identity.py        |   2 +-
 tests/test_names.py           | 270 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 12 files changed, 799 insertions(+), 40 deletions(-)
diff --git a/.github/workflows/catalog.yml b/.github/workflows/catalog.yml
index f07e433..aacc57b 100644
--- a/.github/workflows/catalog.yml
+++ b/.github/workflows/catalog.yml
@@ -1,8 +1,10 @@
# The catalog gate: every pull request that touches a tree runs the same # checks `pyrtan publish` ran locally, with what git knows added: which # files changed, who opened the pull request, and what version each tree
-# had on main. A maintainer merges when it is green; the pages workflow
-# publishes on merge.
+# had on main. The same gate judges names under site/names/: a new name is
+# the author's own login with the author's numeric GitHub id, and only that
+# account changes it. A maintainer merges when it is green; the pages
+# workflow publishes on merge.
name: catalog
@@ -13,6 +15,9 @@ on:
- "examples/**" - "site/gate.py" - "src/pyrtan/catalog.py"
+ - "site/names/**"
+ - "src/pyrtan/names.py"
+ - "src/pyrtan/identity.py"
permissions: contents: read
@@ -28,4 +33,4 @@ jobs:
- run: uv sync --extra identity # the identity tests sign and verify - run: uv run pytest -q - run: uv run pyrtan check examples catalog --quiet
- - run: uv run python site/gate.py --base "origin/${{ github.base_ref }}" --author "${{ github.event.pull_request.user.login }}"
+ - run: uv run python site/gate.py --base "origin/${{ github.base_ref }}" --author "${{ github.event.pull_request.user.login }}" --author-id "${{ github.event.pull_request.user.id }}"
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index fb2a856..243908e 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml
@@ -36,6 +36,8 @@ jobs:
- run: uv sync --extra identity # the identity tests sign and verify - run: uv run pytest -q - run: uv run site/build.sh out
+ # v3, not v4: v4 leaves out dot-directories, and the names are served
+ # from .well-known/pyrtan/.
- uses: actions/upload-pages-artifact@v3 with: path: out diff --git a/README.md b/README.md index 964222b..2a9d91f 100644 --- a/README.md +++ b/README.md
@@ -176,7 +176,8 @@ hosted box is not lent out. The design is in [`_meta/io.md`](_meta/io.md).
| `pyrtan pull NAME[@VERSION]` | Fetches a tree from a catalog into your trees folder, verified against the catalog's index. | | `pyrtan index DIR` | Builds a catalog `index.json` from a directory of trees; what a catalog's CI runs. | | `pyrtan console --out FILE` | Writes the console as one static HTML file, to host anywhere. |
-| `pyrtan id new` | Makes your identity: a root key and the signed document hubs will hold. `id device add pyr:…` delegates to a device key, `id revoke` takes it back, `id route add HOST` says where you can be reached, `id name alice@domain` signs a name claim for a domain to serve, `id check` checks an identifier or any of those documents, `id show` shows them. |
+| `pyrtan id new` | Makes your identity: a root key and the signed document hubs will hold. `id device add pyr:…` delegates to a device key, `id revoke` takes it back, `id route add HOST` says where you can be reached, `id name alice@domain` signs a name claim for a domain to serve (`--into` a checkout for a name at pyrtan.com), `id check` checks an identifier or any of those documents, `id show` shows them. |
+| `pyrtan id resolve NAME@DOMAIN` | What identity a name is, checked against the key's own signature and pinned the first time; a changed answer is refused until `--repin`. `id pins` lists the pins; `id wellknown DIR --domain D` builds `/.well-known/pyrtan/` for a domain to serve names. |
`serve` and `hub` take `--token` (or `$PYRTAN_TOKEN`) to require `Authorization: Bearer` on everything but `/health` and the console, and
@@ -326,9 +327,19 @@ pyrtan id name alice@pyrtan.com --github <your numeric GitHub id>
pyrtan id check pyr:…@hub.example ```
-This is the first step: identifiers, the signed documents (the identity
-document, device notes, name claims) and the command. Names at
-pyrtan.com and grants on hubs come next. The design is in
+`pyrtan id resolve alice@pyrtan.com` fetches the claim the domain serves
+at `/.well-known/pyrtan/name/alice.json`, checks that the key signed it,
+and pins the answer: if the domain later names a different key, you are
+told rather than switched. Any domain can serve names (`pyrtan id
+wellknown DIR --domain D` builds the files). pyrtan.com hands out
+`<your GitHub login>@pyrtan.com` by pull request: `pyrtan id name
+<login>@pyrtan.com --into <checkout>`, then a pull request from that
+account, and the gate checks the account's numeric id
+([`site/names/README.md`](site/names/README.md)).
+
+Built so far: identifiers, the signed documents (the identity document,
+device notes, name claims), names and pins, and the command. Grants on
+hubs come next. The design is in
[`_meta/identity.md`](_meta/identity.md). ## The catalog
@@ -576,7 +587,7 @@ slot refused. The whole set of rules is in
``` src/pyrtan/ the package: tree, nlp, intent, template, conditions, extract, html, transport, runtime, trace, registry, gaps, grow,
- service, hub, federation, identity, page (the console),
+ service, hub, federation, identity, names, page (the console),
catalog, cli examples/ trees that run against real public APIs, and offline against tests/fixtures/examples.json
@@ -605,9 +616,9 @@ 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, and bots made of trees) 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. Identities (keys, device notes
-and name claims, `pyrtan id`) are built as far as their first step;
-names at pyrtan.com and grants on hubs are not. Not yet built: hosting.
+and its own box, and follow other swarms. Identities (keys, device notes,
+name claims, names at pyrtan.com and pins, `pyrtan id`) are built;
+grants on hubs are not yet. Not yet built: hosting.
See [`_meta/design.md`](_meta/design.md) for the runtime's design and [`_meta/platform.md`](_meta/platform.md) for pyrtan.com. diff --git a/_meta/identity.md b/_meta/identity.md index 2e4f77e..3847628 100644 --- a/_meta/identity.md +++ b/_meta/identity.md
@@ -2,10 +2,10 @@
| | | |---|---|
-| **Status** | **First step built** (§10): identifiers, the three documents, signing and checking, and `pyrtan id`. Names at pyrtan.com (§6) and everything on the wire (§7) are proposed, not built. |
+| **Status** | **Two steps built** (§10): identifiers, the three documents, signing and checking, and `pyrtan id`; then names (§6): resolving and pinning, `/.well-known/pyrtan/` for any domain, and pyrtan.com's names by pull request. Everything on the wire (§7) is proposed, not built. |
| **Date** | 2026-10-27 | | **Decision** | A person (or a hub, or a device) is a public key. The identifier is that key, written so that anyone can check a signature from it without asking anybody. Domains, hubs and onion services are where an identity can be *reached* and what it can be *called*, never what it *is*: `pyr:<key>` is the identity, `pyr:<key>@host` says where to find it, and `alice@pyrtan.com` is a name a domain vouches for. pyrtan.com hands out names by pull request, as one name provider among any number. An identity grants nothing by itself: each hub keeps its own list of who may do what. |
-| **Where** | Built: `src/pyrtan/identity.py` (the encoding, documents, signing and checking), `pyrtan id` in `src/pyrtan/cli.py`, `tests/test_identity.py`. Proposed: `src/pyrtan/federation.py` and `hub.py` (signed requests, grants), `src/pyrtan/page.py` (the console's device key), `site/names/` and `site/gate.py` (pyrtan.com's names), `/.well-known/pyrtan/` on any domain. |
+| **Where** | Built: `src/pyrtan/identity.py` (the encoding, documents, signing and checking), `src/pyrtan/names.py` (resolving, pins, `/.well-known/pyrtan/`, pyrtan.com's gate), `pyrtan id` in `src/pyrtan/cli.py`, `site/names/`, `site/gate.py`, `tests/test_identity.py`, `tests/test_names.py`. Proposed: `src/pyrtan/federation.py` and `hub.py` (signed requests, grants), `src/pyrtan/page.py` (the console's device key), `site/names/` and `site/gate.py` (pyrtan.com's names), `/.well-known/pyrtan/` on any domain. |
## 0. The brief
@@ -219,7 +219,10 @@ is signing it; both have to agree.
the domain to serve. **Pinning.** The first identity a name resolves to is remembered (in the
-hub's state file, the console's storage, `~/.config/pyrtan/`). A later
+hub's state file, the console's storage, `pins.json` beside the CLI's
+key). `pyrtan id resolve alice@example.org` resolves, pins and shows the
+identity document if the domain serves one; `--repin` takes a changed
+answer; `pyrtan id pins` lists them and `--forget` drops one. A later
answer naming a different identity is not followed: it is shown beside the pinned one, and the operator re-pins by hand. This is SSH's known-hosts rule, and it is what makes a domain's word safe to take:
@@ -229,8 +232,9 @@ old answer notices.
**pyrtan.com's names.** GitHub Pages serves `/.well-known/` like any other path and sends `Access-Control-Allow-Origin: *`, so the console can resolve names from a browser. The names are files in this repository,
-`site/names/<login>.json`, each a name claim plus two fields only
-pyrtan.com uses:
+`site/names/<login>.json`, each a name claim with two rules only
+pyrtan.com adds, and beside it, optionally, `site/names/<login>.id.json`,
+the identity's document:
- the name is the author's **GitHub login**, lowercased. One person, one name, no disputes to judge, and GitHub has already checked it;
@@ -238,18 +242,28 @@ pyrtan.com uses:
renames, so the gate (`site/gate.py`, run by the `catalog` workflow as it is for namespaces) checks the pull request author's *numeric* id (`github.event.pull_request.user.id`) against the file, not the login.
- A new file must be added by that user; a changed or removed one
- likewise, or by a maintainer.
-
-The gate also checks the claim's signature and the identifier's
-checksum. The `pages` build publishes each claim at
+ A new name must be the author's own login carrying the author's id; a
+ name, or its document, is changed or removed only by the account whose
+ id the claim carried on the base branch, and its `github` never
+ changes. One name per account. To follow a rename, remove the old name
+ and add the new one in the same pull request. A maintainer may merge
+ over the gate to remove a name on request or for abuse.
+
+The gate also checks every claim's signature, that it is for
+`<its file's name>@pyrtan.com`, and that a document beside it is signed
+by the same key. `pyrtan id name alice@pyrtan.com --into <checkout>`
+writes both files, looking the numeric id up from the login (or taking
+`--github`), and runs the gate. The `pages` build runs `pyrtan id
+wellknown site/names --domain pyrtan.com`, which publishes each claim at
`/.well-known/pyrtan/name/<login>.json`, an index of all of them at
-`/.well-known/pyrtan/names.json` for mirrors, and, if the file carries
-one, the identity's latest document at `/.well-known/pyrtan/id/<56>.json`.
-A change is live within the hour, the build's cadence.
-
-**Other domains** do the same by serving the same paths; nothing about
-them is pyrtan.com's business. An operator can serve names from their own
+`/.well-known/pyrtan/names.json` for mirrors, and each document at
+`/.well-known/pyrtan/id/<56>.json`. A change is live within the hour,
+the build's cadence. The Pages upload stays on
+`actions/upload-pages-artifact@v3`: v4 leaves out dot-directories.
+
+**Other domains** do the same by serving the same paths, and `pyrtan id
+wellknown DIR --domain D --out SITE` builds them from a directory of
+claims; nothing about them is pyrtan.com's business. An operator can serve names from their own
hub. An onion service can serve names to anyone who fetches over Tor. ## 7. On the wire
@@ -377,9 +391,13 @@ hub that checks Alice with a shared secret can also forge Alice.
600, beside `identity.json`. Tested with RFC 8032's vectors and the vector in §2; CI installs the extra so signing is tested, and the suite still passes without it.
-- **Second: names.** `site/names/`, the gate's checks, the build's
- `/.well-known/pyrtan/` output, `pyrtan id resolve alice@pyrtan.com`,
- pinning. pyrtan.com hands out names; nothing yet grants by them.
+- **Second, built: names.** `names.py`: resolving, pins, building
+ `/.well-known/pyrtan/` from a directory of claims, and pyrtan.com's
+ gate; `pyrtan id resolve`, `pins`, `wellknown`, `name --into`;
+ `site/names/` with its README, `site/gate.py --author-id`, the
+ `catalog` workflow passing the author's numeric id, and the `pages`
+ build serving the names. pyrtan.com hands out names; nothing yet
+ grants by them.
- **Third: hubs.** Signed requests, `--allow` and grants, members following with a device key, revocation refresh, the console's device key and pairing, with conformance vectors (`tests/conformance/`) so the diff --git a/site/README.md b/site/README.md index 9e68a8e..b5f052d 100644 --- a/site/README.md +++ b/site/README.md
@@ -14,6 +14,7 @@ static directory and publishes it.
| `/rendezvous/seeds.json` | hubs to follow first | `site/rendezvous/seeds.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` |
+| `/.well-known/pyrtan/` | names: `name/<login>.json` (a claim signed by the identity it names), `id/<56>.json` (identity documents), `names.json` (every claim), for `pyrtan id resolve <login>@pyrtan.com` | `pyrtan id wellknown site/names`, after `site/gate.py`; see [`names/README.md`](names/README.md) |
`site/build.sh out` builds the same thing locally; open `out/index.html`. diff --git a/site/build.sh b/site/build.sh index 18f0bc3..b88e60d 100755 --- a/site/build.sh +++ b/site/build.sh
@@ -1,6 +1,7 @@
#!/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 seed list, the source,
+# the names. Run from the
# repository root with pyrtan installed (`uv run site/build.sh`); the Pages # workflow runs the same thing. set -eu
@@ -35,5 +36,8 @@ pyrtan check examples catalog --quiet
python3 site/gate.py # The index and every tree file beside it, as <namespace>/<name>.toml. pyrtan index examples catalog --copy "$out/catalog" --require-namespace
+# Names: site/names/ served as /.well-known/pyrtan/ (name claims, identity
+# documents, and names.json listing them), for `pyrtan id resolve`.
+pyrtan id wellknown site/names --domain "$(cat site/CNAME)" --out "$out"
touch "$out/.nojekyll" echo "built $out: $(find "$out" -type f | wc -l) files" diff --git a/site/gate.py b/site/gate.py index 398a320..8197601 100644 --- a/site/gate.py +++ b/site/gate.py
@@ -1,6 +1,6 @@
"""The catalog gate, as the workflows run it.
- uv run python site/gate.py [--base origin/main] [--author LOGIN] [--changed FILE ...]
+ uv run python site/gate.py [--base origin/main] [--author LOGIN] [--author-id ID] [--changed FILE ...]
Runs :func:`pyrtan.catalog.gate` on this checkout: every tree under ``examples/`` and ``catalog/`` loads clean with a licence and a namespace,
@@ -8,6 +8,12 @@ sits where its namespace and name say, ids are unique, each namespace has
owners. With ``--base`` it asks git which catalog files the pull request changed and what version each had before, so a change must come from an owner (``--author``, the pull request's login) and must bump the version.
+
+Then :func:`pyrtan.names.gate` on ``site/names/``: every name claim and
+identity document holds, a name is a GitHub login, one per account. With
+``--base``, a new name must be the author's own login carrying the
+author's numeric id (``--author-id``), and a name may be changed or
+removed only by the account whose id it carried on the base branch.
Exit 1 with the problems listed, 0 when the gate is open. """
@@ -23,6 +29,7 @@ ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src")) from pyrtan.catalog import gate # noqa: E402
+from pyrtan.names import NAMES_DIR, gate as names_gate # noqa: E402
def git(*args: str) -> str | None:
@@ -36,17 +43,27 @@ def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) ap.add_argument("--base", help="the base ref of the pull request (e.g. origin/main); changes and prior versions come from git") ap.add_argument("--author", help="the pull request's GitHub login; must own any namespace it changes")
+ ap.add_argument("--author-id", type=int, help="the pull request author's numeric GitHub id; must own any name it changes")
ap.add_argument("--changed", nargs="*", default=None, help="changed paths, when not taken from git") args = ap.parse_args(argv) changed = args.changed base: dict[str, tuple[int, bytes]] = {}
+ names_base: dict[str, bytes] = {}
if args.base:
- out = git("diff", "--name-only", f"{args.base}...HEAD")
+ # --no-renames: a rename is a removal and an addition, and both are judged.
+ out = git("diff", "--name-only", "--no-renames", f"{args.base}...HEAD")
if out is None: print(f"gate: cannot diff against {args.base}; checking the tree without change information", file=sys.stderr) else: changed = [line.strip() for line in out.splitlines() if line.strip()] if changed is None else changed for path in changed:
+ if path.startswith(f"{NAMES_DIR}/"):
+ # A name's claim on the base branch says whose it is, for its .id.json too.
+ claim = path[: -len(".id.json")] + ".json" if path.endswith(".id.json") else path
+ prior = subprocess.run(["git", "show", f"{args.base}:{claim}"], cwd=ROOT, capture_output=True)
+ if prior.returncode == 0:
+ names_base[claim] = prior.stdout
+ continue
if not (path.startswith(("catalog/", "examples/")) and path.endswith(".toml")): continue prior = subprocess.run(["git", "show", f"{args.base}:{path}"], cwd=ROOT, capture_output=True)
@@ -58,6 +75,7 @@ def main(argv: list[str] | None = None) -> int:
version = 1 base[path] = (version, prior.stdout) problems = gate(ROOT, changed=changed, author=args.author, base=base)
+ problems += names_gate(ROOT, changed=changed, author=args.author, author_id=args.author_id, base=names_base)
for p in problems: print(f"gate: {p}") scope = f"{len(changed)} changed file(s)" if changed is not None else "the whole checkout" diff --git a/site/names/README.md b/site/names/README.md new file mode 100644 index 0000000..3b21ed1 --- /dev/null +++ b/site/names/README.md
@@ -0,0 +1,46 @@
+# Names at pyrtan.com
+
+A name here is `<your GitHub login>@pyrtan.com`, pointing at your
+pyrtan identity (a key: `pyr:` and 56 characters). Anyone resolves it with
+
+ pyrtan id resolve alice@pyrtan.com
+
+which fetches `https://pyrtan.com/.well-known/pyrtan/name/alice.json`,
+checks that your key signed it, and pins the answer: if this file ever
+names a different key, everyone who resolved it before is told rather
+than switched. pyrtan.com is one name provider among any number; your
+identity is the key, and works without a name.
+
+## Getting one
+
+ pip install 'pyrtan[identity]'
+ pyrtan id new # once; back up the key it names
+ pyrtan id name <login>@pyrtan.com --into <a checkout of this repository>
+
+That writes `site/names/<login>.json` (the claim, signed by your key,
+with your numeric GitHub user id, which it looks up from your login; pass
+`--github ID` to give it yourself) and `site/names/<login>.id.json` (your
+identity document: routes, devices, revocations). Commit both and open a
+pull request **from the GitHub account the name is**. When your identity
+document changes (a device added or revoked), copy the new one over
+with `pyrtan id show --json > site/names/<login>.id.json` and open
+another.
+
+## What the gate checks
+
+The `catalog` workflow runs `site/gate.py` on the pull request:
+
+- every claim is for `<its file's name>@pyrtan.com` and signed by the key
+ it names; every identity document is signed by that same key;
+- a name is a GitHub login, and each GitHub account holds at most one;
+- a **new** name is the pull request author's own login, and the claim's
+ `github` is the author's numeric id;
+- a name is **changed or removed** only by the account whose numeric id it
+ carried before. GitHub frees a login when its owner renames, so the id,
+ not the login, is what owns a name. To follow a rename, remove the old
+ name and add the new one in the same pull request.
+
+A maintainer merges; the `pages` workflow publishes within the hour. A
+maintainer may merge over the gate to remove a name on its owner's
+request or for abuse. The design is in
+[`_meta/identity.md`](../../_meta/identity.md) §6.
diff --git a/src/pyrtan/cli.py b/src/pyrtan/cli.py index 3f36f6f..33a284e 100644 --- a/src/pyrtan/cli.py +++ b/src/pyrtan/cli.py
@@ -27,6 +27,7 @@
pyrtan index DIR [--out FILE] build a catalog index from a directory pyrtan console --out FILE write the console as one static HTML file pyrtan id new|show|check|device|revoke|route|name your identity: a key, any domain
+ pyrtan id resolve NAME@DOMAIN what identity a name is, pinned the first time
``serve`` and ``hub`` take ``--token`` (or ``$PYRTAN_TOKEN``) to require a bearer token, ``--cors ORIGIN`` to let a browser elsewhere call them, and
@@ -297,8 +298,20 @@ def main(argv: list[str] | None = None) -> int:
q.add_argument("host", help="a host[:port] or <56>.onion") q = ids.add_parser("name", help="sign a claim that you are name@domain, for the domain to serve") q.add_argument("name", metavar="NAME@DOMAIN")
- q.add_argument("--github", type=int, metavar="USER_ID", help="your numeric GitHub user id, for a name at pyrtan.com")
- q.add_argument("--out", help="write the claim here (default: stdout)")
+ q.add_argument("--github", type=int, metavar="USER_ID", help="your numeric GitHub user id, for a name at pyrtan.com (with --into, looked up from the name when omitted)")
+ where = q.add_mutually_exclusive_group()
+ where.add_argument("--out", help="write the claim here (default: stdout)")
+ where.add_argument("--into", type=Path, metavar="CHECKOUT", help="put the claim and your identity document into a checkout of this repository's site/names/, ready for a pull request")
+ q = ids.add_parser("resolve", help="what identity a name@domain is, pinned the first time")
+ q.add_argument("name", metavar="NAME@DOMAIN")
+ q.add_argument("--repin", action="store_true", help="accept a different identity than the one pinned")
+ q.add_argument("--json", action="store_true", help="print the claim, and the identity document if the domain serves one")
+ q = ids.add_parser("pins", help="the names you have resolved, and the identity each is pinned to")
+ q.add_argument("--forget", metavar="NAME@DOMAIN", help="drop a pin, so the next resolve takes what the domain says")
+ q = ids.add_parser("wellknown", help="build /.well-known/pyrtan/ from a directory of name claims, for any domain to serve")
+ q.add_argument("names", type=Path, metavar="DIR", help="<name>.json claims, and <name>.id.json identity documents")
+ q.add_argument("--domain", required=True, help="the domain that will serve them; every claim must be for it")
+ q.add_argument("--out", type=Path, required=True, help="the site's root; .well-known/pyrtan/ is written under it")
for q in ids.choices.values(): q.add_argument("--dir", type=Path, default=None, help="where your key and document live (default $PYRTAN_IDENTITY or ~/.config/pyrtan/identity)")
@@ -1292,6 +1305,7 @@ def cmd_id(args: argparse.Namespace) -> int:
return { "new": _id_new, "show": _id_show, "check": _id_check, "device": _id_device, "revoke": _id_revoke, "route": _id_route, "name": _id_name,
+ "resolve": _id_resolve, "pins": _id_pins, "wellknown": _id_wellknown,
}[args.id_command](args, directory) except IdentityError as e: print(f"pyrtan id: {e}", file=sys.stderr)
@@ -1363,7 +1377,7 @@ def _id_check(args: argparse.Namespace, directory: Path) -> int:
if thing != "-" and not Path(thing).is_file(): parsed = identity.parse(thing) if parsed.form == "name":
- print(f"{parsed}: a name; resolving it at {parsed.domain} comes with the names step (_meta/identity.md §6)")
+ print(f"{parsed}: a well-formed name; `pyrtan id resolve {parsed}` asks {parsed.domain} who it is")
else: print(f"{parsed}: a well-formed {parsed.form}, key {identity.decode(parsed.id).hex()}") return 0
@@ -1418,11 +1432,26 @@ def _id_route(args: argparse.Namespace, directory: Path) -> int:
def _id_name(args: argparse.Namespace, directory: Path) -> int:
- from . import identity
- key, _ = _id_load(directory)
- extra = {"github": args.github} if args.github is not None else {}
+ from . import identity, names
+ key, doc = _id_load(directory)
+ github = args.github
+ if args.into and github is None:
+ local = identity.parse(args.name).name
+ github = names.github_id(local)
+ print(f"GitHub says {local} is user {github}", file=sys.stderr)
+ extra = {"github": github} if github is not None else {}
claim = identity.make_name(key, args.name, **extra) text = json.dumps(claim, indent=2, ensure_ascii=False) + "\n"
+ if args.into:
+ written = names.place(args.into, claim, doc)
+ problems = names.gate(args.into)
+ for p in problems:
+ print(f"gate: {p}", file=sys.stderr)
+ if problems:
+ return 1
+ print("wrote " + " and ".join(str(w) for w in written))
+ print(f"now: git checkout -b names/{claim['name']} && git add {names.NAMES_DIR} && git commit && git push -u origin HEAD, and open a pull request from the GitHub account {claim['name']}")
+ return 0
if args.out: Path(args.out).write_text(text, encoding="utf-8") print(f"wrote {args.out}: {claim['id']} claims {claim['name']}@{claim['domain']}; the domain serves it at /.well-known/pyrtan/name/{claim['name']}.json")
@@ -1431,5 +1460,56 @@ def _id_name(args: argparse.Namespace, directory: Path) -> int:
return 0
+def _id_resolve(args: argparse.Namespace, directory: Path) -> int:
+ from . import identity, names
+ claim = names.resolve(args.name)
+ name, ident = f"{claim['name']}@{claim['domain']}", claim["id"]
+ pins = names.Pins(directory / "pins.json")
+ verdict, pinned = pins.compare(name, ident), pins.get(name)
+ if verdict == "changed" and not args.repin:
+ print(f"pyrtan id: {name} now says it is {ident}", file=sys.stderr)
+ print(f" but it was pinned to {pinned['id']} on {time.strftime('%Y-%m-%d', time.localtime(pinned['first']))}", file=sys.stderr)
+ print(" the domain changed its answer, or someone is answering for it. If you know why, pyrtan id resolve --repin", file=sys.stderr)
+ return 1
+ pins.pin(name, ident)
+ pins.save()
+ doc = names.fetch_identity(ident, claim["domain"])
+ if args.json:
+ print(json.dumps({"claim": claim, "identity": doc}, indent=2, ensure_ascii=False))
+ return 0
+ since = {"new": "pinned now", "same": f"as pinned since {time.strftime('%Y-%m-%d', time.localtime(pinned['first'])) if pinned else ''}", "changed": "re-pinned"}[verdict]
+ print(f"{name} is {ident} ({since})")
+ if doc:
+ print("routes: " + (", ".join(doc["routes"]) or "none") + f"; {len(doc['devices'])} device(s), {len(doc['revoked'])} revoked")
+ return 0
+
+
+def _id_pins(args: argparse.Namespace, directory: Path) -> int:
+ from . import names
+ pins = names.Pins(directory / "pins.json")
+ if args.forget:
+ if not pins.forget(args.forget):
+ print(f"pyrtan id: {args.forget} is not pinned", file=sys.stderr)
+ return 1
+ pins.save()
+ print(f"forgot {args.forget}; the next resolve takes what its domain says")
+ return 0
+ if not pins.names:
+ print("no pins yet: pyrtan id resolve NAME@DOMAIN")
+ for name, pin in sorted(pins.names.items()):
+ print(f"{name:32} {pin['id']} since {time.strftime('%Y-%m-%d', time.localtime(pin['first']))}")
+ return 0
+
+
+def _id_wellknown(args: argparse.Namespace, directory: Path) -> int:
+ from . import identity, names
+ domain = identity.check_host(args.domain.lower())
+ count, problems = names.build_wellknown(args.names, args.out, domain)
+ for p in problems:
+ print(f"wellknown: {p} (left out)", file=sys.stderr)
+ print(f"wrote {count} name(s) for {domain} under {args.out / names.WELL_KNOWN}")
+ return 1 if problems else 0
+
+
if __name__ == "__main__": sys.exit(main()) diff --git a/src/pyrtan/names.py b/src/pyrtan/names.py new file mode 100644 index 0000000..66fff3d --- /dev/null +++ b/src/pyrtan/names.py
@@ -0,0 +1,304 @@
+"""Names: ``alice@example.org`` resolved to an identity, pinned, and served.
+
+A name resolves by fetching ``https://<domain>/.well-known/pyrtan/name/<local>.json``,
+a claim signed by the identity it names. The resolver checks that the
+claim is for the name it asked and that the signature holds, then
+compares the answer with the *pin*: the identity the name resolved to
+the first time. A different answer later is never followed; it is shown
+beside the pinned one and the person re-pins by hand, as SSH does with a
+changed host key. That is what makes a domain's word safe to take.
+
+Any domain can be a name provider by serving those files.
+:func:`build_wellknown` writes them from a directory of claims (and of
+identity documents, served at ``/.well-known/pyrtan/id/<56>.json``);
+pyrtan.com's build runs it on ``site/names/``. :func:`gate` is pyrtan.com's
+own rule for that directory: a name is its holder's GitHub login, added
+and changed only by the GitHub account whose numeric id the claim
+carries, because GitHub frees a login when its owner renames.
+
+This module never fetches an ``.onion``: that needs Tor, which pyrtan does
+not reach yet. The design is ``_meta/identity.md`` §6.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import shutil
+import time
+from pathlib import Path
+from typing import Any, Callable
+
+from . import identity
+from .catalog import Problem
+from .identity import IdentityError
+
+WELL_KNOWN = ".well-known/pyrtan"
+NAMES_DIR = "site/names"
+GITHUB_LOGIN = re.compile(r"^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$")
+
+Fetch = Callable[[str], tuple[int, Any]]
+
+
+# ---------------------------------------------------------------- resolving
+
+
+def _fetch(url: str) -> tuple[int, Any]:
+ from .service import http_json
+ return http_json("GET", url, timeout=10.0)
+
+
+def _base(host: str) -> str:
+ host = identity.check_locator(host)
+ if host.split(":")[0].endswith(".onion"):
+ raise IdentityError(f"{host} is an onion service: reaching it needs Tor, which pyrtan does not reach yet")
+ return f"https://{host}"
+
+
+def name_url(name: str) -> str:
+ parsed = identity.parse(name)
+ if parsed.form != "name":
+ raise IdentityError(f"{name!r} is not a name (name@domain)")
+ return f"{_base(parsed.domain)}/{WELL_KNOWN}/name/{parsed.name}.json"
+
+
+def identity_url(ident: str, host: str) -> str:
+ return f"{_base(host)}/{WELL_KNOWN}/id/{identity.normal(ident)[len(identity.PREFIX):]}.json"
+
+
+def resolve(name: str, *, fetch: Fetch | None = None) -> dict[str, Any]:
+ """The checked claim ``name``'s domain serves; IdentityError says why there is none."""
+ url = name_url(name)
+ try:
+ status, claim = (fetch or _fetch)(url)
+ except Exception as e:
+ raise IdentityError(f"{url}: {e}") from None
+ if status == 404:
+ raise IdentityError(f"{identity.parse(name)} has no claim at {url}")
+ if status != 200:
+ raise IdentityError(f"{url} answered {status}")
+ problem = identity.check_name(claim, name=name)
+ if problem:
+ raise IdentityError(f"{url}: {problem}")
+ return claim
+
+
+def fetch_identity(ident: str, host: str, *, fetch: Fetch | None = None) -> dict[str, Any] | None:
+ """The identity document ``host`` serves for ``ident``, if it serves one that holds."""
+ try:
+ status, doc = (fetch or _fetch)(identity_url(ident, host))
+ except Exception:
+ return None
+ if status != 200 or identity.check_identity(doc, ident=ident) is not None:
+ return None
+ return doc
+
+
+# ---------------------------------------------------------------- pins
+
+
+class Pins:
+ """Name -> the identity it first resolved to, in a JSON file."""
+
+ def __init__(self, path: Path):
+ self.path = path
+ self.names: dict[str, dict[str, Any]] = {}
+ if path.exists():
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as e:
+ raise IdentityError(f"{path}: {e}") from None
+ if not isinstance(data, dict) or data.get("pyrtan-pins") != 1:
+ raise IdentityError(f"{path} is not a pyrtan pins file")
+ self.names = data.get("names", {})
+
+ def get(self, name: str) -> dict[str, Any] | None:
+ return self.names.get(str(identity.parse(name)))
+
+ def compare(self, name: str, ident: str) -> str:
+ """``new``, ``same`` or ``changed``."""
+ pin = self.get(name)
+ return "new" if pin is None else "same" if pin["id"] == identity.normal(ident) else "changed"
+
+ def pin(self, name: str, ident: str, *, now: int | None = None) -> None:
+ now = int(time.time()) if now is None else now
+ key, ident = str(identity.parse(name)), identity.normal(ident)
+ old = self.names.get(key)
+ first = old["first"] if old and old["id"] == ident else now
+ self.names[key] = {"id": ident, "first": first, "seen": now}
+
+ def forget(self, name: str) -> bool:
+ return self.names.pop(str(identity.parse(name)), None) is not None
+
+ def save(self) -> None:
+ self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
+ temp = self.path.with_suffix(".json.tmp")
+ temp.write_text(json.dumps({"pyrtan-pins": 1, "names": self.names}, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+ temp.replace(self.path)
+
+
+# ---------------------------------------------------------------- a directory of names
+
+
+def read_directory(directory: Path, domain: str, *, label: str | None = None) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]], list[Problem]]:
+ """The claims (``<name>.json``) and identity documents (``<name>.id.json``) in
+ ``directory``, for ``domain``: ``(claims by name, documents by name, problems)``.
+ Problems name files as ``label/<file>`` (default: the directory as given)."""
+ claims: dict[str, dict[str, Any]] = {}
+ docs: dict[str, dict[str, Any]] = {}
+ problems: list[Problem] = []
+ if not directory.is_dir():
+ return claims, docs, problems
+ label = directory.as_posix() if label is None else label
+
+ def rel(p: Path) -> str:
+ return f"{label}/{p.name}"
+
+ for path in sorted(directory.iterdir()):
+ if path.name.startswith(".") or path.name == "README.md":
+ continue
+ if not path.is_file() or not path.name.endswith(".json"):
+ problems.append(Problem(rel(path), "only <name>.json and <name>.id.json files belong here"))
+ continue
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as e:
+ problems.append(Problem(rel(path), f"not JSON: {e}"))
+ continue
+ if path.name.endswith(".id.json"):
+ docs[path.name[: -len(".id.json")]] = data
+ continue
+ stem = path.name[: -len(".json")]
+ problem = identity.check_name(data, name=f"{stem}@{domain}") if identity.LOCAL.match(stem) else f"{stem!r} is not a name"
+ if problem:
+ problems.append(Problem(rel(path), problem))
+ continue
+ claims[stem] = data
+ for stem, doc in docs.items():
+ where = rel(directory / f"{stem}.id.json")
+ if stem not in claims:
+ problems.append(Problem(where, f"an identity document needs its name's claim, {stem}.json, beside it"))
+ continue
+ problem = identity.check_identity(doc, ident=claims[stem]["id"])
+ if problem:
+ problems.append(Problem(where, problem))
+ docs = {s: d for s, d in docs.items() if s in claims and identity.check_identity(d, ident=claims[s]["id"]) is None}
+ return claims, docs, problems
+
+
+def build_wellknown(directory: Path, out: Path, domain: str) -> tuple[int, list[Problem]]:
+ """Write ``out/.well-known/pyrtan/``: ``name/<name>.json`` per claim, ``id/<56>.json``
+ per identity document, and ``names.json`` listing every claim. Returns how
+ many names were written and the problems; nothing that fails a check is served."""
+ claims, docs, problems = read_directory(directory, domain)
+ root = out / WELL_KNOWN
+ if root.exists():
+ shutil.rmtree(root)
+ (root / "name").mkdir(parents=True)
+ (root / "id").mkdir()
+ for stem, claim in claims.items():
+ (root / "name" / f"{stem}.json").write_text(json.dumps(claim, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
+ newest: dict[str, dict[str, Any]] = {}
+ for doc in docs.values():
+ if doc["id"] not in newest or doc["updated"] > newest[doc["id"]]["updated"]:
+ newest[doc["id"]] = doc
+ for ident, doc in newest.items():
+ (root / "id" / f"{ident[len(identity.PREFIX):]}.json").write_text(json.dumps(doc, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
+ index = {"pyrtan-names": 1, "domain": domain, "names": claims}
+ (root / "names.json").write_text(json.dumps(index, indent=2, ensure_ascii=False, sort_keys=True) + "\n", encoding="utf-8")
+ return len(claims), problems
+
+
+# ---------------------------------------------------------------- pyrtan.com's gate
+
+
+def gate(root: Path, *, changed: list[str] | None = None, author: str | None = None, author_id: int | None = None,
+ base: dict[str, bytes] | None = None) -> list[Problem]:
+ """pyrtan.com's rule for ``site/names/``, on top of every claim and document holding:
+ a name is a GitHub login, at most one per GitHub account (its numeric ``github`` id).
+ Given the pull request's ``changed`` paths, ``author`` (login), ``author_id`` and
+ ``base`` (``path -> bytes`` on the base branch), a new name must be the author's own
+ login and carry the author's id, and a name may be changed or removed only by the
+ account whose id it carried before."""
+ domain = (root / "site" / "CNAME").read_text(encoding="utf-8").strip() if (root / "site" / "CNAME").is_file() else "pyrtan.com"
+ directory = root / NAMES_DIR
+ claims, _, problems = read_directory(directory, domain, label=NAMES_DIR)
+ holders: dict[int, str] = {}
+ for stem, claim in claims.items():
+ where = f"{NAMES_DIR}/{stem}.json"
+ if not GITHUB_LOGIN.match(stem):
+ problems.append(Problem(where, "a name at pyrtan.com is a GitHub login"))
+ github = claim.get("github")
+ if not isinstance(github, int) or isinstance(github, bool) or github <= 0:
+ problems.append(Problem(where, "a claim at pyrtan.com carries \"github\": your numeric GitHub user id"))
+ continue
+ if github in holders:
+ problems.append(Problem(where, f"GitHub account {github} already holds {holders[github]}; one name per account"))
+ holders[github] = stem
+ if not changed:
+ return problems
+ base = base or {}
+ login = (author or "").lstrip("@").lower()
+ for c in changed:
+ if not c.startswith(f"{NAMES_DIR}/") or c.endswith("/README.md"):
+ continue
+ name = Path(c).name
+ stem = name[: -len(".id.json")] if name.endswith(".id.json") else name[: -len(".json")] if name.endswith(".json") else name
+ claim_path = f"{NAMES_DIR}/{stem}.json"
+ before = _github_of(base.get(claim_path))
+ if author_id is None:
+ problems.append(Problem(c, "the gate needs the pull request author's numeric id (--author-id) to judge a change to a name"))
+ continue
+ if before is not None:
+ if before != author_id:
+ problems.append(Problem(c, f"{stem} belongs to GitHub account {before}; only that account changes or removes it (a maintainer may merge over this)"))
+ elif stem in claims and claims[stem].get("github") != before:
+ problems.append(Problem(c, f"{stem} stays with GitHub account {before}; to move it, remove it and add your new login's name"))
+ continue
+ # A new name.
+ if stem not in claims:
+ continue # a stray file, already reported
+ if stem != login:
+ problems.append(Problem(c, f"a new name is your own GitHub login: {stem!r} is not {login or 'the author'}'s"))
+ if claims[stem].get("github") != author_id:
+ problems.append(Problem(c, f"the claim says github {claims[stem].get('github')}; the pull request's author is {author_id}"))
+ return problems
+
+
+def _github_of(raw: bytes | None) -> int | None:
+ if raw is None:
+ return None
+ try:
+ github = json.loads(raw.decode("utf-8")).get("github")
+ except (ValueError, AttributeError):
+ return None
+ return github if isinstance(github, int) and not isinstance(github, bool) else None
+
+
+def place(checkout: Path, claim: dict[str, Any], doc: dict[str, Any] | None) -> list[Path]:
+ """Write a claim (and the identity document) into ``checkout/site/names/``."""
+ directory = checkout / NAMES_DIR
+ if not (checkout / "site").is_dir():
+ raise IdentityError(f"{checkout}: no site/ directory; is this a checkout of github.com/b-i-t-8/pyrtan?")
+ domain = (checkout / "site" / "CNAME").read_text(encoding="utf-8").strip() if (checkout / "site" / "CNAME").is_file() else "pyrtan.com"
+ if claim["domain"] != domain:
+ raise IdentityError(f"this checkout serves names at {domain}; the claim is for {claim['name']}@{claim['domain']}")
+ directory.mkdir(parents=True, exist_ok=True)
+ written = [directory / f"{claim['name']}.json"]
+ written[0].write_text(json.dumps(claim, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
+ if doc is not None:
+ written.append(directory / f"{claim['name']}.id.json")
+ written[1].write_text(json.dumps(doc, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
+ return written
+
+
+def github_id(login: str, *, fetch: Fetch | None = None) -> int:
+ """A GitHub login's numeric user id, from GitHub's public API."""
+ url = f"https://api.github.com/users/{login}"
+ try:
+ status, body = (fetch or _fetch)(url)
+ except Exception as e:
+ raise IdentityError(f"{url}: {e}; pass --github YOUR-NUMERIC-ID") from None
+ if status != 200 or not isinstance(body, dict) or not isinstance(body.get("id"), int):
+ raise IdentityError(f"{url} answered {status}; pass --github YOUR-NUMERIC-ID")
+ return body["id"]
diff --git a/tests/test_identity.py b/tests/test_identity.py index 541c1b6..09e9469 100644 --- a/tests/test_identity.py +++ b/tests/test_identity.py
@@ -308,7 +308,7 @@ def test_cli_check_identifiers(tmp_path, capsys):
code, out, _ = run(capsys, "id", "check", f"{VECTOR}@hub.example") assert code == 0 and "well-formed address" in out and RFC8032[0][1] in out code, out, _ = run(capsys, "id", "check", "alice@pyrtan.com")
- assert code == 0 and "a name" in out
+ assert code == 0 and "well-formed name" in out and "pyrtan id resolve alice@pyrtan.com" in out
code, _, err = run(capsys, "id", "check", VECTOR[:-3] + "zab") assert code == 1 and "pyrtan id:" in err diff --git a/tests/test_names.py b/tests/test_names.py new file mode 100644 index 0000000..3f38ee7 --- /dev/null +++ b/tests/test_names.py
@@ -0,0 +1,270 @@
+import json
+import shutil
+import subprocess
+import sys
+
+import pytest
+
+pytest.importorskip("cryptography")
+
+from pyrtan import identity, names # noqa: E402
+from pyrtan.cli import main # noqa: E402
+from pyrtan.identity import IdentityError # noqa: E402
+from conftest import ROOT # noqa: E402
+
+
+@pytest.fixture
+def alice():
+ return identity.Key.generate()
+
+
+@pytest.fixture
+def bob():
+ return identity.Key.generate()
+
+
+def serving(files):
+ """A fetch that answers from {url: body}, 404 otherwise, and remembers what it was asked."""
+ asked = []
+
+ def fetch(url):
+ asked.append(url)
+ return (200, files[url]) if url in files else (404, None)
+ fetch.asked = asked
+ return fetch
+
+
+# ---------------------------------------------------------------- resolving
+
+
+def test_resolve(alice):
+ claim = identity.make_name(alice, "alice@example.org")
+ fetch = serving({"https://example.org/.well-known/pyrtan/name/alice.json": claim})
+ assert names.resolve("Alice@Example.org", fetch=fetch) == claim
+
+
+def test_resolve_refuses(alice, bob):
+ url = "https://example.org/.well-known/pyrtan/name/alice.json"
+ with pytest.raises(IdentityError, match="no claim"):
+ names.resolve("alice@example.org", fetch=serving({}))
+ with pytest.raises(IdentityError, match="not alice@example.org"):
+ names.resolve("alice@example.org", fetch=serving({url: identity.make_name(alice, "bob@example.org")}))
+ forged = {**identity.make_name(alice, "alice@example.org"), "id": bob.id}
+ with pytest.raises(IdentityError, match="signature"):
+ names.resolve("alice@example.org", fetch=serving({url: forged}))
+ with pytest.raises(IdentityError, match="answered 500"):
+ names.resolve("alice@example.org", fetch=lambda u: (500, None))
+ with pytest.raises(IdentityError, match="boom"):
+ names.resolve("alice@example.org", fetch=lambda u: (_ for _ in ()).throw(OSError("boom")))
+ with pytest.raises(IdentityError, match="Tor"):
+ names.resolve("alice@" + "a" * 56 + ".onion", fetch=serving({}))
+ with pytest.raises(IdentityError, match="not a name"):
+ names.resolve(alice.id, fetch=serving({}))
+
+
+def test_fetch_identity(alice, bob):
+ doc = identity.make_identity(alice, routes=["hub.example"])
+ url = names.identity_url(alice.id, "example.org")
+ assert url == f"https://example.org/.well-known/pyrtan/id/{alice.id[4:]}.json"
+ assert names.fetch_identity(alice.id, "example.org", fetch=serving({url: doc})) == doc
+ assert names.fetch_identity(bob.id, "example.org", fetch=serving({names.identity_url(bob.id, "example.org"): doc})) is None
+ assert names.fetch_identity(alice.id, "example.org", fetch=serving({})) is None
+
+
+def test_pins(tmp_path, alice, bob):
+ pins = names.Pins(tmp_path / "pins.json")
+ assert pins.compare("Alice@pyrtan.com", alice.id) == "new"
+ pins.pin("Alice@pyrtan.com", alice.id, now=100)
+ pins.pin("alice@pyrtan.com", alice.id, now=200)
+ assert pins.get("alice@pyrtan.com") == {"id": alice.id, "first": 100, "seen": 200}
+ assert pins.compare("alice@pyrtan.com", alice.id.upper()) == "same"
+ assert pins.compare("alice@pyrtan.com", bob.id) == "changed"
+ pins.save()
+ again = names.Pins(tmp_path / "pins.json")
+ assert again.get("alice@pyrtan.com")["first"] == 100
+ again.pin("alice@pyrtan.com", bob.id, now=300)
+ assert again.get("alice@pyrtan.com")["first"] == 300
+ assert again.forget("alice@pyrtan.com") and not again.forget("alice@pyrtan.com")
+ (tmp_path / "bad.json").write_text("[]")
+ with pytest.raises(IdentityError, match="pins file"):
+ names.Pins(tmp_path / "bad.json")
+
+
+# ---------------------------------------------------------------- serving a directory of names
+
+
+def test_build_wellknown(tmp_path, alice, bob):
+ d = tmp_path / "names"
+ d.mkdir()
+ doc = identity.make_identity(alice, routes=["hub.example"])
+ (d / "alice.json").write_text(json.dumps(identity.make_name(alice, "alice@example.org")))
+ (d / "alice.id.json").write_text(json.dumps(doc))
+ (d / "bob.json").write_text(json.dumps(identity.make_name(bob, "bob@elsewhere.org"))) # for another domain
+ (d / "carol.id.json").write_text(json.dumps(identity.make_identity(bob))) # no claim beside it
+ (d / "notes.txt").write_text("hi")
+ (d / "README.md").write_text("# names")
+ count, problems = names.build_wellknown(d, tmp_path / "out", "example.org")
+ assert count == 1
+ text = "\n".join(str(p) for p in problems)
+ assert "bob.json: the claim is for bob@elsewhere.org" in text
+ assert "carol.id.json: an identity document needs" in text
+ assert "notes.txt: only" in text
+ root = tmp_path / "out" / ".well-known" / "pyrtan"
+ assert json.loads((root / "name" / "alice.json").read_text())["id"] == alice.id
+ assert not (root / "name" / "bob.json").exists()
+ assert json.loads((root / "id" / f"{alice.id[4:]}.json").read_text()) == doc
+ index = json.loads((root / "names.json").read_text())
+ assert index["domain"] == "example.org" and list(index["names"]) == ["alice"]
+
+
+# ---------------------------------------------------------------- pyrtan.com's gate
+
+
+@pytest.fixture
+def repo(tmp_path):
+ (tmp_path / "site" / "names").mkdir(parents=True)
+ (tmp_path / "site" / "CNAME").write_text("pyrtan.com\n")
+ return tmp_path
+
+
+def put(repo, key, login, github, *, doc=True):
+ claim = identity.make_name(key, f"{login}@pyrtan.com", **({"github": github} if github is not None else {}))
+ (repo / "site" / "names" / f"{login}.json").write_text(json.dumps(claim))
+ if doc:
+ (repo / "site" / "names" / f"{login}.id.json").write_text(json.dumps(identity.make_identity(key)))
+ return claim
+
+
+def raw(claim):
+ return json.dumps(claim).encode()
+
+
+def messages(problems):
+ return "\n".join(str(p) for p in problems)
+
+
+def test_gate_the_whole_directory(repo, alice, bob):
+ put(repo, alice, "alice", 1)
+ assert names.gate(repo) == []
+ put(repo, bob, "bob", 1, doc=False)
+ assert "already holds" in messages(names.gate(repo))
+ put(repo, bob, "bob", None, doc=False)
+ assert "numeric GitHub user id" in messages(names.gate(repo))
+ (repo / "site" / "names" / "bob.json").unlink()
+ put(repo, bob, "b.b", 2, doc=False)
+ assert "is a GitHub login" in messages(names.gate(repo))
+
+
+def test_gate_a_new_name(repo, alice):
+ put(repo, alice, "alice", 1)
+ changed = ["site/names/alice.json", "site/names/alice.id.json"]
+ assert names.gate(repo, changed=changed, author="alice", author_id=1) == []
+ assert "is not mallory's" in messages(names.gate(repo, changed=changed, author="mallory", author_id=1))
+ assert "author is 2" in messages(names.gate(repo, changed=changed, author="alice", author_id=2))
+ assert "--author-id" in messages(names.gate(repo, changed=changed, author="alice"))
+
+
+def test_gate_a_change_or_removal(repo, alice, bob):
+ before = put(repo, alice, "alice", 1)
+ base = {"site/names/alice.json": raw(before)}
+ # The owner rotates the key.
+ put(repo, bob, "alice", 1)
+ assert names.gate(repo, changed=["site/names/alice.json"], author="alice", author_id=1, base=base) == []
+ # Someone who took the login after a rename cannot.
+ assert "belongs to GitHub account 1" in messages(names.gate(repo, changed=["site/names/alice.json"], author="alice", author_id=9, base=base))
+ assert "belongs to GitHub account 1" in messages(names.gate(repo, changed=["site/names/alice.id.json"], author="alice", author_id=9, base=base))
+ # Nor can the owner hand it to another account.
+ put(repo, alice, "alice", 7)
+ assert "stays with GitHub account 1" in messages(names.gate(repo, changed=["site/names/alice.json"], author="alice", author_id=1, base=base))
+ # Removal.
+ for f in ("alice.json", "alice.id.json"):
+ (repo / "site" / "names" / f).unlink()
+ assert names.gate(repo, changed=["site/names/alice.json"], author="alice", author_id=1, base=base) == []
+ assert "belongs to" in messages(names.gate(repo, changed=["site/names/alice.json"], author="x", author_id=2, base=base))
+
+
+def test_site_gate_reads_owners_from_git(tmp_path, alice, bob):
+ """site/gate.py, as the catalog workflow runs it, against a real base branch."""
+ repo = tmp_path / "repo"
+ (repo / "site" / "names").mkdir(parents=True)
+ (repo / "catalog").mkdir()
+ shutil.copy(ROOT / "site" / "gate.py", repo / "site" / "gate.py")
+ (repo / "site" / "CNAME").write_text("pyrtan.com\n")
+ put(repo, alice, "alice", 1)
+ git = lambda *a: subprocess.run(["git", "-c", "user.name=t", "-c", "user.email=t@t", *a], cwd=repo, check=True, capture_output=True)
+ git("init", "-q", "-b", "main")
+ git("add", ".")
+ git("commit", "-q", "-m", "base")
+ git("checkout", "-q", "-b", "pr")
+ put(repo, bob, "alice", 1)
+ git("commit", "-q", "-am", "take alice")
+ gate = lambda *a: subprocess.run([sys.executable, "site/gate.py", "--base", "main", *a], cwd=repo, capture_output=True, text=True)
+ run = gate("--author", "alice", "--author-id", "2")
+ assert run.returncode == 1 and "belongs to GitHub account 1" in run.stdout
+ run = gate("--author", "alice", "--author-id", "1")
+ assert run.returncode == 0, run.stdout
+ # Moving someone's name out of site/names/ is a removal of it, which git would call a rename.
+ git("checkout", "-q", "main")
+ git("checkout", "-q", "-b", "move")
+ git("mv", "site/names/alice.json", "site/alice.json")
+ git("commit", "-q", "-m", "move")
+ run = gate("--author", "mallory", "--author-id", "9")
+ assert run.returncode == 1 and "site/names/alice.json: alice belongs to GitHub account 1" in run.stdout
+
+
+# ---------------------------------------------------------------- pyrtan id
+
+
+def run(capsys, *argv):
+ code = main(list(argv))
+ out = capsys.readouterr()
+ return code, out.out, out.err
+
+
+def test_cli_resolve_pins_and_repin(tmp_path, capsys, monkeypatch, alice, bob):
+ d = str(tmp_path / "me")
+ url = "https://example.org/.well-known/pyrtan/name/alice.json"
+ doc = identity.make_identity(alice, routes=["hub.example"])
+ files = {url: identity.make_name(alice, "alice@example.org"), names.identity_url(alice.id, "example.org"): doc}
+ monkeypatch.setattr(names, "_fetch", serving(files))
+ code, out, _ = run(capsys, "id", "resolve", "alice@example.org", "--dir", d)
+ assert code == 0 and f"alice@example.org is {alice.id} (pinned now)" in out and "routes: hub.example" in out
+ code, out, _ = run(capsys, "id", "resolve", "alice@example.org", "--dir", d)
+ assert code == 0 and "as pinned since" in out
+ files[url] = identity.make_name(bob, "alice@example.org")
+ code, _, err = run(capsys, "id", "resolve", "alice@example.org", "--dir", d)
+ assert code == 1 and bob.id in err and alice.id in err and "--repin" in err
+ code, out, _ = run(capsys, "id", "resolve", "alice@example.org", "--repin", "--json", "--dir", d)
+ assert code == 0 and json.loads(out)["claim"]["id"] == bob.id
+ code, out, _ = run(capsys, "id", "pins", "--dir", d)
+ assert "alice@example.org" in out and bob.id in out
+ code, out, _ = run(capsys, "id", "pins", "--forget", "alice@example.org", "--dir", d)
+ assert code == 0 and "forgot" in out
+ code, _, err = run(capsys, "id", "pins", "--forget", "alice@example.org", "--dir", d)
+ assert code == 1
+
+
+def test_cli_name_into_a_checkout(tmp_path, capsys, monkeypatch, repo):
+ d = str(tmp_path / "me")
+ run(capsys, "id", "new", "--dir", d)
+ monkeypatch.setattr(names, "_fetch", serving({"https://api.github.com/users/alice": {"login": "alice", "id": 4242}}))
+ code, out, err = run(capsys, "id", "name", "alice@pyrtan.com", "--into", str(repo), "--dir", d)
+ assert code == 0, err
+ assert "user 4242" in err and "open a pull request from the GitHub account alice" in out
+ claim = json.loads((repo / "site" / "names" / "alice.json").read_text())
+ assert claim["github"] == 4242 and identity.check_name(claim, name="alice@pyrtan.com") is None
+ assert (repo / "site" / "names" / "alice.id.json").is_file()
+ assert names.gate(repo, changed=["site/names/alice.json"], author="alice", author_id=4242) == []
+ code, _, err = run(capsys, "id", "name", "alice@example.org", "--github", "1", "--into", str(repo), "--dir", d)
+ assert code == 1 and "serves names at pyrtan.com" in err
+
+
+def test_cli_wellknown(tmp_path, capsys, alice):
+ d = tmp_path / "names"
+ d.mkdir()
+ (d / "alice.json").write_text(json.dumps(identity.make_name(alice, "alice@example.org")))
+ code, out, _ = run(capsys, "id", "wellknown", str(d), "--domain", "Example.org", "--out", str(tmp_path / "site"))
+ assert code == 0 and "wrote 1 name(s) for example.org" in out
+ (d / "bad.json").write_text("{}")
+ code, _, err = run(capsys, "id", "wellknown", str(d), "--domain", "example.org", "--out", str(tmp_path / "site"))
+ assert code == 1 and "bad.json" in err