SWIFTPOINT//HUB
Support

Pages and routes — every URL the app serves

Every URL Swiftpoint Hub answers on, who it is for, and what it needs to serve it. This is a route inventory: which path returns which document, image or JSON,

Every URL Swiftpoint Hub answers on, who it is for, and what it needs to serve it. This is a route inventory: which path returns which document, image or JSON, and which gate stands in front of it. Request and response bodies belong to the API reference; the content of each page belongs to the feature chapters such as the Profile Builder, the OLED editor and the Guides system.

Base path

Every path below is relative to BASE_PATH (env var; /swiftpoint in the deployed example). API routers are mounted at ${BASE_PATH}/api/... in server.js:140-147; the docs router, the SEO router and express.static are mounted at BASE_PATH || '/' in server.js:163-166. Order matters: the API routers are consulted first, then /docs/*, then the page routes, then static files. Nothing is registered after express.static, so a static file can never shadow a route.

The meaningful paths live in lib/paths.js (BASE_PATH, GALLERY_PATH = /profiles/hub, galleryPath()), because the sitemap, the header, the footer, the admin redirect and the client scripts all have to agree on where the gallery is.

The header (lib/header.js) is generated from one NAV array and injected into every page. Six items:

Item Href What it opens Marked active on
Browse ./profiles/hub Community gallery / exactly, /profiles/…, /users/…
Profile Builder ./editor Profile Builder /editor
OLED Editor ./oled OLED editor /oled
Checker ./check .spcf checker /check
Guides ./docs Guides /docs
Forum ./forum Forum and tickets /forum, /updates

Each item carries a data-paths attribute; public/js/chrome.js derives the active item from it using longest-prefix matching, with / treated as exact-only and a trailing slash as a pure prefix. Browse is active on a profile page because those pages sit inside Browse; Forum is active on the updates list because both are the same destination. /admin and /records are deliberately absent from NAV, from the footer and from the sitemap, and test/header.test.js asserts that — a site-administration page cannot quietly become a nav item.

Alongside the nav the header carries a Support link (external, SUPPORT_URL), a notification bell with panel and "Mark all read", an #admin-btn that stays hidden until /api/auth/me reports admin, and a login/upload/user-chip cluster with a user menu (Your profile, Ticket queue, Write an update, Admin panel, Log out). See Roles and permissions.

Page routes

These are static HTML files in public/, read at request time and re-injected with SEO meta and JSON-LD by routes/seo.js (or rendered by routes/docs.js for the guides). Each route rewrites <meta charset="UTF-8" /> to add <base href="{SITE_URL}/">, replaces the <!--SEO:META--> marker, drops the template's generic <meta name="description"> so there is only one, and substitutes the title.

Route Source file Audience Gate
/ public/index.html Anyone none
/profiles/hub public/gallery.html Anyone none
/profiles/:id public/profile-beta.html Anyone none
/users/:id public/user.html Anyone none
/editor public/editor.html Anyone none
/oled public/oled.html Anyone none
/check public/check.html Anyone none
/records public/records.html Staff requireAdminPage
/admin public/admin.html Admins requireAdminPage
/docs, /docs/<slug> server-rendered Anyone none
/forum public/forum.html Anyone none
/forum/t/:id public/thread.html Anyone none (404 if the thread is gone)
/updates public/updates.html Anyone none
/privacy, /imprint generated by lib/legal.js Anyone none

/ — front page

For a reader deciding what the site is. Explains the Z3 mouse and the four tools. Server-rendered rather than script-filled, because the front page is what a crawler and a reader without JavaScript see.

  • Four data-stat counters (profiles, games, mappings, members) are rewritten server-side from SQL, so a number is never only present after a script runs.
  • Six newest profiles, 4 published guides (read from the guides engine, linking into /editor?record=…), 3 newest updates and 3 open tickets are rendered into <ul> placeholders. Imported profiles are badged imported rather than being passed off as new uploads.
  • Meta: canonical /, WebSite JSON-LD, og/twitter summary_large_image with /og-image.png declared at 1200×630. The description stays in the template's own markup; this route does not inject a second one.

GALLERY_PATH from lib/paths.js. Registered above /profiles/:id, and it has to be: the single-segment handler answers for anything after /profiles/, so without it the word hub would be read as a profile id and 404. Search, filter by tag or game, sort, upload and pagination are drawn by public/js/app.js. ItemList JSON-LD lists the 20 newest profiles with URL, name, description and author — the honest summary of a list the crawler cannot see, built from the same rows the gallery shows and no more. See Tools gallery and profiles.

/profiles/:id — profile detail

One profile as a full page: button mappings, deep-click stages, nested bindings, DPI and OLED frames, plus comments and voting. There is no macros section — PredecessorId links are drawn as a Nested bindings tree.

  • The legacy /57b form normalises to id 57 in-route (routes/seo.js:910); a non-numeric id is a 404.
  • views is incremented, and a pageview recorded, for every reader except an admin session (isAdminSession).
  • CreativeWork JSON-LD, og:type article, og:image the per-profile /profiles/:id/share.png — the share card shows the actual bindings rather than a logo.
  • The template ships as noindex,follow because it was the beta preview; the route rewrites it to index,follow, since the default layout is the public one.

/users/:id — member page

Public member view: bio, stats, profiles, recent comments and threads. ProfilePage JSON-LD, og:type profile, og:image /og-image/user-<id>.png. It also flips the template's noindex,follow to index,follow. A non-numeric or unknown id gives the 404 page.

/editor — Profile Builder

The .spcf designer. noindex,nofollow, WebApplication JSON-LD with applicationCategory: DesignApplication, og:image /og-image/tool-editor.png. See Profile Builder.

The route injects body[data-can-record="1"|"0"], and the injection is attribute-aware (setHtmlAttr) rather than a literal '<body>' replace: the editor's body is <body data-app-shell>, so a literal replace silently no-ops and the recorder — which gates on document.body.dataset.canRecord — exits before revealing its button, which reads to an admin as a removed feature with nothing anywhere reporting a failure.

/oled — OLED editor

The 64×32 monochrome frame editor. WebApplication / DesignApplication, og:image /og-image/tool-oled.png. See OLED editor.

/check — .spcf checker

Opens a .spcf entirely in the browser; nothing is uploaded or stored. noindex,nofollow, WebApplication with applicationCategory: UtilityApplication. The page loads /js/spcfParser.js, the same UMD file the server parses with. See .spcf checker.

/records — recordings manager

Admin-only authoring surface for guide walkthroughs. noindex,nofollow, og:image /og-image/tool-records.png. Behind requireAdminPage like /admin: recordings are authored content, and asking for this page must never land the visitor somewhere unrelated with no explanation. See Walkthrough recorder.

/admin — admin panel

Users, profiles, comments, reports, staff, settings and the guides editor. Behind requireAdminPage. Only <base> is injected; the panel is a signed-in application, not a landing page. See Admin panel.

The admin page gate

requireAdminPage(req, res, pathname, denied) in routes/seo.js:534:

Session state Response
Signed out 302 to {SITE_URL}/api/auth/discord/login?returnTo={BASE_PATH}{pathname}
Signed in, not admin 302 to {BASE_PATH}/profiles/hub?admin=denied
Signed in, admin the page itself

The gallery reads ?admin=denied and explains it (public/js/app.js); the front page has no such handler, and a silent jump home is what this gate was written against. returnTo is a full browser path including the base prefix — the OAuth callback prepends only the origin. The same rule breaks the login loop: a non-admin who asked for /admin comes back from Discord to /profiles/hub?admin=denied instead of being sent to /admin again (routes/auth.js:176).

/forum, /forum/t/:id, /updates

All three go through serveShell (routes/seo.js:1030), which injects <base> and body[data-base] — the app's own answer to "am I mounted under a prefix", where guessing from the first path segment gets it wrong at the root: on /forum the client would read the site as living under /forum and prefix every API call one segment too deep.

  • /forum — forum index: categories, latest activity, ticket queue. Pageview tracked for non-admins.
  • /forum/t/:id — one thread or ticket. The title carries the status label; DiscussionForumPosting JSON-LD with reply and view counters. A non-numeric or unknown id is the shared 404 page, because a deleted thread must not answer with an empty shell.
  • /updates — changelog, split into website updates and mouse updates.

See Forum and tickets and Updates.

/docs, /docs/<slug>

Server-rendered by routes/docs.js; see Docs routes. See Guides system.

/privacy, /imprint

Rendered from lib/legal.js rather than from files, so the claims cannot drift from the implementation — test/legal-pages.test.js fails when a statement stops matching the code. Both carry the standard header, footer, theme script and auth script, so the header on these pages is live rather than a picture.

Docs routes

routes/docs.js, mounted before the SEO router. All pages are server-rendered HTML with no inline scripts (CSP is script-src 'self').

Method Path Purpose Notes
GET /docs, /docs/ README index Renders st.flat[0]; docs 404 shell if there is no guide at all
GET /docs/og/<slug>.png Per-guide share card 1200×630 from lib/docs-og.js; registered before the generic image matcher; Cache-Control: public, max-age=3600
GET /docs/recordings/<slug>.rec.json A recording referenced by a ::: steps block The one recordings directory; repo file first via eng.resolveRec, then the recordings table
GET /docs/<file> (images only) Local image referenced from a guide .png .jpg .jpeg .webp .gif .svg .avif, MIME from extension
GET /docs/<slug> One guide Tracks a pageview; 301 to the canonical spelling when the engine matched case-insensitively, query string preserved
GET /api/docs-tree Flat metadata list Used by search
GET /api/guides Published walkthroughs Title, recording URL, owning guide, step count; no file paths, no author ids
GET /api/docs-graph Nodes and edges for the notes map
GET /api/docs/<slug>/raw Original markdown text/plain
GET /api/docs/<slug>/discord Discord-ready text export text/plain

Details worth knowing:

  • Matcher order is load-bearing: /docs/og/...png is registered before the generic image matcher so a per-guide card is never served as a guide asset.
  • The .rec.json capture is non-greedy and keeps the suffix so resolveRec can validate it. A recording saved to the hub but never published as a repo file still resolves, which is why the DB fallback exists.
  • One guide, one URL. /docs/profile-builder/Testing and /docs/profile-builder/testing resolve to the same file, so the non-canonical spelling is a 301 with the query string carried over. A folder address (/docs/profile-builder) is not a redirect — it is that folder's own page.
  • A miss anywhere under /docs/* renders render404(): the full docs shell with tree and nav, noindex,nofollow, and "No such guide".

SEO routes

routes/seo.js.

Method Path Purpose Cache
GET /robots.txt Crawler policy, text/plain —
GET /sitemap.xml URL set, application/xml —
GET /og-image.png Home card: the site's numbers and what each of the four tools does public, max-age=86400
GET /og-image/<variant>.png tool-editor, tool-oled, tool-check, tool-records, user-<digits>, gallery public, max-age=86400
GET /profiles/:id/share.png Per-profile card showing its bindings public, max-age=3600
GET /js/spcfParser.js UMD .spcf parser from lib/ public, max-age=3600
GET /js/spcfWriter.js UMD .spcf writer from lib/ public, max-age=3600
GET /js/step-diff.js UMD semantic diff from lib/ public, max-age=3600

Details worth knowing:

  • robots.txt must be plain text. It used to be answered by the API's catch-all 404 with Content-Type: application/json, which a crawler reads as "no robots here"; and a crawler that reads /robots.txt at the host root and a narrower one at /swiftpoint/ takes the narrower, so an inconsistent file is a policy that does not apply. Content: User-agent: *, Allow: /, Disallow: /api/, Disallow: /admin, Disallow: /records, Sitemap: {SITE_URL}/sitemap.xml.
  • sitemap.xml entries: / daily 1.0; /profiles/hub daily 0.9; /docs weekly 0.5; every guide except README monthly 0.7; every profile weekly 0.8 with lastmod; every user weekly 0.4 with lastmod; /forum daily 0.7; /updates weekly 0.6; the 100 most recently active threads weekly 0.5 with lastmod. A sitemap is not an archive, which is why threads are capped.
  • An unknown og-image variant falls back to the home card rather than 404ing: a share preview showing the site's own card beats one showing nothing, and the URLs are generated from the same list the renderer serves, so this is a guard rather than a path anyone takes.
  • /profiles/:id/share.png answers 404 text/plain Not found for a non-numeric id or a render failure.
  • The three /js/*.js routes serve files from lib/, not public/js/, so the browser parses a .spcf with exactly the code the server parses it with. routes/seo.js is mounted before express.static, and no file of the same name exists in public/js/.

See SEO and analytics.

API routes

All JSON unless noted. Auth gates come from routes/auth.js: requireUser → 401 {"error":"Please log in with Discord to do that."}, requireModerator → 403 {"error":"Moderators only."}, requireAdmin → 403 {"error":"Admins only."}. Both role flags are recomputed from the Discord-ID allowlist on every call and never read from the session snapshot, so a session that predates a role change cannot hide a panel from somebody who has it.

Request and response shapes are in the API reference; the tables below are the inventory.

/api/auth (4)

Method Path Auth Purpose
GET /discord/login none 302 to Discord OAuth; state stored in session, returnTo sanitised
GET /discord/callback none Exchange code, upsert user, regenerate session, redirect
POST /logout none Destroy the session
GET /me none Always 200; identity plus recomputed role flags

returnTo sanitisation (safeReturnTo): must start /, must not start //, must not contain a backslash, must contain no control characters — a control character in a Location header is a response-splitting primitive, so the value is refused rather than sanitised. /discord/login answers 503 when DISCORD_CLIENT_ID or DISCORD_CLIENT_SECRET is unset; /discord/callback answers 400 with a short HTML page on any failure. See Auth and roles.

/api/profiles (14)

Method Path Auth Purpose Limits
GET / none List, filter, sort q, tag, game, sort ∈ newest, oldest, name, buttons, dpi, votes, visited, downloaded
GET /tags none Tag list with counts
GET /stats none {profiles, games, mappings}
GET /games none Games present in the table
GET /:id none One profile with its parsed bindings
GET /:id/download none The .spcf, stored 1:1, download counted
POST /:id/vote user value 1, -1 or 0 Cannot vote on your own profile
POST / user Multipart upload 429 at the per-user cap; admins exempt
PATCH /:id owner or admin Metadata only
PUT /:id owner or admin Multipart; replaces the file in place
GET /:id/comments none Comment thread
POST /:id/comments user Add a comment kind: 'patch' only for the profile author
DELETE /:id/comments/:cid comment author or admin Remove one comment
DELETE /:id uploader or admin Delete a profile

Limits in routes/profiles.js: file 2 MB via multer (LIMIT_FILE_SIZE → 413), MAX_TITLE = 80, MAX_AUTHOR = 60, MAX_DESCRIPTION = 400, MAX_COMMENT = 1000, per-user cap default 3 (MAX_PROFILES_PER_USER, overridable at runtime by the max_profiles_per_user setting). The public profile payload deliberately carries no Discord id: ownership is the server's answer to give, not the browser's, and exposing it hands an anonymous visitor a stable identifier for every person on the site.

/api/notifications (4, all user)

Method Path Auth Purpose Limits
GET / user Feed, unread first 50
GET /unread-count user {unread}
GET /stream user Server-sent events text/event-stream, ping every 25 s, X-Accel-Buffering: no
POST /read user {all: true} or {ids: […]} ids integer-filtered, de-duplicated, capped at 100

/api/reports (3)

Method Path Auth Purpose Notes
POST / user Report a profile, comment or post One open report per user per target → 409. A post notifies moderators with threadId + postId so the exact reply can be opened; a profile or comment goes to admins. Reason capped at 500.
GET / admin The queue status ∈ open, resolved, dismissed, all; anything invalid falls back to open
POST /:id/status admin resolved or dismissed 400 if the report is no longer open

/api/admin (8 routes, all admin)

Method Path Auth Purpose Notes
GET /overview admin One payload for the whole panel Users, profiles, the 100 newest comments, reports, the admin's own activity, settings, and the traffic block
GET /settings admin Current runtime limits
POST /settings admin Update a known limit max_profiles_per_user must be an integer 1–100
GET /staff admin Staff list plus knownPeople
POST /staff admin Add a moderator or admin By Discord ID, or by username resolved against accounts that have signed in here; more than one match → 400
PATCH /staff/:discordId admin Promote or demote
DELETE /staff/:discordId admin Remove somebody
POST /profiles/:id/transfer admin Reassign a profile to another user Notifies both sides

/api/admin/docs (9 routes, all admin)

Mounted from routes/docs-write.js inside routes/admin.js, so everything under /api/admin is one surface with one gate — and so the check in test/admin-access.test.js that every admin route sits behind requireAdmin can actually see these routes. The gate is on each route individually rather than inherited: this writes files, and one endpoint in particular deletes them.

Method Path Auth Purpose Notes
GET /api/admin/docs admin Tree plus what a new name would collide with, plus the limits
GET /api/admin/docs/:slug(.*) admin One guide's raw content
POST /api/admin/docs/folders admin Create a folder
POST /api/admin/docs/documents admin Create a document
PUT /api/admin/docs/:slug(.*) admin Save content
PATCH /api/admin/docs/:slug(.*) admin Rename Returns linkedGuides so the editor can show what the rename breaks
PATCH /api/admin/docs/folders/:slug(.*) admin Rename or move a folder
DELETE /api/admin/docs/folders/:slug(.*) admin Two-step delete First call 409 with needsConfirm and counts; second confirms
DELETE /api/admin/docs/:slug(.*) admin Delete a guide
POST /api/admin/docs/preview admin Render markdown as the guide will appear

Preview renders with the GUIDES engine, not the forum's, so wikilinks, relative images and ::: steps resolve against the slug the guide will have; previewing with the wrong renderer shows a link that 404s the moment it is published, which is the one thing a preview exists to prevent. It accepts content or body and answers 400 Nothing to preview. otherwise. Limits in lib/docs/write.js: MAX_NAME = 60, MAX_TITLE = 140, MAX_DESCRIPTION = 300, MAX_BODY = 200000, MAX_TAGS = 12; routes/docs-write.js additionally caps the body at 220 000 so an oversized body is refused before it is parsed. The panel editor caps at 32 000.

/api/users (2)

Method Path Auth Purpose Limits
GET /:id none Public member payload Bio ≤ 300, comments ≤ 100, threads ≤ 10; the Discord id is deliberately absent
POST /:id/bio own account Edit the bio 403 for anybody else

/api/forum (25)

Method Path Auth Purpose Notes
GET / none Forum root payload
GET /categories none Categories
POST /categories mod Create a category Name ≤ 60, description ≤ 300
PATCH /categories/:id mod Rename or reorder
DELETE /categories/:id mod Remove a category
GET /threads none Thread list category, status, q, sort ∈ activity, newest, oldest, replies, views, limit, offset
POST /threads user Start a thread The first post is the body; the author is subscribed; a ticket notifies moderators. Title 4–120, body 2–8000
GET /threads/:id none Thread, posts, and the ticket events trail offset, around, limit default 50 max 200. A view is counted only for signed-in readers
POST /threads/:id/posts user Reply
POST /posts/:id/edit author or staff Edit a post
DELETE /posts/:id mod Tombstone, not a hard delete
POST /threads/:id/pin mod Pin
POST /threads/:id/lock mod Lock
POST /threads/:id/move mod Move to another category
POST /threads/:id/status mod Ticket status Note ≤ 300
DELETE /threads/:id mod Delete a thread
GET /tickets mod Ticket queue
POST /preview user Render a post's markdown Capped at 32 000 (max(forum.MAX_BODY, updates.MAX_BODY))
POST /threads/:id/subscribe user Subscribe
DELETE /threads/:id/subscribe user Unsubscribe
GET /search none Threads and posts q cleaned to 80 chars; up to 20 threads + 20 posts
GET /updates none Changelog data, kinds, accents, tags, counts kind, q, limit, offset
POST /updates mod Write an update
PUT /updates/:id mod Edit an update
DELETE /updates/:id mod Delete an update

Limits in lib/forum.js: MAX_TITLE 120, MIN_TITLE 4, MAX_BODY 8000, MIN_BODY 2, MAX_STATUS_NOTE 300, MAX_CATEGORY_NAME 60, MAX_CATEGORY_DESCRIPTION 300, DEFAULT_LIMIT 50, MAX_LIMIT 200.

There is deliberately no route to edit a thread title — only posts can be edited. A thread's title is its identity in a chat window, a bookmark and a search result; editing it silently breaks all three. See Forum and tickets.

/api/records (7, all admin)

Method Path Auth Purpose Notes
GET / admin List: slug, title, step count, publish state
GET /:slug admin Full .rec.json, parsed
POST / admin Upsert 201 created, 200 updated
PUT /:slug admin Rename only A separate request so a 10 MB recording is not resent
POST /:slug/publish admin Write docs/recordings/<slug>.rec.json and return the embed snippet
POST /:slug/unpublish admin Remove the repo file again
DELETE /:slug admin Delete the recording and its repo file

lib/records.js sets MAX_BYTES = 50 * 1024 * 1024 (screenshots are data URLs, so the limit is generous); server.js allows a 64 MB JSON body. Publishing writes into docs/recordings/ and nothing else — one directory for every recording on the site, so a guide's embed is recordings/<slug>.rec.json whether the guide sits at the root of docs/ or three folders down. Guide embeds read recordings through the public path in routes/docs.js, never through this router. See Walkthrough recorder.

Redirects

From To Status Reason
/admin, /records while gated Discord login, or the gallery 302 requireAdminPage
/api/auth/discord/callback on success origin + returnTo 302 Sign-in flow; a non-admin headed to /admin lands on /profiles/hub?admin=denied instead
/docs/<slug> non-canonical spelling canonical URL, query string preserved 301 One guide, one URL
/docs/ /docs 301 Empty slug
/oled.html /oled 301 Legacy extension URL
/profile.html?id=<n> /profiles/<n> 301 Legacy query-string URL
/profile.html (non-numeric id) / 301 Nothing to show

404 behaviour

There is no global catch-all. An unmatched path falls through to Express's default 404 — including unknown /api/* paths, which are HTML from Express rather than JSON. That is the one place a client has to check the status code instead of parsing a body.

Purpose-built 404s:

Where Response
/docs/* miss (page, image, recording, og card) routes/docs.js render404() — full docs shell, noindex,nofollow, "No such guide", status 404
/profiles/:id, /users/:id, /forum/t/:id routes/seo.js notFoundHtml() — standalone 404 page with a link home, text/html
/profiles/:id/share.png miss 404 text/plain Not found
/api/<router>/<id> miss {"error":"…"}
/api/docs/<slug>/raw, /api/docs/<slug>/discord miss 404, empty body

Error responses

The only error handler in the app is the last middleware in server.js:169:

Condition Status Body
err.code === 'LIMIT_FILE_SIZE' (multer, 2 MB) 413 {"error":"File too large. Maximum size is 2 MB."}
err.type === 'entity.too.large' (JSON body over 64 MB) 413 {"error":"Body too large. Maximum size is 64 MB."}
Anything else 500 {"error":"Server error."}, logged server-side

Auth failures are plain JSON as listed under API routes. The upload 413 arrives from here rather than from the route, which is why the message is the app's wording and not multer's.

Shared chrome on every page

lib/header.js is the single source of the header, lib/footer.js the single source of the footer. Neither is hand-written into the pages.

  • Header: brand SWIFTPOINT//HUB, the six nav items, a theme toggle, a Ctrl K command palette, a Support link, the notification bell with panel and "Mark all read", #admin-btn (hidden until /api/auth/me says admin), and the login/upload/user-chip cluster with the user menu (Your profile, Ticket queue, Write an update, Admin panel, Log out).
  • Footer: Browse profiles, Forum, Updates, Front page, Profile Builder, OLED Editor, Checker, Guides, Sitemap, Privacy, Imprint, plus the unofficial-community-project disclaimer.
  • scripts/sync-header.js writes the generated header and footer into 12 pages between <!-- header:start --> / <!-- header:end --> and <!-- footer:start --> / <!-- footer:end -->, preserving each page's own .tbar tool strip. npm run check:header fails if any page has drifted, and it runs as pretest, so a page cannot ship with a stale header.
  • public/js/theme.js is blocking, sets data-theme on <html> before first paint, cycles dark → light → system, and stores the choice under sph-theme.
  • public/js/chrome.js builds the command palette from the header itself, adds profile search (2+ characters, 180 ms debounce, top 6 results, with a sequence token so a slow response cannot overwrite a newer one) and derives the active nav item from data-paths.
  • public/js/notifications.js polls every 30 s, opens an EventSource on /api/notifications/stream, reconnects after 3 s, caps the badge at 99+, and toasts for 10 s with at most 4 on screen.

See also: API reference for bodies, Roles and permissions for the gates, SEO and analytics for meta and tracking, Security and privacy for CSP, the session cookie and returnTo sanitisation, Configuration and deployment for BASE_PATH and the cache policy, and Known gaps.