API reference — every endpoint
Every JSON endpoint the hub exposes, with the exact request fields, response shapes, status codes and limits. Pages and routes03pagesandroutes.md covers which U
Every JSON endpoint the hub exposes, with the exact request fields, response shapes, status codes and limits. Pages and routes covers which URL serves what; this document covers what goes over the wire.
Conventions
| Concern | Rule |
|---|---|
| Prefix | Every JSON path is ${BASE_PATH}/api/…, mounted in server.js:140-147 |
| Encoding | application/json in and out, except the endpoints explicitly noted (multipart uploads, text/plain, text/event-stream, application/octet-stream) |
| Auth | A session cookie named swiftpoint.sid (server.js:49), issued by express-session and stored in SQLite (lib/session-store.js) |
| CORS | None. Every page is same-origin, so connect-src 'self' in the CSP is sufficient and no preflight exists |
| Body limit | express.json({ limit: '64mb' }) (server.js:35) |
| Errors | { "error": "…" }, and only that |
| Versioning | None. There is no /v1 prefix and no API-key auth |
The 64 MB body limit exists for one reason: a walkthrough recording carries per-step screenshots as data URLs inside a JSON body (Walkthrough recorder). lib/records.js:21 separately refuses a recording document over MAX_BYTES = 50 MB, which is the real ceiling. routes/docs-write.js:27 declares const LIMIT = 220_000 and nothing reads it.
The three gates
All three live in routes/auth.js:258-280 and are exported for the other routers.
| Middleware | Refusal | Message |
|---|---|---|
requireUser |
401 | Please log in with Discord to do that. |
requireModerator |
403 | Moderators only. |
requireAdmin |
403 | Admins only. |
Each gate recomputes the role flags from the Discord-ID allowlist on every call (flagsFor(), routes/auth.js:230-236) rather than reading req.session.user.isAdmin. The session is a snapshot written at sign-in; the allowlist is the only source of truth. A session that predates a promotion would otherwise refuse somebody who now has the role. See Auth and roles.
An admin passes requireModerator because lib/admin.js makes every admin a moderator.
/api/auth
routes/auth.js. Four endpoints.
| Method + path | Auth | Query | Response | Notes |
|---|---|---|---|---|
GET /discord/login |
— | returnTo |
302 to Discord | 503 Discord login is not configured yet. when DISCORD_CLIENT_ID/_SECRET are unset |
GET /discord/callback |
— | code, state |
302 to the destination | 400 plus a small HTML page on any failure |
POST /logout |
— | — | { ok: true } |
Destroys the session server-side |
GET /me |
— | — | always 200 | Never 401, so a signed-out reader can ask |
GET /discord/login stores crypto.randomBytes(24) hex as req.session.oauthState and sends the browser to https://discord.com/oauth2/authorize with response_type=code, scope=identify and a redirect_uri of ${origin}${BASE_PATH}/api/auth/discord/callback.
returnTo is sanitised by safeReturnTo() (routes/auth.js:62-73). It must be a rooted path: anything not starting with /, anything starting with // (protocol-relative), anything containing a backslash, and anything containing a control character is replaced with the fallback. The value is a browser path with the base prefix already included, which is why the callback prepends only the origin.
GET /discord/callback compares state against req.session.oauthState and clears it, exchanges the code for a token, fetches /users/@me, then upserts the user row on discord_id. It reads the destination before req.session.regenerate() (which replaces the session wholesale), sets session.user = { id, discordId, username, avatarUrl, isAdmin, isModerator }, emits a new_user notification to the admins on a first sign-in, and redirects to origin + destination. The username is global_name || username || 'Discord user'; the avatar is a cdn.discordapp.com URL at ?size=64, or null. A signed-in non-admin whose destination matches /\/admin\/?$/ is redirected to the gallery with ?admin=denied instead, so the OAuth loop cannot repeat.
The failure page is deliberately not JSON:
Discord login failed. <a href="https://host/swiftpoint/">Go back</a>
GET /me:
{ "loggedIn": true, "isAdmin": false, "isModerator": true,
"user": { "id": 12, "discordId": "5399…6481", "username": "mecke",
"avatarUrl": "https://cdn.discordapp.com/avatars/…png?size=64",
"isAdmin": false, "isModerator": true } }
Signed out, loggedIn is false, both flags are false and user is null.
/api/profiles
routes/profiles.js. Fourteen endpoints.
| Method + path | Auth | Query / body | Response | Notes |
|---|---|---|---|---|
GET / |
— | q, tag, game, sort |
summary[] plus myVote |
No pagination |
GET /tags |
— | — | string[] |
Sorted distinct tags |
GET /stats |
— | — | { profiles, games, mappings } |
Whole-collection counts |
GET /games |
— | — | string[] |
Canonical catalogue |
GET /:id |
— | — | summary plus detail fields | 404 Profile not found. |
GET /:id/download |
— | — | .spcf bytes |
Increments downloads |
POST /:id/vote |
user | { value } |
{ upvotes, downvotes, myVote } |
Value must be 1, -1 or 0 |
POST / |
user | multipart | 201 summary | Admins exempt from the cap |
PATCH /:id |
user | { title?, authorName?, description? } |
summary | Owner or admin |
PUT /:id |
user | multipart file |
summary | Replaces the file in place |
GET /:id/comments |
— | — | { canPostPatches, comments[] } |
Ownership resolved server-side |
POST /:id/comments |
user | { body, kind? } |
201 comment | kind: 'patch' is author-only |
DELETE /:id/comments/:cid |
user | — | { ok: true } |
Comment author or admin |
DELETE /:id |
user | — | { ok: true } |
Uploader or admin |
The profile summary
rowToSummary() (routes/profiles.js:113-141) is the single public shape of a profile, used by the list, the detail endpoint, the upload response and every write response:
| Field | Source | Notes |
|---|---|---|
id |
profiles.id |
|
title |
title |
|
authorName |
author_name |
The label inside the file, not the uploader |
game |
game |
null when empty; always a single string, never an array |
description |
description |
|
tags |
tags |
Comma column split on ,, empties dropped |
totalButtons |
total_buttons |
|
minDpi, maxDpi |
min_dpi, max_dpi |
|
views, downloads |
same | Coerced to 0 when null |
upvotes, downvotes |
same | Recounted from the votes table on every vote |
createdAt, updatedAt |
created_at, updated_at |
SQLite datetime('now') strings |
uploader |
join on users |
{ id, username, avatarUrl } or null |
There is no Discord id anywhere in a profile payload. Ownership questions ("is this my profile?", "is this my comment?") are the server's to answer, and handing the browser an account id to do it there would give an anonymous visitor a stable identifier for every person on the site. See Auth and roles.
GET /api/profiles/:id extras
Five fields on top of the summary:
| Field | Notes |
|---|---|
formatHeader |
The verbatim first line of the original .spcf |
version |
The format version from the header |
groups |
JSON.parse(parsed_summary) — the display model |
myVote |
The viewer's own vote, 0 when signed out |
isUploader |
Computed server-side by comparing the uploader's Discord id against the session's |
Listing
GET /api/profiles takes four query parameters and nothing else.
| Parameter | Match | Notes |
|---|---|---|
q |
LIKE %…% over title, author_name, description, tags, game |
Trimmed |
tag |
comma-wrapped LIKE over tags |
Matched as %,tag,%, so a tag never half-matches |
game |
game = ? |
Exact |
sort |
See table below | An unknown value falls back to newest |
sort |
ORDER BY |
|---|---|
newest (default) |
created_at DESC |
oldest |
created_at ASC |
name |
title COLLATE NOCASE ASC |
buttons |
total_buttons DESC |
dpi |
max_dpi DESC |
votes |
(upvotes - downvotes) DESC |
visited |
views DESC |
downloaded |
downloads DESC |
There is no pagination. The query has no LIMIT and no OFFSET: the whole matching collection is read, then the viewer's own vote is attached with one extra SELECT over the votes table. GET /api/profiles/stats exists precisely because the gallery cannot compute the totals from the page it is currently showing.
GET /api/profiles/tags, /stats, /games
/tags reads the tags column of every profile, splits on ,, collects the non-empty values into a Set and returns them sorted with localeCompare. /stats is one query with three subselects:
{ "profiles": 412, "games": 27, "mappings": 5840 }
mappings is SUM(total_buttons) over every profile.
/games returns the canonical catalogue: the distinct non-empty game values, each run through games.canonicalGame(), then ordered by games.catalogue() (lib/games.js). A name only present in the database still appears, spelled the way the hub spells it.
POST /api/profiles/:id/vote
{ "value": 1 }
value must be 1, -1 or 0; anything else is 400 Vote must be 1 (up), -1 (down) or 0 (remove). A non-zero vote on your own profile is 400 You can't vote on your own profile.
The row is upserted on (profile_id, user_id). The counters are then recounted, not incremented:
upvotes = (SELECT COUNT(*) FROM votes WHERE profile_id = ? AND value = 1)
downvotes = (SELECT COUNT(*) FROM votes WHERE profile_id = ? AND value = -1)
A non-zero vote emits a new_vote notification to every admin and, unless the profile owner is the actor or is themselves an admin, to the owner as well. A vote of 0 notifies nobody.
POST /api/profiles
multipart/form-data with a file part plus the text fields title, authorName, description, game.
| Refusal | Message |
|---|---|
| 400 | No file uploaded. |
| 400 | Title missing. |
| 400 | The parser's own message, e.g. Profile contains no groups. |
| 429 | You can upload at most N profiles. Delete one first to make room. |
The per-user cap defaults to 3 (MAX_PROFILES_PER_USER, overridable at runtime via the max_profiles_per_user setting, floored at 1). Admins are exempt, and their uploads are recorded with uploaded_by_admin = 1. On success: 201 with the profile summary, and a new_profile notification to the admins.
PATCH and PUT
PATCH /:id accepts title, authorName and/or description. Sending none is 400 Nothing to update. Send title, authorName and/or description.; an empty title is 400 Title cannot be empty.; a non-owner, non-admin gets 403 Only the uploader of this profile or an admin can edit it.
PUT /:id takes a multipart file and optionally description and game. It replaces the stored file in place — source_filename, format_header, version, tags, game, total_buttons, min_dpi, max_dpi, raw_json and parsed_summary are all rewritten from the new parse, while the id, title and author are left alone. A non-owner, non-admin gets 403 Only the uploader of this profile or an admin can update it. Both emit profile_updated to the admins.
Comments
GET /:id/comments returns:
{ "canPostPatches": true,
"comments": [ { "id": 88, "body": "…", "kind": "patch",
"createdAt": "2026-09-30 12:00:00", "userId": 12,
"username": "mecke", "avatarUrl": "…",
"isMine": true, "isAuthor": false } ] }
canPostPatches, isMine and isAuthor are all resolved server-side from Discord ids that never leave the server; isAuthor means "written by the profile's uploader", not "written by you". POST /:id/comments takes { body, kind }. kind is patch only when the author asks for it and the caller is the profile author; otherwise 403 Only the profile author can post patch notes. An empty body is 400 Comment is empty. Emits new_comment to the admins and to the owner (unless the owner is the actor or an admin).
DELETE /:id/comments/:cid — the comment author or an admin; otherwise 403 Only the comment author or an admin can delete it.
Limits and GET /api/profiles/:id/download
| Limit | Value | Where |
|---|---|---|
| File size | 2 MB (multer fileSize) |
routes/profiles.js:15 |
MAX_TITLE |
80 | routes/profiles.js:30 |
MAX_AUTHOR |
60 | routes/profiles.js:31 |
MAX_DESCRIPTION |
400 | routes/profiles.js:32 |
MAX_COMMENT |
1000 | routes/profiles.js:33 |
| Profiles per user | 3 (default) | routes/profiles.js:29 |
A file over 2 MB never reaches the handler: multer raises LIMIT_FILE_SIZE and the error middleware in server.js:170-172 answers 413 { "error": "File too large. Maximum size is 2 MB." }.
The download response is:
Content-Type: application/octet-stream
Content-Disposition: attachment; filename="<slugified title>.spcf"
<format_header>\n<raw_json>
The title is slugified with replace(/[^a-z0-9_-]+/gi, '_'), falling back to profile. Because the stored raw_json is the exact byte range after the first newline of the upload and format_header is the exact first line, the download is byte-identical to what was uploaded; downloads is incremented before the body is sent. See SPCF format.
/api/notifications
routes/notifications.js. Four endpoints, all requireUser.
| Method + path | Body | Response |
|---|---|---|
GET / |
— | up to 50 notifications, unread first |
GET /unread-count |
— | { unread: 3 } |
GET /stream |
— | Server-Sent Events |
POST /read |
{ all: true } or { ids: [...] } |
{ ok: true, unread: n } |
GET / is ORDER BY read ASC, created_at DESC, id DESC LIMIT 50 — unread first, then newest, id DESC breaking ties inside one second. Each row is:
{ "id": 512, "type": "new_comment",
"text": "mecke commented on \"CS2 Utility\": LMB is bound…",
"actorName": "mecke", "profileId": 57,
"threadId": null, "postId": null,
"read": false, "createdAt": "2026-09-30 12:04:11" }
GET /stream sets Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive and X-Accel-Buffering: no — the last one is what stops nginx from buffering the stream into a single response. The handler flushes headers, writes : connected\n\n, starts a 25-second : ping\n\n heartbeat, registers the response in lib/events.js, and unsubscribes plus clears the interval on the request's close event. Each push is a bare data: {…}\n\n frame carrying the same notification object with read: false and a JS-ISO createdAt. The pub/sub is in-process, so a multi-process deployment would need a shared bus.
POST /read de-duplicates ids with a Set, keeps only Number.isInteger values, slices to the first 100, and scopes every UPDATE to the caller's own user_id. Neither ids nor all is 400 Provide ids or { all: true }.
Every notification type the app emits
| Type | Emitted by | Sent to |
|---|---|---|
new_user |
First successful Discord sign-in (routes/auth.js:194) |
Admins |
new_profile |
POST /api/profiles |
Admins |
profile_updated |
PATCH /api/profiles/:id, PUT /api/profiles/:id, POST /api/admin/profiles/:id/transfer |
Admins, plus the new owner on a transfer |
new_comment |
POST /api/profiles/:id/comments |
Admins, plus the profile owner (unless the owner acted or is an admin) |
new_vote |
POST /api/profiles/:id/vote with a non-zero value |
Admins, plus the profile owner under the same rule |
report |
POST /api/reports |
Admins for a profile or comment; moderators and admins for a forum post |
new_ticket |
POST /api/forum/threads when the thread is a ticket |
Staff (moderators and admins) |
ticket_reply |
POST /api/forum/threads/:id/posts on a ticket |
Watchers minus the author, plus the thread author |
forum_reply |
POST /api/forum/threads/:id/posts on a normal thread |
Same |
ticket_status |
POST /api/forum/threads/:id/status |
Same, with the status label and note in the text |
new_update |
POST /api/forum/updates |
Staff |
In every case where a second recipient is told something, the actor is excluded, and the thread author is removed from the watcher list rather than notified twice. See Auth and roles for the roles that decide who receives each fan-out.
/api/reports
routes/reports.js. Three endpoints.
| Method + path | Auth | Body / query | Response |
|---|---|---|---|
POST / |
user | { targetType, targetId, reason } |
201 report |
GET / |
admin | status |
report rows |
POST /:id/status |
admin | { status } |
{ ok: true } |
POST / validates in this order:
| Condition | Status | Message |
|---|---|---|
targetType not profile / comment / post |
400 | Unknown report target. |
targetId not a positive integer |
400 | Invalid report target id. |
reason empty after cleaning (max 500 chars) |
400 | Please explain why you are reporting this. |
| Target row gone, or the post is soft-deleted | 404 | The reported item no longer exists. |
| An open report by this user on this target already exists | 409 | You already reported this. |
Delivery is decided by the target: a post report goes to notifyStaff (moderators and admins) carrying threadId and postId; a profile or comment report goes to notifyAdmins. A moderator who is told about a forum post is somebody who can remove it from the thread.
GET /api/reports?status= accepts open (default), resolved, dismissed or all. An invalid value falls back to open rather than erroring. Rows are ordered open-first, then created_at DESC, and each carries id, targetType, targetId, reason, status, createdAt, reporterUsername (deleted user when the reporter row is gone), profileTitle, commentSnippet (first 120 chars, comments only), and — for post reports only — threadId, postId, postSnippet and postThreadTitle, so a moderator can open the exact reply from the queue.
POST /api/reports/:id/status takes resolved or dismissed; anything else is 400 Status must be resolved or dismissed. The UPDATE is scoped with AND status = 'open', so a report that was already triaged changes zero rows: a missing row is 404 Report not found. and an existing one is 400 Report is already <status>.
Note the asymmetry, and be precise about it: report submission is available to any signed-in user, but report triage is admin-only. The moderation side of lib/admin.js covers the forum ticket workflow; the report queue itself is behind requireAdmin in routes/reports.js:120 and :169, even though a post report is delivered to moderators.
/api/admin
routes/admin.js. Eight endpoints, plus the guides sub-router in the next section.
| Method + path | Body / query | Response | Notes |
|---|---|---|---|
GET /overview |
— | the whole panel | One request |
GET /settings |
— | { settings } |
Current runtime limits |
POST /settings |
{ key, value } |
{ ok, key, value } |
Integer 1-100 |
GET /staff |
— | { staff, people } |
people is the account table |
POST /staff |
{ discordId?, username?, role? } |
201 | By id or by name |
PATCH /staff/:discordId |
{ role } |
updated row | Promote or demote |
DELETE /staff/:discordId |
— | { ok: true } |
|
POST /profiles/:id/transfer |
{ userId } |
{ ok, profileId, userId, username } |
Reassign ownership |
All eight are requireAdmin.
GET /overview is deliberately one payload, because the panel is rendered in one pass:
{ "stats": { "users": 0, "profiles": 0, "comments": 0,
"openReports": 0, "resolvedReports": 0, "traffic": {} },
"users": [], "profiles": [], "comments": [], "reports": [],
"recentActivity": [], "settings": {} }
| Block | Shape |
|---|---|
stats.users through stats.resolvedReports |
COUNT(*) over the whole table |
stats.traffic |
trafficOverview() — see SEO and analytics |
users[] |
{ id, username, avatarUrl, discordId, createdAt, profileCount, commentCount, reportCount }, newest first. The Discord id is present here because the route is admin-only |
profiles[] |
{ id, title, game, createdAt, updatedAt, totalButtons, views, downloads, upvotes, downvotes, uploaderUsername, uploaderId, commentCount, openReportCount } |
comments[] |
At most 100, newest first, joined to the profile title and the author |
reports[] |
Every report, open first, without the forum-post columns GET /api/reports adds |
recentActivity[] |
The caller's own last 20 notifications |
settings |
{ [key]: { label, type, value, fallback } } |
SETTING_DEFS currently holds one entry:
max_profiles_per_user: { label: 'Max profiles per user', type: 'number',
fallback: Number(process.env.MAX_PROFILES_PER_USER) || 3 }
POST /api/admin/settings refuses an unknown key with 400 Unknown setting "<key>". and a non-integer or out-of-range value with 400 Max profiles per user must be an integer between 1 and 100. (the message is built from def.label). A type other than number is 400 Unsupported setting type "<type>". Values are stored as strings in the settings table (lib/settings.js) and read back through getSetting.
GET /staff returns listStaff() plus knownPeople(200) — the account table, so an admin can add a moderator by picking a name instead of copying a 17-digit id. POST /staff takes either a discordId or a username, the latter resolved case-insensitively against knownPeople(200):
| Condition | Status | Message |
|---|---|---|
| More than one account with that name | 400 | More than one account has that name. Use the Discord ID. |
| Nobody who has signed in matches | 400 | Nobody who has signed in here is called @… Add their Discord ID instead — only people who have used the site can be found by name. |
There is no bot token on the site, so a name lookup cannot ask Discord anything. role is admin when the body says so and moderator otherwise. The remaining refusals come from lib/admin.js: A Discord ID is 5–25 digits. Copy it with the copy button on the user. and That account is already on the list.
PATCH /staff/:discordId promotes or demotes. It refuses an account whose source is env with That one comes from ADMIN_DISCORD_IDS in .env. Change it there and restart., an unchanged role with Already a moderator. or Already a admin., and an unknown id with That account is not on the list. DELETE /staff/:discordId refuses the same env-sourced accounts (That one comes from the .env allowlist. Remove it there and restart.) and refuses to remove the last admin: That is the last admin. Add somebody else first.
POST /profiles/:id/transfer takes { userId }. Missing profile is 404 Profile not found.; missing user is 400 Target user not found.; already the owner is 400 Profile already belongs to that user. On success the admins and the target user both get a profile_updated notification, with the text differing (transferred to <name> versus was transferred to you).
The staff management routes are admin-only, and .env.example says so explicitly: moderators cannot reach the admin panel or the recordings page.
/api/admin/docs
routes/docs-write.js, mounted as a sub-router at routes/admin.js:25 so it inherits the /api/admin prefix. Every one of its routes declares its own requireAdmin — these write files, and one of them deletes them, so the gate is on each route rather than on the mount.
| Method + path | Body / query | Response | Notes |
|---|---|---|---|
GET / |
— | { folders, docs, limits } |
The tree plus each doc's title and description |
GET /:slug(.*) |
— | { slug, raw, body, front } |
404 That guide does not exist. |
POST /folders |
{ parent, name } |
201 | |
POST /documents |
{ parent, title, description, tags, order, content } |
201 | |
PUT /:slug(.*) |
{ content, title, description, tags, order } |
saved doc | Body and front matter |
PATCH /:slug(.*) |
{ title } |
{ …, linkedGuides } |
Rename, which also moves the file |
PATCH /folders/:slug(.*) |
{ name } |
{ …, linkedGuides } |
Moves everything inside |
DELETE /folders/:slug(.*) |
?recursive=true |
result or 409 |
Two-step |
DELETE /:slug(.*) |
— | result | |
POST /preview |
{ content } or { body }, plus { slug } |
{ html } |
Guides engine, not the forum's |
The :slug(.*) pattern is required because slugs contain slashes — profile-builder/testing is one slug, not two segments.
GET / returns docsWrite.tree() with title and description lifted out of each document's front matter, and a limits object so the panel editor never has to hard-code the caps:
{
"limits": { "name": 60, "title": 140, "description": 300, "body": 200000 }
}
GET /:slug(.*) splits the raw file into front and body. front.tags is normalised to an array whether the YAML wrote a list or a comma-separated string.
DELETE /folders/:slug(.*) is deliberately two calls. Without ?recursive=true a non-empty folder returns 409 with needsConfirm: true and a files list, so the confirmation prompt can state how many files are about to go rather than asking a yes/no the reader cannot answer.
POST /preview reads content, and falls back to body. It truncates the source to MAX_BODY and calls engine.renderFragment(source, { slug, slugDir }) with the slug the guide will live at, so wikilinks, relative image paths and ::: steps resolve exactly as they will after saving. A non-string body is 400 Nothing to preview. — a preview that renders an empty document is worse than one that refuses, because it looks like a button that stopped working.
Limits from lib/docs/write.js:
MAX_NAME |
MAX_TITLE |
MAX_DESCRIPTION |
MAX_BODY |
MAX_TAGS |
|---|---|---|---|---|
| 60 | 140 | 300 | 200 000 | 12 |
The panel editor's own textarea caps at 32 000 characters; the module allows 200 000 so a guide written elsewhere is not truncated on save. See Guides system.
/api/users
routes/users.js. Two endpoints.
| Method + path | Auth | Response |
|---|---|---|
GET /:id |
— | The public member payload |
POST /:id/bio |
user, own id only | { ok: true, bio } |
GET /api/users/:id is 404 User not found. for an unknown id, and otherwise:
{ "user": { "id": 12, "username": "mecke", "avatarUrl": "…", "bio": "",
"createdAt": "2026-01-04 09:12:00",
"isAdmin": true, "isModerator": true },
"isMe": false,
"stats": { "profileCount": 0, "commentCount": 0,
"upvotesGiven": 0, "downvotesGiven": 0,
"votesReceivedUp": 0, "votesReceivedDown": 0,
"profileViews": 0, "profileDownloads": 0,
"threadCount": 0, "postCount": 0, "maxProfiles": 3 },
"profiles": [], "comments": [], "threads": [] }
user carries no Discord id: this endpoint is public and an account id identifies an account rather than describing a person who uses the site. The avatar stays because it is a public CDN URL and it is how the page shows who wrote something. isModerator is a badge for the reader, not a permission — the API is what decides who may moderate.
stats are eleven subselects over the same user row. postCount excludes soft-deleted posts. maxProfiles is the effective upload cap, so the member page can show "3 of 3" without guessing.
profiles[] is the summary shape minus uploader.id, with uploader reduced to { username, avatarUrl }, ordered created_at DESC. comments[] is capped at 100 with { id, body, kind, createdAt, profileId, profileTitle }. threads comes from forum.threadsByUser(user.id, 10).
POST /api/users/:id/bio is 403 You can only edit your own bio. for anyone else. The bio is cleaned of control characters and tags, collapsed to one line, and truncated to 300 characters.
/api/forum
routes/forum.js. Twenty-five endpoints. Two gates: requireUser for writing, requireModerator for moderation. An admin passes both.
| Method + path | Auth | Query / body | Response |
|---|---|---|---|
GET / |
— | — | stats, categories, latest, latestPosts, updates, updateCounts, isModerator, signedIn |
GET /categories |
— | — | category array |
POST /categories |
moderator | { slug, name, description, kind, sort } |
201 category |
PATCH /categories/:id |
moderator | { name?, description?, kind?, sort? } |
category |
DELETE /categories/:id |
moderator | — | { ok: true } |
GET /threads |
— | category, status, q, sort, limit, offset |
{ threads, total } |
POST /threads |
user | { categoryId, title, body } |
201 { thread, post } |
GET /threads/:id |
— | offset, around, limit |
{ thread, posts, postTotal, before, after, events } |
POST /threads/:id/posts |
user | { body } |
201 { post, thread } |
POST /posts/:id/edit |
user | { body } |
{ post } |
DELETE /posts/:id |
moderator | — | { post } |
POST /threads/:id/pin |
moderator | { pinned } |
{ thread } |
POST /threads/:id/lock |
moderator | { locked } |
{ thread } |
POST /threads/:id/move |
moderator | { categoryId } |
{ thread } |
DELETE /threads/:id |
moderator | — | { ok: true } |
POST /threads/:id/status |
moderator | { status, note } |
{ thread, events } |
GET /tickets |
moderator | status |
{ statuses, threads } |
POST /preview |
user | { body } |
{ html } |
POST /threads/:id/subscribe |
user | — | subscription result |
DELETE /threads/:id/subscribe |
user | — | subscription result |
GET /search |
— | q |
{ q, threads, posts } |
GET /updates |
— | kind, q, limit, offset |
{ kinds, accents, tags, counts, updates, total } |
POST /updates |
moderator | { kind, title, summary, version, accent, tags, body, pinned } |
201 update |
PUT /updates/:id |
moderator | any of the above | update |
DELETE /updates/:id |
moderator | — | { ok: true } |
Every thread-shaped answer is passed through viewerFlags(), which attaches canEdit, canDelete, canModerate, canReply and subscribed. refuse() (routes/forum.js:136) takes both the status and the message from the data layer, so rephrasing a refusal in lib/forum.js cannot silently change what a caller sees.
GET /threads and THREAD_SORTS
sort accepts activity, newest, oldest, replies, views. An unknown value falls back to activity. The mapping in lib/forum.js:402-408:
sort |
ORDER BY |
|---|---|
activity |
t.pinned DESC, t.last_post_at DESC, t.id DESC |
newest |
t.pinned DESC, t.created_at DESC, t.id DESC |
oldest |
t.pinned DESC, t.created_at ASC, t.id ASC |
replies |
t.pinned DESC, reply_count DESC, t.last_post_at DESC |
views |
t.pinned DESC, t.views DESC, t.last_post_at DESC |
Pinned threads lead every sort.
GET /threads/:id
The posts are a window, not the whole thread. Three ways in, and the client picks one:
| Parameter | Window |
|---|---|
?offset=N |
The N-th post onward |
?around=<id> |
The window containing the post somebody was linked to |
| neither | The beginning |
limit defaults to POST_PAGE = 50 and is capped at 200. The answer says how much there is, so a truncated thread is visibly truncated rather than quietly wrong:
{ "thread": {}, "posts": [], "postTotal": 412,
"before": 0, "after": 362, "events": [] }
before and after say how many posts sit outside the window, so the page can offer "earlier replies" and "the latest" without counting anything twice. events is the ticket trail (forum.ticketEvents(id)) for a ticket and an empty array otherwise.
A view is counted only for a signed-in reader. forum.countView(id, viewerId) returns immediately when viewerId is null (lib/forum.js:514-516), so a reload loop and a moderator clicking through their own queue are not the number the thread shows. POST /threads subscribes the author from the start and, when the new thread is a ticket, notifies staff with new_ticket.
There is no route to edit a thread title
Only the first post is editable, through POST /api/forum/posts/:id/edit and only by its author or a moderator. A thread's title is fixed once the thread exists: POST /threads creates it and nothing else changes it.
Vocabularies
| Name | Values | Source |
|---|---|---|
STATUSES |
open, in_progress, on_hold, closed (each with label and hint) |
lib/forum.js:65-70 |
CATEGORY_KINDS |
discussion, support |
lib/forum.js:82 |
Update KINDS |
website, mouse |
lib/updates.js:35 |
ACCENTS |
lime, blue, teal, violet, orange, critical |
lib/updates.js:63-70 |
Update TAGS |
site, builder, oled, checker, profiles, forum, guides |
lib/updates.js:80-88 |
STATUSES, KINDS, ACCENTS and TAGS are served to the client by GET /api/forum/tickets and GET /api/forum/updates so the pickers are built from the same list the server validates against. A client must never be the only source of one of these: an accent that is not in the list means nothing, and a tag that is not in the list matches nothing and shows up in the filter for ever.
POST /preview
Signed-in only, { body } in, { html } out. The source is sliced to PREVIEW_MAX = Math.max(forum.MAX_BODY, updates.MAX_BODY), which is 32 000 — read from the modules that own the two budgets rather than hard-coded, because a release note previewed at 8 000 characters looks right until the part that was cut. It renders with renderForumPost(body, { changelog: isStaff(req) }), so whether ::: security comes back with a badge depends on the viewer's own standing; a member who typed the block by hand and got a badge in the preview would have been promised something that disappears on Post. A non-string body is 400 Nothing to preview.
GET /search
q is cleaned to 80 characters by forum.cleanText. An empty or invalid q returns 200 with an empty result, not an error:
{ "q": "", "threads": [], "posts": [] }
Otherwise up to 20 threads (sort: 'activity') and up to 20 posts. Threads alone would not be an answer for a support desk: the sentence that solved somebody's problem is a post, and it is often in a thread whose title says nothing about it.
Limits
| Constant | Value | Source |
|---|---|---|
MAX_TITLE / MIN_TITLE |
120 / 4 | lib/forum.js:88,93 |
MAX_BODY / MIN_BODY |
8000 / 2 | lib/forum.js:89,94 |
MAX_STATUS_NOTE |
300 | lib/forum.js:90 |
MAX_CATEGORY_NAME / MAX_CATEGORY_DESCRIPTION |
60 / 300 | lib/forum.js:91-92 |
DEFAULT_LIMIT / MAX_LIMIT |
50 / 200 | lib/forum.js:95-96 |
MAX_TITLE / MIN_TITLE (updates) |
140 / 4 | lib/updates.js:91,101 |
MAX_SUMMARY |
180 | lib/updates.js:92 |
MAX_VERSION / MAX_TAGS |
40 / 4 | lib/updates.js:93-94 |
MAX_BODY (updates) |
32000 | lib/updates.js:100 |
DEFAULT_LIMIT / MAX_LIMIT (updates) |
30 / 200 | lib/updates.js:102-103 |
POST_PAGE |
50, capped at 200 | routes/forum.js:199,276 |
See Forum and tickets and Updates.
/api/records
routes/records.js. Seven endpoints, all requireAdmin. This is the authoring surface behind the walkthrough recorder.
| Method + path | Body | Response | Notes |
|---|---|---|---|
GET / |
— | { records } |
Slug, title, step count, publish state, byte length |
GET /:slug |
— | the recording plus stats | 404 Recording not found. |
POST / |
{ slug, title, description, data } |
201 created / 200 updated | Upsert |
PUT /:slug |
{ slug } |
rename result | Rename only |
POST /:slug/publish |
— | { ok, file, embed, dataSrc } |
Writes the repo file |
POST /:slug/unpublish |
— | { ok: true } |
Removes the repo file |
DELETE /:slug |
— | result | Row and file |
POST /api/records takes data as a string: the .rec.json document itself. It must parse as an object with a steps array, or 400 Recording must be a .rec.json document with a "steps" array. A missing or non-string data is 400 Missing recording data., unparseable JSON is 400 Recording data is not valid JSON., and anything over MAX_BYTES = 50 MB is 400 Recording is too large.
PUT /api/records/:slug is rename only. A rename must not force the editor to resend tens of megabytes of screenshot data URLs just to fix a name, so lib/records.js:148-182 moves the row and the published file and returns a warning of Any guide embedding the old slug needs its snippet updated. when the recording was published. A slug that is already taken is 400 A recording called "<slug>" already exists.
POST /api/records/:slug/publish writes docs/recordings/<slug>.rec.json, sets published = 1, and returns:
{ "ok": true,
"file": "docs/recordings/<slug>.rec.json",
"embed": "::: steps \"<title>\" recordings/<slug>.rec.json",
"dataSrc": "/docs/recordings/<slug>.rec.json" }
Guide embeds then hit the public read path in routes/docs.js — the repo file first, the database row as fallback — never this router. See Walkthrough recorder.
The shared read-only JSON endpoints
These live in routes/docs.js, are public, and answer documentation data rather than application data.
| Path | Content-Type | Returns |
|---|---|---|
GET /api/docs-tree |
JSON | eng.apiTree() — the flat guide tree |
GET /api/guides |
JSON | The published walkthroughs: title, URL, step count. No file paths, no author ids |
GET /api/docs-graph |
JSON | eng.graphData() — the link graph between documents |
GET /api/docs/<slug>/raw |
text/plain; charset=utf-8 |
The raw markdown |
GET /api/docs/<slug>/discord |
text/plain; charset=utf-8 |
eng.toDiscord(...) — the same content, reflowed for Discord |
The last two answer 404 with an empty body when the slug is unknown.
The three virtual modules
| Path | Source | Content-Type | Cache |
|---|---|---|---|
GET /js/spcfParser.js |
lib/spcfParser.js |
application/javascript; charset=utf-8 |
public, max-age=3600 |
GET /js/spcfWriter.js |
lib/spcfWriter.js |
application/javascript; charset=utf-8 |
public, max-age=3600 |
GET /js/step-diff.js |
lib/step-diff.js |
application/javascript; charset=utf-8 |
public, max-age=3600 |
All three modules are written as UMD bundles and served straight out of lib/, so the browser and the server run the same .spcf parsing code. That is the point: a checker in the browser and an upload on the server cannot disagree about what a profile contains, because there is only one implementation. See SPCF format.
Status codes used
| Code | Meaning in this app | Representative message |
|---|---|---|
| 200 | Success | — |
| 201 | Created | — |
| 301 | Permanent redirect | /profile.html?id=57 to /profiles/57 |
| 302 | Temporary redirect | /discord/login to discord.com/oauth2/authorize |
| 400 | The request was refused | Title missing. |
| 401 | Not signed in | Please log in with Discord to do that. |
| 403 | Signed in, not allowed | Admins only. |
| 404 | No such row | Profile not found. |
| 409 | Conflict | You already reported this. |
| 413 | Payload too large | File too large. Maximum size is 2 MB. |
| 429 | Rate or quota cap | You can upload at most 3 profiles. Delete one first to make room. |
| 500 | Unhandled server error | Server error. (server.js:177) |
| 503 | Feature not configured | Discord login is not configured yet. |
The 500 body is deliberately generic and the exception is logged server-side rather than returned — see Security and privacy.
Rate limits and caps in one table
| Limit | Value | Enforced in |
|---|---|---|
Uploaded .spcf file |
2 MB | multer in routes/profiles.js:13 |
| JSON body | 64 MB | express.json in server.js:35 |
| Recording document | 50 MB (MAX_BYTES) |
lib/records.js:21 |
Notification ids per POST /read |
100 | routes/notifications.js:76 |
| Notifications per list request | 50 (LIST_LIMIT) |
routes/notifications.js:10 |
Forum search q |
80 chars | routes/forum.js:493 |
Forum list limit |
200 (MAX_LIMIT) |
lib/forum.js:96 |
| Posts per thread window | 200 | routes/forum.js:276 |
| Forum post body | 8000 | lib/forum.js:89 |
| Update body | 32000 | lib/updates.js:100 |
| Guide body | 200000 | lib/docs/write.js:41 |
| Uploads per user | 3 | routes/profiles.js:29 |
MAX_GROUPS |
100 | lib/spcfParser.js:36 |
MAX_MAPPINGS |
1000 (cumulative) | lib/spcfParser.js:37 |
MAX_NESTING |
20 | lib/spcfParser.js:38 |
There is no generic request-rate limiter on the JSON API. The caps above are size caps; the only per-user quota in the app is the profile upload count.
See also Pages and routes, Auth and roles, Admin panel, Data model, SEO and analytics.