API reference¶
Everything is JSON. IDs are generated by the server, so never send an id.
Base URLs¶
| Where | Base URL |
|---|---|
dotnet watch |
http://localhost:5008 |
| Vite dev server | http://localhost:5173 (proxies /api to :5008) |
| Full Docker, locally | http://localhost:8090 |
| Production, over the SSH tunnel | http://localhost:8090 |
| Production, public | https://51.79.242.169.nip.io — /Health only |
Only /Health is publicly reachable
Caddy is default-deny. Every other path on the public domain returns 404
with an empty body, including the admin API. Use the
SSH tunnel.
Authentication¶
Every endpoint except /Health and POST /api/admin/login needs a
logged-in admin session cookie. /api/admin/admins needs the owner
specifically.
The cookie (hakutaku_admin_session) is HttpOnly, so a browser handles it
automatically and JavaScript cannot read it. With curl you need a cookie jar:
-c to save, -b to send.
See Auth and sessions for the mechanics.
Every endpoint¶
| Method | Path | Guard | Purpose |
|---|---|---|---|
| GET | /Health |
— | Liveness check |
| POST | /api/admin/login |
Rate-limited | Sign in, sets the session cookie |
| POST | /api/admin/logout |
Session | Revoke the session, clear the cookie |
| GET | /api/admin/me |
Session | Who the current cookie belongs to |
| GET | /api/admin/admins |
Owner | List all admins, including deactivated |
| POST | /api/admin/admins |
Owner | Create an admin |
| DELETE | /api/admin/admins/{id} |
Owner | Deactivate an admin |
| GET | /api/players |
Admin | List all players |
| POST | /api/players |
Admin | Create a player |
| GET | /api/characters |
Admin | List all characters |
| POST | /api/characters |
Admin | Create a character |
| GET | /api/events |
Admin | List all telemetry events |
| POST | /api/events |
Admin | Record a telemetry event |
There is no PUT and no DELETE on game data, and list endpoints return
the entire table — no paging, no filtering.
Planned changes
The game-data paths are intended to become singular (/api/players →
/api/player), and to become publicly reachable with their own auth
rather than admin-gated. Neither has happened yet.
Health¶
Public through Caddy, because the Jenkins smoke test uses it.
The capital H matters
The route is /Health, and Caddy's handle /Health matcher is
case-sensitive. /health is not published.
Admin: sign in¶
curl -c cookies.txt -X POST http://localhost:5008/api/admin/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"<password>"}'
# 204, Set-Cookie: hakutaku_admin_session=...
Body: username (string), password (string).
Login is by username, not email. Matching is case-insensitive and the username is trimmed.
| Status | When | Body |
|---|---|---|
204 |
Success | Empty — the cookie is in the header |
400 |
Missing field, or username > 254 / password > 256 chars | { "error": "username and password are required" } |
401 |
Wrong password, unknown username, or disabled account | { "error": "invalid credentials" } |
429 |
More than 5 attempts per minute from one IP | Empty body |
Two client traps here
204 means res.json() throws — there is no body to parse. And 429 is the
only error status without an { error } body, because it comes from the
framework's rate limiter rather than from handler code.
All three failure cases return the same 401 on purpose, so the API does not
reveal which usernames exist.
Admin: who am I¶
curl -b cookies.txt http://localhost:5008/api/admin/me
# {"username":"admin","email":null,"role":"owner"}
role is "owner" or "admin". Nothing else is possible.
This is how a front end discovers whether its cookie is still good without
asking for a password again. 401 if there is no cookie, or the session has
expired or been revoked.
Never call this on a timer
It is an authenticated request, so it pushes the 30-minute idle expiry forward. Polling it means the idle timeout can never fire. Call it on app load and on route changes.
Admin: sign out¶
Returns 401 if you were not logged in. Treat that as "already logged out"
rather than an error.
Admin: list admins¶
[
{ "id": 1, "username": "admin", "role": "owner",
"createdAt": "2026-09-25T09:52:16Z", "disabledAt": null },
{ "id": 5, "username": "bob", "role": "admin",
"createdAt": "2026-10-01T04:10:00Z", "disabledAt": "2026-10-01T05:00:00Z" }
]
Owner only. Includes deactivated admins — disabledAt is non-null for
those — so the UI can show state and target a deactivation.
Ordered by id.
Admin: create an admin¶
curl -b cookies.txt -X POST http://localhost:5008/api/admin/admins \
-H "Content-Type: application/json" \
-d '{"username":"bob","password":"a-real-password"}'
# 200 {"id":5,"username":"bob","role":"admin"}
Body: username (string, ≤ 254), password (string, 8–256).
Always creates a regular admin. There is exactly one owner — seeded at first start — and this endpoint cannot create another.
| Status | When |
|---|---|
200 |
Created |
400 |
Missing username, or password outside 8–256 characters |
401 |
Not logged in |
403 |
Logged in as a non-owner admin — { "error": "owner only" } |
409 |
Username already taken (case-insensitively) |
Admin: deactivate an admin¶
Sets disabled_at and immediately revokes that admin's active sessions. Does
not hard-delete the row, and there is no restore endpoint.
| Status | When |
|---|---|
204 |
Deactivated |
401 |
Not logged in |
403 |
Not the owner, or the target is the owner |
404 |
No such admin |
One-way, and it burns the username
The unique index on lower(username) covers disabled rows, so that username
can never be reused.
The owner account can never be deactivated, whoever asks. The seeder only runs when there are zero admins, so disabling the sole owner would lock everyone out with no HTTP way back in.
Game data¶
All three need an admin session today. IDs are GUIDs — treat them as opaque strings; do not parse or format them.
Request bodies¶
| Endpoint | Fields |
|---|---|
POST /api/players |
deviceId (string), xp (integer) |
POST /api/characters |
playerId (GUID of an existing player), name (string) |
POST /api/events |
playerId (GUID of an existing player), eventType (string), data (string holding JSON text) |
timestamp on an event is always set by the server in UTC. Anything you send
for it is overwritten.
Responses echo the created object. Character and event responses also include
"player": null, which you can ignore — it is an unpopulated navigation
property.
Worked example¶
curl -b cookies.txt -X POST http://localhost:5008/api/players \
-H "Content-Type: application/json" \
-d '{"deviceId":"device-1","xp":0}'
# {"id":"<player-id>","deviceId":"device-1","xp":0}
curl -b cookies.txt -X POST http://localhost:5008/api/characters \
-H "Content-Type: application/json" \
-d '{"playerId":"<player-id>","name":"Hero"}'
curl -b cookies.txt -X POST http://localhost:5008/api/events \
-H "Content-Type: application/json" \
-d '{"playerId":"<player-id>","eventType":"level_up","data":"{\"level\":2}"}'
Error shapes¶
Four different shapes, and a client has to handle all of them:
| Shape | Which responses |
|---|---|
{ "error": "message" } |
400, 401, 403, 404, 409 |
| Empty body | 204 (success), and 429 |
Bare 500 |
A playerId that does not exist — an uncaught foreign-key violation |
404 with empty body, Server: Caddy |
Caddy's default-deny, not the app at all |
web/src/api.ts is a working reference for handling these in one place.
Malformed JSON returns 400
From the framework's model binding, before any handler runs.
What does not exist¶
Asked for often enough to be worth listing:
| Wanted | Status |
|---|---|
GET /api/players/{id} |
No — list endpoints only |
| Filtering a player's characters or events | No |
| Paging | No — every list returns the whole table |
PUT / PATCH / DELETE on game data |
No |
| Player login | No — no player-facing auth at all |
| Server keys for game servers | No — blocks the simulator |
| Game-server endpoints | No entity exists |
| An audit log | No table exists |
| Password change / reset | No |
| Restoring a deactivated admin | No, by design |