Gateway
Only an account can write to its own repo, so a collaborator cannot push
to the owner’s account directly. A gateway is a knotless-serve with
--public-url that the owner has delegated to. It checks who is asking,
then writes as the owner.
A gateway is identified as the did:web of its host (a port’s : as
%3A), and serves its own DID document at /.well-known/did.json, with a
#knotless_gateway service, an #atproto_labeler one and a
#knotless_appview one, all at its public URL. --public-url is http(s)://host[:port], with no path.
Delegation
Section titled “Delegation”The gateway is an OAuth client of its own, a confidential one. Its client
ID is /oauth-client-metadata.json on its public URL. It authenticates to
authorization servers with a P-256 key kept in its config directory
(oauth-client-key) and published in that metadata.
The metadata names the redirect <public-url>/oauth/callback,
private_key_jwt with ES256, DPoP-bound tokens, the grant types
authorization_code and refresh_token, client_name knotless gateway and application_type web.
knotless delegatecallsdev.knotless.putDelegationwith a service token.- The gateway pushes an authorization request for the owner at their
PDS, and answers
{url, state}. The owner approves it in a browser. A delegation still waiting after 15 minutes is dropped when the next one starts. - The browser comes back to the gateway’s
/oauth/callback. The gateway keeps the session, and refreshes it as that client. The page saysThe gateway now writes for you. You can close this tab., orThe gateway was not delegated to: <error>; astatenobody is waiting on is 400no delegation is waiting for this. - Meanwhile
knotless delegatecallsdev.knotless.getDelegation?state=, which waits up to 25 seconds. It answers{done: false}to ask again,{done: true}once approved, 403 if the authorization failed, and 404no such delegationfor a state the caller did not start.
The grant is narrow: the four record types a push writes (repo, ref,
claim and pack), blobs, and those collections in the owner’s own
spaces; and what an organization needs: its org
record in public, its orgMember records in public and in its spaces,
its own space for members, and
making and changing the members of its spaces, never deleting one. Each
space type is named, a private repository’s and an organization’s
(Scopes), so the grant reaches no other app’s spaces.
redirect is left out, since a gateway never renames a repository. The
scope is atproto repo:dev.knotless.repo repo:dev.knotless.ref repo:dev.knotless.claim repo:dev.knotless.pack repo:dev.knotless.org repo:dev.knotless.orgMember blob:*/* space:dev.knotless.space?authority=self&collection=dev.knotless.repo&collection=dev.knotless.ref&collection=dev.knotless.claim&collection=dev.knotless.pack&collection=dev.knotless.orgMember&manage=create&manage=update space:dev.knotless.orgSpace?authority=self&collection=dev.knotless.orgMember&collection=dev.knotless.orgAcceptance&manage=create&manage=update.
A grant from before names every space type (space:*), which still
covers all of this; the pages ask for it again all the same, as for any
older grant.
A delegation made before organizations lacks some of it: the
organization’s own space is refused rather than taken for a PDS without
spaces, and knotless delegate again grants the rest. The gateway answers a write
refused for want of scope 403, saying the grant is older than what it
asks for now, and keeps the scope each delegation asked for, so the pages
say so before any write is refused (serve.md). A confidential client’s session
lasts up to two years if it is used at least every three months. The owner
revokes it from their PDS’s account settings.
The pages’ Access tab delegates the same way, for the account logged in
to them, to the server’s own gateway: dev.knotless.delegateWrites makes
the first call with the session the server keeps for the account, and
the page shows the approval page in a new tab while
dev.knotless.awaitDelegation makes the last, until it is done. The
gateway’s grant is its own, apart from the pages’ session.
Sharing a repository for writing records the gateway in its repo record.
A runner landing the merge queue of a repository that records none goes
through the appview’s.
An operator’s bots, knotless-run and knotless-triage, log in through
the gateway too. An app password mints no delegation tokens, so a bot
holding one cannot read private repositories, and a loopback OAuth login
lasts only two weeks. Given --bots <dir>, the gateway is a second
confidential client, /oauth/bot-client-metadata.json on its public URL,
with the same key, the redirect <public-url>/oauth/bot-callback,
client_name knotless bots, and all of knotless’s
scope, private repositories included.
- The operator opens
<public-url>/oauth/bot?account=<handle or did>in a browser. An accountKNOTLESS_BOT_DIDSdoes not name is 403<did> is not one of this server's bots. The gateway pushes an authorization request for it at its PDS and redirects there (303). - The operator approves, signed in as that account.
- The browser comes back to
/oauth/bot-callback. The gateway keeps the session in<dir>/sessions/, and the client key beside it as<dir>/oauth-client-key, and says<did> is logged in for this server's bots. You can close this tab.A refused or failed login is 403the bot was not logged in: <error>, and astatenobody is waiting on 400no bot login is waiting for this. A login still waiting after 15 minutes is dropped when the next one starts.
The bots take <dir> as their config directory (KNOTLESS_CONFIG_DIR),
and act and refresh as that client from there: the session lasts up to
two years if used at least every three months. A stored session comes
before KNOTLESS_PASSWORD, which only logs in when there is none, or
when the session can no longer be refreshed. Without --bots, these
routes are 404 this server keeps no bots' sessions.
Authentication
Section titled “Authentication”On a server without --public-url, every gateway route is 404 this server is not a gateway.
Every call to the gateway carries a service token from
com.atproto.server.getServiceAuth, with the gateway as audience and the
method as lxm. A call without one is 401 a service token is required, and one with a bad or expired token 401. The gateway checks the
token against the signing key in the issuer’s DID document (Multikey, or
the legacy secp256k1 type that PLC still returns), read again when the
copy it read in the last five minutes turns the signature away, as once
the key rotated. A server that is its own gateway signs the tokens its
pages send it on a logged-in account’s behalf itself, with a key kept in
its config directory, rather than ask the account’s PDS: it holds that
account’s session already. Such a token says so in its header, lasts a
minute, and passes only on servers sharing that key. Then it checks, in
order, that the owner has delegated to it, else 403 <owner> has not delegated writes to this gateway; that the repository exists, else 404
<url>: no such repository; and that the issuer is the owner or a
collaborator, else 403 <caller> is not a collaborator on <url>. A runner the
repository names may push too, but only to land its merge queue
(Runners landing a queue); it may not commit.
dev.knotless.pushRefs takes a JSON header line, naming the repository and
the ref updates, followed by the pack, as application/x-knotless-push, at
most 512 MiB. Each update’s src is a 40-character lowercase hex hash, or
absent for a deletion, and its dst starts refs/; else the push is 400
bad update of <dst>.
Pushes to one repository take turns, under a lock in the process. The
gateway syncs its mirror, indexes the pack into it, and refuses a tip the
pack does not bring (400 the pack does not hold <sha>). It then pushes
as the owner, with the same
compare-and-swap as any push,
naming the caller, from its verified token, as the pusher of each claim and
ref it writes, unless the caller is the owner. It answers
results, a {name, error?} per ref: each update lands or is refused on
its own, and the client fails on the first one refused.
Runners landing a queue
Section titled “Runners landing a queue”A runner the repository names lands its merge queue through the gateway
when no runner it names may push (merge-queue.md).
The gateway takes a push from it only if that push is such a landing,
else 403 <caller> runs <url>'s checks, and lands its merge queue here, nothing else: <why>. It checks, against the queue as the records say:
- one branch moves, without force, and the push deletes branches at most
(
a runner lands a batch: one branch moved forward); - the branch exists (
there is no branch <name> to land on); - each commit from the branch’s tip to the new one has one parent, the one
before (
<commit> is not one commit on the one before); - each is the tree a pull queued into that branch makes merged onto the
commit before it, as the runner squashes it, no pull twice
(
<commit> is no pull queued into <branch> squashed), and there is at least one (<commit> is no batch on <branch>); - each branch deleted is the head of one of those pulls, in this
repository (
<name> is not the branch of a pull landed).
So a runner, or whoever holds its account, can land what a maintainer queued, before or without its checks passing, and nothing more.
Commit
Section titled “Commit”dev.knotless.createCommit takes path changes and an optional base, the
commit they were made against. A change writes a file’s data, deletes
it, or, with edits, replaces text in it: each old, which must be in
the file exactly once, by its new, in the file as the commit’s parent
holds it. An agent changing a line of a long file sends that line, not the
file. One with no branch or message is 400 a commit needs a branch and a message. Whether the caller may write is
checked before the commit is queued, so a batch holds only commits that
may land.
Commits to one branch queue up, per owner, repository and branch.
Whichever request takes the branch’s turn, a lock of its own, drains the
queue, builds the commits as a chain on the tip, and lands them as one
forced ref update against the generation it read. Its pusher is the
batch’s caller when every commit in it came from one, and no one otherwise. If the branch moved, the
chain is rebuilt on the new tip, up to eight times with no pause between,
and then every commit in it fails 409 <ref>: <reason>. A change to a
path that also changed since its base is a conflict (409) for that
commit alone; the rest land. Edits are made in the file as the chain
leaves it, so one rebuilt on a new tip keeps what landed meanwhile; an
edit whose old is no longer there once is a 409 for its commit alone,
edit <n> of <path>: …. A build error fails the whole batch: 400 for
bad input, else 500.
Each answer is {sha, parent, changed, batched}, where batched is how
many commits went out in the write that carried it. knotless commit
names the committer as the author when none is given: their handle and
<their did>@knotless.invalid. A request that names no author is
authored with the name knotless and the owner’s
<did>@knotless.invalid. The client
reports attempts as 1 for a commit through a gateway, as it pushed once.
This way many agents can commit to one branch without each losing races: the gateway turns many writers into one.
Protected branches
Section titled “Protected branches”A rules entry on the repository record protects a branch. The gateway
refuses a push to it that:
- deletes the branch:
protected branch: it is not deleted; - moves it anywhere but forward:
protected branch: it only moves forward; - when the rule is linear (its
historyis anything butmerges, or absent), brings it a merge commit:protected branch: no merge commits; squash or rebase. That is any commit with two parents between the branch’s tip and the new one (or in all of its history, for a branch that does not exist yet), even if the commit is already on other branches; - when the rule says
queue, moves it at all, from anyone but the owner and the runner that lands the merge queue:protected branch: pulls land on it only through the merge queue; - when the rule asks for reviews,
moves it, from anyone but the owner and the queue’s runner, other than
by merging an open pull into it:
protected branch: changes land on it only through reviewed pulls; push to another branch and open a pull; - by merging a pull that may not merge yet, its reviews or its checks
short, as
knotless merge --checkwould say:protected branch: <why>.
A merge of a pull is recognized as knotless merge builds one: the new
tip is the pull’s head, a fast-forward, or a commit on the branch’s tip
(and on the head, for a merge commit) of the tree the two merge to. The
gateway then reads the pull’s reviews and checks as merge --check does,
as the repository stands when the push arrives. In a repository that names
runners, the same holds a collaborator’s push to any branch, protected or
not, whose commit’s message ends with a pull’s AT-URI, as every merge’s
does (merge gate: <why> on an unprotected branch); a commit that says no
such thing passes, as the collaborator could push it anyway.
Commits through createCommit build on the tip with one parent, so they
pass the rest; into a queue branch they are refused from a collaborator
before they are batched, 403 <branch> takes pulls only through the merge queue: commit to another branch and open a pull, and into a branch that
asks for reviews, 403 <branch> takes changes only through reviewed pulls: commit to another branch and open a pull. Pulls merge into a linear branch
squashed.
A history value this version does not know reads as linear, so a rule
that a newer client wrote is held at least as strictly. A client rewriting
the record keeps the fields it does not know. A field of a rule’s reviews that
this version does not know blocks merges into the branch, here as in the
client.
The rules hold whoever pushes through the gateway, the owner too, but for
queue, which the owner passes. The owner writes their own records
without any gateway, so the rules bind the owner only as far as their own
client keeps to them.
Organizations
Section titled “Organizations”Any account can be an organization: its members push to each of its
repositories, as collaborators named on each would. Who they are is
looked up where a repository is read, never copied into its record. The
organization’s public dev.knotless.org record says it is one. A
dev.knotless.orgMember record for each account it invited, keyed by
that account’s DID, gives their roles, when they were invited, when the
invitation lapses (expiresAt, 7 days on), and once they are in,
joinedAt. A member’s dev.knotless.orgAcceptance record, keyed by the
organization’s DID, says they joined. One record per member, as Bluesky
keeps one list item per account, means a change touches only that
member’s record, and each has a URI of its own to point at. Membership
has two sides in as many accounts, so it is looked up in the appview’s
index of both (dev.knotless.getOrg), not member by member. The gateway
the organization delegated to (named in its dev.knotless.org record’s
gateway) makes every change, as the organization, even one the
organization asks for itself:
- POST
dev.knotless.putOrgMember What it does invites
member, or changes theirroleLexicon
The invitation is kept in the organization's space where its PDS has spaces, else in its public records. Invited again, an invitation that lapsed starts over.
Input
orgstring (did)The organization. memberstring (did)The account to invite. rolestring, at most 64 bytes,admin,member, optionalTheir role; member when absent. Output
orgstring, at most 300 bytesThe organization's handle, or its DID when none resolves back to it. whostring, at most 300 bytesThe invitee's handle, or their DID when none resolves back to it. rolestring, at most 64 bytes,admin,memberTheir role now. wasstring, at most 64 bytes,invited,lapsed,public,hidden, optionalWhere they stood before: invited, lapsed (invited over 7 days ago), or a public or hidden member. Absent when they were not invited. hadstring, at most 64 bytes,admin,member, optionalThe role they had before, if they had one. firstbooleanWhether this made the account an organization. privatebooleanWhether the invitation is in the organization's space, which only it and its members read; else in its public records. - POST
dev.knotless.removeOrgMember What it does takes
memberout, or withdraws an invitationLexicon
Answers where the account stood before: nothing changes for an account the organization did not invite.
Input
orgstring (did)The organization. memberstring (did)The account to take out. Output
removed- POST
dev.knotless.joinOrg What it does takes them in, once they wrote their acceptance record and while the invitation stands (7 days); the
visibilitythey ask for says which record counts while they move itLexicon
Refused when the organization did not invite the caller, the caller keeps no acceptance record naming it, or the invitation lapsed.
Input
orgstring (did)The organization. visibilitystring, at most 64 bytes,public,hidden, optionalWhich of the caller's acceptance records counts, should they keep one in public and one in the organization's space, as they do moving it: a hidden one, if asked for; else a public one first. Output
orgstring, at most 300 bytesThe organization's handle, or its DID when none resolves back to it. rolestring, at most 64 bytes,admin,memberThe caller's role in it. visibilitystring, at most 64 bytes,public,hiddenWho sees the membership: anyone, or the organization's members. - POST
dev.knotless.leaveOrg What it does takes them out of its spaces, after they deleted their acceptance record, or withdraws their invitation
Lexicon
Answers where the caller stood before.
Input
orgstring (did)The organization. Output
removeOrgMember#removed- POST
dev.knotless.createOrgRepo What it does makes a repository as the organization, as
knotless createdoesLexicon
Makes the repository as
knotless createdoes: public unless asked for private, naming the runners given or those the gateway's appview offers, with no branches until the first push.Input
orgstring (did)The organization. namestring, at most 100 bytesThe repository's name, its record key. privateboolean, optionalWhether to make it private: a space of its own on the organization's PDS, which only its members read. Public when absent. descriptionstring, at most 3000 bytes, optionalWhat the repository is, shown under its name. defaultBranchstring, at most 255 bytes, optionalThe branch its HEAD names, without refs/heads/: main when absent. runnersarray of account, at most 20, optionalThe accounts that run its workflows: those the gateway's appview offers when absent. Output
runnersarray of accountIts runners: a private one's can read it. uninvitedstring, at most 3000 bytes, optionalWhy a private one's runners were not told of it, when they were not. membersarray of accountThe members of the organization who may push to it: on a public one, those anyone may see.
Each takes a service token for its method. The gateway makes one change
to an organization at a time, and writes what it changed into its index
before it answers, so a member pushes the moment they are taken in. A
change goes by the organization’s own records, read again for it: a
space’s records take no compare and swap, so changes are kept from
crossing by making them one at a time, and each record is written over
only the version read, so a change made elsewhere meanwhile is refused
rather than lost. Taking someone in stamps
joinedAt on their record: a acceptance record counts only after that, so
an invitation that lapsed cannot be joined by writing the record alone.
Leaving ends the membership as the member deletes their record, before
the gateway is asked: from when the index hears of it, they push to
nothing and no reader inside the organization counts them. If the gateway
cannot be reached, knotless org leave warns that they may still read
its private repositories, until it takes them out.
The pages make the same changes, as the account logged in to them, through
the same code as knotless org: the appview asks the organization’s
gateway with the session it keeps for that account
(Web data). An account that delegated to
the appview’s gateway from the command line begins an organization from
the pages through it.
Each orgMember record is in one place. Invitations and hidden members
are kept in the organization’s space (type dev.knotless.orgSpace, key
self), which only it and its members read; an invitee may write their
acceptance record there but read nothing else. Those who joined with a
public acceptance record have theirs in its public records, where anyone
may see them. Moving between the two writes the new record before taking
out the old, and while both are there the public one counts. A PDS
without spaces keeps everything in public, and has no hidden members.
A push from a member, hidden or not, passes: the gateway keeps an index
of the organization’s space, synced with the organization’s own session.
Everyone else reads the roster as they can: public members on a public
repository, so a hidden member’s merge, close or label there would count
for no reader, and knotless refuses them upfront. In each private
repository’s space the organization keeps an orgMember record for
each member who pushes to it, written as it adds them to that space, which whoever reads the repository reads, so its collaborators and
runners count hidden members too. A reader inside the organization counts
only those of them still members; anyone else, every one it names. A member who was
shared a private repository on their own (shared on that record, or a
collaborator) stays in its space when they leave.
The gateway holds an OAuth session for the owner, so it is trusted with
writing knotless records to the owner’s account: run your own, or use one
you trust. Every reader still checks what it writes to a repository with
index-pack. See Integrity and trust.