<!-- MIT License. Copyright (c) 2026 Decentralized Science Labs LLC. -->
<!-- summary: Every endpoint of the admin service behind /admin/api/: requests, responses, errors, sign-in, sessions and the cross-site checks. -->
# Admin API reference

The server kit's admin service, `server/admin/collab_admin.py`, answers a small JSON API under `/admin/api/` on a COLLAB server. This page documents every endpoint with its request, response and errors, the sign-in and session rules, and the checks that keep other sites from using an operator's session. It is for developers who work on the admin page or the server kit; operators will find the page itself in [The admin page](admin.md).

## Who it is for

The API has one client: the site's own admin page at `/admin` (`web/js/admin/api.js` and `session.js`). No third-party clients are supported. The endpoints and their shapes may change in any release, and the cross-site checks are built for a page served from the same origin. On the server itself, use the `collab` helper instead (`sudo collab status`, `sudo collab prerender`, `sudo collab admin-token`; see [Status, logs and the collab command](monitoring.md)).

The public site never calls the API, and keeps working while the admin service is stopped. The admin page's Deploy and Check tabs work without it too; Status, Publish and Site settings need it.

## Endpoints at a glance

| Method | Path | Signed in | Body limit | Returns |
|---|---|---|---:|---|
| GET | `/admin/api/health` | no | | `{ok, version}` |
| POST | `/admin/api/login` | no | 4 KB | `{ok, expires}` and the session cookie |
| POST | `/admin/api/logout` | no | 4 KB | `{ok}` and a cleared cookie |
| GET | `/admin/api/state` | yes | | the state document |
| POST | `/admin/api/deployment` | yes | 64 KB | `{ok, config, policy}` |
| POST | `/admin/api/site` | yes | 64 KB | `{ok, site, config, policy}` |
| POST | `/admin/api/prerender` | yes | 4 KB | `{ok, requested}` |
| GET | `/admin/api/prerender/log` | yes | | `{lines}` |

Times are ISO 8601 in UTC, such as `"2026-11-02T17:14:48Z"`. Any other path answers 404, and any other method 405 with an `Allow` header.

## The nginx front

The service listens on `127.0.0.1` only, on `COLLAB_ADMIN_PORT` from `/etc/collab/collab.env` (default 8411), in plain HTTP. nginx proxies `/admin/api/` to it:

- It adds `X-Collab-Proxy: <COLLAB_PROXY_SECRET>` from `/etc/collab/nginx-proxy.conf` (mode 0600). The service refuses every request that does not carry exactly one such header with the right value, compared in constant time: 403 `{"error": "Requests must come through the web server."}`.
- It sets `X-Real-IP` and `X-Forwarded-For` from the connection's address, never from what the client sent. The service believes `X-Real-IP` only on a request with the proxy secret, and counts failed sign-ins by it.
- Rate limits per address: 5 requests a second with a burst of 40 on `/admin/api/`, and on `/admin/api/login` also 6 a minute with a burst of 10. Over a limit nginx answers 429 `{"error": "Too many requests from your address. Wait a few seconds."}` with `Retry-After: 10`.
- When the service is not running, nginx answers 503 `{"error": "The admin service is not running (sudo systemctl status collab-admin)."}`.
- nginx accepts bodies of up to 256 KB on `/admin/api/` and 4 KB on the login; the service's own limits are those in the table above.

Every answer from the service carries `Content-Type: application/json; charset=utf-8`, `Cache-Control: no-store` and `X-Content-Type-Options: nosniff`; nginx adds the site's security headers and `X-Robots-Tag: noindex, nofollow`.

## Cross-site request checks

After the proxy secret, the path and the method, the service checks each request in this order:

1. An `Origin` header, when present, must be exactly the site's origin, `https://<main host>` (with `:port` when `COLLAB_HTTPS_PORT` is not 443), on GET as well: otherwise 403 `This request comes from another site (<origin>).`
2. A `Sec-Fetch-Site` header, when present, must be `same-origin` on a POST, and `same-origin` or `none` on a GET: otherwise 403 `This request comes from another site.`
3. A POST must carry `X-Collab-Admin: 1` (403 `The request lacks the header X-Collab-Admin: 1.`), `Content-Type: application/json` with no parameter but `charset=utf-8` (415 `Send JSON (Content-Type: application/json).`) and, when it has no `Origin`, a `Referer` on the site's origin (403 `The request names no Origin or Referer of this site.`).
4. Every endpoint but health, login and logout needs a valid session cookie: 401 `Sign in first.`
5. A POST body needs a `Content-Length` and no `Transfer-Encoding` (411), may not exceed the endpoint's limit (413 `The request is larger than 4096 bytes.`), must arrive whole (400) and must be one strict JSON object: no repeated keys, no NaN (400 `The request is not valid JSON (the key 'site' appears twice).`, 400 `Send a JSON object.`). An empty body counts as `{}`. Fields an endpoint does not know are refused with 400 and the field.

A page on another site cannot meet these: browsers send the custom header and the JSON type only after a CORS preflight, which the service never grants (OPTIONS answers 405), and the session cookie is `SameSite=Strict` on `Path=/admin`.

## Sign-in and sessions

- The admin token is `COLLAB_ADMIN_TOKEN` in `/etc/collab/collab.env`: 32 hex characters written by the first install. `sudo collab admin-token` prints it; `sudo collab admin-token --new` replaces it, which ends every session.
- A sign-in returns the cookie `collab_admin=v1.<id>.<expiry>.<mac>; Path=/admin; Max-Age=43200; HttpOnly; Secure; SameSite=Strict`: a random session id, its expiry in Unix seconds, and an HMAC-SHA256 of `v1.<id>.<expiry>` in base64url. The key is derived from `COLLAB_SESSION_SECRET`, the admin token and a per-install salt kept in `shared/status/sessions.json`. Sessions last 12 hours.
- Failed sign-ins are limited per address: 10 failures in 10 minutes, then 429 with `Retry-After` in seconds, even with the right token, until the oldest failure is 10 minutes old. An IPv6 /64 counts as one address. A successful sign-in clears the address's count.
- Sign-out is durable. `POST logout` writes the id of every valid session cookie the request carries into `sessions.json`, which the service reads again at every start, so a signed-out cookie stays signed out after a restart. When that file cannot be written, the answer is 500 with an error that says to sign out again or run `sudo collab admin-token --new`, and the cookie is cleared all the same.
- `sessions.json` is read strictly (at most 256 KiB, nesting at most 4, plain ids and whole-number expiries). A missing file, on the first start, or a damaged one starts a fresh salt, which ends every session, with a warning in the log.

## Errors

Every error is a JSON object with `error`, a sentence meant for the operator, and `field` when one value was refused: its place in the posted record or settings, with dots between keys and list indexes (`"factory"`, `"gl1f.0.name"`, `"featuredDaos.3"`, `"rpcs.1.url"`), `""` for the object as a whole, and `"token"` for a wrong token. The admin page maps `field` onto the control that holds that value.

| Status | When |
|---:|---|
| 400 | a body that is not a JSON object, an unknown field, or a refused value (with `field`) |
| 401 | not signed in, or a wrong token at sign-in |
| 403 | no proxy secret, or a request from another site |
| 404 | no such endpoint |
| 405 | the wrong method, with `Allow` |
| 411 | no `Content-Length` |
| 413 | a body over the endpoint's limit |
| 415 | not `application/json` |
| 429 | too many failed sign-ins (the service, with `Retry-After`) or too many requests (nginx, `Retry-After: 10`) |
| 500 | a sign-out that could not be saved, stored files that cannot be read, or a failure of the service (`The admin service failed; its log says why (journalctl -u collab-admin).`) |
| 503 | the service is not running (nginx) |

The service logs one line per request (address, method, path, status, time) and every sign-in and failure to the journal of `collab-admin.service`.

## Health

`GET /admin/api/health` needs no sign-in, only the proxy secret that nginx adds.

```json
{"ok": true, "version": "2.2.0"}
```

`version` is the installed release (`COLLAB_VERSION`).

## Sign in

`POST /admin/api/login` with `{"token": "<admin token>"}` (the only field, a string of at most 512 characters).

```json
{"ok": true, "expires": "2026-11-02T17:14:48Z"}
```

The cookie comes in `Set-Cookie`. A wrong token answers 401 `{"error": "That is not the admin token (sudo collab admin-token shows it).", "field": "token"}`; after ten failures in 10 minutes the next sign-in answers 429 `{"error": "Too many failed sign-ins from your address. Try again in 10 minute(s)."}` with `Retry-After` in seconds (600 right after ten quick failures).

## Sign out

`POST /admin/api/logout` with `{}` answers `{"ok": true}` and `Set-Cookie: collab_admin=; Path=/admin; Max-Age=0; HttpOnly; Secure; SameSite=Strict`. It needs no valid session: it revokes whatever valid cookies it is sent and clears the cookie either way.

## State

`GET /admin/api/state` returns everything the Status tab shows. From the local service of [Running it locally](#running-it-locally), undeployed, trimmed:

```json
{
  "version": "2.2.0",
  "main": "127.0.0.1",
  "aliases": [],
  "origin": "https://127.0.0.1:8443",
  "chainId": 29,
  "deployment": null,
  "site": {"alpha": {"show": true, "text": "Alpha: COLLAB runs on GenesisL1 mainnet and has had no external audit. Use small amounts."}, "announcement": null, "…": "…"},
  "configJs": "/* MIT License. Copyright (c) 2026 Decentralized Science Labs LLC. */\n…",
  "prerender": {"state": "requested", "lastRun": null, "lastOk": null, "error": "", "warnings": [], "note": "",
                "seconds": null, "requested": true, "nextRun": "2026-11-02T05:14:59Z"},
  "policy": {"config": "default-src 'none'; …", "pages": "default-src 'none'; …", "inStep": true, "pending": false, "last": null},
  "certificates": [],
  "certificatesMissing": [],
  "tls": "self-signed",
  "disk": {"path": "/tmp/collab-admin/shared", "total": 270553174016, "free": 10067013632},
  "time": "2026-11-02T05:14:59Z"
}
```

On an installed server `certificates` lists each name with its expiry (`[{"name": "collab.genesisl1.com", "notAfter": "…"}]`) and `tls` is `letsencrypt`.

| Field | Meaning |
|---|---|
| `version`, `main`, `aliases`, `origin`, `chainId` | the installed release, the host names, the site's origin and its chain |
| `deployment` | the published record (`shared/deployments/<chainId>.json`), or `null` |
| `site` | `shared/site.json`, normalized: every key present ([Data formats](data-formats.md#site-settings)) |
| `configJs` | the text of `shared/config.js` |
| `prerender.state` | `never` (no run yet), `requested` (a request is waiting), `running`, `ok`, `warn` (it finished but warned, for example the L1 price or the tokens were not refreshed) or `failed` |
| `prerender.lastRun`, `lastOk`, `seconds` | the last run's start, the last successful run, its duration |
| `prerender.error`, `warnings`, `note` | a failed run's last error line (else its last warning, "" on success); up to 20 warning lines of up to 300 characters; a note such as "not deployed yet: …" |
| `prerender.requested`, `nextRun` | whether a request waits; now when one does, else the next run of the 10-minute timer |
| `policy` | `config`: the Content-Security-Policy that config.js needs; `pages`: the one the release's pages carry; `inStep`: whether they match; `pending`: whether the policy step was requested; `last`: the last policy step's result, or `null` |
| `certificates`, `certificatesMissing`, `tls` | each name with a certificate and its expiry, the names without one, and `letsencrypt` or `self-signed` |
| `disk` | total and free bytes of the filesystem that holds `shared/`, or `null` |
| `time` | the server's clock |

## Publish a deployment

`POST /admin/api/deployment` with `{"record": {...}}` publishes a deployment record on the site; `{"record": null}` unpublishes it.

The record is the format of `deployments/<chainId>.json` and is checked by `check_record()` of `tools/write_web_config.py`, with the site's chain and release: a JSON object of at most 64 keys, `chainId` equal to the site's (29), a numeric `startBlock`, a `version` of the release's major and minor version ("COLLAB DAO v2.2.x" for 2.2.0), the eleven contract keys plus `policyBuilder`, other keys only addresses, an optional `rpc` URL, distinct non-zero addresses with a valid EIP-55 checksum when mixed case, and the `gl1f` and `cipnft` lists checked as config.js checks them. [Data formats](data-formats.md#deployment-records) has the full schema.

On success the service stores the record (the one it replaces goes to `shared/history/`), writes `config.js` from the record and the site settings, requests a prerender, and requests the policy step when the pages' policy no longer fits.

```json
{"ok": true, "config": {"appName": "COLLAB", "chainId": 29, "…": "…", "deployed": true, "addresses": {"factory": "0x…", "…": "…"}, "site": {"…": "…"}},
 "policy": {"config": "…", "pages": "…", "inStep": true, "pending": false, "last": null}}
```

`config` is the configuration object that config.js now holds. Refusals name the field:

```json
{"error": "the record is for COLLAB DAO v2.1.0, but this site runs COLLAB 2.2.0, which needs COLLAB DAO v2.2.x contracts", "field": "version"}
{"error": "the address for factory has a wrong checksum: 0x84EA74d481ee0a5332c457a4d796187f6ba67feb", "field": "factory"}
{"error": "this site serves chain 29; the record is for chain 1", "field": "chainId"}
```

The API checks the record's form; it reads no chain. The admin page runs the Check tab's on-chain checks (the code and wiring of every contract) before it unlocks Publish. Unpublishing writes an undeployed config.js and moves the record to the history.

## Save the site settings

`POST /admin/api/site` with `{"site": {...}}` replaces the site settings with the object sent: keys it leaves out take their defaults. `check_site()` checks them strictly and refuses unknown keys; [Data formats](data-formats.md#site-settings) has the schema, and [Site settings](site-settings.md) what each setting changes.

```json
{"ok": true, "site": {"alpha": {"show": true, "text": "Alpha: …"}, "announcement": {"text": "Voting on the 2027 budget is open.", "href": "/votes", "tone": "info"}, "…": "…"},
 "config": {"…": "…"}, "policy": {"…": "…"}}
```

`site` is the normalized settings as stored. The service writes `shared/site.json` (the old one goes to the history) and config.js; when config.js changed it requests a prerender, and when the needed policy changed (analytics or RPC endpoints, for example) the policy step. Refusals, for example:

```json
{"error": "announcement.tone must be \"info\" or \"warn\"", "field": "announcement.tone"}
{"error": "featuredDaos.0 must be a DAO's slug (3 to 48 of a-z, 0-9 and -, a letter first, no --, no - at the end), got 'Open-Lab'", "field": "featuredDaos.0"}
{"error": "refusing rpcs.0.url http://rpc.example.org: use https (http only for localhost)", "field": "rpcs.0.url"}
{"error": "colour is not a setting (known: alpha, announcement, featuredDaos, hiddenDaos, verifiedTokens, hiddenTokens, seo, links, analytics, rpcs, explorer)", "field": "colour"}
```

## Request a prerender

`POST /admin/api/prerender` with `{}` writes `shared/jobs/prerender.request`; `collab-prerender.path` then starts the job as the user `collab-prerender`. A request that arrives during a run makes the job run again when it ends (three rounds at most).

```json
{"ok": true, "requested": "2026-11-02T05:15:15Z"}
```

Any field in the body is refused (400 `unknown field 'x' (send an empty object)`). Follow the run in `prerender` of `GET state`.

## The prerender log

`GET /admin/api/prerender/log` returns `{"lines": [...]}`: the last 200 lines of `shared/jobs/prerender.log` (from its last 64 KB), `[]` before the first run.

## Running it locally

The unit tests and the admin page's end-to-end tests run the service outside an installed server, from the repository. To try the API by hand, write a throwaway settings file with the test helper, write the undeployed config, and serve:

```bash
python3 - <<'EOF'
import sys; sys.path.insert(0, "server/tests")
from adminenv import write_env
env = write_env("/tmp/collab-admin", main="127.0.0.1", https_port=8443, admin_port=8481, release=".")
print("token", env.token, "proxy", env.proxy_secret)
EOF
python3 server/admin/collab_admin.py config --env /tmp/collab-admin/collab.env
python3 server/admin/collab_admin.py serve --env /tmp/collab-admin/collab.env
# COLLAB admin service 2.2.0 for https://127.0.0.1:8443 on 127.0.0.1:8481
```

Then send what nginx and the admin page would send: the proxy secret, and an `Origin` of the configured origin. The examples pass the cookie in a header:

```bash
A=http://127.0.0.1:8481/admin/api; O=https://127.0.0.1:8443
PX=<proxy secret>; TOKEN=<admin token>
curl -s -H "X-Collab-Proxy: $PX" $A/health
COOKIE=$(curl -s -D - -o /dev/null -H "X-Collab-Proxy: $PX" -H "Origin: $O" -H 'X-Collab-Admin: 1' \
  -H 'Content-Type: application/json' --data "{\"token\": \"$TOKEN\"}" $A/login \
  | sed -n 's/^Set-Cookie: collab_admin=\([^;]*\);.*/\1/p')
curl -s -H "X-Collab-Proxy: $PX" -H "Origin: $O" -H "Cookie: collab_admin=$COOKIE" $A/state
curl -s -H "X-Collab-Proxy: $PX" -H "Origin: $O" -H 'X-Collab-Admin: 1' -H 'Content-Type: application/json' \
  -H "Cookie: collab_admin=$COOKIE" --data @deployment.json $A/deployment   # {"record": {...}}
```

For the admin page itself in a browser, `python3 tools/e2e/admin_site.py --port 8876 --rpc http://127.0.0.1:<sandbox rpc>` runs the same service behind a local HTTPS front that does what nginx does. The tests are in [Testing](testing.md#server-tests).

## Related

- [The admin page](admin.md): the page this API serves.
- [Publish, unpublish and check](publish.md): publishing from the operator's side.
- [Data formats](data-formats.md): the record, site.json and config.js.
- [Operator security checklist](server-security.md): secrets, users and permissions on the server.
- [Security](SECURITY.md#release-220-internal-review-of-the-server-kit): the server kit's review (SV22).
