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.
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).
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-IPandX-Forwarded-Forfrom the connection's address, never from what the client sent. The service believesX-Real-IPonly 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/loginalso 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."}withRetry-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:
- An
Originheader, when present, must be exactly the site's origin,https://<main host>(with:portwhenCOLLAB_HTTPS_PORTis not 443), on GET as well: otherwise 403This request comes from another site (<origin>). - A
Sec-Fetch-Siteheader, when present, must besame-originon a POST, andsame-originornoneon a GET: otherwise 403This request comes from another site. - A POST must carry
X-Collab-Admin: 1(403The request lacks the header X-Collab-Admin: 1.),Content-Type: application/jsonwith no parameter butcharset=utf-8(415Send JSON (Content-Type: application/json).) and, when it has noOrigin, aRefereron the site's origin (403The request names no Origin or Referer of this site.). - Every endpoint but health, login and logout needs a valid session cookie: 401
Sign in first. - A POST body needs a
Content-Lengthand noTransfer-Encoding(411), may not exceed the endpoint's limit (413The request is larger than 4096 bytes.), must arrive whole (400) and must be one strict JSON object: no repeated keys, no NaN (400The request is not valid JSON (the key 'site' appears twice)., 400Send 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_TOKENin/etc/collab/collab.env: 32 hex characters written by the first install.sudo collab admin-tokenprints it;sudo collab admin-token --newreplaces 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 ofv1.<id>.<expiry>in base64url. The key is derived fromCOLLAB_SESSION_SECRET, the admin token and a per-install salt kept inshared/status/sessions.json. Sessions last 12 hours. - Failed sign-ins are limited per address: 10 failures in 10 minutes, then 429 with
Retry-Afterin 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 logoutwrites the id of every valid session cookie the request carries intosessions.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 runsudo collab admin-token --new, and the cookie is cleared all the same. sessions.jsonis 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.
{"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).
{"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, undeployed, trimmed:
{
"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) |
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 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.
{"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:
{"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 has the schema, and Site settings what each setting changes.
{"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:
{"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).
{"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:
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:8481Then 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:
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.
Related
- The admin page: the page this API serves.
- Publish, unpublish and check: publishing from the operator's side.
- Data formats: the record, site.json and config.js.
- Operator security checklist: secrets, users and permissions on the server.
- Security: the server kit's review (SV22).