johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
HTTP and XRPC API
What knotless-serve answers over HTTP. Which flags turn each part on is
in Commands; how the parts work is in
knotless-serve.
An error answers its status with a plain-text message, but under /xrpc/,
where it is JSON as XRPC has one: {"error", "message"}, error naming
its kind (InvalidRequest, AuthenticationRequired, Forbidden,
NotFound, Conflict, UpstreamFailure, InternalServerError) unless an entry
names another. In a path, <authority> is a handle or DID and <repo> a
repository's name; a trailing .git on the name is dropped.
| Status | When |
|---|---|
| 400 | a bad parameter, or a bad repository name |
| 401 | a missing or bad service token |
| 403 | a caller who may not do this |
| 404 | <url>: no such repository, <url>: taken down, <name>: no one's handle (not one an account can have, or one a resolver answered that none has), or a part this server does not run (this server keeps no index, this server is not a gateway) |
| 502 | the PDS or identity lookup failed |
Who asks
A query under Appview or Web data may carry a
service token, as atproto's appviews take one: Authorization: Bearer <jwt>, signed by the asker's PDS (com.atproto.server.getServiceAuth)
with this server's did:web as aud and the query's NSID as lxm. A
PDS proxying to the #knotless_appview service sends one itself. With
none, a query answers as for anyone. A token that does not hold, or one
sent to a server with no --public-url and so no DID, is 401
AuthenticationRequired. With no token, the cookie knotless_session
of someone logged in to the pages names the asker; one that no longer
holds asks as no one. Every query answer is Vary: authorization, cookie,
and one with an asker is Cache-Control: private, no-cache; one that read
a private repository for its asker, private, no-store.
A repository no one may read publicly is read as the asker, with the
session they logged in to the pages with: a member of it reads it as a
public one, and anyone else, asker or not, gets the 404 NotFound a
repository never made gets (access.md).
GET /<owner>/<name>/raw/<rev>/<path>, /archive/<rev>.zip and the .atom feeds read as the cookie's account too,
and varies by cookie; the cards, git and /api/ routes read as no one.
A query may instead prove its asker a member with a delegation token,
for an app that signs people in to their own PDS rather than here:
Atproto-Space-Delegation: <jwt>, from the asker's PDS
(com.atproto.space.getDelegationToken) for the repository's space,
at://<owner DID>/space/dev.knotless.space/<name>, one for each query.
The server checks its signature against the account it names and spends
it: one sent again is 401 AuthenticationRequired, as is one that is no
delegation token, or that names another account than a service token or
cookie sent with it. It reads that one repository as its member, as a
session would, exchanging the token with the space's authority, which
answers only a member; anything else the query reads, it reads as for
anyone, or as the service token or cookie. A token for another space, or
from someone who is not a member, reads as no token does. The queries
answer a page on any other site (CORS, with Authorization and
Atproto-Space-Delegation allowed), which a browser never sends the
cookie with, and so does /.well-known/did.json, where such a page finds
them (#knotless_appview).
Health
GET /healthz
Answers ok while the process runs.
GET /_status
| Parameter | |
|---|---|
repo | <handle or did>/<name>: also read that repository's mirror, as its code page does |
Answers 200 when everything holds, else 503, with Cache-Control: no-store. One feed bringing events is enough for ok; with no feeds
followed, feeds pass.
| Field | |
|---|---|
ok | everything holds |
problems | strings, below; dead feeds are listed even while ok is true |
uptime_seconds | since the process started |
feeds[] | name, url, ok, connected, silent_seconds, stale_after_seconds, events, seq |
index | ok, error; with --index |
mirror | repo, ok, error, commit (the default branch's), packs (.pack files in the mirror); with ?repo= |
A feed never heard from is silent since it was first followed. Its
stale_after_seconds is --stale, else 120.
Problems:
<feed>: not connected<feed>: no event for <n>sno feed is bringing eventsindex: <error>mirror <repo>: <error>, including?repo= takes <handle or did>/<name>
Git smart HTTP
| Route | |
|---|---|
GET /<authority>/<repo>/info/refs?service=git-upload-pack | the ref advertisement |
POST /<authority>/<repo>/git-upload-pack | a fetch |
Any other service is 403 read only: push with knotless:// instead.
The Git-Protocol header is passed to git upload-pack, so protocol v0
and v2, --filter and --depth work. A gzip request body
(Content-Encoding: gzip) is decompressed; bad gzip is 400. Answers are
application/x-git-upload-pack-advertisement or -result, Cache-Control: no-cache, not compressed.
Object API
Under /api/<authority>/<repo>. A failed read of a tree, blob, log, diff
or object is 404; a failure to list the refs is 500.
| Route | Parameters | Answer |
|---|---|---|
GET /refs | {refs: [{name, target}], head} | |
GET /tree | rev, path | {rev, path, entries: [{name, mode, type, sha, size}]} |
GET /blob | rev, path | the raw bytes, application/octet-stream, ETag the blob's hash |
GET /log | rev, path, limit (default 50, at most 1000) | {rev, commits: [{sha, parents, author: {name, email, date}, message}]} |
GET /diff | base, head | git diff base...head, text/x-diff; charset=utf-8 |
GET /object/<sha> | the raw object, application/octet-stream, its type in X-Git-Type, ETag, immutable |
rev, base and head default to HEAD. An empty one, one starting
with -, or one holding a NUL, newline or space is 400 bad revision "<rev>".
path is trimmed of slashes. <sha> is 40 or 64 hex characters, else
400 not an object id: <sha>. A tree or blob read at a 40-character hash is
public, max-age=31536000, immutable, and otherwise no-cache.
Appview
XRPC queries under /xrpc/. All but getProfile, describeServer and getStats need --index, and
without one answer 404 this server keeps no index.
An account shown beside what it did or holds (dev.knotless.defs#actorView:
an owner, an author, a collaborator, a reviewer) is {did, handle, avatar?, bot?}, where bot is true when its dev.knotless.profile says it is a bot,
as the index keeps of every account.
dev.knotless.listRepos
| Parameter | Default | |
|---|---|---|
q | matched against name and description | |
runner | a DID: only repositories whose record names it in runners | |
triager | a DID: only repositories whose record names it in triagers | |
topic | only repositories whose record names it in topics | |
limit | 50 | clamped to 1 to 500 |
cursor | from the page before |
Answers repos (did, name, description?, forkOf?, createdAt,
owner), newest first, and cursor when the page was full. Taken-down
owners and repositories are left out.
The first page of a search (a q, and no runner, triager or topic)
puts some first, at most limit more of each kind, in this order:
- Asked as an account, the private repositories matching
qthat it may read here, eachprivate: its own, and those it was proven a member of, as it read one or as it was asked after in the background. - Those named
q, ignoring case: the account's own first, then originals before forks, each newest first. Searching for a project's name finds it before the forks of it, and before the repositories that only mention it. - Asked as an account, its other public repositories that match.
A kind put first is left out of every page's newest-first rest when all of
it fit on the first page; when more match than limit, the rest lists
them all again in their place. Asked as an account, the answer is that
account's alone.
dev.knotless.searchCode
| Parameter | Default | |
|---|---|---|
q | required: Zoekt's query language, as words, "quoted text", /a regex/, file:, lang:, case:yes | |
repo | a repository's at:// URI, its authority a DID or a handle: only its code | |
limit | 20 | the most files answered, clamped to 1 to 50 |
cursor | from the page before: how many files it held |
Answers files, the best first, files scored alike ordered by
repository, then path, each with its repo (as listRepos
answers one), path, language?, commit? (the commit of the default
branch that was indexed, which its lines are of) and chunks: runs of lines holding
matches, each its startLine, its content and its ranges, a match
by line, byteStart and byteEnd in UTF-8 bytes, and its
highlights?, encoded as getFile's ranges are. languages counts
the files found in each language, most first, its name, color? and
files: its lang: filters left out, so each can be picked, over the
first 1000 files at most, on the first page alone. cursor is absent
on the last page, and once the pages reach 200 files. fileCount and
matchCount count all that was found. Only default branches are
searched, and taken-down owners and repositories are left out. A query
Zoekt cannot read answers 400, and a server without --code-index 501.
dev.knotless.searchOwners
| Parameter | Default | |
|---|---|---|
q | required; matched against the handle, ignoring case | |
limit | 10 | clamped to 1 to 50 |
Answers owners, each an owner (did, handle, avatar?, bot?) and its
repoCount: the accounts owning repositories listRepos lists, those whose
handle starts with q first, then the one with the newest repository. Owners
are matched by the handles the index keeps; an owner whose handle was never
looked up is looked up first, once, and kept.
dev.knotless.describeServer
No parameters. Answers runners, each a did: the one
--default-runner names, which a client names in a new public
repository's record unless its owner names others. Empty when it names
none. client, with --client-minimum, is the oldest client version it
asks for, as minimum and reason. ios, with --ios-minimum-build, is
the oldest build of the iOS app it lets run, as minimumBuild and
reason. It needs no index.
dev.knotless.getForge
| Parameter | Default | |
|---|---|---|
repo | the repository's at:// URI; only its owner's DID is used | |
key | the repository's key | |
limit | 500 | clamped to 1 to 1000 |
cursor | the last URI of the page before |
Answers entries (uri, author, value), every record naming the
repository, sorted by URI, less what moderation removes, and cursor only
when more remain. Records about the repository it was transferred from
are included when both owners agree on the move.
dev.knotless.listChecks
| Parameter | Default | |
|---|---|---|
repo, key | as for getForge | |
ref | only checks of this ref | |
commit | only checks of this commit | |
pull | only checks of this pull's at:// URI | |
limit | 50 | clamped to 1 to 100 |
cursor | from the page before |
Answers checks (uri, runner, value), newest startedAt first, and
cursor when there may be more. Only the runners the repository record
names now count.
dev.knotless.getFeed
| Parameter | Default | |
|---|---|---|
actor | only what this DID did | |
filter | everyone | following: what the account asking and those it follows did, and anything done on its repositories and those it starred |
kind | every kind | only pushes (refs pushed and deleted), pulls or issues (opened and moved between states), comments, stars or repos (created and forked); a kind it does not know answers nothing |
limit | 40 | clamped to 1 to 100 |
cursor | from the page before |
Answers events (createdAt, actor {did, handle, avatar?, bot?}, repo
{did, handle, name}, action) and cursor. action.$type is one of:
$type | Fields |
|---|---|
dev.knotless.getFeed#created | description?, forkOf? |
dev.knotless.getFeed#pushed | ref, created, to?, forced?, count, new, commits (sha, message, merge?) |
dev.knotless.getFeed#deleted | ref |
dev.knotless.getFeed#opened | subject, title, subjectState? (where it stands now, whoever moved it there) |
dev.knotless.getFeed#commented | subject, title, text, verdict?, subjectState? |
dev.knotless.getFeed#changed | subject, title, state, subjectState? |
dev.knotless.getFeed#starred | subject, the repository record |
A push's actor is the account that pushed: a ref's claim or record that
names a pusher the repository lists among its collaborators or runners
is theirs, and any other is the owner's. actor and filter=following
choose pushes by that account too. Pushes to one ref by one account within
an hour of each other are one event. to is the commit the last of them left
the ref at, which a page links to as the ref may since have been deleted. A push walks
at most 100 commits and lists at most 10; count is what it brought, and
new those no other ref had first. forced is a push that left commits
behind, as a rebase or a rewrite does: its count and new leave out the
commits it brought again, those by the same author at the same time with
the same message as one it left behind, so a rebase alone counts none.
changed is a pull or issue whose state changed to open, closed or
merged, and nothing else: labels, the merge queue and a pull pointed at
another branch are not in the feed.
starred comes only with actor (the stars it gave) or filter=following
(the stars of the asker and those it follows). Everyone's feed has none,
since anyone can write as many stars as they like. filter=following with
no one logged in or no service token answers 401. A commit with more than
one parent says merge: true.
dev.knotless.getActorActivity
| Parameter | Default | |
|---|---|---|
actor | the account's DID | |
since | a year before now | the first day counted is the one this time falls on in tz |
tz | UTC | the IANA time zone whose days are counted, as America/New_York; one it does not know is UTC |
Answers days, oldest first, each a date in tz (day, as YYYY-MM-DD) on
which the account did something, with how many repositories it made
(repos), refs it moved (pushes, one for each claim it is credited
with as getFeed credits them, on its repositories or others'), and pulls, issues
and comments it wrote; and repos, each of its repositories ever pushed
to, by name, with when it last was (pushedAt) and its days
(day, pushes, by anyone) since since. Asked by the account itself,
logged in to the pages or with a service token, or by a member of its
private repositories, repos has those it may read too, as this server's
index of their spaces counts their pushes, and the answer is the asker's
alone; days counts nothing private. An account or repository taken down counts
for nothing.
dev.knotless.getProfile
| Parameter | |
|---|---|
actor | a handle or DID |
Needs no index. Answers did, displayName?,
description?, pronouns?, links?, avatar?, bot? (true when its
dev.knotless.profile says it is a bot); an avatar served here
is made absolute from X-Forwarded-Proto (default http) and Host.
With an index, also badges?: each badge the account earned (kind,
tier from 1 to 4, or 1 for a badge earned once, count, and next,
the count the next tier starts at, absent at the top), in the order the pages show them
(badges). An
account that cannot be reached is 400 {"error": "ProfileNotFound", "message": ...}.
dev.knotless.getStats
No parameters, and no sign-in: what the /stats page shows. days, the
last 30 (UTC), oldest first, each with visitors, views and, from the
index, repos, pushes, pulls, issues, comments, checks and
authors; and network?, as dev.knotless.getUsage has it, absent
without an index. Days are zero without --usage. Cached for five minutes
(Cache-Control: public, max-age=300). What only operators read, the
requests, routes, events, slow statements, failures and crawlers, is dev.knotless.getUsage's
(deploy).
Live
The pages follow what happens as it happens as server-sent events (serve). Each answers as the query for the same thing does for the same viewer, a 404 included, lasts ten minutes at most, and ends as the server stops; a browser then asks again by itself.
| Path | Parameters | Events |
|---|---|---|
GET /_live/repo | repo: the repository's at:// URI | changed, its data null, a quarter of a second after the index takes in or lets go a record about the repository, once for those that came meanwhile |
GET /_live/check | repo and uri, as dev.knotless.getCheck; the Last-Event-ID header, where the output was had to | output: what the run printed since, a JSON string, its id the byte where it ends in all the run printed; steps: the steps that ended, as the record has them, each time more do; going: the name of the step going, a JSON string, or null between steps; ended, its data null, once, as the run's steps are over |
Moderation
These need --index and --public-url. The admin methods, and
dev.knotless.getUsage, take a service token for the method,
Authorization: Bearer, from an operator: an account
KNOTLESS_ADMIN_DIDS names. With no token, they take the session cookie
of an operator logged in to the pages, asking from the site's own origin,
as the /admin page does. Without either, from another site, or with a
token for another method or server, they answer 401; from anyone else,
403 <did> is not an operator here.
| Method | Auth | Parameters | Answer |
|---|---|---|---|
POST com.atproto.moderation.createReport | service token, lxm this method | subject, reasonType, reason? | the stored report: id, reasonType, subject, reportedBy, createdAt, reason? |
GET dev.knotless.getLogs | admin | trace, or any of message, level (that or above), minMs; limit (default 500 for a trace, else 100; clamped to 1 to 500); needs --logs | lines, oldest first, each {line}, the JSON line as logged; 400 asking for none of them, 404 keeping no logs |
GET dev.knotless.listReports | admin | limit (default 50, clamped to 1 to 500), cursor (an id: reports before it) | reports, newest first, and cursor when the page is full |
POST com.atproto.admin.updateSubjectStatus | admin | subject, takedown.applied | subject, takedown |
GET com.atproto.admin.getSubjectStatus | admin | did or uri | subject, takedown.applied |
GET com.atproto.label.queryLabels | uriPatterns, repeatable: a URI, or a prefix ending in * | labels | |
GET com.atproto.label.subscribeLabels | cursor | a WebSocket of labels |
Invites
A private repository's owner leaves its runners an invite, and a runner
reads its own, each with a service token for the method from their own
PDS: knotless-run's --discover finds private repositories so
(runner). These need
--index and --public-url, as the methods above do, and answer 401
without a token for the method.
| Method | Auth | Parameters | Answer |
|---|---|---|---|
POST dev.knotless.inviteRunner | service token, lxm this method | runner (a DID, else 400), name (a repository name, else 400): the caller's repository | {}; an invite already there moves to the end |
GET dev.knotless.listRunnerInvites | service token, lxm this method | limit (default 100, clamped to 1 to 500), cursor (one this method gave, else 400) | the caller's invites after cursor, oldest first, each did, name, createdAt; and cursor, where the page ended, the one given when it is empty |
Secrets
An owner leaves a repository's runners secrets, each sealed to one
runner's dev.knotless.runnerKey, so this server holds only ciphertext
(runner). A repository is named by
its owner and its key, never its name, so a rename keeps its secrets.
The owner is the caller, or, named as owner, an organization the caller
is an admin of and has joined; any other owner is 403. Each method takes a service token for itself, from the
caller's PDS, or the pages' login, as their writes take it (403 from
another site), else 401, and needs --index. The pages seal each value
in the browser, to the keys listRunners gives, so this server never
sees one.
| Method | Auth | Parameters | Answer |
|---|---|---|---|
POST dev.knotless.putSecret | service token, lxm this method, or the pages' login | owner (a DID; absent, the caller), repo (a key: 1 to 32 letters and digits), runner (a DID), name (a shell variable's name, not starting KNOTLESS_RUN), refs (at most 16 {pattern}, each 1 to 256 bytes), key (a did:key), sealed (1 to 16465 bytes); any else 400 | {}; one of the same repository, runner and name is replaced |
POST dev.knotless.deleteSecret | service token, lxm this method, or the pages' login | owner (as putSecret), repo; name, runner, or both, to delete only those | deleted, how many of the owner's went |
GET dev.knotless.listSecrets | service token, lxm this method, or the pages' login | owner, repo | secrets, by name then runner, each name, runner, refs, key, updatedAt: every one to the owner, or an admin who joined it as an organization, with sealed only on those sealed to the caller; those sealed to the caller, with sealed, to anyone else |
A subject is a com.atproto.admin.defs#repoRef or a
com.atproto.repo.strongRef, else 400. getSubjectStatus with neither
parameter is 400 com.atproto.admin.getSubjectStatus takes a did or a uri.
updateSubjectStatus with takedown.applied: true takes the subject down
and emits a !takedown label; with false it restores it and emits the
label with neg: true.
queryLabels answers this server's own standing takedowns, at most 250
per pattern, each with ver: 1, src the server's DID and cts the
takedown's time. subscribeLabels sends #labels frames in CBOR (header
{op: 1, t: "#labels"}, body {seq, labels: [label]}): with a cursor,
the labels after it first, then live ones. Labels are unsigned.
Space notifications
A private repository's space's authority, the owner's PDS, tells the server
of writes there once asked to (access).
Each is signed as the space's owner for the server's #knotless_appview
service and this method, else 401; a space not the signer's own, or not a
private repository's, is 403. Both need a gateway DID and a server people
log in to, else 404.
| Method | Auth | Input | Answer |
|---|---|---|---|
POST com.atproto.space.notifyWrite | service token, aud the server's #knotless_appview, lxm this method | space; the rest is not read | {}; the space is synced now, and followed first as its owner if they are logged in here and it is not yet |
POST com.atproto.space.notifySpaceDeleted | the same | space | {}; the space is forgotten, with every record it gave |
Runs as they go
A runner sends the server what its runs print as they print it, and asks
it which of its private repositories' spaces were written to
(runner), each with a
service token for the method from its own PDS, else 401; a token is
checked once a minute at most. These need --index and --public-url, as
invites do.
| Method | Auth | Input | Answer |
|---|---|---|---|
POST dev.knotless.putRunOutput | service token, lxm this method | check: a dev.knotless.check record of the caller's, else 400; startedAt?, the run's start, as its record says: another than the run kept started at starts it anew; offset; text?, at most 20,000 bytes; steps?; going?, the step running now, taken with steps; ended? | {watched}: whether a page follows the run now; 429 while the server holds 1,024 runs, or 128 of the caller's |
GET dev.knotless.awaitSpaceWrites | service token, lxm this method | cursor? | cursor, and writes, each space: those written to after cursor of the spaces whose owners invited the caller, as soon as there is one, else none after 25 seconds; with no cursor, or one of another start of the server or past the 4,096 writes it remembers, at once, none, and missed: true |
Gateway
These need --public-url, and otherwise answer 404 this server is not a gateway. Each XRPC call carries a service token with the gateway's DID as
audience and the method as lxm. Gateway
describes each.
| Route | Input | Answer |
|---|---|---|
GET /.well-known/did.json | the did:web document, with #knotless_gateway, #atproto_labeler and #knotless_appview services | |
GET /oauth-client-metadata.json | the gateway's OAuth client metadata | |
GET /oauth/callback | the authorization server's redirect | a page saying whether the gateway now writes for you |
GET /oauth/bot-client-metadata.json | the bots' OAuth client metadata; with --bots | |
GET /oauth/bot | account, a handle or DID that KNOTLESS_BOT_DIDS names | a redirect to approve a bot's login at its PDS; with --bots |
GET /oauth/bot-callback | the authorization server's redirect | a page saying whether the bot is logged in; with --bots |
POST dev.knotless.putDelegation | {url, state} | |
GET dev.knotless.getDelegation | state | {done} |
POST dev.knotless.pushRefs | a JSON header line {repo, name, updates: [{dst, src?, force?}]}, then a pack; application/x-knotless-push, at most 512 MiB | {results: [{name, error?}]} |
POST dev.knotless.createCommit | {repo, name, branch, message, author?: {name, email}, base?, changes: [{path, data?, executable?}]} | {sha, parent?, changed, batched} |
A change with no data deletes its path.
MCP over HTTP
Served with --public-url, to any account the index has not taken down. The server is its own OAuth authorization server, with
PKCE.
| Route | |
|---|---|
/mcp | the MCP tools, as streamable HTTP with JSON answers and no sessions; takes a bearer token |
GET /.well-known/oauth-protected-resource, /.well-known/oauth-protected-resource/mcp | protected-resource metadata |
GET /.well-known/oauth-authorization-server | authorization-server metadata |
POST /mcp/register | client registration (RFC 7591) |
GET, POST /mcp/authorize | sign-in, with atproto OAuth at the user's PDS |
GET /mcp/oauth/callback | where the user's PDS sends them back |
POST /mcp/token | tokens |
GET /mcp/oauth-client-metadata.json | the client metadata it signs users in with |
Registrations and tokens are signed, not stored.
| Lifetime | |
|---|---|
| Access token | 1 hour |
| Refresh token | 90 days |
| An account's session, from its last token | 90 days |
| Client registration | 100 years |
| Sign-in | 15 minutes |
| Authorization code | 5 minutes |
| Limit | |
|---|---|
| Tool calls running, per account | 8 |
Of those, with a wait | 2 |
| Tool calls started a minute, per account | 120 |
| Sign-ins waiting on PDSes, from everyone | 1000 |
A tool call past an account's limits answers as a tool error, and a sign-in past the server's as a 503 page.
Releases
With --releases <dir> or --releases knotless://<owner>/<name>:
| Route | |
|---|---|
GET /install.sh | the install script, text/x-shellscript; charset=utf-8, pointed at --public-url, else at X-Forwarded-Proto (default http) and Host (default 127.0.0.1:2590) |
GET /releases/... | the directory: latest, and <version>/knotless-<version>-<target>.tar.gz with its .sha256 |
From a repository, the same paths answer from its releases of the
knotless package (packages.md), listed again
each minute:
| Route | |
|---|---|
GET /releases/latest | the latest version and a newline; 404 no release with none |
GET /releases/<version>/<file> | the file, application/octet-stream, read once from the owner's PDS, checked against its record, then kept under the cache directory's serve/releases/ by its SHA-256 |
GET /releases/<version>/<file>.sha256 | <sha256> <file> and a newline, from the record |
A version or file the releases do not have is 404. A repository that cannot be read, or a file that cannot be read or kept, is 502 with the error.
Crates
A Cargo registry of the crates a repository publishes as releases
(packages.md), under
/cargo/<did>/<repo key>/, where <did> is its owner's. What it reads is what
the index has: with none, or for a repository the index lacks or has taken
down, all of it is 404. A token, in Authorization, is <handle>:<app password>, which logs in to the owner's PDS.
| Route | |
|---|---|
GET config.json | {dl, api}, at the request's origin |
GET me | what a token is, as text: where cargo login sends its user |
GET <index path> | a crate's file of the index, by Cargo's layout (1/a, 2/ab, 3/a/abc, ab/cd/abcd): a line per version, the oldest first, no-cache, with an ETag; 304 to one seen, 404 for none |
GET dl/<name>/<version>/download | the .crate, as a release's files are sent |
PUT api/v1/crates/new | as Cargo publishes: the crate uploaded to the owner's PDS and its release made, at most 64 MiB. 400 for a body that is not one, a name or version a release does not take, a Cargo.toml missing, over 1 MiB or saying otherwise, an index line over 100,000 bytes, or a version published already. Once the release is made it answers 200, even should reading it back or indexing it fail, as the feed indexes it soon after |
DELETE api/v1/crates/<name>/<version>/yank, PUT .../unyank | {ok: true}: yanks it, or takes that back |
GET api/v1/crates?q=&per_page= | the crates whose names hold q, each at its latest: {crates: [{name, max_version, description}], meta: {total}}, 10 by default, 100 at most |
Errors are Cargo's, {"errors": [{"detail"}]}. A write with no token, a token
not of that form, one the PDS refuses or another account's is 403.
Images
An OCI registry of the images repositories publish as releases, with
knotless release publish --oci or a push here
(packages.md). An image is named
<owner>/<repo>/<package>, or <owner>/<repo> for the package named as
the repository, the owner by handle; a tag is a version, or latest, the
highest without a pre-release part.
| Route | |
|---|---|
GET /v2/ | with any bearer token, {} and Docker-Distribution-API-Version: registry/2.0; without one, 401 asking for one: WWW-Authenticate: Bearer realm="<this server>/v2/token",service="knotless" |
GET /v2/token | a token: {token, access_token}, anonymous with no credentials, which pulls need; with Basic credentials, a handle (or DID) and an app password, one to push with for an hour, expires_in 3600, made by logging in to their PDS and kept only in memory; a password the PDS refuses is 401 |
GET, HEAD /v2/<name>/manifests/<tag or digest> | the manifest: by tag, the one its release's index.json names; by digest, the file of that digest among the package's releases; as the media type it says (an OCI image manifest if none), with Docker-Content-Digest |
GET, HEAD /v2/<name>/blobs/<digest> | the blob, application/octet-stream, with Docker-Content-Digest and Content-Length, sent from where it is kept as it is read: a release's, or one the owner pushed and no release holds yet |
GET /v2/<name>/tags/list | {name, tags}: the versions that are images, highest first, and latest if there is one |
POST /v2/<name>/blobs/uploads/ | begins an upload: 202 with Location and Docker-Upload-UUID; with ?digest=, the body is the whole blob, ended as a PUT ends one |
PATCH /v2/<name>/blobs/uploads/<id> | the body as the upload's next part: 202 with Range: 0-<last byte> |
PUT /v2/<name>/blobs/uploads/<id>?digest=<digest> | the body as its last part: checked against the digest, uploaded to the owner's PDS and kept; 201 with Location and Docker-Content-Digest |
PUT /v2/<name>/manifests/<digest or version> | a manifest, at most 4 MiB of JSON: by digest, kept as a blob; by a version, the release of it, with an index.json naming it and every blob it reaches, an index's manifests and theirs included |
Each file is read from the owner's PDS once, checked against its record,
and kept by its SHA-256, as the releases are; each package's releases are
listed again each minute. Errors are the spec's,
{"errors": [{"code", "message"}]}: 404 NAME_UNKNOWN for a name that is
not <owner>/<repo>[/<package>], an owner that cannot be found, or a
repository that cannot be read, NAME_INVALID for a repository's name
knotless does not take, MANIFEST_UNKNOWN, BLOB_UNKNOWN and
BLOB_UPLOAD_UNKNOWN; 400 DIGEST_INVALID for an upload ended with no
digest or the wrong one (which ends it), or a manifest put by a digest
not its own, TAG_INVALID for a tag that is not a version (latest
among them), MANIFEST_INVALID and MANIFEST_BLOB_UNKNOWN for a blob or
manifest it reaches that was not pushed; 401 UNAUTHORIZED for a push
with no token, asking for one with scope="repository:<name>:push,pull";
403 DENIED for a push by anyone but the owner, or of a version
published already; 405 UNSUPPORTED for anything else posted, patched
or put; 502 UNKNOWN for what cannot be read, uploaded or kept.
Web data
The pages read what they show from these XRPC queries, as atproto's
appviews answer theirs: each has its lexicon under
lexicons/dev/knotless, with the views
they share in dev.knotless.defs; the pages' TypeScript types are made
from them by @atproto/lex, into web/src/lib/lexicons (npm run lex). A
repository is named by its record's at:// URI, repo, whose authority
may be a handle; an old name, or an owner it moved from, answers under the
name it has now.
| Query | |
|---|---|
dev.knotless.listRepos?q= | repositories matching q (above) |
dev.knotless.searchOwners?q= | the owners of repositories whose handle holds q (above) |
dev.knotless.getActorRepos?actor= | an account's public repositories, each with the language most of it is written in: from the index while a feed is followed, else as its PDS lists them; to the account itself, asking, its private ones too, each private, and to a member asking those it was proven a member of; asked as the account itself, apart, as shared, the private repositories of others it was proven a member of |
dev.knotless.getRepo?repo= | a repository: its owner, description, links (only http and https ones), topics, default branch, counts, languages, collaborators (the first twenty its record names, besides its owner) and collaboratorCount, private if it is, archivedAt if its owner archived it, and site if it has a website this server serves: its url, deployedAt, the commit its deployer named, and its check only when that check is from a runner the repository names; with an asker, viewer: its star, a transfer offered to it, the labels it may put on the repository's pulls and issues, and admin: true when it may change the repository's settings, as its owner or an admin who joined the organization that owns it |
dev.knotless.listReleases?repo=&package=&limit=&cursor= | its releases (packages.md), newest first, 30 a page by default, of one package if given: each with latest when it is its package's latest, yanked when its owner withdrew it, image when its files are an OCI layout's, crate when it is a crate of the repository's Cargo registry, its notes as Markdown and as html, and its files' names, sizes and SHA-256; and every package it publishes, by name, with its latest version, how many releases it has and whether it is an image. None for a private repository or without an index |
dev.knotless.getTree?repo=&ref=&path= | a folder, its README and the newest checks of its last commit |
dev.knotless.listPaths?repo=&ref=&limit= | every folder and file at a revision, the shallowest first, at most 50,000, and truncated when the deepest were left out: for Go to file |
dev.knotless.getLastCommits?repo=&ref=&path= | the newest commit to change each entry of a folder, and at the root, how many commits the ref's history holds |
dev.knotless.getFile?repo=&ref=&path= | a file, highlighted or rendered |
dev.knotless.getBlame?repo=&ref=&path= | who last changed each line of a text file, as git blame finds it: its text, highlighted; its lines in runs (hunks), each with its commit and, where the commit's parent had the file, previous, that parent and the file's path there; and each commit once. 400 for a binary or a file over 256 KiB |
dev.knotless.listCommits?repo=&ref=&path=&limit=&cursor=&subjectOnly= | history, 50 commits a page by default, with the newest checks of each; with subjectOnly, each message only to its first line |
dev.knotless.getCommit?repo=&sha=&firstRows=&paths= | a commit and its diff, a merge's against its first parent; with firstRows, the rows of the first files only, up to 10 files or 1500 lines, passing over any that changes more than 400, the rest with their line counts alone; with paths (repeated, at most 50), only those files, rows and all: what the commit page fetches as the reader comes to each file |
dev.knotless.listBranches?repo=&limit=&cursor=&commits= | the branches, the default first, 100 a page by default; with commits, each with its tip and that commit's newest checks |
dev.knotless.listTags?repo=&limit=&cursor=&commits= | the tags that name a commit, the newest commit first, 100 a page by default; with commits, each with its commit and that commit's newest checks |
dev.knotless.listItems?repo=&kind=&state=&q=&limit=&cursor= | the open or closed pulls or issues that the filter q matches (collaboration.md), review-requested:@me and assignee:@me the asker, each with draft for a draft pull, the reviewers asked, its base's required reviewers among them and named again in requiredReviewers, and the assignees, 50 a page by default, how many of each, and the repository's labels and the items' authors, for a filter to name |
dev.knotless.getThread?repo=&uri=&filesOnly=&firstRows= | a pull or an issue and everything said on it, with stack, the chain of pulls a pull is part of, highest first, and the branch it lands on (into), each comment with its uri, Markdown body and, for a review, the commit it looked at; on a pull's lines, its line (path, side, line, startLine, the lines it names as they were, at most 20, and outdated once that side of the file has changed), the review it went with and resolved; a dismissal's dismisses, the review it dismissed; an answer's reply, the comment starting its thread; for a pull, reviews, where each reviewer stands (outdated once the open pull has moved past the head it looked at, maintainer for the owner or a collaborator, gate for an account the rule protecting its base waits for a review from, dismissed once the owner or a collaborator dismissed it), and in changes the head it is at; for a merged pull, revertedBy ({uri, title}), the revert whose merge undid it; for an issue, mentionedBy, the pulls on the repository whose title or body names it, oldest first, each its item and closes where it names it after a closing keyword; in each html, a bare at:// URI of a pull or issue on the repository, or a link to one, as a link to its page, a bare one showing its title; for a pull of a public repository, the minutes each runner counts for its owner (dev.knotless.minutes); with an index, reactions on the item and on each comment, by content in GitHub's order, each naming the asker's own reaction when it has an asker; and with an asker, what it may do (viewer: setState, hide, revert for a merged pull, assign, dismiss for a pull, and the labels it may set); with filesOnly, a pull's changed files without their rows, and with firstRows too, the rows of the first files, up to 10 files or 1500 lines, passing over any that changes more than 400 |
dev.knotless.getPullDiff?repo=&uri=&paths= | the files of a pull's diff that paths names (repeated, 1 to 50), rows and all: what the pull page fetches as the reader comes to each file |
dev.knotless.getTemplate?repo=&kind=&base= | the template a new pull (at base) or issue is asked to follow, .knotless/<kind>.md, as knotless pull template reads it; {} with none, 400 for a kind that is neither |
dev.knotless.getCompare?repo=&base=&head=&branch= | the repositories a pull can come from (heads: this one, then its forks), and with branch what it brings in from head (this repository if not given) into base (the default branch if not given), as a pull's changes: 404 for a branch on neither |
dev.knotless.getReactions?repo=&uri= | the reactions to a pull or an issue and to each comment on it, as {subjects: [{subject, reactions: [{content, count, reaction}]}]}, leaving out subjects none reacted to: 404 with no index |
dev.knotless.getQueue?repo= | the merge queues, what they ejected, and what they landed last |
dev.knotless.listRuns?repo=&workflow=&limit=&cursor= | the runs of the repository's workflows, or of one, newest first, 30 a page by default and at most 100, each with the ref, pull or batch it ran on; and the workflows on the default branch, those that run only when asked among them |
dev.knotless.getCheck?repo=&uri= | a check, its steps and the end of its log |
dev.knotless.getCheckImage?repo=&uri=&cid= | a picture a check names |
dev.knotless.getAvatar?actor=&cid= | a dev.knotless.profile picture |
dev.knotless.getAttachment?repo=&uri=&cid= | a blob a pull, issue or comment attaches, such as a picture its body shows, from its author's PDS or their repo in a private repository's space; 404 for one its record does not attach |
dev.knotless.getViewer | who is asking (actor), the other accounts the browser is logged in to (accounts), whether this server offers a login (login, only with --public-url), admin: true for an operator, delegated: true when its gateway writes for the asker, organization: true when the asker is an organization, reauth: true when their login to the pages asked for less than the pages ask for now, redelegate: true when the gateway writes for them under a grant that asked for less than it does now, and spaces, whether the asker's PDS has spaces for private repositories, once this server has listed theirs (as they log in) |
dev.knotless.getFollowers?actor=&limit=&cursor= | the accounts following an account, the latest first, 50 a page by default: 404 with no index, ProfileNotFound for an unknown account |
dev.knotless.getFollows?actor=&limit=&cursor= | the accounts an account follows, the same way |
dev.knotless.listThemes?actor=&q=&uris=&limit=&cursor= | dev.knotless.theme records, everyone's or actor's, the latest first, as {themes, cursor}; with q, everyone's whose name starts with it in any case, by name, with no cursor; with uris, repeated, 50 at most, those it names that there are, in its order, with no cursor and the rest passed over: each {uri, creator, name, palette, createdAt}, its palette only the colours that are #rrggbb, and one with no name or no colour it must give left out, as is an account taken down. ProfileNotFound for no such actor, 400 for over 50 uris, 404 with no index |
dev.knotless.listSteps?repo=&q=&limit=&cursor= | the shared steps the index has found (serve.md), everyone's or one repository's (repo, its dev.knotless.repo URI, by handle or DID), by name, 50 a page by default, those whose name starts with q if given, as {steps, cursor}: each at its latest version, the highest that is not a pre-release, as {uri, name, repo, description, version, secretCount, useCount, createdAt}, useCount how many public repositories pin it in the .knotless/uses.lock of their default branch. One taken down, or of an account or repository taken down, is left out. 400 for a repo that is not a repository's URI, 404 for no such repository or with no index |
dev.knotless.getStep?repo=&name= | one of those, as listSteps gives it, with its latest version's commit, its file's path, the uses that takes its major version (<handle>/<repo>/<name>@1, or @0.3 before 1.0.0), its inputs, each {name, description, required, default}, the commands it needs and the secrets it asks for, each {name}, its versions, each {uri, version, commit, createdAt}, the highest first, and usedBy, at most 30 of the repositories pinning it. 404 for no such repository or step, or with no index |
dev.knotless.listNotifications?limit=&cursor=&kind= | what others did that concerns the asker, the latest first, a pull or an issue opened on any repository it looks after (its own, those naming it a collaborator, its organizations'), 50 a page by default, each isRead by seenAt, and a review's verdict; reason mention for a pull, issue or comment whose facets name the asker, dated by the edit that first named the asker, and each record once, for its other reason when it has one the asker hears of, then with mentioned: true; reasonSubject what each is about, the pull or issue a comment is on, else the pull or issue itself, or for a star the repository's record; lineComments, how many line comments a review was sent with; review-requested for a pull, not a draft, that asks the asker to review it, by its author or by the owner or a collaborator (then their dev.knotless.assignment record as uri); assigned for a pull or an issue the owner or a collaborator assigns the asker, by that record; reason invite for an organization's invitation the asker has yet to answer, while it stands, with the organization as author, its dev.knotless.orgMember record of the asker as uri, the role it offers and no repo; reason checks-failed for the asker's pull whose checks failed at the latest run of each workflow, one for each head and base, with a runner the repository names as author, a failed check as uri, the pull as reasonSubject and checks, each {workflow, status, check}; reason ejected for the asker's pull the merge queue took out, with its runner's dev.knotless.queue record as uri and the checks that failed as checks; reason subscribed for a comment on a pull or an issue it subscribed to, and watching for a pull, issue, comment or release on a repository it watches (putSubscription), each since it chose and only when no other reason gives it, a release with itself as reasonSubject and package version as title. Nothing is given of a pull or an issue it muted, or a repository it ignores, but review-requested. A pull, an issue, a comment, a mention in one, or a review request has text, the start of what it says as Markdown, at most 1,000 characters; a review request has reviewed: true once the asker has approved the pull or asked for changes since it was asked; each about a pull or an issue has subjectState, open, closed or merged, as its page says, and checks-failed and ejected are left out once their pull has merged or closed. kind narrows them to one of mentions (with comments heard of for another reason that name the asker), reviews (review requests, and comments with a verdict), comments (those without, subscribed and watching ones too), pulls-and-issues (with those watching), checks (checks-failed and ejected) or stars-and-follows; an unknown kind answers none: 401 AuthenticationRequired with no asker, 404 with no index |
dev.knotless.getUnreadCount | how many of those are new, counting at most 100 |
dev.knotless.getPreferences | the asker's preferences, kept by the appview, as {preferences}, each by its $type: #appearancePref, {theme, palette, source, night}, once it chose one, night the same three for while the system is dark, if it chose two; #themesPref, {themes}, the themes people made that it added, each {uri, name, creator, palette, addedAt}, once it added one; and #notificationPref always, which it hears of: reasons, each listNotifications reason (from its lexicon's knownValues, so a new one is listed) with enabled, and followedOnly, whether only of what accounts it follows did. Until it says, all, from anyone. listNotifications and getUnreadCount leave out the rest: 401 with no asker, 404 with no index |
dev.knotless.listSubscriptions?limit=&cursor= | what the asker chose to hear of, the latest choice first, 50 a page by default, as {subscriptions: [{subject, level, createdAt}], cursor}: a repository it watches at all or releases, or ignores, by its record's URI under the name it has now, and a pull or an issue it is subscribed to or muted. A repository neither index finds is left out: 401 with no asker, 404 with no index |
dev.knotless.getOwnProfile | the asker's dev.knotless.profile alone, as its settings show it (displayName, description, pronouns, links, avatar's address, bot), with nothing of its Bluesky profile: 401 with no asker |
dev.knotless.getAccountData | what knotless data the account logged in to the pages holds, read from its PDS: {did, collections, repos, spaces}, each dev.knotless.* collection it writes in as {collection, count}, its repositories as {name, private, collaborators, members?} (members, for a private one, the accounts that read it but its runners), and the spaces of its private repositories; the pages' cookie only, 401 otherwise |
dev.knotless.getEmails | {count}, how many addresses the asker's dev.knotless.emails claims: only hashes are kept, so they cannot be listed. 401 with no asker |
dev.knotless.listBlocks | the asker's blocks, the latest first, from its own repo: each uri, subject (an actor), repo when it is from one repository only, and createdAt. Those naming a private repository live in its space and are not listed. 401 with no asker |
dev.knotless.getRepoSettings?repo= | what the owner sets: description, links, default branch, protected branches, labels, collaborators, triagers, gateway, an offer to move it (transfer), the gateway the owner delegated to from these pages (delegated), for a private one, to its owner alone, the members who read but do not push (readers), and the accounts its rules name as gates, each once (gates) |
dev.knotless.getRepoStorage?repo= | what it takes up on its owner's PDS, as knotless repo storage counts it: kept per repository, and counted again only once a write to its packs, claims or refs is seen (for a private one, once a sync of its space finds a write), or the pages compact it |
dev.knotless.getOrg?actor= | an organization's members, as the index has its dev.knotless.org and dev.knotless.orgMember records and its members' dev.knotless.orgAcceptance ones: {org, organization, inside, gateway?, members}, gateway the one its public dev.knotless.org record names, each member {actor, role, standing, invitedAt, expiresAt?, joinedAt?}, the first invited first, standing one of invited, lapsed, public and hidden. To anyone, its members in public; to the organization and its members, logged in or with a service token (inside), its invitations and hidden members too, from its space, which this server keeps an index of; to an account it invited, its own entry. organization: false, with no members, for an account that is none; ProfileNotFound for an unknown account |
dev.knotless.getActorStars?actor= | the public repositories an account starred, newest first, each with its language, starCount and forkCount: 404 with no index, ProfileNotFound for an unknown account |
dev.knotless.listActorItems?actor=&kind=&relation=&state=&limit=&cursor= | the open or closed pulls or issues an account opened, is assigned (relation=assigned) or is asked to review (relation=review), on any public repository, newest first, each with repo, its owner and name |
dev.knotless.getMergeStatus?repo=&pull= | whether an open pull can merge now, as knotless pull merge --check says (below): 401 with no asker, 400 for a pull not open, 404 for none |
dev.knotless.listWorkflows?repo=&ref= | the workflows at a ref (the default branch), in file name order, and what runs each there: push, pull, asked (its owner may ask for it there by name), error; 404 for no such ref |
dev.knotless.listRunners?repo= | the runners the repository names, as accounts, for a public repository the minutes each counts for its owner (dev.knotless.minutes), and the key each publishes to seal secrets to (dev.knotless.runnerKey), as keys, {runner, key} |
getRepo's answer has starCount, how many accounts starred it, and
forkCount, how many forks of it there are, when the server keeps an
index and the repository is public, and viewer.star, the
asker's star, when it has an asker. With an index, a public repository
that has published releases has releases: how many (count), the
release to show under its name (release: the latest of the package
named as the repository, else the newest that is its package's latest,
or the newest of all when each is a pre-release), and
its packages, as listReleases gives them. getProfile's has followersCount
and followsCount when the server keeps an index, and
viewer.following, the asker's follow, and viewer.blocking, its block
from all its repositories, when it has an asker; and orgs, the
organizations it is a member of in public, by actor, and to the account
itself, asking, its hidden memberships too, when it has any; and
organization: true when it is an organization, which its page then
shows the members of.
Logging in and stars
The pages log in and star through these, which exist only on a server with
--public-url, else 404. Each POST but the callback is refused 403 not from this site unless its Origin is the site's own.
| Route | |
|---|---|
GET /oauth/web-client-metadata.json | the pages' OAuth client metadata |
POST /oauth/login | {handle, back}: {url}, the PDS's page to approve on, and a knotless_login cookie for 15 minutes; 400 No account answers to <handle>., 403 <handle> is taken down here. Or {create: true, back}: the same, the page being the sign-up page of the PDS --sign-up-pds names, and the account made there logged in as it comes back |
GET /oauth/app-login?handle=&create=&challenge= | the iOS app's login, in the browser it opens: as POST /oauth/login, with challenge a PKCE S256 challenge, but redirecting to the PDS's page rather than answering it; 400 for another length of challenge. Approved, the callback goes on to /oauth/app-return?code=<code>, setting no session cookie; refused, to ?error=refused |
GET /oauth/app-return | a universal link the app answers; in a browser with no app, a page saying to go back to it |
GET /.well-known/apple-app-site-association | names the iOS app as the one that may answer /oauth/app-return, under applinks, and under webcredentials, which iOS asks of a browser session that comes back to an app over https |
POST /oauth/app-token | {code, verifier}: {did, token}, token the value of a knotless_session cookie, which the app sends as that cookie; 400 for a code unknown, spent, over five minutes old or of an account taken down, or a verifier that isn't the challenge's, which spends the code |
GET /oauth/web-callback | where the PDS sends the browser back: on to back (a path on the site, else /), setting knotless_session for 90 days if approved, and keeping the account it replaces, if any, in knotless_accounts; 400 for a login this browser did not start |
POST /oauth/switch | {did}: makes that account, one knotless_accounts holds, the one in use, keeping the one before in its place; 400 This browser is not logged in to that account. |
GET /oauth/site | ?host=<private site's host>&to=<path>, with --pages-domain: to one logged in and a member of the repository, a redirect to /.knotless/enter on that host with a ticket, signed, for that host alone, that lasts a minute and is taken once; to one not logged in, a redirect to /?login=, where the pages open their login and come back here; 404 for a host that names no private site, or one who is not a member |
POST /oauth/logout | {}: revokes the session, refuses every earlier cookie for the account, and goes on as the other account used last, or clears the cookie if there is none |
POST /xrpc/dev.knotless.starRepo | {repo}: stars it as the account logged in, answering {starCount, star} |
POST /xrpc/dev.knotless.unstarRepo | {repo}: takes the account's star back, answering {starCount} |
POST /xrpc/dev.knotless.updateSeen | {seenAt}: keeps that the asker saw its notifications up to then, unless it saw later ones already, answering the {seenAt} kept; with a service token too, and with the cookie only from the site; 400 for a seenAt that is no datetime |
POST /xrpc/dev.knotless.registerPush | {token, environment}, the device's hexadecimal token from Apple and production or sandbox: sends the asker's notifications to it from now on (Push notifications), answering {}; with a service token too; 400 for a token that is not hexadecimal or an environment it does not know |
POST /xrpc/dev.knotless.unregisterPush | {token}: forgets a device of the asker, answering {}; with a service token too |
POST /xrpc/dev.knotless.followActor | {subject}, a DID: follows it as the account logged in, answering {followersCount, following}; 400 for the account itself, ProfileNotFound for no account |
POST /xrpc/dev.knotless.unfollowActor | {subject}: takes the account's follows of it back, answering {followersCount} |
POST /xrpc/dev.knotless.createTheme | {name, palette}: writes a dev.knotless.theme as the account logged in, and indexes it, answering it as listThemes does; 400 for a name empty or over 64 characters, a scheme not light or dark, a colour not #rrggbb, none of background, text, primary and accent, or a text that would not read at 4.5 to 1 on what it is drawn on, each named, as textMuted on background is 1.2 to 1 |
POST /xrpc/dev.knotless.deleteTheme | {uri}: deletes one of the account's own themes, answering {}; 400 for one that is not its own |
POST /xrpc/dev.knotless.updateProfile | {displayName, description, pronouns, links, removeAvatar, bot}, each optional: changes the account's dev.knotless.profile as knotless account profile does, a field given empty taken out, bot true saying the account is a bot and false that it is not, answering what it holds now, as getOwnProfile; 400 for a field too long or a link that is no http or https address |
POST /xrpc/dev.knotless.uploadAvatar | the picture as the whole body, a PNG, JPEG, WebP or GIF: uploads it, made to fit as collaboration.md says, and names it in the profile, answering as updateProfile; 400 for none, or another kind |
POST /xrpc/dev.knotless.addEmail | {email}: claims it, as knotless account email add, answering {changed, count}; 400 for one with no @ |
POST /xrpc/dev.knotless.removeEmail | {email}: takes the claim back, answering the same |
POST /xrpc/dev.knotless.blockActor | {subject, repo}, a handle or DID, and optionally the at:// URI of one of the account's own repositories: blocks it from all of them or from that one, answering {subject, blocking}; 400 for the account itself, another's repository or a URI that is no repository's |
POST /xrpc/dev.knotless.unblockActor | {subject, repo}: takes those blocks back, answering the same |
POST /xrpc/dev.knotless.putPreferences | {preferences}: each takes the place of its kind whole, and the rest stay, answering all of them as getPreferences. A #notificationPref's reasons not named are on, and followedOnly absent is false; an #appearancePref with no theme follows the system, and logging in to the pages copies its theme to the browser's theme cookie, and for custom, its palette to the palette cookie, and its night's to theme-night and palette-night. A #themesPref holds 50 at most, each a copy, so choosing a theme leaves it be. With a service token too, and with the cookie only from the site; 400 for a kind there is none of, a reason there is none of, a theme not named in a-z, 0-9 and - (64 at most), custom with no palette, a palette not as its lexicon says (its contrast is not checked here), a source that is no dev.knotless.theme, or an added theme whose uri is no dev.knotless.theme, whose palette is not as its lexicon says, whose name is over 64 characters, whose creator is no DID or whose addedAt is no time |
POST /xrpc/dev.knotless.putSubscription | {subject, level}: what the asker hears of a repository (all, releases or ignore) or a pull or an issue (subscribed or muted), from now; participating forgets the choice, but on the asker's own repository, which it watches at all until it chooses, it is kept. Choosing the level it has keeps it as of then, and moving a repository between all and releases keeps its releases as of when it began to hear of them. Answers {subject, level, createdAt}. With a service token too, and with the cookie only from the site; 400 for a subject that is no repository, pull or issue, a level it cannot take, or past 1,000 choices; 404 for a repository the asker cannot read |
POST /xrpc/dev.knotless.createRepo | {name, description?, defaultBranch?, private?}: creates a repository in the account logged in, public or with private a space of its own, as knotless repo create [--private] does, answering {uri, name}; 400 for a name no repository can have, one the account has already, or a private one on a PDS without spaces |
POST /xrpc/dev.knotless.updateRepo | {repo, description?, links?, topics?, defaultBranch?}: as knotless repo edit, answering {description, links, topics, defaultBranch} as they now stand; 400 for a link that is not an http or https address, or more than five, and for a topic that is not letters, digits and dashes, or more than twenty |
POST /xrpc/dev.knotless.renameRepo | {repo, name}: as knotless repo rename, answering {uri, name} |
POST /xrpc/dev.knotless.compactRepo | {repo}: as knotless repo compact, answering {packs, folded, claims} |
POST /xrpc/dev.knotless.transferRepo | {repo, to?, name?}: offers it to the account to under name (its own if absent), or with no to withdraws the offer, as knotless repo transfer, answering {transfer}, where it is offered now |
POST /xrpc/dev.knotless.archiveRepo | {repo, unarchive?}: archives it, making it read only, or with unarchive makes it writable again, as knotless repo archive, answering {archivedAt} while it is archived; one already as asked is 400 |
POST /xrpc/dev.knotless.acceptTransfer | {repo}: as knotless repo accept, by the account it is offered to, answering {uri, name} where it is now |
POST /xrpc/dev.knotless.deleteRepo | {repo}: as knotless repo delete, answering {uri} |
POST /xrpc/dev.knotless.deleteAccountData | {did, limit?}, did the account asking: with the pages' cookie, deletes up to limit (400 by default) of its knotless records and spaces with the session kept for it, as knotless account delete-data does, answering {deleted, done}; asked again, it carries on. Once done, the server has forgotten the account, as one deleted at its PDS, keeping takedowns and reports, ended its sessions, the pages' and MCP's, and logged the browser out. With a service token it deletes nothing, and forgets the account once its PDS holds no knotless record, or answers 400 RecordsRemain. 403 Forbidden for a did not the asker's |
POST /xrpc/dev.knotless.createBranch | {repo, name, from?}: as knotless branch create, answering {name, sha} |
POST /xrpc/dev.knotless.deleteBranch | {repo, name}: as knotless branch delete, answering {name} |
POST /xrpc/dev.knotless.renameBranch | {repo, name, to}: as knotless branch rename, answering {name}, the new one |
POST /xrpc/dev.knotless.protectBranch | {repo, branch, merges?, queue?, reviews?: {approvals?, codeOwners?, dismissStale?, gates?: [{did}]}}: as knotless branch protect, answering {rules} |
POST /xrpc/dev.knotless.unprotectBranch | {repo, branch}: as knotless branch protect --undo, answering {rules} |
POST /xrpc/dev.knotless.defineLabel | {repo, name, color?, description?, exclusive?}: as knotless label define, answering {labels} |
POST /xrpc/dev.knotless.removeLabel | {repo, name}: as knotless label delete, answering {labels} |
POST /xrpc/dev.knotless.shareRepo | {repo, with, write?}: as knotless repo share --write, or with write: false as knotless repo share, letting them only read a private repository, answering {collaborators}; 400 to share for writing before delegating, or a public one for reading |
POST /xrpc/dev.knotless.delegateWrites | {}: starts delegating the account's writes to this server's gateway, as knotless account delegate does, answering {url, state}, the page where the account approves the gateway's grant |
POST /xrpc/dev.knotless.awaitDelegation | {state}: waits up to 25 seconds for that approval, answering {done: false} to ask again, or {done: true, gateway}; 400 for a refused grant or a state not started |
POST /xrpc/dev.knotless.inviteToOrg | {org, member, role?}, member a DID or handle: as knotless org add, by the organization or one of its admins, answering {}. The account's first invitation makes it an organization, through the gateway it delegated to, or this server's when its gateway writes for it; 400 for anyone else, or before delegating |
POST /xrpc/dev.knotless.removeFromOrg | {org, member}: as knotless org remove, taking a member out or withdrawing an invitation, answering {} |
POST /xrpc/dev.knotless.setOrgMembership | {org, visibility}, public or hidden: as knotless org join, joining as the account logged in, or showing or hiding its membership, answering {} |
POST /xrpc/dev.knotless.endOrgMembership | {org}: as knotless org leave, leaving or declining an invitation, answering {warning?}, set when the gateway did not take them out |
POST /xrpc/dev.knotless.deleteOrg | {}: as knotless org delete --yes, ending the organization the account logged in is, answering {} |
POST /xrpc/dev.knotless.unshareRepo | {repo, with}: as knotless repo share --undo, answering {collaborators} |
POST /xrpc/dev.knotless.setTriager | {repo, who, scopes}: as knotless triager add, answering {triagers}, and warning when a private repository's new triager could not be told of it |
POST /xrpc/dev.knotless.addAgent, removeAgent | {repo, agent}, a handle or DID: as knotless agent add and remove, answering {agents}, the accounts it names as agents after |
POST /xrpc/dev.knotless.removeTriager | {repo, who}: as knotless triager remove, answering {triagers} |
POST /xrpc/dev.knotless.forkRepo | {repo, name?}: forks it into the account logged in, under its own name or name, as knotless repo fork does, answering {uri, name}; 404 for no such repository, 400 for a name the account has already |
POST /xrpc/dev.knotless.uploadImage?repo= | a picture as the whole body, a PNG, JPEG, WebP or GIF: uploads it to the account logged in, made to fit, to show in a pull, issue or comment on repo, answering {blob, url, width, height}, the last two the size it is drawn at in CSS pixels (collaboration.md); 400 for anything else. Its PDS keeps it once a write below attaches it |
POST /xrpc/dev.knotless.createIssue | {repo, title, body?, images?, labels?}: opens an issue as the account logged in, answering {uri}, with labels (each {name}) put on as labelItem would; 400, with the template, for a body that follows none of the repository's, and, before anything is written, for a label the account may not put on |
POST /xrpc/dev.knotless.createPull | {repo, base, head, branch, title, body?, images?, draft?, labels?, reviewers?}: opens a pull to merge branch of head, this repository or a fork, into base, as a draft with draft, answering {uri}, with labels put on as createIssue does and reviewers (each {did}) asked to review it; 400 for a branch on neither, a body that follows none of the template, or a label the account may not put on |
POST /xrpc/dev.knotless.createComment | {repo, subject, body, verdict?, images?, lines?}: comments on a pull or issue as the account logged in, or with verdict (approve, changes) reviews a pull; lines, each {path, side?, line, startLine?, body}, comment on the pull's diff, as the review's when it has a body or verdict. A comment as subject answers it in its thread. Answers the first it wrote's {uri}; 400 for a comment that says nothing |
POST /xrpc/dev.knotless.editItem | {repo, subject, title?, body?, images?, draft?, addReviewers?, removeReviewers?}: changes the account's own pull, issue or comment, answering {uri}; draft, for a pull alone, makes it a draft or with false marks it ready, and addReviewers and removeReviewers, each [{did}], for a pull alone, ask accounts to review it or stop asking them. images, each {image, width?, height?} with a blob and size uploadImage gave, are attached to what the body links to (collaboration.md). It, createIssue, createPull and createComment write each @handle of the title and body that resolves as a facet naming its DID (collaboration.md) |
POST /xrpc/dev.knotless.deleteComment | {repo, subject}: deletes the account's own comment or review, answering {uri} |
POST /xrpc/dev.knotless.setItemState | {repo, subject, state}: closes (closed) or reopens (open) a pull or issue, or resolves (resolved) or opens (open) the thread a comment on a pull's lines starts, answering the state record's {uri} |
POST /xrpc/dev.knotless.labelItem | {repo, subject, label, removed?}: puts a label on, or takes it off, answering the {labels} it carries now |
POST /xrpc/dev.knotless.assignItem | {repo, subject, did, role?, removed?}: as knotless pull assign (role assignee, the default) or ask-review (reviewer), or with removed their undoing, answering {assignees, reviewers}, each the accounts as they are now; 400 for what the account may not do, or a role it does not know. getThread's viewer.assign says who may assign |
POST /xrpc/dev.knotless.dismissReview | {repo, pull, reviewer, reason}: as knotless pull dismiss-review, a comment on the pull from the owner or a collaborator naming the reviewer's standing review, answering {uri}; 400 for what the account may not do, or a reviewer with no standing review. getThread's viewer.dismiss says who may |
POST /xrpc/dev.knotless.hideItem | {repo, subject, hidden?}: as the owner, hides a pull, issue or comment, or with hidden: false shows it again, answering {hidden} |
POST /xrpc/dev.knotless.renderMarkdown | {text, repo?}: the HTML the pages show text as once posted, relative links inside the repository repo names and each @handle a link to its profile, answering {html}; needs no login, and 400 for text past 20000 bytes, a body's longest |
POST /xrpc/dev.knotless.createReport | {subject, reason, text?}: reports a handle, knotless:// URL or at:// URI to the moderation service, answering {reportId} |
POST /xrpc/dev.knotless.addReaction | {repo, subject, content}: reacts to a pull, issue or comment the repository's pages show, as the account logged in, answering {subject, reactions}; 400 for a content not among the eight, 404 NotFound for a subject the pages do not show |
POST /xrpc/dev.knotless.removeReaction | {repo, subject, content}: takes the account's reactions of that content to it back, answering {subject, reactions} |
Changing the profile, emails and blocks needs the pages' cookie, as starring does, and writes with the session kept for the account. A change to the profile, or a block from all repositories, is indexed at once, so the next page shows it.
updateRepo, compactRepo, archiveRepo,
protectBranch, unprotectBranch, defineLabel, removeLabel,
shareRepo, unshareRepo, setTriager, removeTriager, addAgent,
removeAgent, addRunner and removeRunner are the owner's, or, for an organization's repository,
an admin's who joined it, written as the organization through the
session this server keeps as its gateway (403 when the organization
delegated elsewhere). An admin asks with the pages' login, or with a
service token from their PDS, lxm the method, as knotless does; a
token is only ever taken that way, for the gateway to write, never for
the pages' session of whoever it names. renameRepo, transferRepo and
deleteRepo are the owner's alone: a rename leaves a redirect, which an
organization's grant to its gateway does not reach. Anyone else logged in is 403 Forbidden, whatever the pages
show them. createBranch, deleteBranch and renameBranch
push as the account logged in, as knotless branch create does: a collaborator
through the gateway, which refuses anyone else. getRepo's
viewer.transfer names where the repository is offered, when it is
offered to the asker.
Starring, following, reacting, writing on pulls and issues and changing a
repository need the pages' cookie: a service token, or none, is 401
AuthenticationRequired. What the knotless crate refuses to write, as
a comment on what is no pull or issue, an edit of someone else's comment,
a label its writer may not set or a default branch the repository does
not have, is 400 InvalidRequest in its words, but for what to do about
it, which names the pages' control that does it rather than a command
(the merge gate). Starring a private repository is 404 NotFound,
as a star is public; a reaction on one is written in its space.
Landing and checks
A pull's page lands it, and the checks pages run its workflows, through
these writes, as the account logged in, with the session the server keeps
for it, through the same code as the command line. Each needs the pages'
cookie as starring does (401 without it, 403 from another site), and
answers a refusal 400 InvalidRequest in the command's words: a pull that
conflicts, checks that have not passed, an account that may not. A
private repository's are made and read as the member logged in.
| Route | |
|---|---|
POST /xrpc/dev.knotless.mergePull | {repo, pull, keepBranch}: merges it as knotless pull merge does, waiting for no checks, answering {commit, deleted, closed: [{uri}]}; 400 into a branch that takes pulls only through the merge queue, which queuePull queues it into |
POST /xrpc/dev.knotless.revertPull | {repo, pull, reason}: as the owner or a collaborator, reverts a merged pull as knotless pull revert does, answering {uri, branch, commit}, uri the pull it opens, which is indexed at once; 400 on a pull not merged, or whose undoing conflicts. getThread's viewer.revert says who may |
POST /xrpc/dev.knotless.queuePull | {repo, pull}: puts it in its branch's merge queue, as knotless pull merge --queue, answering {base, position, of}, and ejectedBefore, why, when queued again at the very head the queue last ejected; 400 when the branch it lands on has no queue rule, and while the queue lands through this server's gateway and the owner has not delegated writes to it |
POST /xrpc/dev.knotless.leaveQueue | {repo, pull}: takes it out, as knotless queue leave, answering {} |
POST /xrpc/dev.knotless.setAutoMerge | {repo, pull, undo}: sets it to merge by itself once its checks pass, as knotless pull merge --auto, answering {commit}, the head it was set at; with undo, cancels that, as --auto --undo, answering {}. 400 into a branch that takes pulls only through the merge queue, in a repository that names no runners, and when it is not set to cancel |
POST /xrpc/dev.knotless.retargetPull | {repo, pull, base, head}: points it at other branches, either or both, as knotless pull retarget, answering {} |
POST /xrpc/dev.knotless.requestRerun | {repo, pull} or {repo, ref}, with workflow, and at a ref env: [{name, value}]: as the owner, asks the runners to run them again, as knotless run rerun, answering {uri}, the dev.knotless.rerun record |
POST /xrpc/dev.knotless.addRunner | {repo, runner}, a handle or DID: as the owner, names it a runner, as knotless runner add, answering {runners, warning}, the warning when a private repository could not tell it |
POST /xrpc/dev.knotless.removeRunner | {repo, runner}: as the owner, names it no longer, answering {runners} |
getMergeStatus answers someone asking, by the cookie or a service token,
from what they may read of the repository, a private one read as them, as
listWorkflows and listRunners are: state (clean,
conflicting with conflicts: [{path}], blocked by its checks or
its reviews, or draft, whatever else holds, until its author marks it
ready), and
reason, why not, in a merge's words, and that the asker may not merge
it when it may not; checks (passed, failed,
running, pending, or none when the repository names no runners or
has no workflow that runs on the pull);
method (merge, or squash into a linear branch; fastForward
from older servers);
deletes, the branch it would delete; base and head, the commits; and
may, what the asker's part in the repository lets it do, not whether
that would work now: merge and queue (owner and
collaborators, the queue only with runners), force (owner), base
(those who may close it), head (its author), rerun (owner, with
runners), autoMerge, setting it to merge when its checks pass (owner
and collaborators, with runners, into a branch with no queue rule;
its author may cancel it, as head says). Outside may, the top-level
autoMerge is there once someone set the pull to merge when its checks
pass: state (set; pushed, cancelled by a push from someone who
may not merge it, pushedBy the account they pushed as; or
retargeted, cancelled by its moving to another branch) and by, who
set it, each an account as the pages show one; and queueBlocked: "undelegated" when the asker may queue it but
no runner the repository names may push, so its queue lands through this
server's gateway, and the owner has not delegated writes to it; and
queueOnly: true when its branch takes pulls only through the merge
queue, where mergePull refuses it and the pages queue it with
queuePull; reviews (passed, blocked, or none when its branch asks
for none), reviewsMissing: [{text}], what they lack,
reviewsUncounted: [{text}], the approvals that do not count and why, and
codeOwnersWarnings: [{text}], the CODEOWNERS lines skipped, as knotless pull merge --check prints them. A merge, queue, retarget, or merge when
checks pass set or cancelled is indexed at once, so the page after
shows it.
ref and path together are split at the longest branch or tag name that
starts them, else after the ref's first segment. The pictures are sent as
they are, never as a page. Served beside them, not as queries:
| Route | |
|---|---|
/<authority>/<repo>/raw/<rev>/<path> | a file's bytes |
/<authority>/<repo>/archive/<rev>.tar.gz, .zip | the files at a branch, tag or commit as one archive, as git archive writes it, in a folder <repo>-<rev> (each character but a letter, a digit, ., _ and - made -), Content-Disposition: attachment with that name and the ending; Cache-Control immutable at a commit's full hash, else no-cache; not compressed again. Another ending, or a revision naming no commit, is 404 |
/<authority>/<repo>/commits.atom, tags.atom, releases.atom | Atom feeds, application/atom+xml, of the newest 30 commits on the default branch (each by its author, its message as text, its id its commit page), tags naming a commit (its id and link the tree at the tag, so two tags of one commit are two entries) and releases (its id its record's URI, its notes as HTML, its link the package's releases); with no entries, one dated at the epoch |
/<authority>/<repo>/releases/download/<package>/<version>/<file> | a file of a release the index has, application/octet-stream, Content-Disposition: attachment with its name, Content-Length, its SHA-256 as ETag (a matching If-None-Match is 304), read once from the owner's PDS, checked against its record and kept, as /releases/ keeps its own (packages.md); not compressed. One the index does not have, taken down, or of a private repository is 404, and one that cannot be read 502 |
/card.png, /<authority>/<repo>/card.png, /<authority>/<repo>/<pull or issue>/<did>/<rkey>/card.png | social cards, 1200×630 PNG |
These routes but the release files and archives are compressed (gzip or br). Answers
are Cache-Control: no-cache, but a raw file or archive at a commit's
full hash is immutable, and a failure no-store. A raw file, archive, release file or
card asked for by an old name answers 307 to the same path under the new
name, and the new owner if it moved, keeping the query.