-
-
Notifications
You must be signed in to change notification settings - Fork 50
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.
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 (possiblynil) or aPhilomena.Attribution.Actorstruct (user + IP + fingerprint + ban), depending on whether the action is attributed. -
Authorization inside: the context calls
Philomena.Authorizationagainstlib/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).
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 (possiblynilfor anonymous) or, where the action is attributed (uploads, tag changes, posts), the%Philomena.Attribution.Actor{}struct (user + IP + fingerprint + ban) thatUserAttributionPlugbuilds. 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}) againstlib/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) callverify_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 loadsnil, which is then authorized - and since no rule normally permits acting onnil, 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 visiblecase/with else. Contexts never raise for permission failures on user-facing paths. -
Moderation logging inside, after the transaction commits.
typestrings are stable data (stored in the DB, displayed in the mod-log UI) - pass them explicitly, never derive them.subject_pathstrings are built with thePhilomena.ModerationLogs.Pathshelpers (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/philomenanever referencesPhilomenaWeb. Enforceable viamix xref graph. -
Documentation and tests. Every controller-facing function carries a
@docstating whose behalf it acts on, what happens internally (authorization, logging, enqueueing), and every return shape - plus a@specon 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).
Goals
- Full functional parity with the web controllers: every route in
lib/philomena_web/router.exthat a browser can hit has a v2 equivalent - including image moderation, tag changes, duplicate reports, bans, and the entire/adminnamespace. - A React SPA can implement the whole site (anonymous browsing, registration,
login with TOTP, uploading, commenting, forums, PMs, settings, moderation
tooling) using only
/api/v2plus static assets. - 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.
- A machine-readable contract:
openapi-v2.yaml, ideally auto-generated from the code viaopen_api_spex(§8), is authoritative.
Non-goals
- Changing site behavior. v2 exposes what the contexts do; it does not redesign permissions, rate-limit policy (except where noted), or workflows.
- 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. - Building the React frontend itself. This plan only guarantees the API can support one.
- GraphQL, JSON:API envelopes, HATEOAS. Plain pragmatic REST, same family as v1, so existing API consumers find v2 familiar.
-
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 dedicatedapi_*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/postaccess_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 (
firehosetopic onPhilomenaWeb.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/.
Build these first; every endpoint depends on them.
New router scope:
scope "/api/v2", PhilomenaWeb.Api.V2, as: :api_v2 do
pipe_through [:accepts_json, :api_v2]
...
endThe :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 .../votereplaces 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/DELETEon 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.
Two mechanisms, both first-class:
-
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. -
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-tokenheader; 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.
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
:unauthorizedis 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_idusers 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 soActor.banis populated (v1's pipeline does not). - Changeset errors serialize with
Ecto.Changeset.traverse_errors/2into afield => [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.
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-layercan?idiom the Slime templates use today): e.g.image.moderationisnullfor regular users and{deletion_reason, uploader_ip, approval_state, ...}for staff who may see it. Grouping (rather than nullable scalars scattered through the resource) keepsnullunambiguous - 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 (fromInteractions.user_interactions/2) so the frontend can paint buttons without N+1 requests, same mechanism the current site embeds in the page. -
permissionshints 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 replicatingability.exin TypeScript. Compute these withcan?; 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.
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).
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.
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.
| 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) | - |
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.
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).
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.
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.
CRUD, image add/remove, reorder, subscription, read marker, report;
GET /galleries search. Ctx: Galleries.
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.
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.
-
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.
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.
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.
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.
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.
-
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_authorizedrules apply to v2 writes identically (reuseTorPlugin the pipeline). -
Caching: public reads send
Cache-Controlcompatible with the CDN rules v1 documents; authenticated responses areprivate, 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_commentside effects, perKNOWN-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.
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.
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
nullversus 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.
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.
Settle 1–3 during Phase A; the rest before their phase starts.
- 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.
- Fingerprints for bearer clients. Refuse anonymous-attributed writes without a fingerprint (recommended; matches v1's authenticated-upload precedent) vs synthesizing per-token fingerprints.
- CORS posture. Which endpoints, which origins, whether authenticated CORS is ever allowed (recommend: never; third-party browser apps get public reads only).
- 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.
- Channel NSFW toggle is a cookie today; the API needs it as real state (user setting when logged in; client-side for anonymous).
- 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.
- 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.
| 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 |