This file documents the devRant compatibility API mounted at `/api`. Claude Code auto-loads it whenever a file in `routers/devrant/` is read or edited.

## Routing overview

- `/api` - `devrant/` package: `devRant`-compatible REST protocol (`auth.py`, `rants.py`, `comments.py`, `notifs.py`). Translates devRant requests onto DevPlace data: rants<->posts, comments/votes onto the native engagement layer, devRant integer ids onto each table's auto-increment `id`. Token auth via `devrant_tokens`.

## devRant compatibility API (`routers/devrant/`, `services/devrant/`)

A second REST protocol mounted at `/api` that reproduces the public devRant API shape on DevPlace data, so legacy devRant clients (the `./devranta` reference client is the spec; real captured responses are in `./devranta/api_test_results.json`) run unchanged. It is a pure **translation layer** over the existing domain - it adds no new content model.

**Layout.** `routers/devrant/` is the thin endpoint package: `__init__.py` builds the aggregate router with `dependencies=[Depends(ensure_enabled)]` (gated by the `devrant_api_enabled` setting, default on) and includes `auth.py` (login/register/profile/edit/avatar), `rants.py` (feed, CRUD, vote, favorite, comment, search), `comments.py` (read/edit/delete/vote), `notifs.py` (feed/clear); `_shared.py` holds the `dr_ok`/`dr_error` envelope helpers + the enable gate. `services/devrant/` is the logic: `params.merge_params` (merges query string + body, accepting BOTH form-encoded and JSON since devRant clients use both, GET sends auth as query params), `tokens` (issue/validate/revoke the `devrant_tokens` triple), `ids` (`as_int`, `to_unix`, `post_by_id`/`comment_by_id`/`user_by_id`), `serializers` (`serialize_rant`/`serialize_comment`, `encode_tags`/`decode_tags`), `feed` (`list_rants`/`search_rants`/`load_rant_detail`, offset pagination via `find(_limit, _offset)`), `profile` (`build_profile`), `notifications` (`build_notif_feed`/`clear_notifications`), `avatar` (`avatar_payload` + `render_png` via `cairosvg`).

**ID mapping (load-bearing).** devRant integer ids ARE the auto-increment `id` PK every `dataset` table already has: `rant_id`=`posts.id`, `comment_id`=`comments.id`, `user_id`=`users.id`, `token_id`=`devrant_tokens.id`. No translation table exists - `post_by_id` is `find_one(id=...)`. Serialization converts ISO `created_at` to unix via `ids.to_unix`.

**Auth.** `POST /api/users/auth-token` accepts username OR email, verifies with passlib, and inserts a `devrant_tokens` row (in `SOFT_DELETE_TABLES`; born-live; `key`=`secrets.token_hex`, `expire_time` from `session_max_age_days`). Every later call re-validates `(token_id, token_key, user_id)` through **`_shared.resolve_actor(request, params)`**, which wraps `tokens.resolve_user` with `utils.guards.refuse_suspended` - because this path never touches `require_user`, a moderator's suspension would otherwise not bind here at all (the token resolver's `is_account_active` check covers a **ban** but not a time-boxed suspension). `refuse_suspended` gates mutating methods only, so read endpoints are unaffected. Read endpoints take an OPTIONAL viewer (it may return None); write endpoints return `_shared.unauthorized()` (401) when it does. **`DELETE /api/users/me` deliberately calls the bare `resolve_user`** - it is the account-deletion path and must stay reachable to a suspended user, matching the `/profile/{username}/delete` exemption on the web side.

**Writes reuse the audited native cores - never duplicate.** Implementing this drove four DRY extractions in `content.py` (`apply_vote`, `create_comment_record`, `delete_comment_record`, `set_bookmark`) and one in `utils.py` (`register_account`); the native `routers/votes.py`, `routers/comments.py`, and `auth/signup.py` were refactored onto the SAME functions. So a devRant rant/comment/vote awards XP, fires notifications, writes the audit row, and soft-deletes exactly like the UI path. Rant create calls `content.create_content_item` directly; rant delete calls `content.delete_content_item` (full cascade) and returns the devRant envelope.

**Field mappings.** Rant `text` = `title\n\n content` (inbound rants have no title, `topic` forced to `"rant"`). devRant `tags` round-trip verbatim through a new `posts.tags` JSON column (`init_db` ensures it; `decode_tags` falls back to `[topic]`). `profile_skills` is derived from `bio` (`skills_from_bio`, no native skills field). `favorite`/`unfavorite` map to bookmarks via `set_bookmark`. Avatars are real PNGs from the multiavatar engine at `GET /api/avatars/u/{username}.png`; `user_avatar.i` points at that path with a deterministic `b` background colour. The feed envelope includes the auxiliary devRant keys (`settings`, `set`, `wrw`, `dpp`, `num_notifs`, `unread`, `news`) with safe values so clients parse cleanly.

**Envelope.** `dr_ok(**f)` -> `{success:true, **f}`; `dr_error(msg, status, **extra)` -> `{success:false, error:msg, ...}`. Logical failures stay `200` except bad login -> `400` (matches the captured devRant behaviour). Reference client + captured responses live in `./devranta/`. Host routing (legacy clients hard-code devrant.com) is an infra/DNS concern, out of app scope.

**Cross-cutting note.** AI content correction (`devplacepy/services/correction.py`) hooks the two devRant direct-edit paths alongside the native content/comment/messaging entrypoints, so it applies identically across the web UI, REST/JSON API, Devii, and devRant.
