SWIFTPOINT//HUB
Support

Auth and roles — who can do what

This file describes how a visitor becomes a member, what the four permission levels can do, and how the two lists that decide who is staff are merged. It also c

This file describes how a visitor becomes a member, what the four permission levels can do, and how the two lists that decide who is staff are merged. It also covers the session cookie and the store behind it, and the deliberate absence of Discord ids from every public response.

What authentication is

Authentication is Discord OAuth2 authorization code flow, and nothing else. There is no password, no email address, no local account and no OAuth library: routes/auth.js uses Node's built-in fetch against the two Discord endpoints directly.

GET  https://discord.com/oauth2/authorize?client_id=…&response_type=code&redirect_uri=…&scope=identify&state=…
POST https://discord.com/api/oauth2/token     (form-encoded, grant_type=authorization_code)
GET  https://discord.com/api/users/@me         (Bearer access_token)

The only scope requested is identify, which returns the account id, the display name and the avatar hash. Not email, not guilds. The site has no use for any of it and does not ask for it.

The flow is optional. Without DISCORD_CLIENT_ID and DISCORD_CLIENT_SECRET in the environment:

  • every page stays fully readable and every public API keeps answering,
  • the login button has nothing to point at, and
  • GET /api/auth/discord/login answers
503 {"error":"Discord login is not configured yet."}

routes/auth.js:16 defines isConfigured() as Boolean(DISCORD_CLIENT_ID && DISCORD_CLIENT_SECRET), and both the login route and the callback refuse the same way when it is false.

The flow, step by step

1. GET /api/auth/discord/login

returnTo is optional and purely a convenience. The route:

  1. generates state as 24 random bytes in hex and stores it in req.session.oauthState;
  2. sanitises returnTo with safeReturnTo();
  3. redirects 302 to https://discord.com/oauth2/authorize with client_id, response_type=code, redirect_uri, scope=identify and state.

redirect_uri is built as:

origin + BASE_PATH + /api/auth/discord/callback

where origin is BASE_URL with trailing slashes stripped when that variable is set, and req.protocol + "://" + req.get("host") otherwise (routes/auth.js:20).

safeReturnTo(value, fallback) honours the value only if, after trimming:

Condition Result
empty fallback
does not start with / fallback
starts with // (protocol-relative, another host) fallback
contains a backslash (browsers treat \ as /) fallback
contains U+0000–U+001F or U+007F fallback
otherwise the value as given

The fallback is ${BASE_PATH}/. A returnTo arrives in a query string, so it is treated as hostile; a control character in a Location header is a response-splitting primitive, so the function refuses rather than trying to sanitise it.

The value is a browser path — the thing in the address bar, base prefix included — because that is what the client already has and what a redirect has to reproduce. The callback therefore prepends only the origin, never the base: prefixing the base as well produced /swiftpoint/swiftpoint/.

2. GET /api/auth/discord/callback

In order:

  1. code and state must both be present, and state must equal req.session.oauthState. Any failure answers 400 with a small HTML page:

    Discord login failed. <a href="{site root}">Go back</a>
    
  2. req.session.oauthState = null.

  3. The code is exchanged for a token at https://discord.com/api/oauth2/token. A network error or a non-2xx response is the same 400 page.

  4. GET https://discord.com/api/users/@me is fetched with the bearer token; the same failure rule applies.

  5. The user row is upserted:

    username   = global_name || username || 'Discord user'
    avatar_url = https://cdn.discordapp.com/avatars/<id>/<avatar>.png?size=64      (null without an avatar)
    

    The statement is INSERT … ON CONFLICT(discord_id) DO UPDATE SET username = excluded.username, avatar_url = excluded.avatar_url, so signing in again refreshes the name and avatar and leaves id and created_at alone.

  6. The role flags are computed with isAdminDiscordId() and isModeratorDiscordId().

  7. The destination is read before the session is regenerated, because regenerate() replaces the session wholesale and anything still needed afterwards has to be captured now.

  8. The loop-breaker runs (below).

  9. req.session.regenerate() — the session id changes on privilege change, so a pre-login session id (potentially fixed by an attacker) can never be reused after login. The new session gets { id, discordId, username, avatarUrl, isAdmin, isModerator }.

  10. A first-time sign-in notifies the admins with type new_user and the text New user <name> signed in for the first time.

  11. res.redirect(origin + destination).

The loop-breaker. If the sanitised destination's path matches /\/admin\/?$/ and the account is not an admin, the destination becomes:

${BASE_PATH}/profiles/hub?admin=denied

Without it, a member who asked for /admin would be sent to Discord, come back signed in, be refused by the admin page gate, and be sent to Discord again — forever, with no explanation at any point. The gallery is where ?admin=denied is read and explained (routes/seo.js:534, requireAdminPage()).

3. Logout and identity

POST /api/auth/logout   → destroys the session, answers {"ok":true}
GET  /api/auth/me       → always 200

GET /api/auth/me never rejects and never redirects, whatever the session state:

{ "loggedIn": false, "isAdmin": false, "isModerator": false, "user": null }

When signed in, user carries { id, discordId, username, avatarUrl, isAdmin, isModerator }, and the two role flags are recomputed rather than read from the session (see The three gates).

Roles

Three levels exist in code — a signed-out visitor, a member, and staff split into moderator and admin. The matrix is filled from the guards in routes/*.js; where a permission is additionally owned, the ownership rule is in the cell.

not signed in member moderator admin
Browse the gallery, profiles, tools, guides yes yes yes yes
Download a .spcf yes yes yes yes
Search profiles, forum, updates yes yes yes yes
Read the forum yes yes yes yes
Open a thread no yes yes yes
Reply in a thread no yes, unless the thread is locked yes, locked or not yes
Edit your own post no yes yes yes
Delete your own post no no yes yes
Watch a thread no yes yes yes
Upload a profile no yes yes yes
Edit a profile no only their own only their own any
Delete a profile no only one they uploaded only one they uploaded any
Vote no yes, not on their own profile yes yes
Comment on a profile no yes yes yes
Post a patch note no only on a profile they uploaded same same
Set a ticket status no no yes yes
Pin, lock, move or delete a thread no no yes yes
Edit others' posts no no yes yes
Delete others' posts no no yes, but not a thread's first post yes
Manage forum categories no no yes yes
Read the ticket queue (GET /api/forum/tickets) no no yes yes
Read the report queue (GET /api/reports) no no no yes
Write an update no no yes yes
Record a walkthrough no no no yes
/records page and /api/records no no no yes
/admin page and /api/admin no no no yes
Transfer a profile to another account no no no yes
Resolve or dismiss a report no no no yes
Manage staff no no no yes
Change settings no no no yes

Notes on the cells that are not a plain gate:

  • Deleting a post is requireModerator, never authorship (routes/forum.js:342). A member can edit a post — that is what the "edited" mark means — but removing it is somebody else's decision, and it is the one forum write that cannot be undone: deletePost() sets deleted = 1 and replaces the body with (deleted), keeping the row so replies keep their order. The first post of a thread additionally needs isAdmin, because removing the question is the same act as removing the thread.
  • Deleting a thread is requireModerator (routes/forum.js:383). A whole ticket going away is a moderation act, not an administrative one.
  • The report queue is admin-only even though a reported post is notified to moderators: telling a moderator about something and giving them no way to act is the same as not telling them, so the notification goes out and the queue does not.
  • Recording a walkthrough is admin-only twice over: the recorder button is data-can-record="1" only for an admin session (routes/seo.js:803), and every /api/records route is requireAdmin.
  • Moderators may not delete a profile, open the recordings page or the admin panel. routes/forum.js imports only requireUser and requireModerator, so the admin surface is deliberately unreachable from the forum router, and a test asserts that the import list has not changed.

One honest caveat: the JSON viewerFlags that /api/forum sends (canEdit, canDelete, canModerate, canReply, subscribed) and the isAdminSession() helper used for pageview skipping are read off the session snapshot. The gates on the writes recompute every time; these flags only decide which buttons the page draws.

The three gates

All three are exported from routes/auth.js.

Gate Answers Message
requireUser 401 when there is no req.session.user Please log in with Discord to do that.
requireModerator 403 when isModeratorDiscordId() is false Moderators only.
requireAdmin 403 when isAdminDiscordId() is false Admins only.

The design point that matters is what they read. requireAdmin and requireModerator do not trust req.session.user.isAdmin / isModerator; they call:

function flagsFor(user) {
  const discordId = user ? user.discordId : null;
  return {
    isAdmin: isAdminDiscordId(discordId),
    isModerator: isModeratorDiscordId(discordId),
  };
}

Both role flags are therefore recomputed from the Discord-id allowlist on every call. A session is a snapshot: it was written when the person signed in, and it is a signed cookie blob that lives for hours. An admin who signed in before the moderator role existed would otherwise sit on isAdmin: true, isModerator: false, and every symptom of that is a mystery — the admin panel is there, the ticket queue is not, the "write an update" button does not exist. The allowlist is the only source of truth, so it is asked every time, and the answer is not written back into the session: the session stays a record of who signed in, the flags stay a fact about now.

The practical consequence: changing the allowlist takes effect on the next request, with no re-login and no session invalidation.

The fourth question: who is this, on a public page

A gate answers "may this person do this". Most of the site does not need one — the guides are public — but a guide can be closed to staff, and then something has to ask the same question the gates ask, per request, on a route nobody put behind a gate.

That is viewerFor(req), exported from the same module:

function viewerFor(req) {
  const user = req && req.session && req.session.user;
  return { loggedIn: Boolean(user), ...flagsFor(user) };
}

An anonymous visitor is a viewer with neither flag, which is the strictest reading and the one every public request gets. routes/docs.js asks it on every request and passes the answer to the engine; see Guides system — who may read a guide for what it decides. The rule is the same one the gates follow: the allowlist is asked now, not remembered from sign-in.

The allowlist

lib/admin.js is the only file in the project that reads ADMIN_DISCORD_IDS or MODERATOR_DISCORD_IDS. Its module comment records the previous state:

It used to be parsed independently in three modules — the session that decides what the header shows, the users API that reports the flag, and the notifier that decides who to email. That is three chances for "is this person an admin?" to give three different answers, and the symptom is always the same and always confusing.

test/admin-access.test.js reads the source of the files under routes/ and lib/ and fails if a second one parses either variable; there is a matching test for MODERATOR_DISCORD_IDS.

The environment values are comma-separated strings, trimmed and emptied:

function parseIds(raw) {
  return (raw || '').split(',').map((s) => s.trim()).filter(Boolean);
}

loadStaff() builds the merged list in three passes:

  1. every row of the staff table, with source: 'db' and role normalised to admin or moderator;
  2. then every MODERATOR_DISCORD_IDS entry that is not already present, appended as source: 'env', role: 'moderator';
  3. then every ADMIN_DISCORD_IDS entry, which either upgrades an existing row (found.role = 'admin'; found.source = 'env') or is appended as source: 'env', role: 'admin'.

The environment list is the stronger claim. A database row can never demote an admin and can never remove an env entry, because the env pass runs after the rows and overwrites the role. That is deliberate: the panel writes to the database, and a mistake in the database must not be able to lock everybody out of the site.

require('./db') is lazy, inside a try/catch that swallows every failure, so the env lists work with no database at all — no database, or no table yet. That is exactly the case they are there for, and it is also why a test that only wants the allowlist does not have to open a database file.

The result is cached in a module-level cached variable; reload() clears it and re-reads. Every write path calls reload(): addStaff, setStaffRole, removeStaff. Reading it per request would mean a database query on every page for every visitor, for a list that changes perhaps twice a year.

isModeratorDiscordId() is the union:

return staff.admins.includes(String(discordId)) || staff.moderators.includes(String(discordId));

so an admin is automatically a moderator and the two are never two answers to one question. isAdminDiscordId() returns false for null and '' without touching the database. adminDiscordIds() and moderatorDiscordIds() return .slice() copies, so a caller cannot mutate the cache.

Managing staff

Four write operations, all in lib/admin.js, all reachable only through /api/admin/staff (requireAdmin). See Admin panel.

addStaff({ discordId, username, role, addedBy })

Guard Message
the id does not match /^\d{5,25}$/ A Discord ID is 5–25 digits. Copy it with the copy button on the user.
the id is already in the merged list That account is already on the list.

role is coerced: 'admin' stays admin, anything else becomes moderator. username is String(username || '').slice(0, 64). added_at is datetime('now') in SQL.

setStaffRole(discordId, role)

Guard Message
not in the list That account is not on the list.
the row's source is env That one comes from ADMIN_DISCORD_IDS in .env. Change it there and restart.
the role is already that one Already a <role>.
demoting would leave no admin That is the last admin. Add somebody else first.

The last-admin guard returns the refusal rather than being called for a side effect. Calling it and throwing the answer away is how the last admin on a site demotes themselves; test/staff.test.js exists for exactly that.

removeStaff(discordId)

Guard Message
not in the list That account is not on the list.
the row's source is env That one comes from the .env allowlist. Remove it there and restart.
it would leave no admin That is the last admin. Add somebody else first.

guardNotLastAdmin(discordId)

Counts the admins that survive, not the rows:

const remaining = admins.filter((r) => r.discordId !== String(discordId) || r.source === 'env');
if (admins.length && remaining.length === 0) {
  return { error: 'That is the last admin. Add somebody else first.' };
}

The || r.source === 'env' half is what makes the guard honest: a .env admin cannot be removed, so a database-only admin demoting the last other database admin is harmless and the guard does not claim otherwise. When admins.length is zero the guard stays silent — an empty admin list is a recovery state, not something to make worse.

knownPeople(limit)

The picker behind the panel:

SELECT u.id, u.discord_id, u.username, u.created_at,
       (SELECT COUNT(*) FROM profiles p WHERE p.user_id = u.id) AS profile_count
FROM users u
ORDER BY u.created_at DESC
LIMIT ?

The limit is clamped to 1–500 with a default of 200, and failures return [].

Resolving a @username to an id means knowing the id, and the only place we know it is our own accounts table — there is no bot token in this project, so an account that has never signed in can only be added by its Discord id. routes/admin.js says so rather than pretending the lookup is complete:

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.

and when the name is ambiguous:

More than one account has that name. Use the Discord ID.

The return path. A name is a convenience; the .env entry is the way back in. Because env entries cannot be demoted or removed from the panel, if the staff table is ever wrong — a deleted row, a bad migration, an id mistyped into every row — editing ADMIN_DISCORD_IDS in .env and restarting restores the panel. That is why lib/admin.js treats the env list as the floor and not as a default.

Sessions

name: 'swiftpoint.sid',
secret: process.env.SESSION_SECRET || 'swiftpoint-hub-dev-secret-change-me',
store: new SqliteSessionStore({ ttlMs: 12 * 60 * 60 * 1000 }),
resave: false,
saveUninitialized: false,
cookie: { maxAge: 12 * 60 * 60 * 1000, httpOnly: true, sameSite: 'lax' },
Setting Value Why
name swiftpoint.sid Not the express-session default connect.sid. mecke.dev hosts several apps, and a generically-named cookie on a shared domain is a collision waiting to happen.
maxAge 12 h A work session: long enough to use the admin panel, short enough that an abandoned browser is not a standing credential.
httpOnly true No page script can read the session id.
sameSite lax The callback is a top-level GET navigation from Discord, which lax allows; everything else is cross-site-blocked.
resave false An unchanged session is not rewritten on every request.
saveUninitialized false A visitor who never logs in gets no cookie at all.

Renaming the cookie has a second consequence, stated in server.js:47: every cookie issued before that change is ignored, so a browser holding a stale connect.sid cannot half-authenticate. A session id the server no longer recognises is a signed-out visitor, not a partially signed-in one.

The store

lib/session-store.js, a SqliteSessionStore extends express-session.Store. The table is created in that module, by the class constructor, which is why it is self-contained — and which is why the sessions table is not in lib/db.js: lib/db.js contains no sessions DDL at all, and lib/session-store.js:36 owns it. It is still the same CREATE TABLE IF NOT EXISTS convention, so an existing database picks it up on boot.

CREATE TABLE IF NOT EXISTS sessions (
  sid TEXT PRIMARY KEY,
  data TEXT NOT NULL,
  expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);

Prepared statements, all built once in the constructor:

Statement SQL Used by
get SELECT data, expires_at FROM sessions WHERE sid = ? get()
set INSERT … ON CONFLICT(sid) DO UPDATE SET data, expires_at — an upsert set()
touch UPDATE sessions SET data = ?, expires_at = ? WHERE sid = ? touch()
destroy DELETE FROM sessions WHERE sid = ? destroy(), and get() on an expired row
sweep DELETE FROM sessions WHERE expires_at <= ? constructor and _maybeSweep()
count SELECT COUNT(*) AS n FROM sessions WHERE expires_at > ? count() — an extra beyond the Store contract, handy for debugging
clear prepared inline, DELETE FROM sessions clear()

touch rewrites both the data and the expiry, not just the deadline: the cookie inside data carries its own expires, and leaving that stale would resurrect a session that reads as already-expired.

_expiryFor(session) prefers the cookie's own expiry, because that is the value the browser is enforcing, and falls back to now + ttlMs when there is none.

get() treats an expired row as missing and deletes it on the way out, so an expired session cannot be revived by a stale cookie. JSON.parse revives cookie.expires as a string; express-session's Cookie class turns it back into a Date on the way out.

Why the store exists at all. Without it, express-session keeps sessions in process memory. That means every server restart, every redeploy and every node --watch reload silently signs everybody out — invisible while browsing the gallery and very visible two minutes after signing in, which is what made the admin panel feel unreachable. It is also per-process, so a second worker would never see the first one's logins. There is no session-store package in this project and no reason to add one: the app already depends on better-sqlite3, and a session is a small JSON blob. The Store contract is four methods.

Schema and flow in Data model; the restart test in Testing.

What is never exposed

The public API carries no Discord id anywhere:

Where What is there instead
GET /api/profiles (gallery rows) uploader: { id, username, avatarUrl }, where id is the internal users.id, not the Discord id
GET /api/profiles/:id the same block, plus isUploader computed server-side
GET /api/profiles/:id/comments userId (internal), username, avatarUrl, isMine, isAuthor, and canPostPatches
the uploader block itself name and avatar only

The reason, from routes/profiles.js:100: the only things the client used a Discord id for were ownership questions — is this my comment, my profile? — and those are the server's to answer. Exposing an account id so the browser can work out what the server already knows gives an anonymous visitor a stable identifier for every person on the site, for no feature in return.

The features that used to need the id now read the server's answer:

Feature Before Now
"is this my profile?" compared two Discord ids in the browser isUploader on GET /api/profiles/:id
"is this my comment?" compared two Discord ids isMine per comment
"may I post a patch note?" compared the uploader's Discord id canPostPatches on the comments endpoint
"is this comment the author's?" compared two Discord ids isAuthor per comment
delete my own comment the client decided the server checks comment.user_id

Two places keep the identifiers they genuinely need: /api/admin/* returns discord_id for staff management and the report queue, and GET /api/auth/me still tells a signed-in user their own id.

test/public-api-privacy.test.js asserts all of it, including a source scan that no client script compares two Discord ids any more.

Avatars are public Discord CDN URLs (cdn.discordapp.com), which is why they can appear in a public response at all.

How this is tested

File What it protects
test/auth.test.js A login returns the user where they were going; the session cookie is named for this app; a non-admin is not sent round in circles by /admin; the admin panel sends a signed-out visitor through Discord and back; returnTo is one convention everywhere (a full browser path); the destination stays on this site; ordinary destinations survive intact; a destination with a header-splitting character is refused.
test/auth-roundtrip.test.js The same flow against a real express-session: a login returns the user where they were going; the base path is applied exactly once; a login with no destination lands on the site root, not the domain root; every destination a header link can produce survives; a hostile destination cannot carry the user off-site; the session is logged in afterwards; a replayed callback is rejected; a non-admin asking for the panel is not sent round in circles.
test/admin-access.test.js The admin allowlist is parsed in exactly one place; the admin check is total; every admin API route is behind requireAdmin; filing a report is open to users and moderating one is not; the admin pages refuse before sending any HTML; both admin pages go through the same gate and come back afterwards; the header and the command palette offer the panel only to an admin session; the session is the only thing that grants admin; the moderator allowlist is parsed in exactly one place too; moderation is derived from the allowlist and the client cannot claim it; every moderation route is behind requireModerator; the admin surface is not reachable through the forum.
test/staff.test.js Only an admin can change who is staff; the .env admin is the way back in and cannot be removed; the last admin cannot be removed or demoted; a name resolves only for people who have signed in, and says so; the same account cannot be added twice and promoting is explicit; the allowlist is the union of .env and the table, .env winning.
test/session-store.test.js A session round-trips; an unknown session reads as missing, not as an error; destroy removes it; the cookie expiry survives serialisation; an expired session reads as missing and is cleaned up; a session survives the process that created it; touch pushes the expiry back; the store is wired into the server, not left at the default; and sessions survive a real server restart.
test/public-api-privacy.test.js The gallery and profile responses carry no Discord id; the uploader block is name, avatar and nothing else; ownership is answered, not computed in the browser; no client compares two Discord ids any more; the features that used the id still work, from the server answer; the admin panel keeps the identifiers it works with; the session still tells a user their own id.

Next: The data model · The admin panel · Security and privacy


Back to the documentation index.