~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

7
755 lines · 67.6 KB Raw History
# HTTP and XRPC API

What `knotless-serve` answers over HTTP. Which flags turn each part on is
in [Commands](cli.md#knotless-serve); how the parts work is in
[knotless-serve](../design/serve.md).

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](#appview) or [Web data](#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](../design/access.md#browsing)).
`GET /<owner>/<name>/raw/<rev>/<path>` and `/archive/<rev>.zip` 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>s`
- `no feed is bringing events`
- `index: <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`.

### `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. Asked as an account, the first page
with no `runner`, `triager` or `topic` starts with the private repositories
matching `q` that it may read here, at most `limit` more, each `private`:
its own, and those it was proven a member of, as it read one or as it
was asked after in the background. The answer is then that account's
alone.

### `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?`) 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. 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?}`, `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` |
| `dev.knotless.getFeed#commented` | `subject`, `title`, `text`, `verdict?` |
| `dev.knotless.getFeed#changed` | `subject`, `title`, `state` |
| `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`. 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](../design/serve.md#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](../../deploy/README.md#usage)).

## Live

The pages follow what happens as it happens as server-sent events
([serve](../design/serve.md#live)). 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](../design/runner.md#private-repositories)). 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](../design/runner.md#the-runners-key)). A repository is named by
its owner, the caller, and its key, never its name, so a rename keeps
its secrets. 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 | `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 | `repo`; `name`, `runner`, or both, to delete only those | `deleted`, how many of the caller'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, with `sealed` only on those sealed to it; 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](../design/access.md#an-index-of-every-one-read-here)).
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](../design/runner.md#output-as-it-is-printed)), 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](../design/gateway.md)
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](../design/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.

## Images

An OCI registry of the images repositories publish as releases, with
`knotless publish --oci` or a push here
([packages.md](../design/packages.md#images)). 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`](../../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, and `archivedAt` if its owner archived it; with an asker, `viewer`: its `star`, a `transfer` offered to it, and the `labels` it may put on the repository's pulls and issues |
| `dev.knotless.listReleases?repo=&package=&limit=&cursor=` | its releases ([packages.md](../design/packages.md#on-the-pages)), newest first, 30 a page by default, of one `package` if given: each with `latest` when it is its package's latest, `image` when its files are an OCI layout's, 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.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=` | a commit and its diff |
| `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](../design/collaboration.md#filtering-pulls-and-issues)), `review-requested:@me` the asker, each with `draft` for a draft pull and the `reviewers` it asks, 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, 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`; 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), 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, 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 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](../design/serve.md#shared-steps)), 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; `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`. 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), `pulls-and-issues`, `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.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`), `storage`, as `knotless storage` counts it, the gateway the owner delegated to from these pages (`delegated`), and for a private one, to its owner alone, the members who read but do not push (`readers`) |
| `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.getMergeStatus?repo=&pull=` | whether an open pull can merge now, as `knotless merge --check` says ([below](#landing-and-checks)): 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, 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/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.` |
| `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.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 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](../design/collaboration.md#pictures) 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 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.createRepo` | `{name, description?, defaultBranch?, private?}`: creates a repository in the account logged in, public or with `private` a space of its own, as `knotless 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 describe`, 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 rename`, answering `{uri, name}` |
| `POST /xrpc/dev.knotless.compactRepo` | `{repo}`: as `knotless 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 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 archive`, answering `{archivedAt}` while it is archived; one already as asked is 400 |
| `POST /xrpc/dev.knotless.acceptTransfer` | `{repo}`: as `knotless accept`, by the account it is offered to, answering `{uri, name}` where it is now |
| `POST /xrpc/dev.knotless.deleteRepo` | `{repo}`: as `knotless 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 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`, 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?}}`: as `knotless protect`, answering `{rules}` |
| `POST /xrpc/dev.knotless.unprotectBranch` | `{repo, branch}`: as `knotless unprotect`, answering `{rules}` |
| `POST /xrpc/dev.knotless.defineLabel` | `{repo, name, color?, description?, exclusive?}`: as `knotless labels add`, answering `{labels}` |
| `POST /xrpc/dev.knotless.removeLabel` | `{repo, name}`: as `knotless labels remove`, answering `{labels}` |
| `POST /xrpc/dev.knotless.shareRepo` | `{repo, with, write?}`: as `knotless share --write`, or with `write: false` as `knotless 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 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 unshare`, answering `{collaborators}` |
| `POST /xrpc/dev.knotless.setTriager` | `{repo, who, scopes}`: as `knotless triagers add`, answering `{triagers}` |
| `POST /xrpc/dev.knotless.removeTriager` | `{repo, who}`: as `knotless triagers 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 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](../design/collaboration.md#pictures)); 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?}`: 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. `images`, each `{image, width?, height?}` with a blob and size `uploadImage` gave, are attached to what the body links to ([collaboration.md](../design/collaboration.md#pictures)). It, `createIssue`, `createPull` and `createComment` write each `@handle` of the title and body that resolves as a facet naming its DID ([collaboration.md](../design/collaboration.md#mentions)) |
| `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.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`, `renameRepo`, `compactRepo`, `transferRepo`, `archiveRepo`, `deleteRepo`,
`protectBranch`, `unprotectBranch`, `defineLabel`, `removeLabel`,
`shareRepo`, `unshareRepo`, `setTriager` and `removeTriager` are the
owner's alone: 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` 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. 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 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 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 merge --queue`, answering `{base, position, of}`; 400 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.retargetPull` | `{repo, pull, base, head}`: points it at other branches, either or both, as `knotless 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 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 runners 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` (`fastForward`, `merge`, `squash` into a linear branch);
`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); 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
merge --check` prints them. A merge, queue or retarget 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>/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](../design/packages.md#on-the-pages)); 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.