API.md

API reference

The server-rendered pages in this repo are just one frontend on top of this JSON API — build a different one (React, plain HTML, a mobile app, a bot) against the same endpoints without touching any server code.

Authentication

There are two ways to authenticate, and every endpoint below accepts whichever one applies to it — there's no separate "API mode."

Session cookie (for a human, or a frontend acting on a human's behalf) POST /api/auth/login sets an httpOnly token cookie. A same-origin frontend just needs credentials: 'include' on its fetch calls; nothing else to configure. The cookie is sameSite: lax and (once COOKIE_SECURE=true is set — see .env.example) secure, so it's never readable from JS and never sent over plain HTTP once that flag is on.

Bearer token (for a bot account — see Bots below)

Authorization: Bearer bot_<64 hex chars>

A bot token identifies a real users row with is_bot = 1. Anywhere this doc says "auth required," a valid bot token satisfies it exactly like a session cookie would, with the specific bot restrictions noted under Bots.

Both methods populate the same req.user server-side, so role checks (mod+, admin+, owner) apply identically regardless of which one was used — a bot can never hold an elevated role in the first place (enforced at creation and at the role-change endpoint), so this only matters in practice for the plain auth required endpoints.

Conventions

  • All request/response bodies are JSON unless noted (file uploads are multipart/form-data).
  • Errors are { "error": "message" } with a 4xx/5xx status. Some also add a machine-readable field (e.g. needsVerification, blocked) alongside error — noted per endpoint where it matters.
  • mod+ means mod, admin, or owner — anyone at that rank or above. Likewise admin+ means admin or owner. owner means owner only.
  • IDs in paths (:id) are numeric row IDs unless the path says :username or :name.
  • Timestamps are UTC, YYYY-MM-DD HH:MM:SS.
  • Rate limits are keyed per account (req.user.id), not per IP, so people sharing an IP (offices, NAT) aren't penalized together — see Rate limits for the full table. A limited request gets 429 { "error": "You're doing that too fast. Slow down." } (or an auto-ban message if the pattern looks automated — see src/utils/antiAbuse.js).

Auth (/api/auth)

Method & path Auth Body Notes
POST /register none {username, email, password, age_confirmed} age_confirmed must be truthy (18+ confirmation). Username: 3-20 chars, letters/numbers/underscore. Password: 8+ chars. Disposable email domains rejected. Sends a 6-digit verification code by email (or console-logs it — see Password reset / verification email delivery). Does not log in — returns {ok, needsVerification: true, email}
POST /verify-email none {email, code} Confirms the code (15 min expiry), then logs in (sets the cookie) and returns {user}
POST /resend-verification none {email} Always returns the same generic {ok, message} whether or not the email exists/needs it, so it can't be used to enumerate accounts
POST /login none {username, password} username may be an email too. Sets the cookie, returns {user: {id, username, email, role}}. 403 with needsVerification: true if the email isn't verified yet; 403 with severity: "illegal_activity" for that ban category (generic message otherwise)
POST /logout none Clears the cookie
GET /me required Returns {user: req.user} — works with either a cookie or a bot token
POST /forgot-password none {email} Always returns the same generic response regardless of whether the email is registered. Emails a reset link valid 1 hour, single-use
POST /reset-password none {token, new_password} token from the emailed link's query string

Posts (/api/posts)

Method & path Auth Body / query Notes
GET / optional ?sort=hot|new|top|rising&type=all|text|image|clip&q=<search>&community=<name> Public feed, max 50 results (300 candidates considered before ranking). IP addresses are never included. Pinned posts always sort first regardless of sort. If logged in, posts from blocked users are excluded
GET /:id optional Post + its comments. 404 if removed. 403 (blocked: true) if you've blocked the author
POST / required multipart/form-data: title, body?, community?, is_nsfw?, and either a media file field or media_url (must be http(s)) + post_type? Rate-limited (postLimiter, see below). Title ≤300 chars, body ≤40,000. A file upload is verified against its real file signature (not just the declared Content-Type). The post starts at score 1 (a default self-vote — see Scoring below). Returns {id}
POST /:id/comments required {body, parent_id?} Rate-limited (commentLimiter). Body ≤10,000 chars. Blocked if you're banned from the post's community. Same default self-vote as posts (starts at score 1). Returns {id}
POST /:id/vote required {value: 1 | -1 | 0} Rate-limited (voteLimiter). 0 removes your vote. Bots cannot vote (403). Voting on your own post is rejected (400) and flagged as possible manipulation — repeated attempts can auto-ban the account+IP
POST /:id/pin mod+ Toggles pinned state; logged to the mod-action audit trail. Pinned posts float to the top of every feed they'd normally appear in
DELETE /:id required {reason?} mod+ can remove any post; a community's own creator can also remove posts within their own community even at the user role. Soft-delete only (is_removed)
GET /removed/history mod+ Last 200 removed posts with reason/remover, for review

Scoring defaults: every post/comment starts at score 1, not 0 — the author's own vote is inserted automatically on creation, same as Reddit's own behavior. This self-vote is excluded from XP and "upvotes received" stats (see src/utils/xp.js), so posting doesn't also farm the upvote-XP bonus. You can't vote on your own content afterward (see above) since you already have your one implicit vote.

Comments (/api/comments)

Method & path Auth Body Notes
POST /:id/vote required {value: 1 | -1 | 0} Identical semantics to post voting above, scoped to a comment id

Communities (/api/communities)

Method & path Auth Body Notes
GET / none All communities with post counts
POST / required {name, description?, rules?} Name: 3-21 chars, letters/numbers/underscore, first-come-first-served. Bots cannot create communities (403). Creator becomes the community's mod automatically
GET /:name none Community details
DELETE /:name owner Deletes the community; its posts are soft-removed with reason, then the community row itself is deleted
GET /:name/bans required Community creator or admin+ only
POST /:name/bans required {username, reason?} Same permission as above. Can't ban the community's own creator
DELETE /:name/bans/:username required Same permission as above

Community-level moderation (ban/unban within a community) is available to that community's own creator or site admin/owner — a site-wide mod role has no special power in a community they didn't create; that's the point of per-community moderation existing separately from site staff.


Direct messages (/api/dms)

Method & path Auth Body Notes
GET / required Your threads, each with the other participant, last message preview, and unread count
GET /:username required Full message history with that user; marks their messages as read. 403 if either of you has blocked the other
POST /:username required {body} Rate-limited (dmLimiter). Body ≤5,000 chars. Creates the thread if it doesn't exist yet; revives it for both sides even if either had "deleted" it
DELETE /:username required Soft-deletes the conversation from your own view only — the other participant's view is unaffected, and the underlying messages are retained (not erased) for legal/compliance reasons regardless of what either side does

Notifications (/api/notifications)

Method & path Auth Notes
GET / required Last 50, plus an unread count
POST /:id/read required Marks one as read
POST /read-all required Marks all as read
DELETE /:id required Deletes one
DELETE / required Deletes all of yours

Mod mail (/api/modmail)

Private messaging between a user and the staff team as a whole (not an individual staff member) — think "contact support," with the staff side acting as one shared inbox.

Method & path Auth Body Notes
POST / required {subject, body} Rate-limited (modmailLimiter). Body ≤10,000 chars. Notifies every mod/admin/owner
GET /mine required Your own threads
GET / mod+ ?status=open|closed The shared staff inbox — every thread from every user
GET /:id required A thread's full message history. Its owner or any staff member can view it
POST /:id/reply required {body} Rate-limited. Owner or staff only; reopens a closed thread. Staff replying notifies the owner; the owner replying notifies all staff
POST /:id/status mod+ {status: "open"|"closed"}
DELETE /:id mod+ Permanently deletes the thread and its messages (unlike DMs, no retention requirement here)

Reports (/api/reports)

Method & path Auth Body Notes
POST / required {target_type: "post"|"comment"|"user", target_id, reason?} Snapshots the target's IP at report time, so staff have it without needing separate IP-lookup access. Notifies every mod/admin/owner
GET / mod+ ?status=open Includes the captured IP
PATCH /:id mod+ {status: "open"|"reviewed"|"actioned"|"dismissed"}

Blocks (/api/blocks)

Method & path Auth Body Notes
GET / required Everyone you've blocked
POST / required {username} Blocks affect feed visibility, DMs, and comment visibility both ways
DELETE /:username required Unblocks

Account (/api/account)

Everything here operates on your own account (req.user.id) — there's no :id param, since you can't use these to touch someone else's account.

Method & path Auth Body Notes
PATCH /profile required {display_name?, location?} Both ≤80 chars
POST /username required {username} Same format rules as registration
POST /avatar required multipart/form-data: avatar file JPG/PNG/GIF, 5MB max. Verified against its real file signature
POST /password required {current_password, new_password} New password: 8+ chars
POST /delete required {current_password} Anonymizes, doesn't hard-delete: username/email/password become unusable, personal fields cleared, but posts/comments stay (soft-removed, attributed to a generic deleted identity) so other people's threads don't break. The sole remaining owner can't delete their own account without transferring ownership first

Bots (/api/bots)

Self-service bot accounts for automation — see Authentication above for how a bot actually calls the rest of the API once it has a token. Every route here requires a human session (a bot can't manage other bots, including itself).

Method & path Auth Body Notes
POST / required (human) {username} Creates a bot account owned by you. Returns {id, username, token}the token is shown exactly once, never retrievable again (only its hash is stored)
GET / required (human) Bots you own. Never includes tokens
POST /:username/regenerate-token required (human, owner of that bot) Old token stops working immediately. Returns the new {token}
DELETE /:username required (human, owner of that bot) Revokes the token (the bot account and its post/comment history remain, same as human account deletion)

A bot account can use any endpoint a human can, except:

Blocked for bots Route Why
Voting POST /api/posts/:id/vote, POST /api/comments/:id/vote no vote manipulation by automation
Creating communities POST /api/communities ditto
Being promoted POST /api/admin/users/:id/role a bot can never hold mod/admin/owner
Managing bots any /api/bots/* route a bot can't create/revoke/regenerate bots, even its own

Full walkthrough with a working curl example: see the "Bot API" section in README.md.


Admin (/api/admin)

Role requirements vary per route — see the table. As a quick summary: account bans/unbans/listing are mod+; IP visibility (lookups, the IP-ban list) and issuing IP bans are admin+; manual raw-IP entry, any unban (account or IP), role changes, the streamer badge, and DMCA takedown are owner-only.

A staff member can only ban/IP-ban someone with a strictly lower rank than themselves — mods can't touch other mods/admins, admins can't touch other admins, nobody can ever touch the owner.

Method & path Auth Body Notes
GET /users mod+ User list for the ban UI. Mods never see email or last_ip in the response. Admin sees the real email only once that user is account- or IP-banned
POST /users/:id/ban mod+ {reason, category?: "standard"|"illegal_activity"} reason required. illegal_activity category requires admin+
POST /users/:id/unban owner Direct unban. Mod/admin must go through the request workflow below instead
POST /unban-requests mod+ {username, note} note required. Notifies every owner
GET /unban-requests mod+ Owner sees all; mod/admin see only their own submitted requests
POST /unban-requests/:id/approve owner Lifts the ban
POST /unban-requests/:id/deny owner
POST /users/:id/role owner {role: "user"|"mod"|"admin"} Can't set/target owner this way; bots can never hold an elevated role
POST /users/:id/streamer owner Toggles the verified-streamer badge, independent of role
GET /ip-bans admin+ The full IP-ban list
POST /ip-bans owner {ip_address, reason, category?} Manual raw-IP entry, not tied to a specific post/user
DELETE /ip-bans/:ip owner
POST /ip-bans/by-post/:postId admin+ {reason, category?} IP-bans whoever posted it; response includes the ip_address banned
POST /ip-bans/by-username/:username admin+ {reason, category?} Same, by username instead of a post
GET /posts/:id/ip, GET /comments/:id/ip, GET /users/:id/ip, GET /users/by-username/:username/ip admin+ Raw IP lookups ("who posted this / what's this user's IP")
POST /dmca-takedown owner {username, reason, category?, case_reference?} Bans the account and their last known IP in one call, and logs a fulfilled entry in the legal-requests log automatically

A private paper trail of law-enforcement/legal data requests, not something the public or ordinary users can submit through the app.

Method & path Auth Body Notes
GET / mod+ ?status= View the log (situational awareness for staff)
GET /export owner Downloads the full log as a ZIP (CSV + JSON inside)
GET /:id mod+
POST / owner {agency, requester_name?, requester_contact?, document_type, data_category?, case_number?, target_description, notes?} document_type ∈ subpoena/warrant/court_order/dmca/other
PATCH /:id owner {status?, data_provided?, notes?} status ∈ pending/fulfilled/rejected
DELETE /:id owner

User search (/api/users/search)

Method & path Auth Query Notes
GET / none ?q=<text> Up to 6 usernames matching (username + avatar only), for the typeahead in the header search bar

Leaderboard (/api/leaderboard)

Method & path Auth Query Notes
GET / none ?limit=1-100 (default 50) XP leaderboard, computed live from posts/comments/votes — see src/utils/xp.js for the exact weights (10/post, 2/comment, 2/post-upvote, 1/comment-upvote; a post/comment's own default self-vote doesn't count toward the upvote weights)

Chat (/api/chat)

A single, site-wide live chat room (flat message log, no threading, no per-community rooms — a reasonable future extension but out of scope today). Polling-based rather than WebSocket, to match the rest of this app's request/response style — client JS polls every few seconds.

Method & path Auth Body / query Notes
GET /messages none ?since_id=<id>&limit=1-200 Public read, same as the main feed. No since_id: returns the most recent limit (default 50), oldest-first. With since_id: everything newer than that id, for polling
POST /messages required {body} Rate-limited (chatLimiter, see below). Body ≤2,000 chars. Returns {message}
DELETE /messages/:id required {reason?} Your own message, or mod+ for anyone's. Soft-delete (is_removed)


Content safety scanning

Any endpoint that accepts a file upload (post media, avatars) optionally runs it through automated content screening before accepting it — a client integrating against this API should be prepared for a 403 on an otherwise-valid-looking upload request.

Arachnid Shield — CSAM-specific hash matching. A match immediately bans the uploader's account and IP (illegal_activity category) and returns a generic 403 {"error": "This account has been suspended."} — deliberately generic, not describing why.

Configured via .env (ARACHNID_SHIELD_USERNAME/PASSWORD) and no-ops safely if unset — uploads work normally without it, just without that layer of screening. See src/utils/arachnidShield.js.


Rate limits

All keyed per-account, 60s windows unless noted:

Action Limit
Auth endpoints as a whole (/api/auth/*) 30 per 15 min
Voting (posts + comments share one budget) 20 per minute
Creating a post 10 per hour
Creating a comment 20 per 10 min
Sending a DM 20 per minute
Mod mail (new thread or reply) 10 per 10 min
Chat message 30 per minute

Hitting a limit repeatedly (a pattern that looks automated rather than an occasional burst) can escalate to an automatic account+IP ban — see src/utils/antiAbuse.js.


Password reset / verification email delivery

If SMTP_HOST isn't configured in .env, verification codes and password reset links are printed to the server console instead of emailed — fine for local/self-hosted testing, see README.md for setting up real SMTP delivery.