Status: alpha. The API is stable in shape but may change before 1.0.
Base URL: same origin as the web app (/api/* is proxied to the API service by
the reverse proxy in production). All responses are JSON with the error shape
{ "error": "<message>", "code": "<MACHINE_CODE>" }.
Two httpOnly; secure; sameSite=lax cookies (sent automatically by browsers):
| Cookie | Set by | Lifetime | Purpose |
|---|---|---|---|
anki_site |
POST /api/site/unlock |
30 days (sliding) | Site-wide access gate (HMAC of SITE_PASSWORD) |
anki_session |
/api/auth/login, /api/auth/register, /api/admin/login |
JWT_EXPIRY (default 7d) |
User or admin JWT |
The add-on upload endpoint uses a header API key instead of cookies.
| Caller | How |
|---|---|
| Anonymous | nothing |
| Visitor | anki_site cookie |
| Registered user | anki_site + anki_session (user JWT) |
| Admin | anki_session (admin JWT) — bypasses the site gate |
| Add-on | X-API-Key: ask_… header (no cookies) |
| Endpoint | anon | site | user | admin | api-key |
|---|---|---|---|---|---|
GET /api/health |
200 | 200 | 200 | 200 | – |
GET /api/decks/:slug |
401 | 200 | 200 | 200 | – |
GET /api/decks/:slug/review/next |
401 | 401 | 200 | 200 | – |
POST /api/upload |
401 | 401 | 401 | 401 | 201 |
DELETE /api/admin/decks/:slug |
401 | 403 | 403 | 204 | – |
The full matrix is enforced by tests in server/test/routes/authMatrix.test.ts.
GET /api/health → 200 { "status": "ok" } (checks DB). No auth.
POST /api/site/unlock{ "password": "…" }→200 { "unlocked": true }(setsanki_site).401 WRONG_SITE_PASSWORD. Rate-limited 5/min/IP.GET /api/site/status→200 { "unlocked": true|false }.
POST /api/auth/register{ "username", "password" }→201 { "user": { id, username, isAdmin:false } }. Username:/^[a-zA-Z0-9_-]{3,32}$/, password ≥6 chars. Rate-limited 5/min/IP.POST /api/auth/login{ "username", "password" }→200 { "user": … }. Rate-limited 10/min/IP.POST /api/auth/logout→200 { "ok": true }(clears session cookie).GET /api/auth/me→200 { "user": { id, username, isAdmin } }.DELETE /api/auth/me→204(deletes account + all review progress/log).
POST /api/admin/login{ "username", "password" }→200 { "user": { id:0, username, isAdmin:true } }. Env-var credentials. Rate-limited 5/min/IP.GET /api/admin/decks?page=1&limit=20→{ items:[AdminDeck], total, page, limit }.POST /api/admin/decks(multipart)file+name, optionalslug?,description?,expires_at?→201 { slug, deckId, cardCount, noteCount, mediaCount }. Ifslugexists → re-upload (update) flow preserving user progress.PUT /api/admin/decks/:slug{ name?, description?, allow_download?, allow_review?, expires_at? }→200 { slug }.DELETE /api/admin/decks/:slug→204(cascades notes/cards/media/progress; deletes files).GET /api/admin/users?page=1&limit=20→{ items:[User], total, page, limit }.DELETE /api/admin/users/:id→204.POST /api/admin/api-keys{ "label" }→201 { id, label, key_prefix, key }(key shown once).GET /api/admin/api-keys→{ items:[ApiKey] }(prefix + status only).DELETE /api/admin/api-keys/:id→204(setsrevoked_at).
GET /api/decks?page=1&limit=20→{ items:[{id,slug,name,description,card_count,uploaded_at}], total, page, limit }(excludes expired).GET /api/decks/:slug→{ id, slug, name, description, card_count, allow_download, allow_review }.GET /api/decks/:slug/cards?page=1&limit=20→{ items:[{id, anki_card_id, card_type, front_html, back_html, css}], total, page, limit }.GET /api/decks/:slug/download→ binary.apkg(application/vnd.anki.apkg).403 DOWNLOAD_DISABLEDif disabled.GET /api/decks/:slug/media/:filename→ media binary.404for traversal/unknown names.
POST /api/upload, header X-API-Key: ask_…, multipart file + name, optional slug?, description? → 201 { slug, deckId, cardCount, noteCount, mediaCount }. 401 bad/revoked key, 400 BAD_APKG / APKG_NOT_ZIP, 413 too large. Rate-limited 20/hour/IP.
All responses set Cache-Control: no-store. Rate-limited 120/min/user.
GET /api/decks/:slug/review/next→{ card: {id,card_type,front_html,back_html,css} | null, progress: {status,due_at,isNew} | null, reason?: "no_cards"|"daily_limit" }.POST /api/decks/:slug/review{ card_id, rating: "again"|"hard"|"good"|"easy", time_ms? }→ next step (same shape as/next).GET /api/decks/:slug/review/stats→{ total, new, learning, review, due_now, reviewed_today }.POST /api/decks/:slug/review/reset→200 { ok: true }.
States: new → learning → review. Learning steps [1min, 10min]; graduate on
"Good" at the last step (graduating interval 1 day). "Again" lapses a review
card back to learning. Ease starts at 2500 (‰), floor 1300. 20 new cards per
user per deck per UTC day. See PLAN.md § "Simplified Spaced Repetition" for
the full transition table.
All list endpoints accept ?page=1&limit=20 (limit clamped to 1–100).