-- UOMysticmoon database schema (MariaDB) -- Run automatically by the MariaDB container (docker-entrypoint-initdb.d) on a -- fresh volume, and idempotently by ensureSchema() on every server boot. CREATE TABLE IF NOT EXISTS users ( id INT AUTO_INCREMENT PRIMARY KEY, -- COLLATE is pinned to a case-insensitive (_ci) collation so uniqueness and -- findByUsername lookups both fold case identically ('Foo' == 'foo'). This is -- the atomic backstop for the username-uniqueness race (see the register / -- change-username duplicate-key handling). username VARCHAR(32) NOT NULL COLLATE utf8mb4_general_ci UNIQUE, -- Nullable: SSO-provisioned players have no password until they choose to set -- one. A NULL hash means password login is impossible for that account -- (validatePassword returns false). password_hash VARCHAR(72) NULL, role ENUM('admin','editor','moderator','player') NOT NULL DEFAULT 'admin', -- Optional contact email (players). Not unique — SSO emails may repeat. Used -- only for display + a future self-serve reset. email_verified is wired now so -- an eventual SMTP verification flow needs no schema change. email VARCHAR(255) NULL, email_verified TINYINT(1) NOT NULL DEFAULT 0, -- Account lifecycle, independent of role: staff can disable/ban a player -- without changing their role. active = normal; disabled = admin-locked; -- banned = moderation ban; pending = reserved for future email-verify gating. -- Enforced in requireAuth + login (non-active is rejected). status ENUM('active','pending','disabled','banned') NOT NULL DEFAULT 'active', totp_secret VARCHAR(64) NULL, -- base32 TOTP secret (opt-in 2FA) totp_enabled TINYINT(1) NOT NULL DEFAULT 0, -- Any session token issued before this instant is rejected (see requireAuth). -- Bumped on password change / "log out everywhere". NULL = no cutoff yet. tokens_valid_after DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, last_login_at DATETIME NULL, last_login_ip VARCHAR(45) NULL -- IPv6-capable, set on each login ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS posts ( id INT AUTO_INCREMENT PRIMARY KEY, category ENUM('news','five_on_friday','newsletter','screenshot') NOT NULL, title VARCHAR(200) NOT NULL, slug VARCHAR(220) NULL, excerpt VARCHAR(400) NULL, body MEDIUMTEXT NULL, image_url VARCHAR(500) NULL, published TINYINT(1) NOT NULL DEFAULT 0, author_id INT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, published_at DATETIME NULL, CONSTRAINT fk_posts_author FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL, INDEX idx_posts_feed (category, published, published_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Wiki categories / sections. Defined before wiki_pages so the FK resolves on a -- fresh install. Pages reference a category (nullable = "Uncategorized"). CREATE TABLE IF NOT EXISTS wiki_categories ( id INT AUTO_INCREMENT PRIMARY KEY, slug VARCHAR(120) NOT NULL UNIQUE, title VARCHAR(200) NOT NULL, description VARCHAR(400) NULL, sort_order INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS wiki_pages ( id INT AUTO_INCREMENT PRIMARY KEY, slug VARCHAR(120) NOT NULL UNIQUE, title VARCHAR(200) NOT NULL, body MEDIUMTEXT NULL, excerpt VARCHAR(400) NULL, category_id INT NULL, published TINYINT(1) NOT NULL DEFAULT 1, sort_order INT NOT NULL DEFAULT 0, updated_by INT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, published_at DATETIME NULL, CONSTRAINT fk_wiki_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL, CONSTRAINT fk_wiki_category FOREIGN KEY (category_id) REFERENCES wiki_categories(id) ON DELETE SET NULL, FULLTEXT INDEX idx_wiki_search (title, body) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Wiki tags (many-to-many with pages). CREATE TABLE IF NOT EXISTS wiki_tags ( id INT AUTO_INCREMENT PRIMARY KEY, slug VARCHAR(120) NOT NULL UNIQUE, label VARCHAR(120) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS wiki_page_tags ( page_id INT NOT NULL, tag_id INT NOT NULL, PRIMARY KEY (page_id, tag_id), CONSTRAINT fk_wpt_page FOREIGN KEY (page_id) REFERENCES wiki_pages(id) ON DELETE CASCADE, CONSTRAINT fk_wpt_tag FOREIGN KEY (tag_id) REFERENCES wiki_tags(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Internal-link index, rebuilt on each save. target_slug may point at a page -- that does not exist yet (a "red link"). CREATE TABLE IF NOT EXISTS wiki_links ( source_page_id INT NOT NULL, target_slug VARCHAR(120) NOT NULL, CONSTRAINT fk_wiki_links_src FOREIGN KEY (source_page_id) REFERENCES wiki_pages(id) ON DELETE CASCADE, INDEX idx_wiki_links_target (target_slug) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Per-save content snapshots for history / diff / restore. CREATE TABLE IF NOT EXISTS wiki_revisions ( id INT AUTO_INCREMENT PRIMARY KEY, page_id INT NOT NULL, title VARCHAR(200) NOT NULL, body MEDIUMTEXT NULL, excerpt VARCHAR(400) NULL, category_id INT NULL, editor_id INT NULL, change_note VARCHAR(280) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_wiki_rev_page FOREIGN KEY (page_id) REFERENCES wiki_pages(id) ON DELETE CASCADE, CONSTRAINT fk_wiki_rev_editor FOREIGN KEY (editor_id) REFERENCES users(id) ON DELETE SET NULL, INDEX idx_wiki_rev_page (page_id, id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS settings ( `key` VARCHAR(64) PRIMARY KEY, value TEXT NULL, updated_by INT NULL, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_settings_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS activity_log ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NULL, action VARCHAR(64) NOT NULL, detail TEXT NULL, ip VARCHAR(45) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_activity_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL, INDEX idx_activity_created (created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Pluggable SSO / OAuth2 provider configuration. Rows exist for the built-in -- providers ('google', 'discord') once an admin configures them, plus any custom -- OIDC/OAuth2 providers (id = a slug). Client secrets are stored ENCRYPTED -- (client_secret_enc) and are never returned to a client. Built-in providers -- hardcode their endpoint URLs in code; the *_url columns are used only by -- custom (oidc/oauth2) providers. CREATE TABLE IF NOT EXISTS auth_providers ( id VARCHAR(64) PRIMARY KEY, -- 'google' | 'discord' | custom slug kind ENUM('google','discord','oidc','oauth2') NOT NULL, name VARCHAR(80) NOT NULL, enabled TINYINT(1) NOT NULL DEFAULT 0, client_id VARCHAR(255) NULL, client_secret_enc TEXT NULL, -- AES-256-GCM ciphertext, never exposed authorize_url VARCHAR(500) NULL, -- custom providers only token_url VARCHAR(500) NULL, userinfo_url VARCHAR(500) NULL, scopes VARCHAR(500) NULL, priority INT NOT NULL DEFAULT 100, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Account linking: maps an external SSO identity to an internal user. A login via -- SSO succeeds only if a matching (provider, subject) row exists (link-only — -- external identities are never auto-provisioned into accounts). UNIQUE(provider, -- subject) guarantees one external identity maps to exactly one internal user. CREATE TABLE IF NOT EXISTS user_identities ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, provider VARCHAR(64) NOT NULL, -- matches auth_providers.id subject VARCHAR(191) NOT NULL, -- external stable user id (sub / discord id) email VARCHAR(255) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_identity_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, UNIQUE KEY uq_identity_provider_subject (provider, subject), INDEX idx_identity_user (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Long-lived, revocable refresh tokens for mobile (Android) bearer-token auth. -- The opaque refresh token is never stored in the clear — only its sha256 hash — -- so a DB read does not leak usable tokens. Rows are rotated on every refresh -- (old row revoked, new row inserted) and revoked on logout. Web cookie sessions -- do NOT use this table; it is purely for the mobile bearer flow. CREATE TABLE IF NOT EXISTS mobile_refresh_tokens ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque refresh token device_hash VARCHAR(32) NULL, -- from sessionService.sessionMeta (best-effort) user_agent VARCHAR(255) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at DATETIME NOT NULL, revoked_at DATETIME NULL, CONSTRAINT fk_mrt_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, INDEX idx_mrt_user (user_id), INDEX idx_mrt_expires (expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Denylist of revoked web/cookie session tokens, keyed on the JWT `jti` minted -- per session in createSession. A single logout adds this session's jti here; -- requireAuth rejects any token whose jti is present. Rows self-expire: expires_at -- mirrors the token's own exp, after which the JWT fails verification anyway, so -- the row is dead weight and gets pruned. "Log out everywhere" / password change -- do NOT use this table — they bump users.tokens_valid_after instead (one row vs. -- one-per-session). This is the web/cookie analogue of mobile_refresh_tokens. CREATE TABLE IF NOT EXISTS revoked_sessions ( jti CHAR(36) PRIMARY KEY, -- the session's JWT jti (uuid v4) user_id INT NULL, expires_at DATETIME NOT NULL, -- mirrors the token exp (prune after) revoked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_revoked_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, INDEX idx_revoked_sessions_expires (expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Discord bot control (Phase 1). Singleton row (id = 1) holding the bot's -- config — the token is encrypted at rest (bot_token_enc) the same way OAuth -- client secrets are, and is only ever decrypted server-side to push to the -- bot process over the internal API; it is never returned to the admin UI -- and the bot process never reads this table directly. `status`/`status_detail` -- /`last_connected_at` are last-known-state mirrors of what the bot reported, -- shown in the admin panel between polls. CREATE TABLE IF NOT EXISTS bot_config ( id INT PRIMARY KEY DEFAULT 1, guild_id VARCHAR(32) NULL, bot_token_enc TEXT NULL, application_id VARCHAR(32) NULL, enabled TINYINT(1) NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'disconnected', status_detail VARCHAR(500) NULL, last_connected_at DATETIME NULL, updated_by INT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_bot_config_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL, CONSTRAINT chk_bot_config_singleton CHECK (id = 1) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Outbound email configuration (Gmail over OAuth2 / SMTP XOAUTH2). Singleton row -- (id = 1), mirroring bot_config: the DB only ever holds the AES-256-GCM-encrypted -- refresh token, never plaintext, and the client id/secret are NOT stored here — -- they are read live from the `google` auth_providers row. The refresh token is -- captured by the in-app "Connect Gmail" consent flow and is write-only over the -- admin API (never returned; responses expose only hasRefreshToken). CREATE TABLE IF NOT EXISTS email_config ( id INT PRIMARY KEY DEFAULT 1, provider VARCHAR(20) NOT NULL DEFAULT 'gmail_oauth2', enabled TINYINT(1) NOT NULL DEFAULT 0, sender_email VARCHAR(255) NULL, -- connected Gmail address (from userinfo) sender_name VARCHAR(120) NULL, -- optional From display name refresh_token_enc TEXT NULL, -- AES-256-GCM ciphertext, never exposed status VARCHAR(20) NOT NULL DEFAULT 'unconfigured', status_detail VARCHAR(500) NULL, last_verified_at DATETIME NULL, updated_by INT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_email_config_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL, CONSTRAINT chk_email_config_singleton CHECK (id = 1) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ── uo-link sidecar ──────────────────────────────────────────────────────── -- Connection config for the uo-link sidecar (the HTTP + WebSocket bridge to the -- ServUO shard). Singleton row (id = 1), mirroring bot_config/email_config: the -- DB only ever holds the AES-256-GCM-encrypted shared-secret auth token, never -- plaintext, and it is only decrypted server-side (to call the sidecar). It is -- never returned to the admin UI — responses expose only `hasToken`. base_url is -- the REST endpoint, ws_url the live-feed endpoint; both are configurable because -- in production the sidecar runs on a different host from the website. `status`/ -- `plugin_connected`/`last_event_at`/`boot_id` mirror the sidecar's last-known -- state for the admin panel between polls; `boot_id` tracks server.hello.bootId -- so a shard restart can be detected (and caches dropped). CREATE TABLE IF NOT EXISTS uo_link_config ( id INT PRIMARY KEY DEFAULT 1, base_url VARCHAR(255) NULL, ws_url VARCHAR(255) NULL, auth_token_enc TEXT NULL, protocol INT NOT NULL DEFAULT 1, enabled TINYINT(1) NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'disconnected', status_detail VARCHAR(500) NULL, plugin_connected TINYINT(1) NOT NULL DEFAULT 0, last_event_at DATETIME NULL, boot_id VARCHAR(64) NULL, updated_by INT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_uo_link_config_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL, CONSTRAINT chk_uo_link_config_singleton CHECK (id = 1) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Append-only log of notable shard events ingested from the uo-link WebSocket -- feed. The site OWNS this data (it does not query the sidecar's SQLite): the WS -- client writes here, and the public/admin read endpoints + live feeds read from -- here. Only "notable" kinds are logged (sales, deaths, murders, mob.killed, -- IDOC transitions, quests, skill.gain, fame/karma, audit.*, cheat.*, link.*, -- server.*). High-frequency kinds (char.vitals, economy.supply) are NOT logged -- here — they update shard_online / shard_economy instead, keeping the log lean. -- dedupe_key = sha1(kind + t + stable-json(payload)); with the UNIQUE index it -- makes INSERT IGNORE idempotent so WS-reconnect backfill never double-inserts. CREATE TABLE IF NOT EXISTS shard_events ( id BIGINT AUTO_INCREMENT PRIMARY KEY, kind VARCHAR(48) NOT NULL, t BIGINT NOT NULL, -- event time, epoch ms (from the sidecar) boot_id VARCHAR(64) NULL, -- shard boot id at ingest (server.hello.bootId) payload JSON NOT NULL, -- the full event object dedupe_key CHAR(40) NOT NULL UNIQUE, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_shard_events_kind_t (kind, t), INDEX idx_shard_events_t (t) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Current online players. Upserted on mob.login, refreshed on char.vitals, and -- removed on mob.logout. Cleared wholesale when the shard restarts (a new -- server.hello.bootId). web_id is the linked website user id (present when the -- account is linked), so the roster can be correlated to site accounts. CREATE TABLE IF NOT EXISTS shard_online ( serial VARCHAR(20) NOT NULL PRIMARY KEY, -- mobile serial (opaque hex key) name VARCHAR(120) NULL, acct VARCHAR(120) NULL, web_id INT NULL, map VARCHAR(40) NULL, x INT NULL, y INT NULL, z INT NULL, hits INT NULL, hits_max INT NULL, mana INT NULL, mana_max INT NULL, stam INT NULL, stam_max INT NULL, str INT NULL, dex INT NULL, `int` INT NULL, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_shard_online_acct (acct) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Total-gold-supply time series (from the periodic economy.supply event). Kept -- append-only so the public status page can render a supply-over-time sparkline. CREATE TABLE IF NOT EXISTS shard_economy ( id BIGINT AUTO_INCREMENT PRIMARY KEY, accounts INT NULL, -- number of accounts included in the total gold BIGINT NULL, -- total gold supply across all accounts t BIGINT NOT NULL, -- sample time, epoch ms created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_shard_economy_t (t) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Current decay stage per house, upserted on house.decay. is_idoc is a derived -- flag (stage == 'IDOC') so the public "houses in danger" list is a cheap -- indexed lookup rather than a scan. CREATE TABLE IF NOT EXISTS shard_houses ( serial VARCHAR(20) NOT NULL PRIMARY KEY, stage VARCHAR(24) NULL, -- Somewhat | Fairly | Greatly | IDOC | Collapsed | ... map VARCHAR(40) NULL, x INT NULL, y INT NULL, z INT NULL, region VARCHAR(120) NULL, name VARCHAR(160) NULL, owner_serial VARCHAR(20) NULL, owner_acct VARCHAR(120) NULL, built_on DATETIME NULL, last_refreshed DATETIME NULL, is_idoc TINYINT(1) NOT NULL DEFAULT 0, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_shard_houses_idoc (is_idoc) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Site-side mirror of in-game-account → website-user links. The sidecar is the -- source of truth (it tags the game account with the websiteUserId on -- /link/confirm); this table mirrors it so the player portal can list a user's -- linked accounts and enforce ownership on roster/vendor reads without a shard -- round-trip. account is unique (one game account maps to at most one site user); -- a single user may link several game accounts. CREATE TABLE IF NOT EXISTS shard_account_links ( account VARCHAR(120) NOT NULL PRIMARY KEY, user_id INT NOT NULL, char_name VARCHAR(120) NULL, linked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_shard_links_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, INDEX idx_shard_links_user (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Discord bot moderation core (Phase 2). These tables are owned by the bot -- process (its own DB pool, bot/src/db.js) — the main server never reads or -- writes them. They live in the same physical database as everything else -- (per the spec's "shared instance, clearly prefixed where needed" option) -- purely because there's no separate migration tooling to stand up a second -- database for a single-guild v1 bot. -- Per-guild key/value config the bot needs at runtime (currently just the -- mod-log channel; filters/schedules/role-menu config lands here in later -- phases). Set via the `/modlog set` slash command, not the admin panel — -- unlike bot_config (identity/connection secrets), this is routine Discord -- server administration staff already do inside Discord. CREATE TABLE IF NOT EXISTS guild_config ( guild_id VARCHAR(32) NOT NULL, `key` VARCHAR(64) NOT NULL, value VARCHAR(500) NULL, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (guild_id, `key`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Audit trail + mod-log source of truth for ban/kick/mute/warn actions. -- duration_seconds is only set for timed mutes; NULL for permanent -- ban/kick/warn actions. CREATE TABLE IF NOT EXISTS mod_actions ( id INT AUTO_INCREMENT PRIMARY KEY, guild_id VARCHAR(32) NOT NULL, action_type ENUM('ban','kick','mute','warn') NOT NULL, target_user_id VARCHAR(32) NOT NULL, target_tag VARCHAR(120) NULL, staff_user_id VARCHAR(32) NOT NULL, staff_tag VARCHAR(120) NULL, reason VARCHAR(500) NULL, duration_seconds INT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_mod_actions_target (guild_id, target_user_id, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Standing warnings, separate from mod_actions so /warnings can list active -- warnings per user. expires_at is unused in Phase 2 (no decay/escalation -- yet — deferred, see mute/warn command comments) but the column is cheap to -- add now rather than migrate in later. CREATE TABLE IF NOT EXISTS warnings ( id INT AUTO_INCREMENT PRIMARY KEY, guild_id VARCHAR(32) NOT NULL, target_user_id VARCHAR(32) NOT NULL, target_tag VARCHAR(120) NULL, staff_user_id VARCHAR(32) NOT NULL, staff_tag VARCHAR(120) NULL, reason VARCHAR(500) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at DATETIME NULL, INDEX idx_warnings_target (guild_id, target_user_id, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Banned-word list (Phase 3). `word` is stored as the admin typed it; matching -- normalizes both sides at runtime (case, leetspeak, repeated chars — see -- bot/src/filter/normalize.js), so the stored value doesn't need every -- obfuscated variant. severity drives the auto-action: delete-only, delete + -- warn, or delete + mute (see messageFilter.js). The role/channel allowlist -- that bypasses filtering entirely lives in guild_config (keys -- filter_allow_roles / filter_allow_channels, CSV of snowflake ids) rather -- than a separate table — it's a short, rarely-changed list. CREATE TABLE IF NOT EXISTS filter_words ( id INT AUTO_INCREMENT PRIMARY KEY, guild_id VARCHAR(32) NOT NULL, word VARCHAR(200) NOT NULL, severity ENUM('delete','warn','mute') NOT NULL DEFAULT 'delete', added_by VARCHAR(32) NULL, added_by_tag VARCHAR(120) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uq_filter_words_guild_word (guild_id, word) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Scheduled/recurring messages (Phase 4). A row is EITHER recurring -- (cron_expression set, run_at NULL — reposts on the node-cron schedule -- forever until disabled/removed) OR one-off (run_at set, cron_expression -- NULL — posted once, then sent_at is stamped so the scheduler's due-message -- sweep never reposts it). content is plain text for now — the original spec -- allows richer embed JSON here, deferred since authoring embed JSON through a -- single slash-command string option isn't practical without a modal/admin UI. CREATE TABLE IF NOT EXISTS scheduled_messages ( id INT AUTO_INCREMENT PRIMARY KEY, guild_id VARCHAR(32) NOT NULL, channel_id VARCHAR(32) NOT NULL, content VARCHAR(2000) NOT NULL, cron_expression VARCHAR(100) NULL, run_at DATETIME NULL, enabled TINYINT(1) NOT NULL DEFAULT 1, sent_at DATETIME NULL, created_by VARCHAR(32) NULL, created_by_tag VARCHAR(120) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT chk_schedule_kind CHECK ( (cron_expression IS NOT NULL AND run_at IS NULL) OR (cron_expression IS NULL AND run_at IS NOT NULL) ), INDEX idx_scheduled_due (run_at, sent_at, enabled) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Self-assignable role menus (Phase 5). Button-based, not reaction-based — -- avoids needing the messageReactionAdd/Remove events and their own intent. -- `mapping` is a JSON array of {roleId, label}, validated against at click -- time (see bot/src/discord/roleMenuHandler.js) so a stale/foreign button -- customId can't toggle an untracked role. Auto-role-on-join is simpler and -- reuses guild_config (key auto_role_id) rather than a table of its own. CREATE TABLE IF NOT EXISTS role_menus ( id INT AUTO_INCREMENT PRIMARY KEY, guild_id VARCHAR(32) NOT NULL, channel_id VARCHAR(32) NOT NULL, message_id VARCHAR(32) NOT NULL, mapping TEXT NOT NULL, created_by VARCHAR(32) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uq_role_menus_message (message_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Timed role assignments (temp-mute-equivalent roles, timed event roles). -- Swept once a minute (bot/src/roles/tempRoleSweeper.js) — expired rows have -- their Discord role removed and the row deleted. UNIQUE(guild,user,role) so -- re-granting the same temp role just refreshes its expiry via ON DUPLICATE -- KEY UPDATE rather than stacking duplicate rows. CREATE TABLE IF NOT EXISTS temp_roles ( id INT AUTO_INCREMENT PRIMARY KEY, guild_id VARCHAR(32) NOT NULL, user_id VARCHAR(32) NOT NULL, role_id VARCHAR(32) NOT NULL, expires_at DATETIME NOT NULL, created_by VARCHAR(32) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uq_temp_roles_user_role (guild_id, user_id, role_id), INDEX idx_temp_roles_expires (expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Audit trail for the auto-rotating primary invite (Phase 6). triggered_by -- NULL means the weekly scheduled rotation did it, not a staff member — see -- bot/src/invites/inviteRotator.js, shared by both /invite rotate and the -- cron job so both paths log identically. The channel invites are created in -- is configured separately in guild_config (key invite_channel_id). CREATE TABLE IF NOT EXISTS invite_log ( id INT AUTO_INCREMENT PRIMARY KEY, guild_id VARCHAR(32) NOT NULL, channel_id VARCHAR(32) NOT NULL, invite_code VARCHAR(20) NOT NULL, triggered_by VARCHAR(32) NULL, triggered_by_tag VARCHAR(120) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, revoked_at DATETIME NULL, INDEX idx_invite_log_guild (guild_id, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Guild member join/leave events (Phase 6b). Powers the dashboard's joins/leaves -- feeds and the invite-usage view. Bot-owned (written by bot/src/discord/ -- guildMemberAdd.js + guildMemberRemove.js). For joins, invite_code/inviter_* -- record which invite was used when the bot could attribute it (best-effort, see -- bot/src/discord/inviteTracker.js) — NULL when undeterminable or for leaves. -- These are member lifecycle events, not moderation actions, hence separate from -- mod_actions. CREATE TABLE IF NOT EXISTS member_events ( id INT AUTO_INCREMENT PRIMARY KEY, guild_id VARCHAR(32) NOT NULL, event_type ENUM('join','leave') NOT NULL, discord_user_id VARCHAR(32) NOT NULL, username VARCHAR(120) NULL, invite_code VARCHAR(20) NULL, inviter_id VARCHAR(32) NULL, inviter_tag VARCHAR(120) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_member_events_guild (guild_id, created_at), INDEX idx_member_events_user (guild_id, discord_user_id, created_at), INDEX idx_member_events_invite (guild_id, invite_code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Automated content-filter hits (Phase 6b): one row per message the word filter -- or the foreign-invite filter deleted. Separate from mod_actions (which still -- records the resulting warn/mute) so the dashboard can show filter volume in -- its own right. `matched` holds the offending word (word hits) or the blocked -- invite code (invite hits); `action_taken` is what the pipeline did. Bot-owned -- (bot/src/discord/messageFilter.js). CREATE TABLE IF NOT EXISTS filter_hits ( id INT AUTO_INCREMENT PRIMARY KEY, guild_id VARCHAR(32) NOT NULL, hit_type ENUM('word','invite') NOT NULL, discord_user_id VARCHAR(32) NOT NULL, username VARCHAR(120) NULL, channel_id VARCHAR(32) NULL, matched VARCHAR(200) NULL, action_taken ENUM('delete','warn','mute') NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_filter_hits_guild (guild_id, created_at), INDEX idx_filter_hits_user (guild_id, discord_user_id, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Automated spam-detection hits (Phase 6b): rate-limit / mass-mention / -- mass-emoji triggers. As with filter_hits, mod_actions still logs the resulting -- warn; this records the detection itself for the dashboard's spam feed. -- Bot-owned (bot/src/discord/messageFilter.js via bot/src/filter/spamFilter.js). CREATE TABLE IF NOT EXISTS spam_hits ( id INT AUTO_INCREMENT PRIMARY KEY, guild_id VARCHAR(32) NOT NULL, spam_type ENUM('rate_limit','mass_mention','mass_emoji') NOT NULL, discord_user_id VARCHAR(32) NOT NULL, username VARCHAR(120) NULL, channel_id VARCHAR(32) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_spam_hits_guild (guild_id, created_at), INDEX idx_spam_hits_user (guild_id, discord_user_id, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Staff notes on a Discord user, surfaced in the admin moderation dashboard -- (Phase 6). Unlike the tables above, this one is SERVER-owned — it is written -- and read only by the main site (moderation.controller), never by the bot. -- Keyed by discord_user_id (a snowflake, matching mod_actions.target_user_id) so -- notes attach to a Discord identity even when it has no linked site account. -- Notes are never user-visible; admin_only notes are further restricted to the -- admin role (moderators see staff_only only) — enforced in the query layer. CREATE TABLE IF NOT EXISTS mod_notes ( id INT AUTO_INCREMENT PRIMARY KEY, discord_user_id VARCHAR(32) NOT NULL, author_user_id INT NULL, author_tag VARCHAR(120) NULL, body TEXT NOT NULL, visibility ENUM('staff_only','admin_only') NOT NULL DEFAULT 'staff_only', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_mod_notes_author FOREIGN KEY (author_user_id) REFERENCES users(id) ON DELETE SET NULL, INDEX idx_mod_notes_user (discord_user_id, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Generic CMS pages composed from a fixed palette of blocks (the page builder). -- `blocks` is a JSON array of block-envelope objects ({ id, type, version, -- visible, props }); it is stored as text and parsed/validated in app code -- against the block registry (server/src/blocks) on every save — the same -- pattern role_menus.mapping uses, since MariaDB's JSON type is just LONGTEXT and -- the driver hands it back as a string anyway. The seo_*/og_image/canonical_url/ -- robots and layout/nav_* columns are metadata/settings surfaced grouped in the -- API response; several have no consumer yet but are cheap to add now and painful -- to retrofit once real pages exist. published_at mirrors posts: stamped the first -- time a page goes to 'published'. CREATE TABLE IF NOT EXISTS pages ( id INT AUTO_INCREMENT PRIMARY KEY, slug VARCHAR(160) NOT NULL UNIQUE, title VARCHAR(200) NOT NULL, blocks MEDIUMTEXT NOT NULL, -- JSON array of block objects status ENUM('draft','published') NOT NULL DEFAULT 'draft', protected TINYINT(1) NOT NULL DEFAULT 0, author_id INT NULL, -- SEO / social metadata (grouped under `metadata` in the API response). seo_title VARCHAR(200) NULL, meta_description VARCHAR(400) NULL, og_image VARCHAR(500) NULL, canonical_url VARCHAR(500) NULL, robots VARCHAR(100) NULL, -- Presentation / navigation (grouped under `settings` in the API response). layout ENUM('default','full_width','landing') NOT NULL DEFAULT 'default', show_in_nav TINYINT(1) NOT NULL DEFAULT 0, nav_group ENUM('main','footer','account','hidden') NULL, nav_order INT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, published_at DATETIME NULL, CONSTRAINT fk_pages_author FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL, INDEX idx_pages_status (status), INDEX idx_pages_nav (show_in_nav, nav_group, nav_order) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Announcement pipeline. One row per publish event of a news post; the table -- doubles as the job queue (a light in-process poller — utils/announceWorker.js -- — sweeps it for due legs). Two INDEPENDENT delivery legs so a Discord outage -- never blocks or retries the in-game town-crier leg and vice versa. `status` is -- a derived rollup of the two legs (see announceJobs.logic.js): done when both -- legs done, failed when both exhausted, partial in between. Each leg tracks its -- own attempt count, last error, and next-due time for exponential backoff. -- post_id is INT (matches posts.id) and cascades so deleting a post reaps its -- jobs. posts.announce_job_id points back at the latest row for admin lookups. CREATE TABLE IF NOT EXISTS announce_jobs ( id INT AUTO_INCREMENT PRIMARY KEY, post_id INT NOT NULL, status ENUM('pending','partial','done','failed') NOT NULL DEFAULT 'pending', towncrier_status ENUM('pending','done','failed') NOT NULL DEFAULT 'pending', towncrier_attempts SMALLINT NOT NULL DEFAULT 0, towncrier_last_error TEXT NULL, towncrier_next_attempt_at DATETIME NULL, discord_status ENUM('pending','done','failed') NOT NULL DEFAULT 'pending', discord_attempts SMALLINT NOT NULL DEFAULT 0, discord_last_error TEXT NULL, discord_next_attempt_at DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_announce_jobs_post FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE, INDEX idx_announce_due (towncrier_status, towncrier_next_attempt_at), INDEX idx_announce_due_discord (discord_status, discord_next_attempt_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Migrations for databases created before the wiki upgrade. Each statement uses -- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get -- these columns from the CREATE TABLE above; existing installs get them here. -- (The category foreign key is only added on fresh installs; on upgraded databases -- referential integrity for category_id is enforced in application code.) -- Opt-in TOTP two-factor columns for databases created before login hardening. ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret VARCHAR(64) NULL; ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled TINYINT(1) NOT NULL DEFAULT 0; -- Session-revocation cutoff for databases created before token revocation landed. ALTER TABLE users ADD COLUMN IF NOT EXISTS tokens_valid_after DATETIME NULL; -- Moderation dashboard (Phase 6): add the 'moderator' role to databases created -- before it. MODIFY has no IF NOT EXISTS form, but re-declaring the same ENUM is -- an idempotent no-op, so it is safe to run on every boot. -- Player accounts: widen the enum again to include 'player' (self-service public -- accounts). Same idempotent-MODIFY pattern. ALTER TABLE users MODIFY COLUMN role ENUM('admin','editor','moderator','player') NOT NULL DEFAULT 'admin'; -- Player accounts: make password_hash nullable (SSO-only players), pin the -- username collation (case-insensitive uniqueness backstop), and add the player -- columns to databases created before this. MODIFY is an idempotent no-op when -- the column already matches; ADD COLUMN IF NOT EXISTS is safe to re-run. ALTER TABLE users MODIFY COLUMN password_hash VARCHAR(72) NULL; ALTER TABLE users MODIFY COLUMN username VARCHAR(32) NOT NULL COLLATE utf8mb4_general_ci; ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255) NULL; ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified TINYINT(1) NOT NULL DEFAULT 0; ALTER TABLE users ADD COLUMN IF NOT EXISTS status ENUM('active','pending','disabled','banned') NOT NULL DEFAULT 'active'; ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_ip VARCHAR(45) NULL; -- Player self-registration mode: disabled | password | sso | both. Default off, -- so the system behaves exactly as today until an admin opts in. INSERT IGNORE INTO settings (`key`, value) VALUES ('player_registration', 'disabled'); ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL; ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL; ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published TINYINT(1) NOT NULL DEFAULT 1; ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS sort_order INT NOT NULL DEFAULT 0; ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published_at DATETIME NULL; ALTER TABLE wiki_pages ADD FULLTEXT INDEX IF NOT EXISTS idx_wiki_search (title, body); -- News → town-crier + Discord announcement pipeline. Add the announcement-state -- columns to posts on databases created before the pipeline landed. announced_at -- is stamped once both legs deliver; announce_job_id points at the announce_jobs -- row for the post's admin status panel. Kept as a plain column (not a hard FK) -- so the idempotent boot migration never trips over a re-added constraint — the -- pointer is resolved in application code and the CASCADE on announce_jobs.post_id -- already keeps the two tables consistent. ALTER TABLE posts ADD COLUMN IF NOT EXISTS announced_at DATETIME NULL; ALTER TABLE posts ADD COLUMN IF NOT EXISTS announce_job_id INT NULL;