SWIFTPOINT//HUB
Support

Admin panel — running the site

The panel at /admin is the site's back office: counts, traffic, the user and profile tables, the report queue, staff management, one setting, and a markdown edi

The panel at /admin is the site's back office: counts, traffic, the user and profile tables, the report queue, staff management, one setting, and a markdown editor for the guides. It is admin-only at every layer — the page route, every API route, the header link, and the client's own front gate. This page covers what it shows, what it can change, the guards around the staff list, and — at the end — the things it deliberately does not do. See 15 Roles and permissions for who counts as an admin.

Getting in and what it is not

Route /admin in routes/seo.js, page public/admin.html, script public/js/admin.js.

The panel is:

  • noindex,nofollow in its own markup, and Disallow: /admin in robots.txt (alongside /api/ and /records)
  • deliberately absent from the nav. test/header.test.js asserts that neither /admin nor /records is claimed by any nav item, and that the five nav match sets stay disjoint so two items can never both be "current". The reasoning is in the test: claiming one of the five tools would be a lie, so they stay out. The #admin-btn link is in the header bar, hidden by default.

The server-side gate

requireAdminPage(req, res, pathname, denied) runs before any HTML is sent:

function requireAdminPage(req, res, pathname, denied) {
  const user = req.session && req.session.user;
  if (!user) {
    const login = `${siteUrl('/api/auth/discord/login')}?returnTo=${encodeURIComponent(`${BASE_PATH}${pathname}`)}`;
    res.redirect(302, login);
    return true;
  }
  if (!user.isAdmin) {
    res.redirect(302, siteUrl(denied || `${GALLERY_PATH}?admin=denied`));
    return true;
  }
  return false;
}
Session Response
Signed out 302 to the Discord login with returnTo=<path>
Signed in, not admin 302 to /profiles/hub?admin=denied
Admin the page

The gallery is the redirect target because it is the page that reads and explains the query — js/app.js raises the toast "The admin panel is only available to admin accounts." Bouncing to the front page instead, which is what this used to do, meant asking for the panel silently landed on the gallery with no sign-in prompt and no way back.

The gate returns a boolean, not the response: res.redirect() returns undefined in Express 4, so a gate that returned the redirect object would never stop the route, and every visit would log ERR_HTTP_HEADERS_SENT. test/admin-access.test.js asserts return true; and return false; are both present and return res.redirect is not.

What the tests pin down

test/admin-access.test.js asserts:

  • the admin allowlist is parsed in exactly one place — routes/auth.js, routes/users.js, lib/notify.js, routes/admin.js and server.js must not read process.env.ADMIN_DISCORD_IDS
  • isAdminDiscordId is total: [null, undefined, '', 0, false, NaN, {}, []] all read as "not an admin", and a numeric id that is on the list still matches
  • every route in routes/admin.js, routes/records.js and routes/docs-write.js is behind requireAdmin
  • the admin pages refuse before sending HTML, and both /admin and /records go through the shared gate
  • the header renders #admin-btn hidden and only public/js/auth.js unhides it, from the session's own isAdmin
  • the command palette lists the panel only when if (admin && !admin.hidden)
  • requireAdmin reads the allowlist through flagsFor(req.session.user).isAdmin and never req.session.user.isAdmin — a snapshot goes stale the moment a role is added to the code
  • the admin surface is not reachable through the forum: routes/forum.js has no requireAdmin, and routes/admin.js and routes/records.js have no requireModerator

The client-side front gate

public/js/admin.js checks again before it renders anything:

if (!window.SPHAuth) {
  el.sub.textContent = 'Could not verify your session — reload the page.';
  return;
}
const me = await window.SPHAuth.ready();
if (!me || !me.loggedIn) { window.location.href = `${BASE}/api/auth/discord/login`; return; }
if (!me.isAdmin) { window.location.href = `${BASE}/profiles/hub?admin=denied`; return; }

A missing window.SPHAuth is a distinct path from "not signed in". auth.js used to be deferred, which meant it arrived after this file and the gate read "helper not loaded yet" as "not signed in" — bouncing a valid admin to Discord. The panel served a 200 and then threw the admin straight out. test/script-order.test.js asserts the missing-helper branch exists, asserts js/auth.js is never deferred on any page, and asserts no page script anywhere uses the collapsing gate pattern window.SPHAuth && await window.SPHAuth.ready() || null.

The layout

Reading order down #main:

Element Id Source
Tool-strip search admin-search Placeholder: "Search everything — username, profile title, comment…"
Search hint admin-search-hint #admin-search-hint
Title — Admin panel
Subtitle admin-sub Signed in as <name> · <local timestamp, en-GB>
Stats stat-grid Users, Profiles, Comments, Open reports
Traffic traffic-grid Four windows
Guides guides-section Tree + editor
Settings settings-section max_profiles_per_user
Staff staff-section Add form + list
Reports reports-section #report-list
Users #users-table / #users-body
Profiles #profiles-table / #profiles-body
Recent comments #comment-list
Activity sidebar #activity-section / #activity-list
Transfer modal #transfer-overlay

Everything except staff comes from one call, GET /api/admin/overview: stats (with traffic nested), users, profiles, comments, reports, recentActivity, settings. Staff is a second call, GET /api/admin/staff, because it changes on its own schedule — an admin promotes somebody between page loads and it has nothing to do with the overview numbers.

comments is capped at LIMIT 100 and recentActivity at LIMIT 20 in the SQL. There is no pagination on either, and no control to change that.

The sidebar is a sibling of the main column inside .admin-layout, so it sits beside the tables rather than below them.

One debounced input filters the whole page. input → 150 ms → lowercase the trimmed value into query → renderAll() re-renders users, profiles, comments, reports and activity. matches() is a plain String(str ?? '').toLowerCase().includes(query).

Matched fields per section:

Section Fields searched
Users username
Profiles title, uploaderUsername, game
Comments username, body, profileTitle
Reports reporterUsername, reason, profileTitle
Activity text, type

When a query is present the hint reads Showing matches for "<q>" (curly quotes). The empty states change with it:

Section With a query Without
Users No matching users. No users yet.
Profiles No matching profiles. No profiles yet.
Comments No matching comments. No comments yet.
Reports No matching reports. No reports.
Activity No matching activity. No activity yet.

The users and profiles empty states are a full-width table row (colspan 5 and 10 respectively) styled num admin-empty.

Stats and traffic

Four traffic cards, each views and uniques over a window:

Card Titles Ids
60 minutes Visits last 60 min t-h1-views, t-h1-unique
24 hours Visits last 24 hours t-h24-views, t-h24-unique
7 days Visits last 7 days t-d7-views, t-d7-unique
30 days Visits last 30 days t-d30-views, t-d30-unique

The analytics model, honestly

lib/analytics.js is 44 lines:

const SALT = process.env.SESSION_SECRET || 'swiftpoint-hub-analytics-salt';
function hashVisitor(ip) {
  return crypto.createHmac('sha256', SALT).update(String(ip || '')).digest('hex');
}
function trackPageView(ip, path) {
  db.prepare('INSERT INTO pageviews (visitor_hash, path) VALUES (?, ?)')
    .run(hashVisitor(ip), String(path || '/').slice(0, 255));
  if (++insertCounter % PRUNE_EVERY === 0) {
    db.prepare("DELETE FROM pageviews WHERE created_at < datetime('now', '-30 days')").run();
  }
}
  • the visitor hash is HMAC-SHA256(client IP, SESSION_SECRET || 'swiftpoint-hub-analytics-salt'), so the raw IP is never stored — it is not truncated, not salted separately, not kept
  • only the path is kept, truncated to 255 characters
  • uniques are COUNT(DISTINCT visitor_hash)
  • rows older than 30 days are pruned on every 200th insert
  • there is no referrer, no user agent, no session id, no cookie
  • page views from admin sessions are skipped on the static pages, and on a profile page the admin view is also excluded from the views counter (if (!isAdminSession(req)) guards both)

There is no date-range picker, no chart and no per-path breakdown in the panel. See 19 SEO and analytics.

The users table

#users-table, ordered by created_at DESC.

Column Content
User The avatar (#cell-avatar) when there is one, and the username linking to /users/{id}
Joined fmtDate
Profiles profile_count
Comments comment_count
Reports report_count — reports filed by this user

The row also carries discordId and avatarUrl, which the panel uses for the transfer modal and nothing else the table shows. That is the one surface where the id is published, and test/public-api-privacy.test.js asserts it stays behind the admin guard.

The profiles table

#profiles-table, ordered by created_at DESC.

Column Content
Title Linked to /profiles/{id}
Game p.game or –
Uploader uploader_username or –
Created fmtDate
Views p.views
DL p.downloads
Votes upvotes - downvotes, with a + when non-negative
Comments comment_count
Reports open_report_count — the cell gains cell-warn above zero
(blank) Open link and a Transfer button

Transfer

The Transfer button opens #transfer-overlay:

Element Id Content
Heading — Transfer profile
Sub-line transfer-sub “<title>” → assign to:
Select transfer-user Every user except the current uploader
Buttons transfer-cancel, transfer-confirm Cancel / Transfer

The backdrop and Escape both close it. Confirming is POST /api/admin/profiles/:id/transfer with { userId }. The server refuses a missing profile (404), an unknown user (400 Target user not found.) and the current owner (400 Profile already belongs to that user.), then notifies the admins ("Profile "X" transferred to Y") and the new owner.

Transfer exists because a profile belongs to an account, and an account can go away. Without it the only options are deleting the profile or leaving it owned by somebody who cannot reach it. (Note a small dead branch: the option template contains (current owner), but the current uploader is filtered out of the list before rendering, so that suffix can never appear.)

Reports

#report-list, ordered CASE r.status WHEN 'open' THEN 0 ELSE 1 END, r.created_at DESC — open first, newest first inside each group.

Each row:

Part Content
Type label Comment report when targetType === 'comment', otherwise Profile report
Status chip .r-status plus .r-<status>, only when the status is not open
Meta <reporter> · <date>
Target comment on "<profile title>" or profile "<title>"
Snippet commentSnippet, only for comment reports, first 120 characters
Reason The reported reason
Actions For an open report only: View (to /profiles/<profileId>), Resolve, Dismiss

Resolve and Dismiss are both POST /api/reports/{id}/status with { status: 'resolved' } or { status: 'dismissed' }, followed by a full reload. The update is conditional on status = 'open', so a second attempt returns 400 Report is already <status>. and an unknown id 404.

Be precise about the moderator gap

This is the part worth stating plainly:

  • A report filed on a forum post notifies moderators (notifyStaff) and carries the thread id and post id, so the exact reply can be opened. routes/reports.js says why:

    The person who answers the forum is the person who can remove a post from the thread it sits in, so the notification goes to the moderators as well and links straight at the post. Telling a moderator about it and giving them no way to act is the same as not telling them.

  • But the triage endpoints behind /api/reports are requireAdmin: GET /api/reports (the queue) and POST /api/reports/:id/status (resolve/dismiss). test/admin-access.test.js asserts both, with the reasoning that the queue exposes other people's reported content.

So a moderator is notified of a bad post and cannot clear the report themselves. They can delete the post from the thread, which is the action that actually resolves it; the report row stays open in an admin's queue until an admin looks at it.

There is a second, smaller gap in the same area: the panel's own GET /api/admin/overview does not join forum_posts or forum_threads, and its report mapper emits no threadId, postId or postSnippet. A post report therefore renders in the panel as a "Profile report" against profile "unknown" with a View link to /profiles/null. routes/reports.js does carry the fields; the panel's query does not use them. Both belong in Known gaps.

Comments and activity

Comments

#comment-list, newest first, capped at 100 server-side. Each row:

<span class="comment-meta mono">@username · patch notes · 04 Mar 2026, 09:12</span>
<div class="comment-snippet">…body…</div>
<a class="btn btn--ghost" href="/profiles/{id}">on “<profile title>”</a>

The · patch notes marker appears when kind === 'patch'. There is no delete, no edit and no report button on a comment in this list — comment deletion is on the profile page.

Activity

#activity-list, from the admin's own notifications rows, LIMIT 20. TYPE_LABELS:

type Label
new_user User
new_profile Profile
profile_updated Update
new_comment Comment
new_vote Vote
report Report

An unread item gets unread; a row with a profileId becomes an <a> to that profile, otherwise a plain <div>. The label chip carries a t-<type> class.

Settings

One setting, in #settings-section:

Setting Key Type Range
Max profiles per user max_profiles_per_user number 1–100, integer

The note under the input distinguishes the two cases precisely, and the parenthetical is the fallback number, not the word "fallback":

Active: 3 — same as env default (MAX_PROFILES_PER_USER).
Active: 5 — saved value overrides env default (3).

The first branch fires when the effective value equals String(fallback); the second prints the fallback in parentheses. POST /api/admin/settings sends { key, value }, and on success the note is overwritten with:

Saved — active limit is now 5.

An unknown key is 400 Unknown setting "<key>".; a non-integer or out-of-range number is 400 Max profiles per user must be an integer between 1 and 100.; an unsupported type is 400 Unsupported setting type "<type>".

Under the hood lib/settings.js is a two-function key/value table — getSetting(key, fallback) and setSetting(key, value) with an ON CONFLICT DO UPDATE upsert. fallback for this key is Number(process.env.MAX_PROFILES_PER_USER) || 3. See 17 Data model.

Staff management

#staff-section, backed by GET /api/admin/staff → { staff, people }.

Adding

Element Id Content
Who staff-name <datalist id="staff-people"> with username · discordId for every account that has ever signed in
As staff-role moderator or admin
Add staff-add Enter in the input also submits

The bare value decides the payload: an all-digits string is sent as { discordId }, anything else as { username }. Notes appear in #staff-note-line.

Situation Message
Empty input Type a Discord ID, or pick a name from the list.
Added Added <raw>.
In flight Adding…
Name matches more than one account 400 More than one account has that name. Use the Discord ID.
Name matches nobody 400 Nobody who has signed in here is called @<name>. Add their Discord ID instead — only people who have used the site can be found by name.
Malformed id 400 A Discord ID is 5–25 digits. Copy it with the copy button on the user.
Already listed 400 That account is already on the list.

knownPeople(200) is the account table ordered by join date, capped at 200 by the call and 500 by the function.

Why a name is only sometimes enough. There is no bot token on this site — login is plain OAuth2 with fetch — so nothing here can ask Discord who somebody is. The only place an id is known is the site's own users table. An account that has never signed in can only be added by typing its Discord id, and the error message says exactly that rather than pretending the lookup is complete.

The list

#staff-list is split into two counted groups:

Admins <n>
Moderators <n>

A row shows the role badge, the name (linking to /users/{addedUserId} when known, otherwise the raw Discord id in .staff-who--anon), the id in .mono, and — for env rows — a .staff-from tag reading .env.

Env rows are locked: the actions cell is replaced by a single locked span titled "Set in .env — change it there and restart", with no buttons at all.

Actions

Action Call Confirmation / note
Make mod / Make admin PATCH /api/admin/staff/:discordId { role } #staff-note-line: Now a <role>. They will need to sign in again for the panel.
Remove DELETE /api/admin/staff/:discordId Remove <name>? They lose the forum and the panel immediately.
Demote or remove an env entry refused That one comes from ADMIN_DISCORD_IDS in .env. Change it there and restart. (role change) / That one comes from the .env allowlist. Remove it there and restart. (removal)

The guards, which are the point

  1. The allowlist is the union of the .env lists and the staff table, and the .env list wins. loadStaff() folds the staff rows first, then pushes moderator rows for MODERATOR_DISCORD_IDS, then for ADMIN_DISCORD_IDS sets role = 'admin' and source = 'env' on whatever row already exists. A database row can therefore never demote or remove an env entry, and never re-grant admin to somebody in the moderator list.

  2. The last admin cannot be removed or demoted. guardNotLastAdmin(discordId) counts the admins that survive the action — not the rows — and returns That is the last admin. Add somebody else first. The function's comment names the failure it prevents:

    The guard returns the refusal. Calling it for its side effect and throwing the answer away is how the last admin on a site demotes themselves; there is a test for exactly that.

  3. The same account cannot be added twice — That account is already on the list.

  4. A Discord id must match /^\d{5,25}$/.

  5. Usernames are truncated to 64 characters on insert.

  6. A role change to the role already held is refused: Already a <role>.

test/staff.test.js covers the whole set, including "the .env admin is the way back in, and cannot be removed" and "a name resolves only for people who have signed in, and says so".

The return path

If the table is ever wrong — every admin demoted, an env id removed by hand, a bad migration — the .env list is the way back in. It is not editable from the panel by design, and the section's own note says so: "Anyone listed in .env stays put — that is the way back in if something here goes wrong."

The role change is the one thing that requires a re-sign-in, and the panel says so, because requireAdmin reads the allowlist on each request while the session flag is only a snapshot.

The guides editor

public/js/admin-guides.js, exposing window.SPAdminGuides = { loadTree, open, save }. The guides are files in docs/ and the renderer reads them from there, so the editor uploads nothing and keeps no copy. See 11 The guides system for the engine and the writing API.

The tree

GET /api/admin/docs returns folders (with parent, slug, name, docCount) and docs. renderTree() walks folders recursively:

Row Indent Contents
Folder 8 + depth * 12 px The visibility tick box, the name (titled with its slug), the document count, and three actions: + (new guide in here), Rename, Del
Guide 20 + depth * 12 px The visibility tick box and a <button> titled with the description or the slug; the open one gets is-active

Empty state: No guides yet.

Who may read it

A tick box per folder and per guide, and a line under the open guide's fields (#g-vis, #g-vis-note). It writes PUT /api/admin/docs/visibility with { scope, target, level }, where level is staff, admin or null to take the rule away.

The box shows the row's own rule, not the effective one. A guide inside a closed folder is closed but its box is unticked, and the note says which folder closed it and that ticking the box there only gets stricter — a box that reads "closed" with nothing ticked in it looks broken, and one that silently means something else is worse. admin.css styles .g-tree-vis as a 14px square in a 12.5px tree; it is a real checkbox in a <label>, because "may this person read this" is a yes/no fact and has to be reachable by keyboard.

The rule is not the file's business and is not in the front matter: it lives in doc_visibility, a rename carries it along, and deleting drops it. What it changes on the public side — the tree, the search list, the notes map, the sitemap, and the 404 a closed guide answers — is in 11 The guides system.

Opening a guide

open(slug) fetches GET /api/admin/docs/:slug and fills #g-title, #g-desc, #g-tags (comma separated) and #g-order, points #g-view at /docs/{slug}, then loads the body as saved — the H1 included, because:

The front matter is what the tree shows, the heading is what the page prints. Show the body as it is saved, heading and all.

#g-state then reads Saved.

The editor

SPMarkdown.create({ … }) with:

Option Value Why
previewUrl /api/admin/docs/preview The docs engine, not the forum's
previewField 'content' Not body — sending body produced a blank Preview tab for every guide ever written
previewData `() => ({ slug: state.slug
previewClass 'd-prose' The guides' own prose rules, because that is the engine that produced the HTML
draftKey 'sp:admin:guide' localStorage draft
rows / max 18 / 32000
blocks true The changelog blocks; the toolbar is the only place they appear

Save

PUT /api/admin/docs/{slug} with { content, title, description, tags, order }, then:

Result Note
Changed Saved. It is live at /docs/<slug>
Unchanged Nothing had changed.

clearDraft() runs after a successful save, and a beforeunload guard fires whenever state.dirty.

Create, rename, delete

Action Call Prompt / message
New guide POST /api/admin/docs/documents with the current guide's parent folder Title of the new guide, then Created /docs/<slug>
Rename a guide PATCH /api/admin/docs/:slug New title — this also renames the file, and so the URL
Rename a folder PATCH /api/admin/docs/folders/:slug New folder name — every guide inside moves with it
Delete a guide DELETE /api/admin/docs/:slug Delete /docs/<slug>? The file is removed from the repository.
Delete a folder two DELETE …?recursive=true see below

A rename that would break inbound links says so, because linkedGuides counts the guides whose raw text contains the old path:

Renamed to /docs/<new>. 4 guide(s) link to the old path and will need updating.

Deleting a folder is two requests

The first DELETE …?recursive=true returns 409 with needsConfirm. The panel then asks:

<slug> holds 3 file(s) and 0 folder(s). Delete it and everything in it?

Only a confirmed second call deletes. A folder here holds recordings, so the number is the point.

The button fetches the 8 most recent threads and the 8 most recent updates, lists them as 1. Update · <title> / 2. Thread · <title> in a single prompt, asks for the link text, and inserts a markdown link. Not an embed, on purpose — forum content changes and gets deleted, and a guide that quotes a ticket is wrong next month. When there is nothing to link to it says Nothing to link to yet — publish an update or start a thread first.

Gaps in the editor

Documented rather than implied:

  • No folder re-ordering. There is an order number input in the Details disclosure, but nothing that reorders the tree — no drag control, no move up/down. Folders come back in localeCompare order, full stop.
  • No search in the tree. With a folder per section and dozens of guides, finding one is scrolling.
  • No multi-select or bulk move. One guide at a time.
  • No tree-position preview. The panel cannot show you where a new guide will land before you create it.

What the panel does not do

Stated precisely, because "the admin panel" sounds like more than it is:

  • There is no profile-delete button anywhere in the markup. The staff note says admins "can also delete profiles", and DELETE /api/profiles/:id exists and works — but the panel offers no control for it. Deleting a profile is done from the profile page itself, where the uploader's Delete profile button lives, and only if you are the uploader or an admin. grep -i delete public/admin.html public/js/admin.js returns the prose in the staff note and the staff DELETE call, nothing else.
  • No pagination on the users or profiles tables, or on comments or activity. The server caps them (all rows, 100, 20); the panel renders what it is given.
  • No per-section refresh. Everything reloads from GET /api/admin/overview in one go after a mutation.
  • No report-detail view. The queue shows a target label and a 120-character snippet; there is no page for a single report.
  • No traffic date-range picker and no per-path breakdown.
  • No bulk staff import or export.
  • No settings beyond max_profiles_per_user. SETTING_DEFS has exactly one entry.
  • No link to the recordings page (/records) from anywhere in the panel, even though docs/recordings/ sits right there in the tree the panel already walks.

And one deliberate omission: logout is not wired in admin.js. chrome.js owns it and reloads the page so the gate runs again. Wiring it here too would POST the logout twice — the comment in admin.js says so, and the panel's upload-btn handler sits directly beneath it as the only header control the panel adds.

How this is tested

File What it protects
test/admin-access.test.js One allowlist, a total check, every admin route behind requireAdmin, the pages refusing before serving, the header and palette offering the panel only to an admin, and the forum not being a path to the admin surface
test/staff.test.js Only an admin changes staff, the .env admin cannot be removed, the last admin cannot be removed or demoted, a name resolves only for people who have signed in, no duplicate adds, the union with .env winning
test/script-order.test.js js/auth.js in <head> on every page, no page script collapsing "could not check" into "signed out", the admin page not bouncing on an unverifiable session, and /admin deciding server-side before serving
test/dom-contract.test.js A page script cannot abort the page on one null; the vote box is wired to the ids the markup has
test/header.test.js "The admin panel is one click away in the bar" — #admin-btn present, hidden by default, and outside the user menu, on every page
test/forum.test.js The admin surface is unreachable through the forum
test/legal-pages.test.js The generated legal pages list max_profiles_per_user and the .env staff lists among what the site stores

See Testing.


Related: 11 The guides system, 15 Roles and permissions, 17 Data model, 19 SEO and analytics, 21 Configuration and deployment, 22 Testing, 23 Known gaps.