apps/ui: Warp's design system for Vue 3 + Tailwind v4. Tokens, fonts, icons and logo come from the brand package, with a Tailwind theme on the tokens and Vue components on the brand component styles. apps/admin: the admin panel as a separate, optional service. warp-admin embeds the built Vue app and proxies only the login endpoints and /_warp/admin/v1 to the homeserver. Pages: dashboard, users, rooms, federation, registration and tokens, server notices, application services, audit log. Also: pnpm workspace, go.work entry, Dockerfile, an `admin` compose profile, Makefile targets, a CI job with a proxy smoke test, and docs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
546 lines
18 KiB
Markdown
546 lines
18 KiB
Markdown
# Admin API
|
|
|
|
Everything under `/_warp/admin/v1` is Warp's own, not Matrix. Clients use
|
|
the Client-Server API; this is the operator's surface and the backend for
|
|
the admin panel ([`apps/admin`](../apps/admin/README.md), a separate,
|
|
optional `warp-admin` service).
|
|
|
|
## Authentication
|
|
|
|
A normal Matrix access token belonging to an account with the admin flag.
|
|
The panel logs in the way any client does, so there is no second credential
|
|
to manage:
|
|
|
|
```sh
|
|
TOKEN=$(curl -s localhost:8008/_matrix/client/v3/login \
|
|
-d '{"type":"m.login.password","identifier":{"type":"m.id.user","user":"root"},"password":"..."}' \
|
|
| jq -r .access_token)
|
|
|
|
curl localhost:8008/_warp/admin/v1/stats -H "Authorization: Bearer $TOKEN"
|
|
```
|
|
|
|
The token must be in the `Authorization` header. Unlike the C-S API, the
|
|
`?access_token=` form is not accepted here: an admin token in a URL ends up
|
|
in every access log between the browser and the server.
|
|
|
|
A valid token without the admin flag gets `403 M_FORBIDDEN` rather than a
|
|
404. The caller is a legitimate user of this server who is not allowed here,
|
|
and pretending the endpoint does not exist only makes the panel harder to
|
|
debug.
|
|
|
|
Make the first admin from the command line:
|
|
|
|
```sh
|
|
./bin/warp create-account --username root --admin
|
|
```
|
|
|
|
## Endpoints
|
|
|
|
### `GET /federation`, `PUT /federation`
|
|
|
|
The runtime federation switch of PLAN.md section 3.
|
|
|
|
```json
|
|
{
|
|
"enabled": false,
|
|
"toggleable": true,
|
|
"allowlist": [],
|
|
"denylist": [],
|
|
"lists_edited": false
|
|
}
|
|
```
|
|
|
|
`PUT {"enabled": true}` takes effect on the next request, with no restart,
|
|
and answers with the same document. `toggleable` is `false` when
|
|
`federation.allow_runtime_toggle` pinned the setting in the config file; a
|
|
`PUT` then returns `409` and the panel should grey the switch out rather
|
|
than offer something that will bounce.
|
|
|
|
The lists are lower-cased and sorted. `lists_edited` is `true` when they
|
|
were set through `PUT /federation/lists` and replace the config file's.
|
|
|
|
### `PUT /federation/lists`, `DELETE /federation/lists`
|
|
|
|
```json
|
|
{"allowlist": ["friend.example"], "denylist": []}
|
|
```
|
|
|
|
Replaces both lists. They take effect on the next request in either
|
|
direction, and are kept in `server_settings` in place of the config
|
|
file's, across restarts. Both have to be in the body (`[]` for an empty
|
|
one): a panel that sent one and had the other silently kept would be one
|
|
bug away from leaving a server it meant to cut off on the allowlist. A
|
|
name that is not a server name is `400 M_INVALID_PARAM`.
|
|
|
|
`DELETE` forgets the edited lists and puts the config file's back. Both
|
|
answer with the `GET /federation` document.
|
|
|
|
Unlike the switch, the lists are editable whatever
|
|
`federation.allow_runtime_toggle` says: they are not a switch the file can
|
|
pin.
|
|
|
|
### `GET /federation/destinations`
|
|
|
|
Delivery health per remote server: the panel's federation status view.
|
|
Every server this one has queued something for, or has failed to reach,
|
|
with how much it is still owed.
|
|
|
|
```json
|
|
{
|
|
"running": true,
|
|
"destinations": [
|
|
{
|
|
"server_name": "matrix.org",
|
|
"failures": 0,
|
|
"retry_at_ms": 0,
|
|
"last_success_ms": 1760600000000,
|
|
"last_failure_ms": 0,
|
|
"queued_pdus": 0,
|
|
"queued_edus": 0
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
`running` is whether the outbound queue is up — `false` while federation is
|
|
switched off. `failures` counts consecutive failed sends; while it is above
|
|
zero the server is being backed off, and `retry_at_ms` is when the next
|
|
attempt is due. The backoff doubles from ten seconds to six hours.
|
|
|
|
A server started without a signing key has no federation at all, and both
|
|
destination endpoints answer `404`.
|
|
|
|
### `POST /federation/destinations/{serverName}/reset`
|
|
|
|
Forget a server's backoff and try it now — for an operator who knows the
|
|
server is back and does not want to wait out a six-hour retry. Answers
|
|
`{}`.
|
|
|
|
### `GET /registration`, `PUT /registration`
|
|
|
|
The same shape, without the lists, for open registration.
|
|
|
|
### `GET /users`
|
|
|
|
```
|
|
GET /users?search=ali&limit=50&offset=0
|
|
```
|
|
|
|
Ordered by localpart. `search` matches a substring of the localpart or the
|
|
display name, case-insensitively. `total` is the number of users matching
|
|
the query rather than the number returned, so the panel can page.
|
|
|
|
```json
|
|
{
|
|
"users": [
|
|
{
|
|
"user_id": "@alice:warp.example",
|
|
"display_name": "Alice",
|
|
"admin": false,
|
|
"deactivated": false,
|
|
"created_at_ms": 1760000000000
|
|
}
|
|
],
|
|
"total": 1,
|
|
"limit": 50,
|
|
"offset": 0
|
|
}
|
|
```
|
|
|
|
### `POST /users`
|
|
|
|
```json
|
|
{"username": "alice", "password": "...", "display_name": "Alice", "admin": false}
|
|
```
|
|
|
|
`201` with the created user. This is how an operator adds an account to a
|
|
server with registration turned off — which is the normal state of a
|
|
private Warp — so it ignores the registration switch deliberately: it is
|
|
the admin's own action, not a public sign-up. `username` is normalised
|
|
(lower-cased) like any other localpart. A taken name is
|
|
`400 M_USER_IN_USE`, an unusable one `400 M_INVALID_USERNAME`.
|
|
|
|
### One user: `/users/{userID}`
|
|
|
|
`{userID}` is a full user ID of this server, `@alice:warp.example`. A user
|
|
of another server is `400 M_INVALID_PARAM`, one that does not exist
|
|
`404 M_NOT_FOUND`.
|
|
|
|
`GET /users/{userID}` is the list row plus what it takes a count to know:
|
|
|
|
```json
|
|
{
|
|
"user_id": "@alice:warp.example",
|
|
"display_name": "Alice",
|
|
"admin": false,
|
|
"deactivated": false,
|
|
"created_at_ms": 1760000000000,
|
|
"devices": 2,
|
|
"joined_rooms": 5,
|
|
"media": {"uploads": 12, "bytes": 3145728, "quota_bytes": 104857600, "custom_quota": false}
|
|
}
|
|
```
|
|
|
|
`joined_rooms` is absent on a server without a signing key, which has no
|
|
rooms. Every change below answers with this document, so the panel can
|
|
redraw from the response.
|
|
|
|
| Endpoint | Body | What |
|
|
|---|---|---|
|
|
| `PUT /users/{userID}/admin` | `{"admin": true}` | grant or revoke the admin flag |
|
|
| `POST /users/{userID}/deactivate` | `{"erase": false}` (optional) | deactivate the account |
|
|
| `POST /users/{userID}/reactivate` | — | let it log in again |
|
|
| `POST /users/{userID}/password` | `{"password": "...", "logout_devices": true}` | set a new password |
|
|
|
|
**Deactivation** does what the user's own `POST /account/deactivate` does.
|
|
Every token stops working at once; the devices go, with their keys and
|
|
pushers; the user leaves every room, rejects every invite and withdraws
|
|
every knock. With `erase`, the display name and avatar are cleared first,
|
|
so the leave events carry neither. The name stays taken: a new user with
|
|
the old user's ID would inherit what other servers and clients remember
|
|
about the old one. Deactivating a deactivated account changes nothing.
|
|
|
|
**Reactivation** gives back the ability to log in, with the old password
|
|
unless a new one is set. Nothing else comes back: the rooms were left and
|
|
the devices are gone.
|
|
|
|
**A password reset** logs every device out unless `logout_devices` is
|
|
`false`. An admin resetting a password is usually doing it because
|
|
somebody else has the account.
|
|
|
|
An admin cannot revoke their own admin flag or deactivate themselves
|
|
(`403`). Both are one click from locking the last admin out of the panel,
|
|
and both are easy to do by accident on a table of users. Another admin can
|
|
still do either.
|
|
|
|
### `GET /users/{userID}/devices`, `DELETE /users/{userID}/devices[/{deviceID}]`
|
|
|
|
```json
|
|
{"devices": [{"device_id": "QWERTYUIOP", "display_name": "Phone", "created_at_ms": 1760000000000,
|
|
"last_seen_ms": 1760600000000, "last_seen_ip": "203.0.113.4", "user_agent": "Element/1.11"}]}
|
|
```
|
|
|
|
`DELETE` on one device logs that session out, taking its keys and pushers
|
|
with it. An unknown device is `404`. On the collection it logs out every
|
|
device. Unlike the client's own device deletion, no password is asked for:
|
|
the admin token is the authority here.
|
|
|
|
### `GET /users/{userID}/media`, `PUT`/`DELETE /users/{userID}/media/quota`
|
|
|
|
```json
|
|
{"uploads": 12, "bytes": 3145728, "quota_bytes": 104857600, "custom_quota": false}
|
|
```
|
|
|
|
What the user has uploaded, against their quota. Every upload counts at
|
|
its own size, even when two are the same bytes and the server keeps one
|
|
copy: a quota is about what a user asked this server to keep.
|
|
`quota_bytes` is the quota in force, `0` for none. `custom_quota` says
|
|
whether it was set for this user or comes from
|
|
`media.max_user_storage_bytes`.
|
|
|
|
`PUT {"max_bytes": 1073741824}` gives the user a quota of their own (`0`
|
|
for no limit). `DELETE` puts them back on the configured one. A quota below
|
|
what the user already has deletes nothing; it stops further uploads until
|
|
they are back under it.
|
|
|
|
An upload that would go over is refused as it arrives, with
|
|
`413 M_TOO_LARGE`. Every client already tells the user an upload was too
|
|
big, and that is what it was. Two uploads racing for the last of a quota
|
|
can both get in: a quota bounds what a user keeps, it is not an
|
|
accounting system.
|
|
|
|
### Registration tokens
|
|
|
|
Tokens with a use limit and an expiry, for the `m.login.registration_token`
|
|
stage of sign-up. The stage is in the flow when `registration.tokens` is
|
|
non-empty or `registration.require_token` is on. The config file's tokens
|
|
and these are both accepted. The config file's are never listed here:
|
|
they are in the config file, and this would be the one place a secret
|
|
from it is served over HTTP.
|
|
|
|
```
|
|
GET /registration_tokens
|
|
POST /registration_tokens {"token": "...", "length": 16, "uses_allowed": 5, "expiry_time_ms": 1760000000000}
|
|
GET /registration_tokens/{token}
|
|
PUT /registration_tokens/{token} {"uses_allowed": null, "expiry_time_ms": 1760000000000}
|
|
DELETE /registration_tokens/{token}
|
|
```
|
|
|
|
A token:
|
|
|
|
```json
|
|
{
|
|
"token": "invite-1",
|
|
"uses_allowed": 5,
|
|
"completed": 2,
|
|
"expiry_time_ms": null,
|
|
"created_at_ms": 1760000000000,
|
|
"valid": true
|
|
}
|
|
```
|
|
|
|
`uses_allowed` and `expiry_time_ms` are `null` for no limit. `valid` is
|
|
whether a registration could use the token now. The list also carries
|
|
`"required"`, whether sign-up asks for a token at all. A token made on a
|
|
server that does not ask is never needed.
|
|
|
|
`POST` with no `token` generates one of `length` characters (16 by
|
|
default) from `A-Za-z0-9`. A chosen token is 1 to 64 of the characters
|
|
`A-Za-z0-9._~-`, the spec's grammar. A taken or malformed one is
|
|
`400 M_INVALID_PARAM`. It answers `201`.
|
|
|
|
`PUT` changes only the fields in the body; `null` removes a limit. Raising
|
|
`uses_allowed` on a used-up token makes it usable again. `DELETE` does not
|
|
affect accounts registered with the token.
|
|
|
|
A use is spent when the account is created, not when the stage is passed,
|
|
so an abandoned sign-up costs nothing. Checking and spending are one
|
|
statement, so two sign-ups racing for the last use cannot both have it.
|
|
The one that loses is refused with `403 M_FORBIDDEN` at the end of its
|
|
flow.
|
|
|
|
### `POST /server_notices`
|
|
|
|
```json
|
|
{"user_id": "@alice:warp.example", "content": {"msgtype": "m.text", "body": "Maintenance at 22:00 UTC."}}
|
|
```
|
|
|
|
Sends a message from the server to one of its users, and answers
|
|
`{"room_id": "...", "event_id": "..."}`. Needs `server_notices.enabled`
|
|
and a signing key. Without them it is `409`, the server's configuration
|
|
refusing.
|
|
|
|
Each user gets one notice room. It is made the first time, by the notices
|
|
user (`server_notices.localpart`, created at startup), and carries the
|
|
`m.server_notice` tag in the user's room account data, so a client can
|
|
show it apart from other rooms. A second notice goes to the same room. A
|
|
user who left is invited back. The user cannot answer: the power levels
|
|
give them none of the levels a message needs. The user does not have to be
|
|
online: the invite and the message wait for them.
|
|
|
|
`content.body` is required. A missing `msgtype` becomes `m.text`. An
|
|
unknown user is `404`; a deactivated one `403`.
|
|
|
|
### `GET /appservices`
|
|
|
|
The registered application services and how delivery to each is going.
|
|
Registrations are files read at startup, so this is a view, not an editor.
|
|
Tokens are never shown.
|
|
|
|
```json
|
|
{
|
|
"appservices": [
|
|
{
|
|
"id": "telegram",
|
|
"url": "http://localhost:29317",
|
|
"sender": "@telegrambot:warp.example",
|
|
"rate_limited": false,
|
|
"protocols": [],
|
|
"namespaces": {
|
|
"users": [{"regex": "@telegram_.*:warp\\.example", "exclusive": true}],
|
|
"aliases": [],
|
|
"rooms": []
|
|
},
|
|
"delivery": {
|
|
"stream_pos": 1204,
|
|
"behind": 0,
|
|
"txn_id": 88,
|
|
"events_delivered": 412,
|
|
"failures": 0,
|
|
"last_success_ms": 1760600000000,
|
|
"last_failure_ms": 0,
|
|
"retry_at_ms": 0
|
|
}
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
`delivery` is absent for a service without a URL, which is sent nothing.
|
|
`behind` is how many stream positions it has still to read: not all of
|
|
them will concern it. While `failures` is above zero the service is being
|
|
backed off, `last_error` says why, and `retry_at_ms` is the next attempt.
|
|
See [appservices.md](appservices.md).
|
|
|
|
### `GET /audit_log`
|
|
|
|
```
|
|
GET /audit_log?limit=50&before=1234&actor=@root:warp.example&action=user.deactivate
|
|
```
|
|
|
|
Every change made through the admin API, newest first:
|
|
|
|
```json
|
|
{
|
|
"entries": [
|
|
{
|
|
"id": 1235,
|
|
"ts_ms": 1760600000000,
|
|
"actor": "@root:warp.example",
|
|
"action": "user.deactivate",
|
|
"target": "@mallory:warp.example",
|
|
"details": {"erase": true}
|
|
}
|
|
],
|
|
"next_before": 1235
|
|
}
|
|
```
|
|
|
|
The log is paged by ID, not by offset. It grows at the top while an admin
|
|
reads it, and an offset would shift under them. Pass `next_before` as
|
|
`?before=` for the next page; it is absent on the last one. `actor` and
|
|
`action` filter exactly.
|
|
|
|
The actions:
|
|
|
|
| Action | Target | Details |
|
|
|---|---|---|
|
|
| `federation.switch` · `registration.switch` | — | `enabled` |
|
|
| `federation.lists` · `federation.lists_reset` | — | `allowlist`, `denylist` |
|
|
| `federation.destination_reset` | server name | — |
|
|
| `user.create` | user | `admin` |
|
|
| `user.admin` | user | `admin` |
|
|
| `user.deactivate` · `user.reactivate` | user | `erase` |
|
|
| `user.password` | user | `logout_devices` |
|
|
| `user.device_delete` | user | `device_id`, or `all` |
|
|
| `user.media_quota` | user | `max_bytes` (`null` for a reset) |
|
|
| `room.purge` · `room.force_leave` | room | `user_id` |
|
|
| `registration_token.create` · `.update` · `.delete` | token | `uses_allowed`, `expiry_time_ms` |
|
|
| `server_notice.send` | user | `room_id`, `event_id` |
|
|
|
|
An entry is written after the change has been made. If the write fails,
|
|
that is logged and the request still succeeds: the change has happened,
|
|
and a `500` for something that worked would have the panel retry it. The
|
|
same information is in the server log either way.
|
|
|
|
### `GET /rooms`
|
|
|
|
```
|
|
GET /rooms?search=ops&limit=50&offset=0
|
|
```
|
|
|
|
Ordered by joined-member count, biggest first, then by room ID — the second
|
|
key is not decoration, because paging an order that is not total can show
|
|
the same room on two pages and skip another, and ties in member count are
|
|
the common case. `search` matches a substring of the room ID, name, topic or
|
|
canonical alias, case-insensitively.
|
|
|
|
```json
|
|
{
|
|
"rooms": [
|
|
{
|
|
"room_id": "!abc:warp.example",
|
|
"room_version": "11",
|
|
"creator": "@alice:warp.example",
|
|
"name": "Operations",
|
|
"topic": "Ops chat",
|
|
"canonical_alias": "#ops:warp.example",
|
|
"join_rule": "public",
|
|
"history_visibility": "shared",
|
|
"guest_access": "forbidden",
|
|
"published": true,
|
|
"joined_members": 12,
|
|
"created_at_ms": 1760000000000
|
|
}
|
|
],
|
|
"total": 1,
|
|
"limit": 50,
|
|
"offset": 0
|
|
}
|
|
```
|
|
|
|
Everything after `creator` is read from the room's current state and kept in
|
|
the room's own row. That is denormalisation on purpose: this listing and the
|
|
public room directory both order every room by member count and page the
|
|
result, which cannot be done by reading each room's state in turn.
|
|
|
|
### `GET /rooms/{roomID}`
|
|
|
|
The list row plus the things only a count can answer.
|
|
|
|
```json
|
|
{
|
|
"room_id": "!abc:warp.example",
|
|
"members": 15,
|
|
"invited_members": 2,
|
|
"joined_members": 12,
|
|
"aliases": ["#ops:warp.example"],
|
|
"forward_extremities": ["$sPfXR..."]
|
|
}
|
|
```
|
|
|
|
`members` counts every `m.room.member` event in the current state whatever
|
|
the membership, because a room with one member and forty bans is not the
|
|
same room as one with a single member. `forward_extremities` is where the
|
|
room's DAG ends; there is normally exactly one, and more than one means the
|
|
room has forked.
|
|
|
|
### `DELETE /rooms/{roomID}`
|
|
|
|
Purges the room: its events, its state, its aliases and its row. There is no
|
|
undo and the members are not told — the only way to tell them would have
|
|
been to add an event to a room that no longer exists.
|
|
|
|
It is a `DELETE` on the room rather than a `POST` to an action because that
|
|
is what it is, and because the method makes a panel's confirm dialog
|
|
mandatory in a way a `POST` does not. Purging a room that is not there is
|
|
`404`, not a silent success: an operator who sees an `OK` has to be able to
|
|
believe the room was there.
|
|
|
|
### `POST /rooms/{roomID}/force_leave`
|
|
|
|
```json
|
|
{"user_id": "@alice:warp.example"}
|
|
```
|
|
|
|
Makes a user leave without asking the room's rules whether they may. A user
|
|
stuck in a room — one whose power levels are broken, or whose last moderator
|
|
has gone — is what this exists for, and in such a room the rules are exactly
|
|
what is in the way. It is still an ordinary membership event, signed and
|
|
added to the room, so the other members see what happened.
|
|
|
|
A user who is not in the room gets `403`: there is nothing to leave.
|
|
|
|
### `GET /stats`
|
|
|
|
The dashboard payload.
|
|
|
|
```json
|
|
{
|
|
"server_name": "warp.example",
|
|
"version": "0.0.0-dev",
|
|
"database": "sqlite",
|
|
"users": 12,
|
|
"admins": 1,
|
|
"deactivated": 0,
|
|
"devices": 19,
|
|
"rooms": 4,
|
|
"published_rooms": 1,
|
|
"events": 312,
|
|
"federation_enabled": false,
|
|
"registration_enabled": false
|
|
}
|
|
```
|
|
|
|
The three room counters are zero on a server that started without a signing
|
|
key, which has no roomserver and so no rooms.
|
|
|
|
## Errors
|
|
|
|
The same envelope as the Client-Server API — `{"errcode": ..., "error":
|
|
...}` — so the panel needs one error path, not two. `409 M_FORBIDDEN` is
|
|
specific to this API: the request was well formed and the server's own
|
|
configuration is what refuses it.
|
|
|
|
## Not here yet
|
|
|
|
The room endpoints are still the Phase 3 set. Still missing: a room's
|
|
state and recent events, for an operator looking at a report, and a way to
|
|
publish or withdraw a room from the directory without borrowing a member's
|
|
token. Media has quotas but no purge or quarantine yet; see
|
|
[media.md](media.md#known-gaps).
|
|
|
|
A server started without a signing key has no roomserver. The four room
|
|
endpoints then answer `503` rather than failing on the first call, and
|
|
server notices answer `409`.
|