GAME KAT·A·LOG // DOCSTechnical Reference

Game Kat·a·log - Technical Reference#


Architecture#

Game Kat·a·log follows the same lightweight family architecture as the other local apps: one Node.js HTTP process, SQLite persistence, no browser framework, and no build step for application code.

games-app/
  server.js                 HTTP entrypoint, static serving, route dispatch
  server/
    constants.js            shared Kat·a·log domains, provider identity and batch policy
    admin.js                loopback gate, admin API, backups and maintenance
    auth.js                 scrypt passwords, sessions, account changes, throttling
    user-location.js        throttled offline country/city resolution for admin accounts
    user-activity.js        throttled last-active persistence for admin accounts
    request-url.js          fail-closed HTTP request-target parsing and path decoding
    traffic-metrics.js      persistent inbound/outbound HTTP byte counters
    backup.js               hourly compressed SQLite snapshots and retention
    activity.js             public-safe Signal ledger plus announcement draft/publish/pin projection
    public-profiles.js      opt-in aggregate collector-profile projection
    patch-data.js           private Patch-thread storage and read/unread lifecycle
    patch-routes.js         authenticated Ping and public/private Patch HTTP boundary
    forum-data.js           forum categories, threads, posts, ownership, and moderation queries
    forum-pages.js          server-rendered public forum views using the shared app shell
    forum-routes.js         forum page/API dispatcher and public contribution boundary
    db.js                   schema, migrations, validation, scoped game queries
    canonical-store.js      canonical game identities, platform releases, copy links and conflict audit
    preferences.js          validated per-account view, search, filter and sort state
    pegi.js                 opt-in PEGI HTTP lookup and result parser
    pegi-bulk.js            account-scoped conservative PEGI enrichment jobs
    hltb.js                 native Node HLTB lookup, session setup, parsing
    hltb-bulk.js            account-scoped conservative timing enrichment jobs
    events.js               authenticated server-sent event fan-out
    covers.js               SteamGridDB client, throttling, matching, artwork selection
    cover-storage.js        validated local image storage and cover migration
    image-policy.js         256 KiB JPEG processing for covers and avatars
    showcase-covers.js      atomic public decorative-cover Kat·a·log writer
    showcase-pool.js        account-scoped owned and shared decorative-cover selectors
    thegamesdb.js           TheGamesDB boxart search, CDN URL parsing and credential checks
    app-integration-store.js shared application-credential storage and legacy migration
    app-integrations.js     configured SteamGridDB, IGDB and Steam application credentials
    steam-library.js        official Steam Web API profile resolution and owned-game client
    steam-import.js         account connection, dry-run classification and restart-safe batched import
    gog-library.js          GOG authorization, token refresh and complete paginated library client
    gog-import.js           GOG connection, dry-run classification and restart-safe batched import
    igdb.js                 IGDB OAuth token lifecycle, search, ratings, metadata and artwork mapping
    igdb-bulk.js            conservative account-scoped IGDB enrichment jobs
    steam-store.js          Steam Store description lookup
    description-bulk.js     Steam-first, quota-safe missing-description scan
    cover-provider-utils.js shared title/platform normalization for artwork providers
    cover-provider-bulk.js  reusable account-scoped external-cover batch engine
    katalog-policy.js     conservative publication eligibility and identity policy
    katalog-store.js      shared-index schema, queries, links and moderation state
    katalog-cover-store.js independent durable cover copies for shared/private rows
    katalog-service.js    fail-closed promotion and add-to-library orchestration
    katalog-runtime.js    single wired Kat·a·log service instance
    katalog-pages.js      server-rendered browse/detail/Signal pages and dynamic sitemap
    katalog-routes.js     isolated public page and Kat·a·log API dispatcher
    version.js              validated atomic reads/writes of the VERSION file
  admin/
    index.html              localhost control-panel markup
    style.css               dense terminal-style admin theme
    announcements.css       isolated Signal announcement panel styling
    patch.css               isolated private Patch queue styling
    integrations.css        isolated shared-provider management styling
    js/                     dashboard, accounts, integrations, announcements, private rows, public review, tools and shared ES modules
  scripts/
    generate-docs.js        Markdown-to-HTML documentation generator/checker
    normalize-covers.js     idempotent existing-cover normalization command
  public/
    index.html              application and authentication markup
    app.js                  browser state, rendering, auth, forms, API calls
    js/events.js            cookie-authenticated SSE stream parser and reconnect
    js/game-sorting.js      client ordering for live incremental card updates
    js/game-groups.js       canonical private-card grouping with platform-filter expansion
    js/platforms.js         grouped platform Kat·a·log and release-name matching
    js/title-autocomplete.js local/provider suggestions and duplicate warnings
    js/announcement-format.js safe shared rich-text formatter for Signal notices
    js/public-profile.js    reusable Signal profile dialog and public-profile fetch
    js/forum-page.js        forum composer, owner actions, themed confirmation, and SSE refresh binding
    js/patch.js             admin Patch queue rendering and reply controls
    js/patch-ui.js          reusable Patch composer and Ping conversation UI
    js/patch-page.js        public server-rendered page adapter for Patch and Ping
    js/katalog-public.js    release-detail dialog and one-click private-library add bindings
    js/katalog-navigation.js persistent authenticated-shell Kat·a·log navigation
    js/hltb-ui.js           manual HLTB selection, card estimates, form state
    js/cover-provider-settings.js per-account TheGamesDB connection and shared IGDB scan controls
    js/igdb-ui.js           IGDB match selection, form metadata and detail presentation
    js/library-import.js    shared connection, review selection and live import dialog controller
    js/steam-import.js      Steam-specific import adapter
    js/gog-import.js        GOG-specific import adapter
    js/cover-result-images.js failed-thumbnail fallback to provider originals
    js/artwork-url.js       accepted remote and durable-local artwork URL policy
    js/ui-policy.js         browser pagination, lookup limits and interaction timing
    css/
      foundation.css       reset, structural layout, and baseline responsive rules
      theme.css            dense dark operator theme and primary components
      library.css          legible typography, header art, cards, and game tools
      public-profile.css   opt-in collector profile controls and dialog
      landing.css          authentication landing page and promotional modules
      features.css         later feature-specific components and viewport rules
      patch.css            private Patch and Ping dialogs, unread alert state
      katalog.css          standalone public Kat·a·log and detail-page theme
      forum.css            public forum surfaces and responsive composer theme
      library-import.css   compact shared library-import dialogs and connection controls
    manifest.webmanifest    installable-app metadata
    favicon.svg             application icon
    icon-192.png            installable-app icon
    icon-512.png            high-resolution installable-app icon
    social-preview.*        source SVG and rendered 1200x630 social card
    robots.txt              crawler policy for public and private surfaces
    sitemap.xml             static public fallback; runtime serves the release-aware sitemap
    docs/                   generated standalone HTML documentation
  docs/
    user-guide.md           user documentation source
    technical.md            this file
  test/
    auth.test.js            sessions, password changes, isolation
    backup.test.js          hourly ZIP creation and scheduling
    pegi.test.js            PEGI result parsing
    pegi-bulk.test.js       exact-title matching, skips, and job notifications
    cover-providers.test.js provider parsing, image URLs and platform aliases
    cover-provider-bulk.test.js reusable cover-job updates and race protection
    igdb.test.js            IGDB mapping, server-side authentication and batch updates
    steam-library.test.js   Steam reference resolution, API mapping and privacy failures
    steam-import.test.js    import classification, linking, creation and repeat safety
    gog-library.test.js     GOG authorization, visible/hidden pagination and token failures
    gog-import.test.js      GOG classification, linking, selection validation and repeat safety
    cover-storage.test.js   provider allow-list, image validation and local migration
    image-policy.test.js    cover/avatar dimensions, format and byte ceilings
    cover-result-images.test.js browser thumbnail fallback contract
    artwork-url.test.js    durable local artwork across landing, header and background
    hltb.test.js            HLTB parsing, normalization, current search route
    hltb-bulk.test.js       exact-title matching, skips, and circuit breaker
    hltb-ui.test.js         null-safe new/edit form metadata state
    preferences.test.js     persistence, account isolation, validation and cascading
    platforms.test.js       PC storefront taxonomy and PEGI release mapping
    sorting.test.js         client sort behavior, null placement, accent parity
    events.test.js          SSE framing, replay isolation, and session revocation
    covers.test.js          conservative cover-title normalization
    seo.test.js             canonical metadata, crawler policy, image dimensions
    admin.test.js           localhost gate and cross-account admin summaries
    katalog-*.test.js     promotion policy, persistence, privacy, pages and workflow
    canonical-store.test.js canonical identity, release, backfill and conflict invariants
    version.test.js         arbitrary release-string persistence and validation
    constants.test.js       shared domain, provider identity and browser-policy contracts
  VERSION                   release string displayed in the application header
  games.db                  runtime SQLite database

Values shared by multiple server features live in small policy modules rather than a catch-all file. server/constants.js owns stored game enums and batch behavior, server/site-config.js owns deployment identity, server/validation-policy.js owns input and Kat·a·log query limits, server/runtime-policy.js owns HTTP-process sizing, public-stat caching, and shutdown behavior, and provider-specific limits stay beside their provider implementation. Browser pagination, lookup thresholds, upload limits, and interaction timings live in public/js/ui-policy.js; visible game labels and browser-visible product identity live in public/js/game-labels.js and public/js/site-config.js. The localhost panel has its own polling and interaction intervals in admin/js/admin-policy.js. This keeps policy discoverable without coupling browser modules to CommonJS server code.

Request flow#

Browser
  -> static file request ----------------------> server.js -> public/
  -> GET /katalog or /game/:slug -------------> katalog-routes.js -> katalog-pages.js
  -> public Kat·a·log JSON/add request --------> katalog-service.js -> katalog-store.js
  -> localhost /admin/* -----------------------> admin.js -> admin/ + SQLite/VERSION
  -> POST /api/login or /api/register --------> server.js -> auth.js -> SQLite
  -> authenticated /api/* + HttpOnly cookie --> auth.js -> user identity
                                                    |
                                                    +-> user-location.js (offline country/city)
                                                    +-> db.js (user-scoped query)
                                                    +-> pegi.js + pegi-bulk.js (lookup/jobs)
                                                    +-> hltb.js + hltb-bulk.js (lookup/jobs)
                                                    +-> covers.js (configured lookup/bulk scan)

  <- authenticated /api/events event stream <------ events.js <- job progress/game changes

All authenticated routes resolve the session before dispatching feature logic. They pass the numeric user ID into every database operation rather than trusting a client-provided owner ID.

Collector progression#

Progression is split into three focused server modules. progression-policy.js is the dependency-free definition of XP event defaults, the exact Gamebooks triangular level curve (1000 × level × (level + 1) / 2), and collector titles. progression-store.js owns the SQLite tables, configurable amounts, and atomic idempotency key (user_id, event, ref). progression-service.js maps a complete game record to eligible one-time awards and evaluates account milestones.

user_progression stores the account XP total and one-time backfill marker. progression_events stores every granted award with its stable reference; uniqueness guarantees that toggling a field cannot farm XP. progression_config is localhost-admin-editable and changes future awards only. No interval, login, uptime, or idle path grants XP. Regular authenticated boot reads the already-stored progression summary and never triggers a historical backfill. Game create/update, enrichment SSE paths, Kat·a·log additions, forum posts, and a first avatar set all feed the same service. Forum replies award the author and, once per thread, award its owner when another account engages. It emits a progression-updated SSE event only when XP changes. public/js/progression-ui.js hydrates its animation baseline from the authenticated summary before connecting SSE, so either the live event or the save response animates from the XP already on screen and the duplicate is ignored. Signal independently derives any historical level crossings from that immutable XP ledger, preserving the original award timestamp and never duplicating an already recorded level.

The authenticated SPA and crawlable server-rendered pages share the same header progress component. Its dynamic fill uses a fully themed semantic progress value rather than an inline CSS declaration, because the public page Content Security Policy intentionally rejects inline styles. Refreshing Signal, Forum, or the public Kat·a·log therefore preserves the same fill shown in My Kat·a·log without introducing an SVG layout surface into the header.

Patch and Ping use patch_threads and patch_messages. A Patch can be submitted anonymously or under the authenticated account; ordinary accounts see only their own Ping threads while the protected operator account maps the same view to every Patch using the distinct admin unread state. Rows maintain separate sender and admin unread state plus independent soft-delete flags. New Patches and user replies target the operator through ping-updated; operator replies target the owning account. The normal authenticated SSE stream updates the persistent Ping badge, attention state, and disabled availability without exposing message content. When SMTP is configured, the SMTP username (falling back to the sender address) receives a best-effort notice for new Patches and user replies; an operator reply likewise attempts a notice to any supplied user email. server/email-templates.js owns the responsive, self-contained dark templates for Patch, Ping, password-reset, and SMTP-test mail. Every template safely escapes user text, includes preview text and a plain-text fallback, and has an explicit action link. server/mailer.js owns delivery only, so route handlers choose a named transactional mail rather than construct HTML. Mail delivery never affects whether the saved message succeeds. Neither support thread content nor metadata is eligible for Signal, Kat·a·log, sitemap, or forum output.

Kat·a·log dispatch runs before the generic authenticated API gate because browse, detail, and search are intentionally public. Only the add-to-library endpoint authenticates. Private create/edit and enrichment flows call syncGameSafely; Kat·a·log failures are logged and contained, so they cannot turn a successful account-scoped save into an error. canonical-store.js sits below both private and public persistence, so identity synchronization is shared rather than reimplemented in either route layer.


Runtime and dependencies#

Environment variables:

VariableDefaultPurpose
PORT3005HTTP listen port
HOST0.0.0.0Listen address
DB_PATH./games.dbSQLite database path
PUBLIC_URLhttps://gamekat.netAbsolute origin for canonical pages, sitemap entries, email actions, and SMTP greeting identity
OWNER_USERNAMEoldest accountOptional protected operator account override used by Patch/Ping; matching is case-insensitive
VERSION_FILE./VERSIONRelease-string file; primarily useful for isolated tests or custom deployments
BACKUP_DIR./backupsHourly ZIP backup destination
STEAMGRIDDB_API_KEYblankOptional shared SteamGridDB application-key fallback
THEGAMESDB_API_KEYblankOptional server-wide TheGamesDB key
IGDB_CLIENT_IDblankOptional server-wide IGDB/Twitch application Client ID
IGDB_CLIENT_SECRETblankOptional server-wide IGDB/Twitch application Client Secret
STEAM_WEB_API_KEYblankOptional server-wide Steam Web API key fallback
GOG_CLIENT_IDGalaxy clientOptional GOG authorization-client override
GOG_CLIENT_SECRETGalaxy clientOptional GOG authorization-secret override
GOG_REDIRECT_URIGalaxy success pageOptional GOG authorization redirect override

Start the server with npm start. Development watch mode is available through npm run dev.


Database#

SQLite runs in WAL mode with foreign keys enabled.

The main database and generated ZIP snapshots are forced to owner-only mode 0600. This matters because the database contains password hashes, sessions, account-scoped provider credentials, and GOG refresh tokens. GOG tokens remain server-side and are removed when the collector disconnects GOG or deletes the account.

users#

ColumnNotes
idInteger primary key
usernameCase-insensitively unique
emailOptional and case-insensitively unique
password_hash64-byte scrypt result encoded as hex
saltRandom 16-byte salt encoded as hex
public_profileBoolean opt-in for the aggregate public collector profile
last_country, last_cityApproximate location from recent authenticated activity; nullable
location_updated_atUnix timestamp used to throttle offline GeoIP refreshes
last_active_atUnix timestamp of recent authenticated activity, throttled to one write per minute
created_at, updated_atSQLite timestamps

sessions#

ColumnNotes
tokenRandom 32-byte token encoded as 64 hex characters; primary key
user_idForeign key to users, cascading on account deletion
expires_atUnix timestamp
created_atSQLite timestamp

Expired sessions are purged when the server starts. Valid sessions receive a rolling two-week expiry on authenticated requests. SSE heartbeats validate the existing expiry without writing the session row every 20 seconds.

An inline pre-render marker adds the resuming-session document class before the body is painted. Because the HttpOnly cookie is deliberately invisible to JavaScript, the marker cannot inspect it. The class hides the public authentication surface and exposes a non-sensitive session-resume screen only while /api/auth/me asks the server to validate the cookie. Successful validation reveals the application immediately in its loading state; collection, statistics, metadata, and decorative artwork continue asynchronously. Failed authentication reveals the login screen without an expiry warning because the absence of a cookie is the normal logged-out state. This prevents logged-out UI from flashing for an authenticated account without putting session state into browser storage.

user_preferences#

ColumnNotes
user_idPrimary key and cascading foreign key to the owning account
library_viewgrid or list
search_queryCurrent library search text
platform_filter, ownership_filter, pegi_filterCurrent Kat·a·log filters
status_filter, missing_filter, favorite_filterCurrent workflow and data-gap filters
sort_orderOne of the server and client supported sort identifiers
updated_atSQLite timestamp of the latest persisted preference change

Missing rows produce safe defaults. server/preferences.js validates every enum, limits free-text fields, and upserts the complete preference snapshot. Rows cascade with account deletion. The browser never uses localStorage or sessionStorage; the account record is the single persistent source of workspace settings across devices.

games#

ColumnNotes
idInteger primary key
user_idOwner account foreign key
title, platformRequired identification
pegiNull or 3, 7, 12, 16, 18
ownershipowned or wanted
play_statusPreserved underlying state: backlog, playing, completed, paused, or abandoned
hiddenBoolean integer; hidden rows are omitted from ordinary library queries and aggregate user-facing statistics
media_formatphysical, digital, or unknown
cartridge_numberOptional integer
publisher, release_year, notesOptional metadata
ratingOptional private personal score from 0.5 to 5 in half-star increments
favoriteBoolean integer
pegi_urlSource search URL when PEGI-assisted
pegi_descriptors, pegi_releasesJSON arrays containing content labels and exact platform/date strings
pegi_advice, pegi_outlinePEGI consumer guidance and synopsis
pegi_content_issues, pegi_other_issuesDetailed rating rationale and additional concerns
hltb_id, hltb_title, hltb_urlSelected HowLongToBeat record and source provenance
hltb_main_story, hltb_main_extraMain Story and Main + Sides hour estimates
hltb_completionist, hltb_all_stylesCompletionist and All Styles hour estimates
hltb_updated_atTimestamp of the selected HLTB metadata
cover_url, cover_source, cover_match_titleSelected artwork and match provenance
igdb_id, igdb_slug, igdb_urlSelected IGDB identity and source provenance
canonical_game_id, canonical_release_idNullable links to the shared title-level identity and exact platform release
igdb_rating, igdb_rating_countIGDB community score and vote count
igdb_critic_rating, igdb_critic_rating_countIGDB aggregated critic score and review count
igdb_genres, igdb_themes, igdb_developersValidated JSON metadata lists from IGDB
igdb_updated_atLast accepted IGDB metadata timestamp
steam_app_idStable Steam application identity; unique within one account when present
steam_playtime_minutes, steam_last_played_atPlaytime and last-played snapshot from the latest import
gog_product_idStable GOG product identity; unique within one account when present
description, description_source, description_source_urlSelected game description and its required source attribution
created_at, updated_atSQLite timestamps

Indexes cover owner, platform, ownership, PEGI, case-insensitive title, canonical identity, non-null per-account Steam AppIDs, and non-null per-account GOG product IDs.

Library connections and canonical_external_ids#

steam_connections stores one resolved SteamID64, public persona name, profile URL, and last successful import timestamp per account. It never stores the application API key. Disconnecting removes this pointer without deleting imported games or their Steam identities.

gog_connections stores one GOG user identity, display profile URL, access token, refresh token, token expiry, and last successful import timestamp per account. The password and GOG browser cookie never enter the application. Tokens remain server-side, are omitted from every status and import response, and are deleted on disconnect or account deletion. Existing public-profile-only rows migrate additively and are reported as requiring one new authorization rather than being mistaken for a complete connection.

canonical_external_ids maps a provider identity such as steam:<AppID> or gog:<product ID> to one canonical game. This lets imported records share a stable title identity immediately, before richer IGDB metadata exists. A later high-confidence IGDB enrichment can move that local provider mapping to the IGDB-backed canonical identity without copying personal collection data into the shared tables.

canonical_games, canonical_releases, and identity audit#

canonical_games is the title-level factual identity. An IGDB-backed row is keyed by the stable igdb:<id> identity rather than display text, so alternate punctuation, localized names, and edition labels do not split one game. Public records without IGDB data receive a conservative local:<normalized-title> fallback. The first accepted title remains the display title; later synchronization fills missing or newer factual metadata without letting an account edit erase known provider data.

canonical_releases stores platform-level releases beneath a canonical game. Its identity includes canonical game, normalized platform, optional edition, and optional region. The current game form supplies platform while edition and region are reserved for later release-specific workflows. Private games rows remain user-owned copies: their ownership, format, rating, notes, status, favorite, and cover are not moved into the canonical layer.

canonical_relationships reserves explicit links such as remaster, remake, expansion, or collection without falsely merging those products into one identity. canonical_identity_conflicts records a disagreement instead of silently replacing a private IGDB identity when a linked public release points to another IGDB game. The localhost public-review summary exposes canonical title, release, and unresolved-conflict counts.

The first startup after this schema arrives performs an additive, transactional backfill and records its version in canonical_migrations. Later restarts skip the collection-wide scan; ordinary create, edit, enrichment, and public-link paths maintain identities incrementally. Existing public releases receive canonical links, IGDB-backed private copies receive their provider identity, and existing public/private links attach copies to the matching release. A legacy public record without IGDB data can adopt the high-confidence IGDB identity already stored on its linked private source; two different non-null IGDB identities are never merged. Unreferenced fallback identities are pruned after reconciliation. No private personal data is copied into canonical tables. Repeating a migration version does not create duplicate identities or releases or churn release timestamps.

catalogue_entries#

This table stores one shared factual release and links it to canonical_games and canonical_releases. It includes a stable unique slug, PEGI and HLTB facts, optional IGDB identity, scores and credits, publisher/year, a katalog-owned cover URL, source provenance, confidence reasons, and a candidate, public, or rejected moderation state. Public browse results group releases by canonical game before pagination when no platform filter is active; records not yet linked retain normalized-title fallback. The store scans only lightweight identity columns to establish group boundaries, then hydrates full descriptions and metadata for the requested page. A platform filter intentionally returns individual release rows and uses SQL count/limit/offset pagination. Detail URLs retain the stable per-release slug, and the dialog exposes sibling releases belonging to the same canonical game. The grouped primary release supplies the card cover and sitemap URL, while every underlying release remains an independently moderated factual record. IGDB supplementation updates an existing public release only when it is first linked or its selected IGDB timestamp changes, so unrelated private saves do not churn public sitemap timestamps. If two private records normalize to the same title and platform but carry different non-null IGDB identities, synchronization reports an identity conflict without linking either identity incorrectly, replacing the established public release, or surfacing a SQLite uniqueness failure.

Public projections explicitly remove contributor account ID, source private-game ID, confidence reasons, moderation state, and internal creation data. Personal fields do not exist in this table at all.

This join table records which private game rows are represented by a shared release. A private game can link to only one Kat·a·log entry, while (catalogue_id, user_id) prevents duplicate links for one account. Before synchronization, a link whose IGDB identity or platform release no longer matches the edited private row is detached, preventing its rating from remaining attached to the wrong public game if creation of a replacement entry fails. Public reads calculate an anonymous rating average and rating count by joining these links to non-null private games.rating values; those aggregate fields appear from the first rating, while individual scores and account identities are never exposed. All foreign keys cascade. Deleting an account or private row removes only its link; the independently owned public release and cover remain intact. Unreferenced canonical identities are pruned incrementally after relevant edits and deletions, plus one inexpensive cleanup pass at startup.

Automatic publication requires a durable /covers/<random>.<ext> asset, substantive PEGI data, an HLTB record with a reported duration, and exact normalized title matches for both cover and HLTB provenance. Complete ambiguous records become candidates. Rejected records are sticky and cannot be republished by a later background synchronization without administrator action.

cover_provider_credentials stores per-account TheGamesDB credentials keyed by (user_id, provider). user_integrations retains legacy per-account SteamGridDB rows for compatibility, but new configuration no longer writes them.

app_integrations stores one JSON credential set per application provider. SteamGridDB, IGDB, and Steam Web API access are configured through the loopback-only admin panel and resolved for every account. On first access, an additive migration copies the protected owner's existing SteamGridDB and IGDB values into this table; the legacy rows are deliberately left untouched. Existing application rows always win, so later startups cannot overwrite an administrator's replacement credentials. Environment variables remain fallbacks when no stored application connection exists. Status endpoints expose booleans and job state only; stored secrets and IGDB access tokens are never returned to a browser. IGDB access tokens remain process-memory cache entries and are refreshed before expiry.

Username comparison is case-insensitive. Renaming an account later does not alter ownership because all collection queries use the immutable numeric user ID.


Authentication and authorization#

The authentication design is a reduced version of the gamebooks app's model.

Passwords#

Usernames#

Sessions#

Login throttling and account locks#

The process keeps recent failed login/registration attempts by client IP. Eight failures within 15 minutes produce HTTP 429. Successful authentication clears that IP's failure list. The throttle resets when the Node.js process restarts.

Each account also records consecutive incorrect passwords in SQLite. Five failed passwords temporarily lock that account for 15 minutes; a correct login clears the count. Local administrators can apply an indefinite manual lock or unlock from Accounts. Locking revokes all active sessions immediately and blocks existing session authentication. The oldest account is the owner unless OWNER_USERNAME overrides it. That owner account is protected from admin deletion and locking, and it cannot be renamed into an unprotected identity.

Password-reset tokens are random 256-bit values. SQLite stores only their SHA-256 hashes, limits them to one hour, and invalidates previous tokens for the account only after SMTP has accepted the new reset email for delivery. Reset messages use a branded multipart email: an HTML button and linked fallback URL for capable mail clients, plus a plain-text fallback. The public authentication screen swaps its sign-in form for dedicated request and new-password panels, and removes a received token from the visible URL before rendering it. Consuming a token updates the scrypt password hash transactionally, clears temporary login-lock state, and revokes every existing session. Requests always return the same message whether or not an account/email exists.

Approximate account location#

server/user-location.js mirrors Gamebooks' offline GeoIP approach. A successful login resolves the nginx-forwarded client address immediately; authenticated activity refreshes it no more than once every ten minutes. Input must first pass Node's strict IP parser, then geoip-lite returns an approximate two-letter country code and city. SQLite stores only those display values and the refresh timestamp, never the source IP. Resolution is best-effort, so a missing/corrupt GeoIP database cannot block login or authenticated API work. The localhost Accounts table shows the country flag, a themed full-country-name tooltip, and city. Its desktop-specific fixed column plan keeps Actions right-aligned and uses the available panel width without a stray scrollbar; narrower viewports deliberately regain horizontal table scrolling before any column can be clipped.

Account activity#

server/user-activity.js records authenticated use no more than once per minute, with immediate writes after registration and successful login. The value is operational admin metadata and is never included in public profiles or Signal. Existing accounts without a recorded value fall back to their most recent game update and then their join date. The localhost Accounts table orders accounts by most recent activity, shows both the timestamp and whole days inactive, and hides accounts at 31 days or more behind one compact, themed reveal row until requested.

Isolation invariant#

Every collection query includes user_id = authenticatedUserId. Updates and deletes use both game ID and user ID. A game belonging to another account therefore behaves as nonexistent and returns HTTP 404.

The client never sends or selects user_id.


HTTP API#

All JSON responses use Cache-Control: no-store. Registration, login, public configuration, public aggregate site statistics, and the public showcase pool are available without an account; the owned-only showcase scope requires a valid session. Collection routes require a valid session cookie; bearer authentication remains a compatibility path for non-browser clients. Admin routes use a separate loopback-only boundary and do not accept normal account sessions as a substitute.

Authentication#

MethodRoutePurpose
POST/api/registerCreate an isolated account with password confirmation and optional email
POST/api/loginVerify credentials and create session
POST/api/password-reset/requestRequest a non-enumerating password-reset email
POST/api/password-resetConsume a one-time reset token and set a new password
GET/api/configReturn the current public version string
GET/api/site-statsReturn cached public aggregate Stats for Nerds data without private records or account identifiers
GET/api/showcase/coversReturn randomized public covers plus the signed-in account's owned covers; scope=owned restricts the result to that account's owned games
POST/api/logoutDelete current session
GET/api/auth/meResolve current user
GET, PUT/api/preferencesRead or replace the current account's validated workspace settings
PUT/api/accountChange username and/or password after current-password verification
POST/api/account/avatarUpload a browser-cropped JPEG avatar, maximum 256 KB
DELETE/api/account/avatarRemove the current avatar and restore the initial fallback

Collection#

MethodRoutePurpose
GET/api/gamesReturn one filtered, grouped library page plus total/page metadata
POST/api/gamesCreate a game for current user
GET/api/games/:idRead owned game
PUT/api/games/:idReplace editable metadata on owned game
DELETE/api/games/:idDelete owned game
GET/api/statsAccount-scoped aggregates
GET/api/metaPlatforms, version, PEGI capability, shared integration availability, current user
GET/api/eventsAuthenticated SSE stream for job progress and changed games
POST/api/patchCreate a private operator-support Patch, anonymously or as the signed-in account
GET/api/pingSigned-in account's private Patch conversations and unread count; operator sees the admin queue
POST/api/ping/:id/read, /replyMark a Ping thread read or append an owner/operator reply
DELETE/api/ping/:idHide a thread for its owner or the operator queue
GET/api/activityPublic Kat·a·log Signal entries plus the optional pinned announcement
GET/api/activity/streamPublic SSE refresh signal for Kat·a·log Signal
GET/api/public/user/:usernameOpt-in public collector identity, progression summary, aggregate stats, and every owned, non-hidden platform
GET/signalCrawlable public Kat·a·log Signal page; attaches to the public SSE stream
GET/api/pegi/search?q=...Explicit server-side PEGI search
GET/api/pegi/statusMissing-metadata count and current account job state
POST/api/pegi/bulkStart an account-scoped conservative metadata scan
GET/api/hltb/search?q=...Search HLTB for manual timing selection
GET/api/hltb/statusMissing-timing count and current account job state
POST/api/hltb/bulkStart an account-scoped exact-title timing scan
GET/api/descriptions/statusMissing-description count, source availability, and job state
GET/api/descriptions/search?q=...&platform=...Search Steam Store plus connected IGDB and TheGamesDB descriptions
POST/api/descriptions/bulkStart an account-scoped Steam-first missing-description scan
GET/api/steam/statusRead shared-key availability and the current account's non-secret Steam connection
PUT, DELETE/api/steam/connectionResolve and connect a Steam profile reference, or disconnect it without deleting games
GET/api/steam/import-previewFetch and classify the connected profile's owned library without writing games
POST/api/steam/importRe-fetch, validate and restart-safely batch-import selected owned AppIDs
GET/api/gog/statusRead the current account's non-secret GOG connection
PUT, DELETE/api/gog/connectionExchange a one-time GOG authorization result, or delete its server-side tokens without deleting games
GET/api/gog/import-previewFetch and classify ordinary plus GOG-hidden library pages without writing games
POST/api/gog/importRe-fetch, validate and restart-safely batch-import selected GOG product IDs
GET/api/covers/statusProvider configuration, missing count, and bulk progress
GET/api/covers/search?q=...Search portrait covers for manual selection
GET/api/titles/autocomplete?q=...Return account-local matches, public Kat·a·log releases, and IGDB suggestions when connected, otherwise SteamGridDB suggestions; local=1 skips the remote provider and exact=1&platform=... performs the save-time duplicate check
GET/api/igdb/search?q=...Search IGDB through the server-wide application connection
POST/api/covers/bulkStart an account-scoped exact-title scan for missing covers
GET/api/cover-providers/:provider/statusTheGamesDB or IGDB connection state, missing count, and job progress
PUT/api/cover-providers/thegamesdb/configValidate and store the signed-in account's TheGamesDB credentials
DELETE/api/cover-providers/thegamesdb/configRemove the signed-in account's TheGamesDB credentials and fall back to deployment configuration, if present
POST/api/cover-providers/:provider/bulkStart a conservative TheGamesDB cover scan or IGDB metadata scan

Signal returns the full 30-day public-safe activity window and groups it by the browser's local calendar day. Contribution rows include only the linked public release's PEGI value; public/js/activity-feed.js accepts the five valid ratings and applies the matching PEGI link color, leaving absent or invalid values on the existing muted fallback. On desktop, the reusable renderer projects that one ordered payload into a 55/45 newspaper layout: KAT·A·LOG // UPDATES occupies the wider left lane, COLLECTORS // SIGNAL occupies the right lane, and announcements span both above them. The desktop newspaper renderer is retained for an empty payload so both lane mastheads and their quiet states remain visible. The grid stretches both lane containers to the taller track so the separating rule reaches the bottom of the feed. At 760 pixels and below, the renderer selects one unified chronological stream. A media-query listener rerenders from the cached payload when that breakpoint changes, retaining stable day/account expansion keys without keeping a hidden duplicate feed that would fetch every cover and avatar twice. The landing-page preview continues using the original compact renderer rather than the newspaper layout. Six or more Kat·a·log contributions from the same account within one day become a single themed summary with an accessible inline expander; runs of up to five, joins, level-ups, and announcements remain individual entries. Before an SSE refresh replaces feed markup, the client snapshots expanded groups by local day and account, then restores the matching groups. New activity therefore does not collapse a contribution list the visitor is already reading.

users.public_profile is a separate opt-in from hide_from_activity. Signal projects the boolean so public/js/activity-feed.js renders a real profile button only for opted-in, unlocked accounts. server/public-profiles.js rejects private, locked, and missing accounts through the same 404 response, then returns only avatar, join date, level/title, aggregate library counts, public contribution count, and five leading platforms. It never selects email, location, notes, per-game records, personal ratings, credentials, or settings. Avatar upload/removal and account privacy changes invalidate the public Signal projection through SSE. The reusable native dialog preserves the current Signal view, uses a start-and-release backdrop check so dragging text outside cannot close it, and aborts an unfinished request when closed or replaced.

List query parameters are q, platform, ownership, playStatus, pegi, missing, favorite, and sort. platform=__multiple_platforms__ is a reserved private-library value that returns every visible copy belonging to a canonical game recorded on at least two distinct platforms; unmatched legacy rows use the same normalized-title fallback as card grouping. Same-platform duplicates do not qualify, and this special value keeps cards grouped rather than expanding them like a specific platform. While it is active, an SSE game update reloads the filtered result because one changed row cannot determine group membership safely. The interface presents ownership as the broader Library filter; it accepts owned_physical, owned_digital, wanted, or hidden. The two owned values combine the stored owned collection state with the corresponding media format, while hidden selects the independent visibility flag. playStatus=hidden remains accepted for saved-setting and API compatibility, but the visible Play status filter contains only actual gameplay states. An absent hidden-library filter always adds hidden=0. The public API exposes a hidden row's effective playStatus as hidden, while the separate flag preserves its prior stored play state. Since that underlying state is deliberately not projected, the browser clears Play status when Hidden is selected and leaves Hidden when a play status is selected, keeping initial SQL results and later SSE patches equivalent. Preference normalization automatically moves an older saved playStatus=hidden selection into the Library filter and clears incompatible combinations. missing accepts pegi, igdb, cover, hltb, description, either, or both; either means any enrichment data set is absent and both means all are absent. Missing-PEGI filtering and automatic PEGI enrichment include Evercade like every other platform, but all automatic enrichment queues omit hidden rows. Legacy missingPegi=1 and missingCover=1 requests remain accepted.

Sort values cover ascending/descending title, platform, publisher, release year, PEGI, collection and play-state priority, favorites, creation/update timestamps, cartridge number, ascending/descending values for all four HLTB estimates, and ascending/descending IGDB user and critic scores. SQL ordering always puts null numeric metadata last. Text ordering uses the same accent-insensitive normalization as collection search and includes numeric ID tie-breakers for deterministic placement. The focused public/js/game-sorting.js module mirrors those contracts for cards patched into the current result set through SSE, preventing live enrichment from temporarily using a different order than the server response.

Avatar filenames contain only the authenticated numeric user ID, timestamp, and random suffix. The browser center-crops and compresses before upload; the server independently decodes and reprocesses the image through the shared policy before accepting it, guaranteeing a 512×512 JPEG no larger than 256 KiB. Avatars are stored beneath public/avatars/; replacement and removal delete only the filename recorded for that account after a basename traversal check. Avatar binaries are excluded from Git.

Opening the account dialog deliberately transfers focus to its Close button rather than a text input. This maintains keyboard focus inside the native dialog without summoning an on-screen keyboard on mobile devices. The dialog does not use autofocus, delayed input focus, or viewport-specific focus behavior.

Public Kat·a·log#

MethodRoutePurpose
GET/katalogServer-rendered public browse/search/filter page
GET/game/:slugCanonical server-rendered public release URL; opens the Kat·a·log detail dialog for browsers and provides VideoGame JSON-LD to crawlers
GET/sitemap.xmlDynamic standard sitemap containing Signal, one stable primary URL for each grouped public game, and current update dates
GET/api/catalogue/search?q=...Small public factual search projection for discovery/autocomplete
GET/api/catalogue/game/:slugPublic factual release projection without contributor/private identifiers
POST/api/catalogue/:id/libraryAuthenticated one-click private copy with duplicate protection

The add route accepts only collection and media-format choices. The server supplies all factual fields from the public entry, initializes remaining personal fields to safe defaults, creates an independent cover copy, and links the new row transactionally at the service level. If creation or linking fails, the partial private row and copied cover are removed. Authenticated browse rendering loads the account's lightweight game identities once, matches every release on the current page, and passes only an in-memory account-specific map to the page renderer. It adds an Owned pill without changing the shared public projection or guest HTML. The detail action carries the matched private row ID through client navigation, preserves the current library filters, and opens the existing read-only private details dialog; a /?game= fallback provides the same result after a full navigation.

Local administrator API#

The admin interface is available at http://127.0.0.1:3005/admin/. It is intentionally not an account role. A request is accepted only when the TCP peer is loopback. If nginx-style X-Real-IP or X-Forwarded-For headers are present, the first reported client must also be loopback. This prevents the public reverse proxy from exposing admin merely because it connects to Node locally.

MethodRoutePurpose
GET/api/admin/statsRuntime and whole-database counts
GET/api/admin/liveLightweight one-second process resource and uptime snapshot
GET/api/admin/accountsAccount activity, location, collection, cover, and session counts
GET, POST/api/admin/announcementsList all notices or create a draft
PATCH, DELETE/api/admin/announcements/:idEdit or permanently delete one notice
POST/api/admin/announcements/:id/publish, /unpublish, /pin, /unpinChange publication or single-pin state and refresh Signal via SSE
GET/POST/DELETE/api/admin/patch and /api/admin/patch/:id/*Localhost-only Patch queue, read state, replies, and removal
GET, PUT/api/admin/mailRead non-secret SMTP status or save SMTP settings
POST/api/admin/mail/testSend a test message to the configured sender
GET/api/admin/integrationsRead non-secret SteamGridDB, IGDB, and Steam application connection states
PUT/api/admin/integrations/:providerValidate and replace shared steamgriddb, igdb, or steam application credentials
DELETE/api/admin/accounts/:id/sessionsRevoke every active session for one account
PATCH/api/admin/accounts/:id/lockManually lock or unlock an account; locking revokes sessions
DELETE/api/admin/accounts/:idDelete an account, its avatar, and cascaded games, sessions, integration settings, and preferences
GET/api/admin/games?q=...Search up to 250 games across accounts
DELETE/api/admin/games/:idPermanently remove one explicitly selected game
GET/api/admin/catalogue?q=...&status=...List shared entries and moderation counts
PATCH/api/admin/catalogue/:idEdit shared factual metadata, or set candidate, public, or rejected state
PUT/api/admin/catalogue/:idReplace the shared cover from a supported artwork-provider URL
DELETE/api/admin/catalogue/:idDelete a shared entry and its katalog-owned cover
GET, PUT/api/admin/versionRead or atomically replace the release string
POST/api/admin/database/checkpointTruncate-checkpoint the SQLite WAL
POST/api/admin/database/optimizeRun SQLite planner optimization
POST/api/admin/database/vacuumRebuild the SQLite database file
GET, POST/api/admin/backupsList or trigger the current hour's compressed SQLite backup
DELETE/api/admin/backups/:nameDelete one validated backup filename

The Dashboard mirrors the Gamebooks refresh cadence: collection and Kat·a·log totals refresh every 60 seconds, while the lightweight live cards (heap, RSS/CPU, traffic, application age, and session uptime) refresh every second. Application age starts with the earliest user or game record in the database. Uptime is persisted across restarts. Every positive clean-stop or heartbeat gap increases total downtime; the fifteen-second threshold determines whether the existing uptime session continues or a new one begins.

Admin static files and API responses use restrictive security headers. Backup names are server-generated and deletion accepts only that exact filename shape. Backups are stored in backups/, which is excluded from Git.

server/backup.js creates one consistent SQLite snapshot at process startup and then exactly on each hour. The snapshot is compressed with the host zip command, published by atomic rename, and its temporary raw SQLite file is always removed. A second attempt in the same hour is a no-op. Archives older than 15 days are pruned during each run.

Version file#

VERSION contains one nonempty, arbitrary single-line string of at most 80 characters. It is not limited to semantic versions. The admin writes a temporary sibling and atomically renames it over the target; the main header fetches the value through /api/config on page load. Changing the version does not require restarting Node, though already-open app tabs refresh it on their next reload.


PEGI integration#

PEGI exposes a public Kat·a·log search but no documented public developer API. server/pegi.js therefore performs opt-in HTTPS requests after the user selects either Look up title or the account-level Fill PEGI details batch action.

The parser extracts displayed title, publisher, rating, descriptors, exact platform releases, year, consumer advice, brief outline, content-specific issues, and other issues. A lookup reads PEGI's reported result count and requests subsequent zero-based result pages, up to a hard limit of 10 pages. Later pages are fetched concurrently, individual later-page failures do not discard successful results, and duplicate records are removed using title, publisher, rating, and release data. Descriptor and release arrays are stored as validated JSON; long PEGI text is length-limited before persistence. Merged results are cached in process memory for one hour per normalized query. Each request has a 12-second timeout and a 4 MB response limit. Transient rate-limit and 5xx responses are retried twice with short backoff; exhausted failures return a calm availability message instead of exposing the provider's raw HTTP status.

The client renders descriptors as compact card badges, with purchase and paid-random-item labels receiving a distinct warning treatment. The complete record uses a themed <details> disclosure inside the game form so lengthy guidance does not increase every card's footprint.

The Fill PEGI details action in Account Settings starts an in-memory, account-scoped job. It considers games that have neither a saved PEGI source record nor extended PEGI metadata, including Evercade titles. Before each external request it reloads the game and skips it if it was deleted or enriched since the job began. Every remaining title is searched across the same paginated catalog, then accepted only through normalized exact-title matching; an unambiguous exact platform release is preferred. Ambiguous results remain unchanged for manual review. The enrichment update touches only PEGI fields, publisher, and release year, preserving ownership, play state, notes, format, favorite state, platform, title, and cover. Requests are paced by 500 ms, and five consecutive lookup failures stop the job instead of repeatedly hitting a failing provider. Completed metadata remains in SQLite; active job state itself is intentionally process-local.

This integration is deliberately nonessential. Parsing or network failure returns HTTP 502 with a PEGI fallback URL; manual game creation remains available.


IGDB integration#

IGDB is optional and server-side. Its Client ID and Client Secret are application credentials rather than collector credentials, so one localhost-admin connection serves every account. IGDB_CLIENT_ID and IGDB_CLIENT_SECRET remain a deployment-level fallback. The Twitch application must use the Confidential client type because Public clients cannot issue a secret; IGDB recommends http://localhost for the otherwise-unused redirect URL. server/igdb.js exchanges those credentials at Twitch's client-credentials endpoint, keeps the access token only in memory, refreshes it before expiry, and sends the required Client ID and bearer headers to IGDB. Browser code never receives either credential or token. The metadata response tells the editor only whether IGDB is available, keeping its lookup control disabled when it is not.

IGDB calls pass through one serialized request lane, keeping concurrent autocomplete, cover, description, and batch work below the published four-request-per-second limit. Concurrent requests for the same credentials share one in-flight token exchange, and identical searches share one in-flight game request. Requests are capped by a timeout, retried once after an authorization or rate-limit response, and cached for 30 minutes by normalized query and credential fingerprint. Expired entries are pruned and the cache is capped at 500 searches. Results map the IGDB identity, source URL, description, release year, publisher and developer credits, genres, themes, platforms, cover image, community rating/count, and aggregated critic rating/count. Manual autocomplete failures are deliberately silent; the user can continue typing normally.

public/js/igdb-ui.js owns explicit match selection and form presentation. It fills only blank publisher, year, description, and cover fields while storing the selected IGDB identity and ratings. User and critic ratings are deliberately absent from cards and appear in private and public game-detail views. A chosen remote cover follows the ordinary save path, so it is validated, resized, and persisted locally rather than remaining dependent on the IGDB CDN.

server/igdb-bulk.js scans only visible games without an IGDB identity. It accepts one normalized exact-title result on the saved platform, reloads each row before writing, and skips records changed during the run. Successful writes preserve personal fields and existing factual values, may fill blank publisher/year/description/cover data, synchronize eligible public Kat·a·log facts, and publish igdb-job plus game-updated SSE events. Job state is process-local; stored metadata is durable.

Genres and themes remain separate labeled groups in private and public detail views. Compact teal genre chips and violet theme chips feed the existing Kat·a·log search instead of adding another filter dropdown. They use a dedicated metadata-chip component rather than inheriting the larger uppercase card-badge treatment. Private and public SQL search both inspect the validated IGDB genre and theme JSON, while public release structured data exposes genres to search engines. Stats for Nerds expands genres only from public releases and deduplicates platform releases by canonical game identity, so no private title or account data enters that aggregate.

Steam library import#

Steam import uses the official IPlayerService/GetOwnedGames, ISteamUser/GetPlayerSummaries, and ISteamUser/ResolveVanityURL Web API methods. The application API key is stored once in app_integrations by the loopback-only administrator, with STEAM_WEB_API_KEY as an optional deployment fallback. Collectors provide only a SteamID64, vanity name, or Steam Community profile URL. Credential validation and every Steam request remain server-side.

The preview is a strict dry run. server/steam-import.js compares each AppID against the account first, then uses normalized exact title matching. An existing AppID is already imported; one same-title Steam row without an AppID is safe to link; any already-linked or multiple Steam match is ambiguous; a title found only on another platform becomes a new Steam copy; everything else is new. Only new records, other-platform copies, and single safe links are selected by default. Import re-fetches the remote library, accepts at most 5,000 distinct positive integer AppIDs, and rejects any ID absent from that response. The browser retains the complete result and selection state but mounts no more than 250 matching review rows; filtering exposes records outside that slice without creating a multi-thousand-node dialog.

Matching builds AppID and normalized-title indexes once, avoiding a collection scan for every remote title. Writes and canonical identity updates run in five-record SQLite transactions and yield to Node's event loop after every batch. Collector XP is recorded in the same small batches, followed by one collection-wide milestone scan instead of reloading and filtering the complete library once per imported game. Imported rows are known to be ineligible for publication, so the import does not run the public eligibility pipeline thousands of times; later enrichment uses the ordinary synchronization path. This keeps unrelated HTTP requests and SSE heartbeats responsive during a large import. A process failure can leave completed batches in place, but the AppID uniqueness rule and fresh server-side classification make a retry safely continue rather than duplicate them. Already-written rows in the submitted retry are also passed through idempotent import-XP recovery, closing the gap where a process could stop after a database batch but before progression processing. New games use owned, digital, Steam, and backlog defaults while retaining the imported playtime and last-played snapshot. Linking changes only Steam identity and snapshot fields on the existing row. steam-import-progress SSE events report the fetching, importing, processing, and completion phases. Progression is published once with only level-crossing details in the SSE payload, avoiding thousands of redundant XP animations, while one final games-imported event makes other open sessions reload collection counts and cards. The service rejects profile replacement or disconnection while its import phase is active, and both the service and HTTP boundary reject a second concurrent import for the same account.

GOG library import#

GOG does not document a general consumer-library API for third-party collection managers. server/gog-library.js uses the long-standing Galaxy-client authorization flow used by open-source GOG clients. The browser opens GOG's own authorization page and submits only the resulting one-time success URL to this server. The server strictly accepts the expected embed.gog.com/on_login_success redirect or a bounded bare code, exchanges it server-side, validates the authenticated account through userData.json, and stores the returned tokens without exposing them in an API response. Expired access tokens are refreshed before a library read.

The library client calls the authenticated account/getFilteredProducts endpoint separately with hiddenFlag=0 and hiddenFlag=1, follows both pagination sets under one combined 200-page ceiling, validates response shape, deduplicates stable numeric product IDs, and applies a 20-second timeout to each request. When a product appears in both sets, the ordinary result wins. The source hidden flag is displayed only during review; every created private row receives Backlog, never Hidden. These endpoints remain unofficial and can change without notice.

server/gog-import.js mirrors Steam's conservative workflow without sharing provider state. Preview and import index the account once by GOG product ID and normalized exact title. One unlinked same-title GOG row is linkable; multiple GOG matches are ambiguous; other-platform matches create a new GOG copy. The initial preview publishes page counts over gog-import-progress, so large libraries have determinate progress before the review list exists. The server then re-fetches all pages and rejects selected IDs absent from that fresh result. Five-record transactions, event-loop yields, idempotent import XP recovery, and SSE updates keep a large library observable without blocking unrelated requests. New games use owned, digital, GOG, and backlog defaults. GOG product IDs also enter canonical_external_ids, allowing stable grouping before optional IGDB enrichment.

public/js/library-import.js owns the provider-neutral connection, bounded review rendering, selection, themed progress, and safe dialog behavior. The Steam and GOG browser modules provide only identities, labels, connection summaries, and provider-specific row metadata.

HowLongToBeat integration#

HowLongToBeat does not provide a documented public developer API. server/hltb.js uses Node's built-in fetch implementation against HLTB's current token-gated search route, obtains the short-lived search token from its initialization endpoint, and sends only the current x-auth-token header used by HLTB's public client. The same results expose HLTB game-image filenames, which are offered only within an explicit per-game Request cover search // never by bulk cover work. The provider is native JavaScript: it does not spawn Python, invoke the old Downloads script, or add a Python dependency.

Responses are reduced to a numeric record ID, title, source URL, similarity score, and four hour values: Main Story, Main + Sides, Completionist, and All Styles. Search results are cached for 30 minutes, provider calls are serialized, and each request has a 20-second timeout. Authentication is refreshed once after an authorization failure. The private endpoint can change without notice, so this remains optional assistance and lookup errors never block ordinary game editing.

The Fill HLTB times account action runs an in-memory, account-scoped job over games with no selected HLTB record. Each queued game is reloaded before lookup, and the database update also requires its HLTB ID to remain null. Automatic selection requires exactly one punctuation-, case-, trademark-, whitespace-, and accent-normalized exact title. Ambiguous editions and fuzzy matches remain untouched for manual selection. Requests are spaced by 1.5 seconds; five consecutive provider failures pause the job. Successful records are persisted immediately and published as targeted game-updated SSE events.

The game form owns HLTB state in the focused public/js/hltb-ui.js module. Lookup requests carry a local sequence guard: changing the title or reopening the dialog invalidates an older response so it cannot populate a different game form. Cards show a compact four-column estimate strip, while the form shows the complete labels and source link. Grid cards use a column layout with consistent two-line title and two-row badge areas plus a bottom-anchored action row. Games without HLTB data retain a muted four-column timing frame with dashes, so optional metadata does not change the card or grid-row structure. Narrow single-column mobile cards release the title and badge height limits to keep their full content visible. Compact list view retains the same four-value strip in a dedicated desktop column; narrow list rows wrap it beneath the title rather than removing information. The No HLTB info data-gap filter is available independently and participates in the combined any/all missing-data modes.


Cover-art integration#

The artwork layer supports SteamGridDB, TheGamesDB, and IGDB. SteamGridDB supplies portrait grids and fallback title autocomplete. TheGamesDB supplies front boxart and platform metadata from its CDN. IGDB supplies cover art alongside its game metadata. Manual lookup runs configured sources concurrently, preserves provider provenance, and returns successful results even when another source is unavailable.

SteamGridDB and IGDB use shared application credentials stored in app_integrations and managed from the localhost-only admin panel. TheGamesDB requires an account-scoped API key stored in cover_provider_credentials; its key page requires an authenticated TheGamesDB site account, so Account Settings links to sign-in/registration separately from the key page. STEAMGRIDDB_API_KEY, THEGAMESDB_API_KEY, IGDB_CLIENT_ID, and IGDB_CLIENT_SECRET provide optional deployment fallbacks. Secrets are validated before storage and never returned to the browser.

Cover-status responses expose only whether lookup is configured. Account Settings shows shared SteamGridDB and IGDB availability plus their account-scoped scan controls, but no shared credential fields. TheGamesDB retains its disabled green Connected field and explicit empty replacement mode. The admin panel likewise reports only Connected or Not connected and always leaves replacement fields empty.

The add/edit title field searches the authenticated account's own titles, the public Kat·a·log, and IGDB after three characters when connected; SteamGridDB remains the remote fallback. Browser requests are delayed by 100 ms, stale requests are aborted, remote results are capped at ten, and provider results are cached server-side for 30 minutes. Existing entries appear first with platform and ownership context. Local collection search, title suggestions, and duplicate identity checks normalize Unicode combining marks before comparison, making accented and unaccented spellings equivalent. SQL LIKE wildcards supplied by the user are escaped.

When a selected suggestion carries an IGDB ID, that identity plus normalized platform defines a possible duplicate. Otherwise the check falls back to the exact case-insensitive, whitespace-normalized title-and-platform pair. Save-time validation uses a dedicated account-scoped exact lookup rather than the autocomplete result limit, so large collections cannot bypass the warning. The warning can open the existing record. Creating another entry requires an explicit themed confirmation, but remains permitted for multiple copies or editions; another platform is never treated as the same private row. The authenticated autocomplete route deliberately returns local results plus an empty remote list when no key is configured or SteamGridDB fails. The interface shows no provider warning, toast, empty state, or loading indicator: remote autocomplete is optional assistance and manual entry always remains available.

Manual lookup sends the title and selected platform to the configured sources. SteamGridDB searches up to four title candidates for portrait static grids. TheGamesDB requests front boxart with platform filtering and constructs image URLs only from the API's returned CDN bases. Results are cached in memory for 30 minutes. Provider failures are isolated, so one healthy source can still populate the chooser. TheGamesDB result tiles use its original image URL directly because its generated preview derivatives are intermittently absent; other provider thumbnails retain the generic original-art fallback, including detection of a cached failure before the error listener mounts.

Selected, automatically matched, and manually uploaded covers are not hotlinked permanently. server/cover-storage.js accepts HTTPS downloads only from the supported providers' CDN domains, validates every redirect before following it, caps source responses at 12 MB, and verifies JPEG/PNG/WebP file signatures. The game editor also accepts a JPEG, PNG, or WebP upload as compact client-processed image data; no file is stored until the game save succeeds. The shared server-side server/image-policy.js then applies EXIF rotation, limits the longest edge to 900 pixels without enlargement, converts to JPEG, and iteratively compresses until the result is no larger than 256 KiB. Only the processed image is written through a collision-safe temporary filename and atomically published under public/covers/. SQLite stores the resulting /covers/... path while retaining provider and matched-title provenance. These static files are unauthenticated and therefore publicly reachable through https://gamekat.net/covers/...; they stream from disk with exact content lengths and immutable one-year cache headers because filenames never change in place. Replacement, game deletion, account deletion, and admin Kat·a·log deletion remove the corresponding local file.

Normal startup never scans the whole library. Replaying Kat·a·log eligibility or opening every cover with Sharp can monopolize the single Node process on a large collection, so cover maintenance is deliberately explicit: npm run covers:normalize performs the local 900-pixel/256-KiB JPEG normalization, while the established cover-storage functions can localize legacy HTTPS URLs when deliberately invoked. New and edited games are synchronized immediately through their normal request paths. Cover conversion atomically writes a replacement before changing the database URL and removes the old file only when no database record still references it.

Each source has an independent missing-cover scan. Jobs consider only games without a cover and reload each queued record before making a request. Games deleted or manually covered after queuing are skipped; the database update also requires the cover to remain empty. TheGamesDB additionally requires a platform match and exactly one normalized title record; several regional images belonging to one record are not treated as ambiguous. Five consecutive provider errors pause that job. Progress and individual card updates use SSE, jobs remain in memory, and saved results remain in SQLite across restarts.

Description lookup queries Steam Store first and accepts only a single normalized exact-title result during automatic filling. If it does not find one, a configured TheGamesDB key enables a platform-aware overview fallback. TheGamesDB HTTP 403 and 429 responses are terminal for the current description job, so a rejected or quota-limited request pauses the batch rather than consuming further allowance. Other source failures are isolated where the alternate source can answer. Each persistence operation compare-and-swaps against an empty description, preserving manual edits made during a scan.

Cards use a centered, full-card image with a dark left-to-right gradient, mirroring Gamebooks' cover-background treatment. Images use native lazy loading so only the visible portion of a large collection is requested.

On authenticated entry, the browser starts the core library requests and reveals the workspace immediately, without awaiting their responses. public/js/artwork-url.js admits both legacy HTTPS artwork and validated /covers/... paths. The showcase query has two explicit scopes: My Kat·a·log requests only the current account's covered owned rows, while Kat·a·log, Signal, and Forum combine public catalogue_entries covers with that account's owned covers. Guests receive only the public pool; wishlisted rows and other accounts' non-public games are never eligible. The logged-out loader also falls back to the generated, Git-ignored public/cover-showcase.json Kat·a·log if an older running server process returns no covers; the file exposes only already-public randomized paths and is regenerated after normalization. Durable storage therefore feeds the login background, promo modules, authenticated header and app background consistently. The private loader scopes itself to #library-view and restores any missing slots when navigation returns there. Server-rendered Kat·a·log, Signal, and Forum requests independently call the randomized shared-pool query; client navigation imports each response's new fan and never clones the previous view's deck. HTML declares each cover group once with data-cover-slots; the browser generates its non-semantic positioning nodes, element type, base class, and numbered modifier classes. The single loose promo cover uses the same declarative mounting pass through data-cover-decoration. Repeated empty cover tags are therefore absent from maintained markup. The controller mark, separator rules, status dots, progress fill, and modal spacing use CSS or meaningful elements rather than empty helper tags. Empty live regions remain only where runtime content is intentionally inserted. Decorative images preload in a genuine one-at-a-time queue, so artwork never competes with the application shell or floods the browser connection pool. Each image may take up to six seconds; failed candidates are skipped in favor of the next shuffled URL. The successful set is committed to the decorative field in one synchronous batch rather than mutating the page after every image. A focused dropdown defers that commit until it loses focus, preventing background artwork from dismissing native filter menus in Chromium browsers. Logged-out artwork work is canceled as soon as authentication succeeds. If fewer unique images succeed than there are slots, successful covers repeat instead of leaving permanent holes. Stale work is discarded if the account changes while images are loading. The field reuses the login artwork geometry and opacity, has no pointer interaction, and is reduced to four slots on narrow screens. It does not make another provider request or expose another account's private cover selection.


Browser application#

Platform identity is resolved consistently across library cards and details, public-release summaries and edition links, Signal, public collector profiles, autocomplete results, import-review matches, and public platform statistics. Server-rendered public platform labels carry data-platform-theme; public/js/katalog-public.js hydrates them after initial render and every partial result or dialog replacement.

public/app.js is a zero-dependency ES-module browser orchestration entry point. Its state contains the authenticated user, games, account statistics, platform list, result render limit, selected view, and loading state. Static platform taxonomy, release-text matching, and the centralized platform-theme resolver live in public/js/platforms.js; this includes PC storefronts and launchers such as Steam, GOG, and Epic Games Store as first-class filterable platforms. The resolver uses an explicit exact-name map rather than family regexes, while public/css/library.css owns researched solid and gradient identity anchors. Shared low-specificity CSS derives lightly tinted charcoal surfaces, borders, and hover states while retaining neutral accessible text. Historically multicolor identities use restrained gradients; monochrome, conflicting, or insufficiently documented identities intentionally stay neutral. Evercade references its red console, purple arcade, and blue home-computer cartridge families. Unknown custom platforms receive one neutral fallback. Generic PEGI PC releases do not overwrite a selected storefront, while server-side PEGI matching normalizes those storefronts to PC for edition matching. public/js/game-groups.js groups private cards by canonicalGameId when available and normalized title as a compatibility fallback, only when no platform filter is active. It retains each original game row as a selectable copy. A grouped card reuses its platform badge as a themed dropdown rather than rendering an extra strip that changes card height. public/js/version-picker.js owns its markup, platform-theme classes, open/close state, viewport-aware upward placement, direct copy selection, and Arrow, Home, End, and Escape keyboard behavior. The complete platform label remains on one line, and duplicate copies on one platform add their record ID. The editor retains its expanded, identically color-coded copy picker. Live game updates rerender the current grouped page so its membership cannot become stale. Authenticated event streaming lives in public/js/events.js; incremental card ordering lives in public/js/game-sorting.js; title suggestions, including local public-Kat·a·log hits, live in public/js/title-autocomplete.js; HLTB form and card presentation lives in public/js/hltb-ui.js; public/js/katalog-navigation.js fetches and swaps only the public-Kat·a·log content view while retaining the mounted app header, account control, add-game action, library DOM, and browser history; and public/js/katalog-public.js binds native release-detail dialogs and their one-click add action in either the standalone public document or that mounted view. Returning to the private library closes an open release dialog before hiding the public content region, removes its modal top layer and invisible backdrop, and suppresses the dialog's normal return-to-public history update. server/app-shell.js distinguishes full authenticated document loads from partial view requests: a signed-in hard refresh on Signal, Forum, Kat·a·log, or a release URL receives the real SPA shell at the unchanged URL, then restores that content view below the mounted header. Guests retain crawlable server-rendered HTML, and partial fetches identify themselves with X-GameKat-Partial. Consequently the persistent +Game control opens the existing dialog from every authenticated view rather than linking back to the library. Server-rendered release pages check the signed-in account for the same canonical IGDB/platform identity, with title/platform fallback for unmatched records; a match renders an already-added state instead of an add form. The POST endpoint retains duplicate validation for races, and the browser turns its duplicate response into that same already-added action instead of showing an error. The four top-level views share the same .hero spacing, minimum height, padding, heading typography, and cover-fan geometry; only their kicker, branded section heading, and descriptive copy differ.

server/site-stats.js owns the public Stats for Nerds projection. Its SQL returns anonymous aggregates only: account counts, collection states, metadata coverage, public-release growth, ratings, progression, community activity, public-platform totals, and public IGDB genre totals. Free-form platform and genre strings are read only from already-public releases, never private game rows. Its app level uses the Gamebooks pooled formula, scaling total XP by every registered collector; collectors without a progression row therefore still affect both the app-level scale and average-level denominator. The interface floors average collector level before display. The XP event-type total comes from the complete XP_EVENTS policy rather than counting only event names that have already occurred. The module also measures the SQLite file, durable cover directory, selected source trees, process memory, host CPU/RAM, and uptime settings. server/hardware-policy.js keeps the deployment CPU release date discoverable, while server/resource-metrics.js takes one-second process samples and maintains running session means for normalized CPU use, heap used, heap total, and RSS; its timer is unref'd and cannot keep the process alive during shutdown. server/traffic-metrics.js maintains cumulative raw HTTP byte counters per Node-facing socket. A socket's first completed or closed request includes the request bytes Node consumed before invoking the application handler, and later keep-alive requests record only bytes not already accounted for. Reading the counters and clean shutdown also sample active sockets, so long-lived SSE traffic does not wait for disconnection before appearing. Closed sockets are sampled once and removed from the active map. Totals persist in runtime_settings every 50 accounting updates and on clean shutdown, and both public Stats and the one-second localhost dashboard read the same counters. The accounting format is versioned; upgrading from the original post-handler snapshot logic resets only the incompatible traffic totals so inbound and outbound begin from the same reliable epoch. Because nginx terminates the public connection, these values describe HTTP bytes between clients or the reverse proxy and Node, not encrypted internet-interface traffic. Restart accounting mirrors Gamebooks: every positive clean-stop or heartbeat gap increases total downtime, while only a gap above the 15-second threshold resets the current session clock. The public uptime percentage retains two decimal places so a small but real downtime total cannot round to a displayed 100 percent. Expensive filesystem and database work is cached with the snapshot for 15 seconds, while resource averages continue sampling between snapshots. public/js/stats-ui.js owns dialog lifecycle and semantic table construction; public/js/stats-format.js owns compact numbers, byte sizes, durations, percentages, coverage labels, and the 365-day year conversion used for aggregate HLTB hours; public/css/stats.css owns its responsive three/two/one-column presentation. Delegated triggers let the same module work from the login footer and mounted or server-rendered public headers without adding another application-shell render path. At mobile width, public/js/mobile-action-dock.js moves the existing live community-action group directly beneath document.body, where the shared theme fixes it to the lower-left viewport beside the existing lower-right add-game control. The moved node temporarily retains the top-actions styling contract, so button decoration, active states, Ping attention, and the established header theme continue to apply outside the header. The same node, handlers, and badge are preserved, and the module restores it to its original header position when the viewport returns to desktop width. It observes the authenticated shell's hidden state and has a synchronous CSS guard, so controls never escape onto the login screen and are returned to the shell on logout. The same grouped markup is emitted for guest and authenticated public headers, so Stats remains available without crowding the brand row.

All icon-only close controls and search clear controls use the shared public/assets/ui-icons.svg#close symbol and the green close-control palette. Feature styles may change the control's dimensions to fit their dialog header, but do not replace its icon, colors, or interaction states.

All displayed dates, month and weekday names, and grouped numbers use an explicit en-US interface locale on both server-rendered and browser-rendered surfaces. They never inherit the browser, operating-system, or server locale. Locale-aware lowercase operations used only for matching do not affect visible language. The Bulgarian Biseri description in the shared studio directory is intentional editorial content.

The authenticated library footer mirrors the family branding used by Gamebooks: koldKat productions followed by a copyright year. Hovering or keyboard-focusing the production mark opens a themed, accessible mini-directory for Pathmap and Biseri; both external destinations open in a separate tab. COPYRIGHT_START_YEAR lives in public/js/site-config.js; the browser displays that year initially and automatically expands it to a range in later years.

Public CSS is split by responsibility and loaded in deliberate cascade order: foundation.css, theme.css, library.css, landing.css, then features.css. Later modules refine shared primitives established earlier, so the order in public/index.html must be preserved. Standalone public Kat·a·log pages also load the landing and feature layers for the same low-opacity cover spread used by the authenticated shell; their server-rendered slots use only validated local public-cover paths. Every module is source-formatted rather than minified; production compression, if desired, belongs at the HTTP layer rather than in the maintained source.

The event client reads SSE through fetch() and a ReadableStream so reconnects can send Last-Event-ID for replay while using the same-origin session cookie. It reconnects after interruption and stops immediately on logout or page exit; standalone Signal and public-shell Ping streams have the same explicit page-exit cleanup. The server disables nginx buffering, revalidates the session without extending it or writing SQLite on each 20-second heartbeat, and rotates long-lived connections after ten minutes. Logout, password changes, admin revocation, expiry, or account deletion therefore close an existing stream as well as blocking its reconnect. Every account has a bounded 2,048-event replay window; the client returns its last event ID after a disconnect so card and progress changes from the gap are replayed in order. If an unusually long interruption exceeds that window, a reset event triggers a correctness resync.

The public nginx location should explicitly support the long-lived stream:

location / {
    proxy_pass http://127.0.0.1:3005;
    proxy_http_version 1.1;
    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;
    proxy_buffering off;
    proxy_cache off;
    proxy_read_timeout 3600s;
}

An isolated net::ERR_INCOMPLETE_CHUNKED_ENCODING entry means the proxy or upstream ended an open event stream without a normal HTTP terminator. The browser client catches that interruption and reconnects with its last event ID. Repeated short-lived failures back off from 2.5 seconds to a 30-second ceiling; a stable connection resets the delay. Repeated warnings indicate a proxy timeout or unstable upstream process; they do not require reloading the library grid.

Cover, PEGI, and HLTB workers publish account-targeted progress plus game-updated records. The browser immediately patches a changed record already present on the current page, then performs one debounced page refresh so server-side title grouping, sort position, totals, and page membership remain authoritative. Updates received while a filter/search request is in flight are keyed by game ID and coalesced into the same refresh. Private browsing never transfers or JSON-hydrates the whole matching library: the database first establishes group boundaries from lightweight identity rows and fetches full metadata only for the requested 50-card page. Administrator version writes broadcast version-updated through both the authenticated account stream and the small public header stream, replacing every open header's release string immediately. Collection and summary requests also carry a client-side sequence and account check, so a slower or previous-account HTTP response cannot overwrite newer state. Likewise, a late unauthorized response can end only the same session generation that issued it, never a newer login. Logout clears the in-memory collection and account-specific header artwork before another account can enter.

Startup#

  1. Show the neutral session-resume surface before first paint.
  2. Call /api/auth/me; the browser supplies any HttpOnly session cookie automatically.
  3. Show authentication on absence/failure, or apply the returned account preferences and mount the library on success.
  4. Load games, statistics, and metadata in parallel using those preferences.

An HTTP 401 on a protected request made from an active application advances the client session generation, returns to login, and reports that the session expired. A 401 from the initial /api/auth/me probe is treated as an ordinary logged-out visit and does not show that warning. No browser-stored token needs to be removed.

Rendering#

Game cards are generated from escaped values. While a library request has no existing cards to preserve, the grid area shows a compact animated controller derived from the green-outline favicon; it is hidden as soon as cards or the genuine empty state can render. Refreshes with existing cards never replace them with the loader. Reduced-motion clients receive the same status as a static mark. Filters are sent to the server rather than applied to a global cross-user data set. Search uses a 220 ms query debounce. View, search, filter, and sort changes are separately debounced into /api/preferences, which stores the validated snapshot in SQLite. Dirty preference state retries after a transient failure and is flushed with a keepalive request when the page exits or before logout. Rendering is batched in groups of 120.

Dialog pointer safety#

Backdrop dismissal tracks pointerdown and pointerup. It closes only when both events target the dialog backdrop. This prevents a text-selection drag that begins inside the form and ends outside from dismissing the dialog.

Destructive actions use themed HTML dialogs in both the public application and localhost admin. Native browser alert, confirm, and prompt APIs are not used.


Static serving#

server/request-url.js accepts only HTTP origin-form request targets rooted at /, rejects authority-form, absolute, control-character, and backslash targets, and catches malformed percent-encoding before it reaches static-path decoding. Invalid targets receive a controlled 400 response instead of escaping the asynchronous server boundary. server.js then resolves valid requested paths beneath public/, rejects traversal outside that directory, assigns MIME types, and serves maintained static content with Cache-Control: no-cache plus an ETag. Browsers therefore revalidate on every load, receive a cheap 304 Not Modified response for unchanged files, and pick up changed JavaScript, CSS, manifests, and app icons without manual ?v=N URL updates. Durable cover files retain immutable one-year caching because their filenames change when their content changes. API content uses no-store.

Admin assets are not beneath the public directory. server/admin.js serves an explicit file allowlist only after the request passes the loopback check.

Generated documentation is available at:

Search and social metadata#

The public landing page uses https://gamekat.net/ as its canonical URL. Its focused title and description, Open Graph and Twitter large-image fields, install manifest, and WebApplication JSON-LD consistently describe multi-platform collection tracking, public release discovery, wishlists and backlogs, deep filtering, PEGI/HLTB assistance, cover art, and cross-device account preferences. Structured data also links the public guide and GitHub repository. The domain inspires the Game Kat·a·log wordmark, whose separators are true middle dots. The social image is authored as public/social-preview.svg and rendered to the crawler-compatible public/social-preview.png at 1200×630.

robots.txt permits the landing page, /signal, /forum, /katalog, release pages, and public guide while excluding /api/, /admin/, and account avatars. Runtime /sitemap.xml is generated as a plain standard URL-set with Signal, Forum, stable public release slugs, public forum threads, and their update dates; candidates and rejected Kat·a·log entries never appear. The maintained static file remains a landing/Signal/Forum/Kat·a·log/guide fallback. Signal uses CollectionPage JSON-LD and receives public, filtered activity through /api/activity/stream; the forum uses the same public shell and its own /api/forum/stream; browse pages use CollectionPage JSON-LD, and release pages use VideoGame JSON-LD with eligible aggregate ratings. A canonical release URL renders the public Kat·a·log with that release detail dialog already open, so search visitors get the same detail surface as people browsing the Kat·a·log. The manifest includes 192×192 and 512×512 PNG icons in addition to the scalable favicon.

The authentication landing markup contains six visible, descriptive feature cards covering platform breadth, querying, PEGI/HLTB metadata, cover workflows, cross-device preference persistence, and public discovery with private tracking. This gives non-JavaScript crawlers useful product content without exposing any private collection data. Backups and local administration remain documented operational features rather than headline public marketing claims.


Documentation workflow#

Markdown files in docs/ are the source of truth.

Generated guide pages load the section-aware table-of-contents behavior from public/js/docs-navigation.js. Keeping this behavior in a same-origin external module satisfies the public Content Security Policy while updating the highlighted left-hand section during scrolling, hash jumps, and direct fragment loads.

npm run docs:build   # regenerate public/docs/*.html
npm run docs:check   # fail if generated HTML is stale

npm test runs both the Node test suite and the documentation consistency check.


Testing#

Test fileContract
test/auth.test.jsAccount isolation, sessions, password invalidation
test/user-location.test.jsOffline GeoIP normalization, throttling, and IP non-persistence
test/pegi.test.jsPEGI HTML parsing
test/pegi-bulk.test.jsExact-title/platform selection, late-change skipping, and account job events
test/hltb.test.jsHLTB response parsing, title similarity, and the current search route
test/hltb-bulk.test.jsExact-title selection, late-change skipping, job events, and failure circuit breaker
test/hltb-ui.test.jsNull-safe new-game and saved-game browser metadata normalization
test/preferences.test.jsPer-account persistence, validation, isolation, and deletion cascade
test/sorting.test.jsClient HLTB null-last ordering and deterministic accent-insensitive title sorting
test/events.test.jsSSE framing, account-isolated replay, and revoked-session closure
test/covers.test.jsConservative cover-title normalization
test/seo.test.jsCanonical/social metadata, crawler policy, and asset dimensions
test/admin.test.jsLoopback/proxy boundary and whole-database admin summaries
test/katalog-policy.test.jsComplete/exact automatic publication and candidate boundaries
test/katalog-store.test.jsIdentity deduplication, public projection privacy, search, and sticky rejection
test/katalog-service.test.jsIndependent covers, private-copy defaults, duplicate rejection, and fail-closed sync
test/katalog-pages.test.jsSSR metadata, escaping, safe links, and dynamic sitemap output
test/backup.test.jsHourly ZIP creation, deduplication, cleanup, and scheduler timing
test/version.test.jsVersion-file persistence and input validation
test/site-stats.test.jsPublic aggregate accuracy, privacy boundaries, modular UI wiring, and responsive dialog behavior
test/stats-format.test.jsAggregate HLTB hours converted into compact year, day, and hour durations
test/resource-metrics.test.jsOne-second process sampling and finite running session means

Run all checks with npm test.

The authentication test uses a disposable SQLite database under /tmp and removes its main, WAL, and shared-memory files afterward.


Operations and backups#

Stop with Ctrl+C or send SIGTERM. The server closes SQLite before exiting.

The server automatically creates compressed, consistent live database backups at startup and hourly, retaining 15 days. Cover binaries under public/covers/ are deliberately excluded from those ZIP files. The admin Tools tab lists, triggers, and removes the database archives and can checkpoint the WAL. For a simple offline database backup:

  1. Stop the server.
  2. Copy games.db to a dated backup location.
  3. Restart the server.

When backing up a live WAL database, use SQLite's backup API or include a proper checkpoint procedure. Copying only games.db during an active write can omit transactions still present in games.db-wal.

The database and generated cover files are excluded from Git. Source code, generated documentation, tests, and the empty public/covers/.gitkeep directory marker can be versioned normally.


Known boundaries#

Generated from docs/technical.md. Do not edit this HTML directly.