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 asksfor, 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 lateranswer 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 ownhub. 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 --quietpython3 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 hasowners. 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: E402def 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+ continueif 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 0if 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 outcode, _, 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