SWIFTPOINT//HUB
Support

Configuration and deployment — running it

How to bring the hub up on a new machine, what every environment variable does, how it sits behind a reverse proxy, and the one deploy step that actually matter

How to bring the hub up on a new machine, what every environment variable does, how it sits behind a reverse proxy, and the one deploy step that actually matters. Also documents the shared header/footer synchroniser, because it is part of the build.

Requirements

Node.js >= 18. That is the only prerequisite. There is no database to install (the database is a file), no build step, no bundler, no transpiler, and no CSS framework — public/ is served exactly as it is written to disk.

Dependencies, and why each one is there (package.json:15):

Dependency Why
express@^4.19.2 The HTTP layer. Routing, static files, the error handler.
better-sqlite3@^11.3.0 Synchronous SQLite. WAL mode, so reads never block the writer and there is no connection pool to manage.
express-session@^1.18.0 Session middleware. Backed by a custom store in lib/session-store.js — see The database.
markdown-it@^15.0.2 The guides engine (lib/docs/).
markdown-it-container@^4.0.0 The ::: block syntax on top of the same engine — steps, video, callout, tabs.
highlight.js@^11.12.0 Server-side syntax highlighting for code blocks in the guides and in forum posts.
multer@^2.0.0 Multipart uploads, for .spcf and .rec.json submissions.
dotenv@^16.4.5 Loads .env into process.env at the very top of server.js:3.
@napi-rs/canvas@^1.0.5 Server-side PNG rendering. Draws the Open Graph cards (lib/og-card.js) and the per-profile share images (lib/share-image.js) with the same modules the browser uses.

Authentication adds no dependency. Discord's OAuth2 flow is a redirect, one POST to the token endpoint, one GET to /users/@me, and Node 18's built-in fetch is enough (routes/auth.js). There is no passport, no openid-client, no JWT library.

Setup

npm install
cp .env.example .env    # edit the secrets before anything else
npm start

The server listens on port 3000 by default and prints its URL on boot (server.js:180).

Command What it does
npm start node server.js.
npm run dev node --watch server.js — restarts on file change.
npm test Runs check:header first, then node --test. See Testing.
npm run sync:header Rewrites the generated header and footer into the static pages.
npm run check:header The same thing in read-only mode: exits 1 if any page has drifted.

Environment variables

.env.example is the annotated starting point (25 lines, comments in German). The table below is the full set, including the two the example file does not mention.

Variable Default Required What it does Notes
PORT 3000 no HTTP port. Read in server.js:21. Also the fallback used when BASE_URL is unset.
BASE_PATH '' (empty) no The sub-path the app is mounted under, e.g. /swiftpoint. Read in server.js:22 and normalised in lib/paths.js:15 (trailing slashes stripped). Must match the proxy config exactly. Every route is mounted at ${BASE_PATH}/....
BASE_URL derived no The public origin, without the base path, e.g. https://mecke.dev. Read in routes/auth.js:21, routes/seo.js:25 and lib/docs/index.js:30. Unset, it falls back to http://localhost:${PORT}. Used for the Discord redirect_uri and for every absolute URL siteUrl() builds (canonical tags, sitemap entries, OG image URLs). Must not include BASE_PATH — the code appends it.
SESSION_SECRET swiftpoint-hub-dev-secret-change-me yes in production Cookie signing key (server.js:51). Must be changed. It is also the HMAC salt for the analytics visitor hash (lib/analytics.js:6), so leaving the default is a privacy problem as well as a security one: with a published salt the pageview hashes are reproducible.
DB_PATH ./data/swiftpoint-hub.db no SQLite file location (lib/db.js:7). Parent directory is created automatically.
DISCORD_CLIENT_ID '' for login Discord OAuth2 client id (routes/auth.js:13). Empty means login is disabled; /api/auth/discord/login answers 503 Discord login is not configured yet. (routes/auth.js:81).
DISCORD_CLIENT_SECRET '' for login Discord OAuth2 client secret (routes/auth.js:14). Same as above — both must be set.
ADMIN_DISCORD_IDS '' no Comma-separated Discord user ids with admin rights (lib/admin.js:31). The bootstrap and the floor. An id here is an admin forever and cannot be removed in the admin panel.
MODERATOR_DISCORD_IDS '' (empty) no Comma-separated Discord user ids with forum moderator rights (lib/admin.js:32). Admins are automatically moderators; isModeratorDiscordId() is the union (lib/admin.js:50). Moderators cannot delete profiles or open the admin panel.
MAX_PROFILES_PER_USER 3 no Upload limit per account. The real default is `Number(process.env.MAX_PROFILES_PER_USER)
FONTS_DIR unset no Directory of .ttf files for the server-rendered cards. Not in .env.example. See Fonts.
NODE_ENV — — Not referenced anywhere in the code. There is no dev/prod branch anywhere in this repository. If you are looking for a way to turn something off in production, there is nothing to set.

A complete working .env:

PORT=3000
BASE_PATH=/swiftpoint
BASE_URL=https://mecke.dev
SESSION_SECRET=put-a-long-random-string-here
DB_PATH=./data/swiftpoint-hub.db

# Discord OAuth2 — create the application at
# https://discord.com/developers/applications
# and set the redirect URI to
# https://mecke.dev/swiftpoint/api/auth/discord/callback
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=

ADMIN_DISCORD_IDS=539941280738836481
MODERATOR_DISCORD_IDS=

MAX_PROFILES_PER_USER=3

Discord application setup

  1. Create the application at https://discord.com/developers/applications.
  2. Under OAuth2 -> Redirects, add exactly https://your-domain/BASE_PATH/api/auth/discord/callback (with /swiftpoint included if BASE_PATH is set — the app builds the URI from origin(req) + BASE_PATH, routes/auth.js:29).
  3. Copy the Client ID and Client Secret into .env.
  4. Leave the scopes as identify — that is the only scope the app requests (lib/legal.js:38).

Without the two client values the site is fully readable and browsable; only the sign-in button is inert, and it returns 503.

Behind a reverse proxy

nginx forwards /swiftpoint/ to 127.0.0.1:3000 unchanged — no path list, no exceptions:

location /swiftpoint/ {
    proxy_pass http://127.0.0.1:3000;
    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

Because nothing is rewritten, an nginx -s reload is not needed when only this app changes — the proxy config never changes, only the process behind it.

app.set('trust proxy', 1) (server.js:31) trusts exactly one hop. That is what makes req.ip the real client (used for pageview hashing and for rate limits) and req.protocol report https, so any URL the app builds from the request — the OAuth redirect_uri above all — is not silently downgraded to http. Trusting more would let a client spoof its own X-Forwarded-For.

Caddy, equivalently, in one line:

swiftpoint.your-domain.tld { reverse_proxy 127.0.0.1:3000 }

TLS is required in production. HSTS (Strict-Transport-Security: max-age=31536000) is only sent on HTTPS responses (server.js:102), and the Discord OAuth redirect must be https or Discord will refuse it. Note the header deliberately omits includeSubDomains.

Deploying

The single step:

sudo systemctl restart swiftpoint-hub.service

Then verify the running version is actually the new one:

curl -s -o /dev/null -w '%{http_code}\n' https://your-domain/BASE_PATH/forum

A running process only loads code at start. A page or a route you built and tested locally still answers Cannot GET ... online until you restart — which looks like a proxy problem and is not one. If a route 404s, check systemctl status swiftpoint-hub.service first: it prints how long the process has been up.

There is never a migration step. The SQLite file is the only state, so a deploy is a restart, and a backup is a file copy.

The database

The file is created automatically on first start, including the data/ directory (lib/db.js:8 does fs.mkdirSync(..., { recursive: true })). Every table is CREATE TABLE IF NOT EXISTS, and the five default forum categories are seeded with ON CONFLICT(slug) DO NOTHING inside a transaction (lib/db.js:356) — idempotent, and a no-op on every boot after the first.

Back it up by copying the file. It is in WAL mode (lib/db.js:10), so a hot copy should include the -wal and -shm siblings, or use the safe form:

sqlite3 data/swiftpoint-hub.db ".backup /path/to/backup.db"

scripts/sync-header.js is the only file in scripts/. It writes the canonical header (from lib/header.js: headerHtml, START, END) and the canonical footer (from lib/footer.js: footerHtml, FOOTER_LINKS, NOTE) into twelve static pages, so the copies in the markup cannot drift from the generator.

File Active nav key Footer
public/index.html '' yes
public/gallery.html browse yes
public/forum.html forum yes
public/thread.html forum yes
public/updates.html forum yes
public/check.html check yes
public/editor.html editor no — app shell
public/oled.html oled no
public/records.html '' yes
public/user.html '' yes
public/admin.html '' yes
public/profile-beta.html browse yes

Mechanics:

  • The block is delimited by <!-- header:start --> / <!-- header:end -->, with a legacy <header class="hdr">…</header> fallback for pages not yet migrated. A page with neither is reported as SKIP and counted as drift.
  • extractSub() (scripts/sync-header.js:84) finds the page's own tool strip — <div class="tbar">, or the pre-redesign .hdr-sub — and returns its inner markup by counting nested <div>s rather than matching to the first </div>. A non-greedy regex would silently truncate a strip that contains its own wrappers (the guides search does).
  • extractSubClass() (scripts/sync-header.js:66) preserves extra classes such as tbar--scroll, so a variant page keeps asking for it from its own markup instead of the generator needing to know which page is which.
  • A page marked "no footer" that still carries one has it removed, and that counts as drift.

Output is ok when unchanged, wrote when rewritten, and with --check DRIFT <file> (run: npm run sync:header) on stderr. Any drift exits 1. Extra arguments act as a filename filter:

node scripts/sync-header.js forum     # only public/forum.html
node scripts/sync-header.js --check   # the npm run check:header equivalent

Why the footer lives in the markup rather than being built by JS: a page with no JavaScript still has to carry the unofficial disclaimer. That line is the one piece of this site that is not optional, and it must not depend on a script loading.

See Project structure for where these files sit in the tree.

Runtime settings you can change without a deploy

One setting: max_profiles_per_user, stored in the settings table and edited in the admin panel. It overrides the MAX_PROFILES_PER_USER environment default, and the panel states which of the two is in force (public/js/admin.js:280). Reads go through lib/settings.js (getSetting / setSetting).

The staff roster is also editable at runtime, from the staff table, but the .env entries are the stronger claim: an id in ADMIN_DISCORD_IDS or MODERATOR_DISCORD_IDS cannot be demoted or removed in the panel. The union is computed in lib/admin.js:loadStaff() and cached until a write calls reload(). This 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.

See Admin panel.

Fonts for the server-rendered cards

FONTS_DIR, when set, is registered into @napi-rs/canvas at require time by both lib/og-card.js:27 and lib/share-image.js:17. It must contain:

  • SpaceGrotesk.ttf
  • Inter.ttf
  • JetBrainsMono.ttf

Anything missing is silently skipped: each registration is wrapped in a try/catch, and when FONTS_DIR is unset the block does not run at all. The card is still drawn with system fonts — a missing FONTS_DIR degrades the typography, never the image.

A checklist for a new environment

  1. Node.js >= 18 available.
  2. npm install.
  3. cp .env.example .env; set SESSION_SECRET to a long random string.
  4. Set BASE_PATH and BASE_URL to match the domain and the proxy mount point.
  5. Create the Discord application, set the redirect URI with BASE_PATH included, and fill in the two client values — or leave them empty to run the site read-only.
  6. Put your own Discord user id into ADMIN_DISCORD_IDS.
  7. npm start, then curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/.
  8. Put it behind a TLS-terminating reverse proxy, passing Host, X-Forwarded-For and X-Forwarded-Proto.
  9. Install a systemd unit and use sudo systemctl restart as the deploy step.
  10. Back up data/swiftpoint-hub.db (plus -wal and -shm, or use .backup).