SWIFTPOINT//HUB
Support

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.

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.