Skip to content

API v2 Plan

Nighty edited this page Jul 13, 2026 · 1 revision

API v2 Plan

A plan for building /api/v2: a complete, RESTful JSON API with websocket event streams, covering everything the current web controllers can do - including moderation and admin functionality - so that a React-based frontend can be built entirely against it.

Audience: the human team building API v2. Prerequisite reading: openapi.yaml (the current v1 surface). The conventions of the domain contexts you will be calling are summarized in §1.1.

1. The context layer v2 builds on

Every action on the site is available as a domain context function with a uniform shape:

  • Actor-first: Images.hide_image(actor, image_id, params). The actor is either the current user (possibly nil) or a Philomena.Attribution.Actor struct (user + IP + fingerprint + ban), depending on whether the action is attributed.
  • Authorization inside: the context calls Philomena.Authorization against lib/philomena/users/ability.ex. Callers never re-implement permission checks.
  • Uniform errors: {:error, :unauthorized}, {:error, :not_found}, {:error, :ban}, {:error, %Ecto.Changeset{}}, plus a handful of bespoke atoms per action.
  • Moderation logging inside: contexts write their own mod-log entries.
  • Page structs for read assemblies: Images.ImagePage, Topics.TopicPage, Profiles.ProfilePage, etc. - typed structs carrying every record a page needs.

Consequently, API v2 is a second, thin head over the same contexts. An API controller action is: decode request → call the same context function the HTML controller calls → render JSON. There is no business logic to write, no authorization to duplicate, and no risk of the API and the website disagreeing about what an action does. If you find yourself writing an Ecto.Query or a can? check in an API controller, stop - the function you need either exists in a context or belongs in one (extend the context, following §1.1, and keep the HTML controller working).

1.1 Conventions when extending a context

v2 will occasionally need a context function that does not exist yet. New functions must match the existing surface - read a few neighbors in the context you are touching, and hold to these rules:

  • Actor-first signatures. Fun.action(actor, ...). The actor is the current user (possibly nil for anonymous) or, where the action is attributed (uploads, tag changes, posts), the %Philomena.Attribution.Actor{} struct (user + IP + fingerprint + ban) that UserAttributionPlug builds. Reads either scope their query by what the actor may see or authorize the loaded record before returning it.
  • Authorization inside, and first. Call Philomena.Authorization.authorize/3 (a thin Canada wrapper returning :ok | {:error, :unauthorized}) against lib/philomena/users/ability.ex, the single source of truth for permissions. Authorize before validating input, so malformed params from an unprivileged caller answer "unauthorized" rather than leaking validation detail.
  • Ban checks inside. Writes call Philomena.Authorization.verify_write_access/1 (ban → {:error, :ban}, then nil fingerprint → {:error, :unauthorized}); GET-guarded actions (new/edit) call verify_not_banned/1.
  • Id handling inside. Raw request ids parse via Philomena.IntegerId: an id that could never name a row is {:error, :not_found}; a well-formed unknown id loads nil, which is then authorized - and since no rule normally permits acting on nil, it answers {:error, :unauthorized}.
  • Global error shapes only. {:error, :unauthorized}, {:error, :not_found}, {:error, :ban}, and {:error, %Ecto.Changeset{}} are the shapes a fallback renders; anything bespoke is a per-action atom the controller handles in a visible case/with else. Contexts never raise for permission failures on user-facing paths.
  • Moderation logging inside, after the transaction commits. type strings are stable data (stored in the DB, displayed in the mod-log UI) - pass them explicitly, never derive them. subject_path strings are built with the Philomena.ModerationLogs.Paths helpers (plain interpolation, not ~p).
  • Page assemblies return typed structs (<Context>.<Noun>Page: struct in its own file, assembly function on the main context module, @enforce_keys + @type t) carrying raw records - markdown rendering is a presentation concern and stays in the web layer.
  • lib/philomena never references PhilomenaWeb. Enforceable via mix xref graph.
  • Documentation and tests. Every controller-facing function carries a @doc stating whose behalf it acts on, what happens internally (authorization, logging, enqueueing), and every return shape - plus a @spec on real types (add @type t :: %__MODULE__{} to schemas as needed). It ships with context-level tests covering the authorization matrix (anonymous/user/moderator/admin/banned).

2. Goals and non-goals

Goals

  1. Full functional parity with the web controllers: every route in lib/philomena_web/router.ex that a browser can hit has a v2 equivalent - including image moderation, tag changes, duplicate reports, bans, and the entire /admin namespace.
  2. A React SPA can implement the whole site (anonymous browsing, registration, login with TOTP, uploading, commenting, forums, PMs, settings, moderation tooling) using only /api/v2 plus static assets.
  3. Live updates over websockets: a public firehose (as today) and authenticated per-user streams (notifications), designed so the React frontend doesn't need to poll.
  4. A machine-readable contract: openapi-v2.yaml, ideally auto-generated from the code via open_api_spex (§8), is authoritative.

Non-goals

  1. Changing site behavior. v2 exposes what the contexts do; it does not redesign permissions, rate-limit policy (except where noted), or workflows.
  2. Touching /api/v1. v1 stays frozen and compatible; it shares context functions with v2 but nothing else (no shared serializers, no shared routes). Deprecating v1 is a decision for after the React frontend ships.
  3. Building the React frontend itself. This plan only guarantees the API can support one.
  4. GraphQL, JSON:API envelopes, HATEOAS. Plain pragmatic REST, same family as v1, so existing API consumers find v2 familiar.

3. What exists today, and what carries over

  • v1 (/api/v1/json, /api/v1/rss): read-only except image upload. ~22 controllers: image/tag/comment/post/profile/filter/forum reads, search endpoints, reverse image search, oembed. Auth is an API token passed as a ?key= query parameter. No writes to comments/forums, no account management, no moderation, no admin. These controllers are thin wrappers over dedicated api_* context functions (e.g. Filters.api_show_filter/2, Images.api_search_images/2). Treat them as precedent for the calling pattern only, not for semantics: several encode v1-only restrictions that v2 must not inherit - notably the forum/topic/post access_level == "normal" hard filter (v1 shows everyone the anonymous view; v2 is viewer-scoped like the web layer, so staff see restricted forums) and the uniform 404 collapse of unauthorized/not-found (v2 has the richer §4.3 error mapping). v2 loaders are the same actor-first functions the HTML controllers call.
  • Firehose: a public Phoenix channel (firehose topic on PhilomenaWeb.UserSocket) broadcasting new images, comments, and posts, rendered with v1 API views, broadcast from inside two controllers.
  • Web pipeline plugs that assemble per-request UI state: current filter, notification counts, site notices, forum list, admin counters, adverts. A React frontend still needs this data - it arrives via a bootstrap endpoint and websocket updates (§5.12, §6) instead of plugs.

Carries over unchanged: the contexts, ability.ex, the search query language, media processing, and the token infrastructure (users.authentication_token). Everything else about v2 is new code under lib/philomena_web/controllers/api/v2/ and lib/philomena_web/views/api/v2/.

4. Foundations

Build these first; every endpoint depends on them.

4.1 Routing and pipelines

New router scope:

scope "/api/v2", PhilomenaWeb.Api.V2, as: :api_v2 do
  pipe_through [:accepts_json, :api_v2]
  ...
end

The :api_v2 pipeline handles, in order: request-ID/telemetry, auth (§4.2), EnsureUserEnabledPlug, ban lookup, current filter resolution, pagination extraction, and attribution (UserAttributionPlug, so contexts receive a fully-populated Actor - see §4.5 for the fingerprint question). Sub-pipeline: :api_v2_authenticated (401 when anonymous) for account, notification, and conversation routes. There is deliberately no staff-only pipeline - admin routes are in the router like any other route, and the context rejects non-staff actors with {:error, :unauthorized}, exactly as the HTML admin controllers do today. The router never encodes permissions.

URL conventions: mirror the web router's resource naming (it is already aggressively RESTful), but use proper HTTP verbs on sub-resources instead of singleton create/delete controllers where it reads better:

  • PUT /api/v2/images/:id/vote {"up": true} / DELETE .../vote replaces the vote/downvote singleton pair.
  • PUT /api/v2/images/:id/fave, DELETE .../fave; same pattern for hide, subscriptions (images, topics, forums, galleries, channels, tags via .../watch), read markers.
  • Moderation toggles are PUT/DELETE on a named sub-resource: PUT /api/v2/images/:id/lock/comments, DELETE /api/v2/topics/:id/stick, etc.
  • Everything else is standard GET /xs, POST /xs, GET /xs/:id, PATCH /xs/:id, DELETE /xs/:id.

4.2 Authentication

Two mechanisms, both first-class:

  1. Bearer tokens - Authorization: Bearer <token> using the existing per-user API token. Accept ?key= too for drop-in v1 client migration, but document the header as canonical. This serves scripts and third-party apps.
  2. Session cookies - the React frontend authenticates with the same session cookie the current site uses. v2 gets JSON auth endpoints (§5.9): login (returning a TOTP-required challenge when applicable), TOTP submission, logout, registration, password/email flows. Cookie requests must present a CSRF token (x-csrf-token header; token obtainable from the bootstrap endpoint). Bearer requests skip CSRF.

Decision to make early (§11.1): whether the SPA uses cookies (recommended - httpOnly, existing infrastructure, revocation for free) or bearer tokens. Build assuming cookies; bearer support costs nothing extra since the plug tries both.

4.3 Errors

One error renderer (the JSON analogue of PhilomenaWeb.FallbackController), declared via action_fallback in every v2 controller:

Context result Status Body
{:error, :unauthorized} (anonymous) 401 {"error": {"code": "unauthenticated", ...}}
{:error, :unauthorized} (logged in) 403 {"error": {"code": "forbidden", ...}}
{:error, :not_found} 404 {"error": {"code": "not_found", ...}}
{:error, :ban} 403 {"error": {"code": "banned", "ban": {reason, valid_until, generated_ban_id}}}
{:error, %Ecto.Changeset{}} 422 {"error": {"code": "invalid", "fields": {...}}}
rate limited 429 {"error": {"code": "rate_limited"}} + Retry-After

Notes:

  • The 401/403 split on :unauthorized is a deliberate improvement over the HTML fallback (which redirects everyone to /); it costs nothing because the split is made in the renderer, not the contexts.
  • The ban body must carry enough for the frontend to render the ban screen (reason, expiry, the generated_ban_id users quote to appeals). Note the current {:error, :ban} rendering is browser-pipeline-only; the v2 renderer is a fresh implementation, and the v2 pipeline must run the ban lookup so Actor.ban is populated (v1's pipeline does not).
  • Changeset errors serialize with Ecto.Changeset.traverse_errors/2 into a field => [messages] map - this is what React form validation consumes.
  • Bespoke context errors (e.g. {:error, :invalid_target} from tag-change revert) get per-endpoint 4xx mappings, enumerated in the OpenAPI spec as they are encountered.

4.4 Pagination, serialization, and viewer scoping

Pagination: v1 style - page/per_page parameters, response carries total plus the item array under a resource-named key. Keep the established key names (images, tags, posts, ...) so v1 consumers can port mechanically. For high-churn feeds (comment lists, mod queues) also accept the search-based cursor techniques v1 users already employ (sort + filter on id); no new cursor machinery in the first release.

Serializers: one canonical JSON view per schema (Api.V2.ImageJSON, TagJSON, ...), used by every endpoint that returns that resource - list and show endpoints return the same shape, lists just return more of them. Start from the v1 field sets (documented in openapi.yaml) and extend; never fork a second shape for the same resource. New in v2, because the React frontend needs them:

  • Viewer-scoped fields, stable shape. Serializers take the actor, but the JSON shape never varies by viewer - every field is always present so client codegen produces one type per resource. Privileged data is grouped into nullable adjunct objects, populated only when can? allows (reusing the same view-layer can? idiom the Slime templates use today): e.g. image.moderation is null for regular users and {deletion_reason, uploader_ip, approval_state, ...} for staff who may see it. Grouping (rather than nullable scalars scattered through the resource) keeps null unambiguous - it means "not visible to you," never "visible and empty." Never populate a field the viewer couldn't see on the website.
  • interactions: image lists include the viewer's votes/faves/hides (from Interactions.user_interactions/2) so the frontend can paint buttons without N+1 requests, same mechanism the current site embeds in the page.
  • permissions hints on detail endpoints (e.g. image show includes "permissions": {"edit_tags": true, "hide": false, ...}). This is how the React frontend decides what UI to draw without replicating ability.ex in TypeScript. Compute these with can?; they are hints only - the context remains the enforcer.

Filters: as in v1, reads are filtered by the viewer's current filter (filter_id param overrides). The frontend gets spoiler data (spoilered tag ids + complex spoiler matches) from the bootstrap/filter endpoints so it can render spoilers client-side, like the current JS does.

4.5 Writes, attribution, and anonymous users

Contexts require attribution (IP + fingerprint) for attributed writes, and Authorization.verify_write_access/1 rejects writes with a nil fingerprint. The React frontend keeps the existing fingerprint mechanism (the JS fingerprint the current site computes, sent as a header or cookie the fetch_fingerprint plug reads). Third-party bearer-token clients have no fingerprint; decide in §11.2 whether v2 mints a per-token synthetic fingerprint or simply refuses anonymous-capable writes without one (v1's upload endpoint already requires authentication, so refusing matches precedent).

Captcha: anonymous posting/uploading currently requires captcha. The v2 pipeline needs a JSON-friendly captcha flow - endpoint to fetch a challenge, solution passed in the write request, verified by the same CheckCaptchaPlug logic. Design this once, in foundations; it blocks anonymous writes only.

Uploads: multipart POST /api/v2/images (file or scraper_url, as v1) and POST /api/v2/images/scrape for URL fetching. Same for avatars and admin image attachments (adverts, badges, tag images).

4.6 Rate limiting

Apply the existing limits per pipeline (the LimitPlug machinery). Policy, non-negotiable: staff (admin/moderator/assistant) are never rate-limited. The plug must check the resolved user's staff status before counting, on every v2 route - moderation tooling burst-fires requests and must not throttle.

5. Endpoint inventory

The parity contract, grouped the way the work should be split. Each row is a family of endpoints; the authoritative per-route list is derived from the web router (§9 explains the audit). "Ctx" names the context module(s) that expose the needed functions.

5.1 Images and search

Surface Endpoints Ctx
Browse/search GET /images, GET /search/images (+ q, sort, filter params), GET /images/:id, GET /images/:id/navigate (next/prev/index in a search), GET /images/:id/related, GET /images/random, featured image Images, Images.Search
Upload POST /images, POST /images/scrape Images, PhilomenaProxy
Metadata edits PATCH .../tags, .../sources, .../description; history: GET .../tag_changes, .../source_changes Images, SourceChanges, TagChanges
Interactions vote/fave/hide/subscription/read PUT+DELETE; GET .../favorites (fave list) Images
Reverse search POST /search/reverse DuplicateReports
Other search GET /search/{tags,posts,comments,galleries,filters} respective contexts
Autocomplete GET /autocomplete/tags, compiled autocomplete binary existing autocomplete modules
Oembed keep at v1 (it serves external embedders; no v2 work) -

5.2 Image moderation

Approve, delete (with reason)/restore, destroy (hard-delete), feature, repair, hash removal, source-history wipe, uploader change, anonymous toggle, scratchpad, comment/description/tag locks, tamper (vote/fave removal). All PUT/POST/DELETE sub-resources of /images/:id; all are Images context functions with internal mod-logging.

5.3 Comments

GET /images/:id/comments (paginated, plus the "page containing comment X" lookup the frontend needs for permalinks), POST, GET/PATCH .../comments/:id, history, hide/unhide/delete/approve moderation sub-resources, report creation, site-wide GET /comments recent-comments feed. Ctx: Comments, Reports, Versions.

Markdown: comment/post/description bodies return raw markdown and rendered HTML (rendered per-viewer, since embeds respect the viewer's filter). The React frontend needs rendered HTML unless it reimplements the comrak fork; preview is POST /markdown/preview (replaces post/preview).

5.4 Tags

GET /tags, GET /tags/:slug (tag page: details, aliases, implications, description, dnp notices), PATCH /tags/:slug (staff edit), alias management, tag image upload/removal, reindex trigger, watch/unwatch, usage detail (Tag.DetailController), GET /tags/fetch (bulk by name - the current fetch/tags). Ctx: Tags.

5.5 Forums, topics, posts, polls

Forum list/show + subscription; topic create/show (posts paginated)/title update, subscription, read marker, move/stick/lock/hide moderation; post create/edit/history, hide/delete/approve moderation, report; poll edit, votes list/create/delete. Ctx: Forums, Topics, Posts, Polls, PollVotes. The web slug-based nesting (/forums/:forum/topics/:slug) carries into the API; keep slugs - they are the public identifiers.

5.6 Galleries

CRUD, image add/remove, reorder, subscription, read marker, report; GET /galleries search. Ctx: Galleries.

5.7 Filters, settings, DNP

Filter CRUD, list (user/system/public/recent), make-current, spoiler/hide tag quick-add/remove, publicize; user settings read/update (the settings form, including local-storage-backed prefs which stay client-side in React); DNP entry list/show/create/update, artist DNP management. Ctx: Filters, Users, DnpEntries.

5.8 Notifications, conversations, channels

Notification list by category, unread counts, mark-read (the categories mirror Notifications.category_for_param/1); conversation list/show/create, message create, read/hide toggles, message approve (mod); report conversation. Channels list/show, subscription, read, NSFW toggle (cookie-based today - becomes a real per-user/per-session setting in the API, see §11.5). Ctx: Notifications, Conversations, Channels.

5.9 Account and profiles

  • Auth: POST /session (login; responds {"totp_required": true} + short-lived challenge token when applicable), POST /session/totp, DELETE /session, POST /registrations, confirmation/password-reset/ unlock flows (request + redeem), deactivation/reactivation, email/password/ name change, TOTP enable/disable (secret + QR provisioning payload), API key view/regenerate.
  • Profiles: GET /profiles/:slug (the ProfilePage assembly: badges, recent uploads/faves/comments/posts, commission, links, stats), description/scratchpad edit, artist link CRUD, award (badge) admin, commission CRUD + items, report user.
  • Ctx: Users, UserAuth (session machinery stays web-layer), Profiles, ArtistLinks, Badges, Commissions.

5.10 Moderation (non-admin-namespace)

Tag changes list/revert/mass-revert/delete; duplicate reports list/show/create/accept/accept-reverse/claim/reject; reports (user-facing "my reports" index); IP and fingerprint profiles + their source/tag change listings; moderation log index; approval queue. Ctx: TagChanges, DuplicateReports, Reports, UserIps, UserFingerprints, ModerationLogs.

5.11 Admin namespace

Everything under /admin: report queue + claim/close; approvals; artist link verification queue (verify/contact/reject); DNP queue + state transitions; user/subnet/fingerprint bans CRUD; site notices CRUD; adverts CRUD + image; forums CRUD; badges CRUD + image; mod notes CRUD; user admin (edit roles, activation, verification, unlock, erase, api-key revoke, downvote/vote wipe, full wipe, force-filter); batch tag update; donations. Route as /api/v2/admin/..., mirroring the web namespacing - but remember authorization lives in the contexts, not the router.

5.12 Site chrome (bootstrap)

GET /api/v2/bootstrap - one request the React shell fires on load, returning: current user (or null) + settings, CSRF token, current filter (with spoiler/hide compilations), unread notification count, conversation count, site notices, forum list, staff-only counters (duplicate reports, reports, artist links awaiting - the AdminCountersPlug payload), current live channels. Also individually addressable (GET /notifications/counts, GET /site_notices, ...) for refreshing; the websocket (§6) pushes deltas. Rules (/rules), static pages (/pages/:slug + history), staff list, stats, and themes round out the misc reads. Adverts get a GET /adverts/serve?image_id= endpoint recording impressions, with click tracking kept as the existing redirect route.

RSS (/api/v1/rss/watched) stays at v1; RSS is a delivery format, not part of the JSON API.

6. Websockets

Phoenix Channels on a dedicated v2 socket (/api/v2/socket), leaving the existing UserSocket/firehose untouched for current consumers.

Topics:

Topic Auth Events
firehose:v2 none (public data only) image:create, image:update, image:process (thumbnails ready), comment:create, post:create - payloads rendered with the v2 serializers, anonymous viewer scope
user:{id} required, own id only notification:new, notification:counts, conversation:message - powers the live badge counts in the React shell
image:{id} none new/edited comments, tag/source/description changes, processing state - for live-updating an open image page
staff:queues staff report/duplicate/approval counter deltas - live admin counters

Socket auth: Phoenix.Token minted per session, delivered in the bootstrap payload; the socket connect/3 verifies it and assigns the user. Anonymous connects are allowed (public topics only). Channel join/3 authorizes per-topic (own user:{id}; staff check via can? for staff:queues).

Event source: contexts emit plain domain events over Phoenix.PubSub after successful writes (image created, comment created, notification delivered); a web-side subscriber renders the v1 and v2 payloads and broadcasts to the respective channels. Do this as a foundations task: it removes the view-rendering broadcasts currently living in ImageController/TopicController (the one place broadcasting still happens in a controller) and gives every later event a pattern to copy. Keep the event structs small and typed (%Images.Events.Created{image_id: ...}); subscribers load + serialize, so PubSub payloads never carry stale records.

Viewer-scoped payload caveat: channel broadcasts are rendered once, not per subscriber - so socket events carry only anonymous-visible data plus ids. Clients holding elevated permissions or custom filters refetch details over REST; events are invalidation signals first, payloads second.

7. Cross-cutting concerns

  • CORS: v2 is same-origin for the React frontend; third-party browser apps get read-only CORS (GET + public endpoints) as v1 effectively has. Decide the exact allowlist in §11.3.
  • Tor: the ensure_tor_authorized rules apply to v2 writes identically (reuse TorPlug in the pipeline).
  • Caching: public reads send Cache-Control compatible with the CDN rules v1 documents; authenticated responses are private, no-store. ETags are nice-to-have, not first release.
  • Idempotency: PUT/DELETE interaction endpoints are idempotent by construction (setting a state, not toggling). Note the known oddities that are not idempotent (e.g. approve_comment side effects, per KNOWN-ODDITIES.md) - v2 exposes them as-is; fixing is out of scope.
  • Telemetry: per-endpoint metrics from day one (request counts, latencies, error rates, tagged by route + status), since v2 will carry the entire site's traffic once React ships.

8. Specification and documentation

Spec-driven, defined in code: the spec is ideally auto-generated with the open_api_spex hex package - operation specs on the v2 controllers, shared schema modules alongside the serializers - with openapi-v2.yaml at the repo root dumped from it and committed, so consumers still get one static file.

Workflow per endpoint family: write the operation and schema specs (paths, params, request/response schemas, error responses) → review them like code → implement → validate responses against the schemas in tests (open_api_spex ships cast/validate helpers for exactly this). Response schemas are shared components (Image, Tag, Comment, ...) matching the canonical serializers one-to-one - exactly one schema per resource, with the permission-gated adjunct objects (§4.4) modeled as nullable sub-schemas, so generated client types are complete and viewer-independent; when a serializer changes, the same PR changes its schema module. Put a CI check in place early that regenerates openapi-v2.yaml and fails on drift from the committed copy, and diffs the spec's route list against mix phx.routes output for the v2 scope - spec, router, and committed file must never disagree.

The parity tracker lives here, not in the spec: §9's audit table is maintained as a checklist in this document, updated as families land.

9. Testing and the parity audit

Parity audit (do this first): generate the definitive route inventory with mix phx.routes, filter to browser-pipeline routes, and classify every route: v2 endpoint (the normal case), client-side concern (theme switching, local prefs), delivery format kept elsewhere (RSS, oembed), or deliberately dropped (list the reasons). The classified table goes in this document. "Done" for API v2 means every row is checked off. Expect ~200 web controllers to collapse to noticeably fewer v2 endpoints (singleton create/delete pairs merge into PUT/DELETE).

Tests:

  • API controller tests are thin: auth wiring (anonymous vs user vs staff vs banned vs bearer-token), status codes, serializer shape. One test file per controller, following test/CONVENTIONS.md.
  • Do not re-test business logic through the API - the context suites (3,800+ tests) own the authorization matrices and behavior. If an API test wants to assert domain behavior, the assertion belongs in the context suite.
  • Serializer tests pin one thing globally - the shape is identical for every viewer class - and per resource, which adjunct objects are null versus populated for anonymous/user/staff viewers. These are the contract tests for the spec's schemas.
  • Channel tests for join authorization and event delivery per topic.
  • The existing 201 web controller test files must stay green throughout - v2 must not perturb the HTML site. Any context extension v2 needs follows the §1.1 conventions and gets context tests with it.

10. Delivery phases

Ordered so that a React frontend can start early against reads and grow with the API. Each phase ships behind nothing - v2 is additive, so partial deployments are safe (mark the scope experimental in docs until Phase H is complete).

Phase A - Foundations. Router scope + pipelines, auth plug (bearer + cookie + CSRF), error renderer, pagination plumbing, serializer conventions and the first canonical serializers (image, tag, user), bootstrap endpoint skeleton, rate limiting with the staff exemption, open_api_spex scaffold, CI checks, the parity audit table. One team, everyone together - these conventions must be settled before parallel work starts.

Phase B - Read core. Images/search/navigation, tags, comments (read), forums/topics/posts (read), galleries (read), profiles (read), filters (read), autocomplete, static content (rules/pages/staff). This is the largest single phase but almost entirely serializer + spec work. Parallel by domain after the image cluster lands as the exemplar.

Phase C - Auth and account. Session/registration/TOTP/recovery flows, settings, API keys, avatar, filter writes + make-current. After C, a React frontend can support logged-in browsing.

Phase D - Interactions and user writes. Votes/faves/hides/subscriptions/ read markers, uploads + scraping, comment/post/topic creation and editing, captcha flow for anonymous writes, PMs, reports (create), commissions, artist links, DNP requests, tag watching.

Phase E - Websockets. Domain events refactor, v2 socket, firehose:v2, user channel, image channel, bootstrap integration. (Can start in parallel with D once foundations are stable; the events refactor is the critical path.)

Phase F - Moderation. Image/comment/post moderation sub-resources, tag changes + reverts, duplicate reports, approval queue, IP/FP profiles, mod log.

Phase G - Admin. The /api/v2/admin namespace.

Phase H - Hardening. Parity audit closure, load testing on the hot reads (image search, image show), CDN/cache header verification, security review (auth flows, CSRF, IDOR sweep against viewer-scoping - §4.4), docs pass over the spec, publish v2 as stable.

Suggested split for a small team: one person owns foundations + spec tooling + auth (A, C); domain pairs take B/D/F/G areas along domain cluster boundaries (images+search, forums+comments, account+profiles, moderation+admin); websockets (E) is one person once the events pattern exists. Review rule: every endpoint PR includes its spec diff and is reviewed against the corresponding web controller for parity.

11. Open decisions

Settle 1–3 during Phase A; the rest before their phase starts.

  1. SPA auth transport. Cookie session + CSRF (recommended) vs bearer token in JS. Cookies reuse everything and keep tokens out of JS-readable storage; decide and document.
  2. Fingerprints for bearer clients. Refuse anonymous-attributed writes without a fingerprint (recommended; matches v1's authenticated-upload precedent) vs synthesizing per-token fingerprints.
  3. CORS posture. Which endpoints, which origins, whether authenticated CORS is ever allowed (recommend: never; third-party browser apps get public reads only).
  4. Rendered vs raw markdown in payloads (§5.3): both (recommended, costs response size), or raw-only + a WASM build of the comrak fork for the frontend (costs a build pipeline; keeps payloads slim). Both is the safe default; revisit if payload size hurts.
  5. Channel NSFW toggle is a cookie today; the API needs it as real state (user setting when logged in; client-side for anonymous).
  6. v1 deprecation policy - out of scope here, but v2's stability promise (what counts as a breaking change, how additive fields are handled) must be written down in the spec's intro before v2 is declared stable.
  7. Per-viewer socket payloads - accepted limitation (§6): events carry anonymous-visible data plus ids. Revisit only if the React frontend measurably suffers from the refetch pattern.

12. Risks

Risk Mitigation
Parity gaps discovered late ("the website can do X, the API can't") The §9 route audit is built in Phase A and is the definition of done; every route classified up front
Serializer shape churn breaking the React frontend mid-build Spec-first workflow + serializer contract tests; additive-only changes after a family is marked stable
Viewer-scoping mistakes leaking staff-only data Serializers take the actor explicitly (no global state); dedicated IDOR/field-leak review in Phase H; adjunct-population tests per serializer and viewer class
Auth flows (TOTP, recovery) are fiddly and security-sensitive One owner for all of Phase C; reuse UserAuth machinery wholesale; security review before stable
Websocket events drift from REST shapes Events rendered by the same canonical serializers; subscriber code lives next to the views
Context functions turn out to be HTML-shaped (flash-message-ish returns, redirect-oriented results) Fix the context function itself, never work around it in the API layer; the contexts serve both heads and fixes belong there
v2 traffic swamps endpoints v1 never exposed (e.g. profile pages) Telemetry from day one; load test hot paths in Phase H before the React cutover