A knot backed by enroute.sh forked from anirudh.fi/enroute-knot
README.md

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 at refs/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 under refs/cobs/* in the same repository. Its HEAD names 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. The org.tangled.git.* reads are served from it.
  • knot.git: the meta repository for the knot's did:web, holding the knot's own chain (member and block grants) at refs/atproto/commit, the firehose seq floor at refs/atproto/seq, deletion tombstones at refs/atproto/deleted, genesis operations PLC hasn't confirmed at refs/atproto/plc/pending, and, as knot2, the spindle outbox at knot.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 a did reserved with reserveKey (whose PLC document must already name the knot's key and service), or mints a did:plc whose 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 a source (#sourceTangled repo DID or #sourceExternal https URL) the import worker fetches it and pushes it into Enroute; GET /xrpc/org.tangled.repo.getImportStatus reports 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) and deleteBranch (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's HEAD after 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, admin and --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 take swapRecord; a stale one is a 409 InvalidSwap. 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.member and org.tangled.knot.block (repo = the knot's did:web, token for the knot, admins only) and org.tangled.graph.collaborator (owner only).
    • org.tangled.repo.manifest/self edits (owner only; a new name moves the repository's path).
  • POST /xrpc/org.tangled.review.submitPatch and GET /xrpc/org.tangled.repo.listRecordVersions, as knot2.
  • POST /xrpc/com.atproto.repo.uploadBlob (token for the repo DID) and GET /xrpc/com.atproto.sync.{getBlob,listBlobs}: attachments.
  • Every grant, manifest, contributed record, and refs change is a #commit on 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 (capabilities tangled1.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 for org.tangled.git.receivePack. After a push, the knot rewrites the repository's refs record from the previous one and the push's commands, queues an org.tangled.event.push per public ref for the spindle the manifest names (delivered by org.tangled.event.notifyEvent under the knot's service auth, in order per repository, with knot2's backoff), and publishes a signed org.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.