enroute-knot #
A Tangled knot that keeps its git in Enroute. The knot
passes git smart HTTP through to Enroute and answers Enroute's signed hooks
with Tangled's identity and access rules. Like knot2, it is also the atproto
PDS for every repository it hosts: each repository's DID names the knot as its
#atproto_pds, and the knot serves that DID's signed repository, with its
org.tangled.repo.manifest and org.tangled.git.refs records, and a firehose
that relays and Tangled's indexer follow.
The knot is knot2/crates/knot-enroute. The other crates are the knot2 crates
it builds on, copied from Tangled's core monorepo with their paths unchanged:
knot-record and knot-events at 931a910, the rest at 8bebbd6 (identical
at 931a910).
Run #
knot-enroute \
--knot-did did:web:knot.example.com \
--data-dir /var/lib/knot-enroute \
--knot-key /var/lib/knot-enroute/knot.key \
--hooks-url http://127.0.0.1:5556/api/enroute/hooks \
--hook-public-key /etc/enroute/hooks-signing-key.pub.pem \
--enroute-git http://127.0.0.1:8080 \
--enroute-api http://127.0.0.1:50051 \
--admin did:plc:you
| Flag | Default | |
|---|---|---|
--listen |
0.0.0.0:5555 |
Where git clients, browsers, and relays reach the knot. |
--hooks-listen |
127.0.0.1:5556 |
Where Enroute calls the hooks. Keep it private. |
--hooks-url |
Enroute's hooks.endpoint_url, byte for byte. |
|
--hook-public-key |
Enroute's hook signing key, as an SPKI PEM. | |
--enroute-git |
http://127.0.0.1:8080 |
Enroute's git listener. The read mirrors fetch from it too, so git must be on the knot's PATH. |
--enroute-api |
http://127.0.0.1:50051 |
Enroute's gRPC API, where the knot makes and removes repositories, lists refs, and deletes branches. Keep it private. |
--knot-did |
did:web:<host>. Tokens are addressed to it, and repositories' DID documents point at https://<host>. |
|
--plc-directory |
https://plc.directory |
Where repository DIDs are minted. |
--data-dir |
The knot's own state; see below. Made on first run. | |
--knot-key |
The knot's secp256k1 key, 64 hex digits; made on first run. It signs every repository's PLC genesis, every chain commit, and push events. | |
--admin |
knot2's server.admins: may create repositories, delete any (forcing past the owner's declaration), and grant or revoke membership and blocks. Repeat for more. |
|
--admission |
closed |
knot2's server.admission: closed lets admins and members create repositories, open anyone not blocked. Members are granted on the knot's chain, not by flag. |
--crawler / KNOT_CRAWLERS |
knot2's list | Relays asked to crawl the knot at startup, comma-separated in the env. --crawler '' asks none. |
--trusted-proxy-header / KNOT_XRPC_TRUSTED_PROXY_HEADER |
The header a reverse proxy names the client in, so firehose subscribers are capped per client (8) rather than per proxy. | |
--trusted-proxy / KNOT_XRPC_TRUSTED_PROXIES |
Addresses or CIDR blocks whose header is honored. None means any peer's. | |
--legacy-admin-secret / KNOT_LEGACY_ADMIN_SECRET |
knot2's legacy admin secret: Basic auth as admin with it may call createInviteCode, which isn't served without it. |
|
--lfs-max-object-bytes / KNOT_LFS_MAX_OBJECT_BYTES |
5368709120 |
knot2's lfs.max_object_bytes: the largest attachment stored. |
--lfs-free-space-floor-bytes / KNOT_LFS_FREE_SPACE_FLOOR_BYTES |
1073741824 |
knot2's lfs.free_space_floor_bytes: uploads are refused (507) below this much free disk. |
--social-repo-budget-bytes / KNOT_SOCIAL_REPO_BUDGET_BYTES |
1073741824 |
knot2's social.repo_budget_bytes: how many bytes of contributed records and attachments one repository may hold. |
Data dir #
Git objects stay in Enroute. The data dir holds what knot2 keeps beside them:
chains/<repoDid>.git: a bare repository per hosted repo DID, holding that DID's signed atproto chain atrefs/atproto/commit(signed commits, MST blocks, and each commit's firehose frame): the refs record, the manifest, collaborator grants, and contributed records. Contributed records (tickets, comments, reactions, labels, patches, artifacts) are kept, as in knot2, as collaborative objects underrefs/cobs/*in the same repository. ItsHEADnames the repository's default branch.mirrors/<repoDid>.git: a bare mirror of the repository's branches and tags, fetched from Enroute when a read needs it after the repository changed. Theorg.tangled.git.*reads are served from it.knot.git: the meta repository for the knot'sdid:web, holding the knot's own chain (member and block grants) atrefs/atproto/commit, the firehose seq floor atrefs/atproto/seq, deletion tombstones atrefs/atproto/deleted, genesis operations PLC hasn't confirmed atrefs/atproto/plc/pending, and, as knot2, the spindle outbox atknot.git/spindle-outbox.json.lfs/: knot2's blob store: attachment blobs per repo DID; a repository's prefix is removed when it's deleted. git-lfs itself isn't served.
At startup the knot rebuilds which repositories it hosts, who owns them, and
the grant lists from the chains; starts the firehose past every seq already
issued; asks the crawlers to crawl it; starts knot2's PLC sweeper, which
posts queued geneses (or an update signed with the knot's rotation key) until
each hosted did:plc names the knot's key and service; and starts the
spindle outbox's delivery loop and the import worker.
Surface #
POST /xrpc/org.tangled.repo.createRepo,deleteRepo,reserveKey: as knot2, with a single-use service token addressed to the knot. createRepo takes adidreserved withreserveKey(whose PLC document must already name the knot's key and service), or mints adid:plcwhose genesis is queued and posted, with the PLC sweeper retrying a failed post. It makes the repository in Enroute with its default branch, then writes its chain. With asource(#sourceTangledrepo DID or#sourceExternalhttps URL) the import worker fetches it and pushes it into Enroute;GET /xrpc/org.tangled.repo.getImportStatusreports progress.POST /xrpc/org.tangled.repo.mintToken(token for the repo DID): a repo token signed with the knot's key, for anyone who may push.POST /xrpc/org.tangled.git.setDefaultBranch(token for the repo DID, owner only) anddeleteBranch(token for the knot, owner or collaborator): the refs record is rewritten from Enroute's refs. Enroute v0.4.0 can't move a repository'sHEADafter creation, so a new default reaches the refs record and the reads but not what a clone checks out.POST /xrpc/org.tangled.git.receivePack: knot2's push auth probe.POST /xrpc/org.tangled.temp.server.redeemInviteCode(token for the knot): spends a code and makes the caller a member, a grant on the knot's chain.createInviteCode(Basic,adminand--legacy-admin-secret, served only when that's set) mints one:{code, url, expiresAt}, single-use, two days, kept in memory as knot2 keeps them, so a restart forgets them.POST /xrpc/com.atproto.repo.createRecord/putRecord/deleteRecord, as knot2:- contributed records on a repository (repo and token = the repo DID):
org.tangled.track.ticket,org.tangled.feed.comment,org.tangled.feed.reaction,org.tangled.label.definition,org.tangled.repo.artifact; the knot mints their record keys (a label definition's is its slug). Edits and deletes takeswapRecord; a stale one is a 409InvalidSwap. Who may write follows the manifest's contribution policy and knot2's authorities (authors and maintainers). - grants, rkey = the subject's DID:
org.tangled.knot.memberandorg.tangled.knot.block(repo = the knot'sdid:web, token for the knot, admins only) andorg.tangled.graph.collaborator(owner only). org.tangled.repo.manifest/selfedits (owner only; a new name moves the repository's path).
- contributed records on a repository (repo and token = the repo DID):
POST /xrpc/org.tangled.review.submitPatchandGET /xrpc/org.tangled.repo.listRecordVersions, as knot2.POST /xrpc/com.atproto.repo.uploadBlob(token for the repo DID) andGET /xrpc/com.atproto.sync.{getBlob,listBlobs}: attachments.- Every grant, manifest, contributed record, and refs change is a
#commiton the firehose. GET /xrpc/org.tangled.git.{getTree,listCommits,listBranches,getBranch,listTags,getTag,getBlob,getEntry,getBlame,getDiff,getArchive,listLanguages,listRefs}, as knot2, from the read mirror.- The atproto PDS surface for every hosted repo DID and the knot's
did:web:com.atproto.sync.{getRepo,getLatestCommit,getRecord,getBlocks,listRepos,getRepoStatus,subscribeRepos},com.atproto.repo.{getRecord,listRecords,describeRepo},com.atproto.server.describeServer,com.atproto.identity.resolveHandle. GET /xrpc/org.tangled.knot.describeKnot(capabilitiestangled1.0,knot-acl,repo-did-input,atproto-firehose,knot-repo-token-mint,knot-social-writes),GET /xrpc/_health,GET /.well-known/did.json.- Everything else is git, passed through to Enroute, at
/{repoDid}or knot2's/{owner}/{name}(owner DID or handle). Anyone may read a hosted repository; its owner and collaborators, not blocked, may push, with a service token fororg.tangled.git.receivePack. After a push, the knot rewrites the repository's refs record from the previous one and the push's commands, queues anorg.tangled.event.pushper public ref for the spindle the manifest names (delivered byorg.tangled.event.notifyEventunder the knot's service auth, in order per repository, with knot2's backoff), and publishes a signedorg.tangled.git.pushEvent.
Every route answers CORS from any origin.
Not served, unlike knot2: git-lfs, and git over SSH.
Push #
Push over HTTPS with a service token from your PDS, minted by Bluesky's
goat as a git credential helper. skills/push-with-goat
walks through it, and is written so you can point a coding agent at it.
Test #
cargo test -p knot-enroute
The end-to-end test runs only when ENROUTE_BIN names an enroute binary
(v0.4.0). It starts enroute dev, creates a repository on the knot, then
pushes and clones through the knot.