# enroute-knot A Tangled knot that keeps its git in [Enroute](https://enroute.sh). 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 ```sh 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:`. Tokens are addressed to it, and repositories' DID documents point at `https://`. | | `--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/.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/.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`](skills/push-with-goat/SKILL.md) walks through it, and is written so you can point a coding agent at it. ## Test ```sh 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.