Phase 3's acceptance criterion 1, made real. Three things, one review: **The dead bindings.** `client/src/api/client.js` still carried ~190 lines of UO namespaces — `shard`, `atlas`, the two SSE URLs, `admin.shard/shardOps/atlas/ userShard`, the uo-link and town-crier calls, `player.shard` — with zero core consumers since slice 3 deleted the views. module-uo vendors its own bindings. The five assertions core's `apiClient.test.js` made about those URLs moved with them (Module-uo#5); the encoding test that used `governorHistory` now uses a core route. **The copy.** Core is the platform, not one game's site, so its words are game-neutral now: `About`, `Screenshots`, `Website`'s cards, `Status` (which was never about a game server at all — it reports site mode), `Wiki`, `SiteFooter`, the default hero, `brand.js`'s tagline and description, the seeded wiki categories, and two user-visible NavEditor strings that named a module's admin screen by its proper name. Which game an instance is for is the operator's to say — BRAND_* vars, the hero editor, CMS pages — and every real instance already does: `.env.uomysticmoon.example` sets both brand strings explicitly, so nothing live changes wording. Wiki page SLUGS are untouched: `seedDefault*` only inserts what is absent, so renaming one adds a duplicate page to every install. Also gone: an orphan comment block in `schema.sql` describing the spawn-atlas tables slice 1 took away, and the two settings rows core seeded for a module (`game_account_signup`, `uo_link_protocol_3_migrated`). The second was a live defect — see Module-uo#5, which takes ownership of both and repairs the one-shot migration core's ordering had disabled. **The check.** `scripts/checkModuleIdentifiers.js` + `npm run check:modules`, first step of the server-tests job because it needs no dependencies. It reads CODE, not prose — file names, import specifiers, route path literals, declared identifiers and property names — per §5.2, so core's English may still say "shard" where saying it is worth more than the word costs. Two things it gets right only because getting them wrong was tried first: it matches WHOLE WORDS (a substring pass flags `defaultImage`, which contains "ultIma", four times in this repo), and it strips comments and string bodies in one character walk (a comment contains quotes, a string contains `//`) — the `checkImports.js` lesson. It has its own 17-test suite, because a boundary check that silently stops checking is worse than none. The three §6.5 grandfathering allowlists are exempt by name, and an exemption that stops matching fails the build rather than lingering. BREAKING CHANGE: core no longer seeds `game_account_signup` or `uo_link_protocol_3_migrated`; module-uo's schema fragment does. An install running core without module-uo keeps whatever rows it already has and gains no new ones — nothing in core reads either key. Deferred to slice 5, deliberately: README.md's 48 UO mentions, including a `## Shard integration (uo-link)` section and the architecture diagram. That is documentation, which §5.2 does not cover, and it belongs with the phase-closing docs pass rather than half-done here. Co-Authored-By: Claude <noreply@anthropic.com>
906 lines
52 KiB
SQL
906 lines
52 KiB
SQL
-- Runic Gateway 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.
|
|
--
|
|
-- CORE ONLY. The 27 shard_* / uo_link_* tables left with module-uo in Phase 3
|
|
-- and live in its schema fragment, which core replays immediately after this
|
|
-- file (MODULE_API.md 2.6). Two of them carry a foreign key INTO users, which
|
|
-- is why that order matters and why the reverse -- a core table referencing a
|
|
-- module table -- must never appear here: it would make core unable to boot
|
|
-- without a module installed.
|
|
|
|
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)
|
|
device_name VARCHAR(100) NULL, -- friendly label the app may send (M9)
|
|
user_agent VARCHAR(255) NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
last_used_at DATETIME NULL, -- last time this session token was issued/used (M9)
|
|
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;
|
|
|
|
-- Mobile SSO authorization bridge (M9). Two short-lived, self-pruning tables that
|
|
-- bridge a browser SSO redirect flow to the native app. They carry the app↔website
|
|
-- PKCE + CSRF state (a SECOND PKCE layer, distinct from the website↔IdP PKCE the
|
|
-- sso_tx cookie already carries) and the one-time code the app trades for bearer
|
|
-- tokens. No secret is stored in the clear: code_challenge is a hash by construction
|
|
-- and the authorization code is stored as a sha256 hash only (same pattern as
|
|
-- mobile_refresh_tokens / user_invites / password_resets). See docs BACKEND_DESIGN §3/§4.
|
|
CREATE TABLE IF NOT EXISTS mobile_auth_sessions (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
session_id CHAR(36) NOT NULL UNIQUE, -- uuid; carried inside the signed sso_tx (mode 'mobile')
|
|
provider VARCHAR(40) NOT NULL, -- provider id, validated enabled at /start
|
|
code_challenge VARCHAR(255) NOT NULL, -- app-supplied PKCE S256 challenge (base64url)
|
|
redirect_uri VARCHAR(255) NOT NULL, -- app callback; EXACT-match against the allowlist
|
|
state VARCHAR(255) NOT NULL, -- app-generated opaque CSRF value, echoed to the app
|
|
status ENUM('pending','completed','consumed') NOT NULL DEFAULT 'pending',
|
|
user_id INT NULL, -- set once SSO resolves the account
|
|
trust_device TINYINT(1) NOT NULL DEFAULT 0, -- user ticked "trust this device" on the Custom Tab TOTP form;
|
|
-- a BOOLEAN only — the trust token itself is minted at /exchange
|
|
-- and returned over that app→server call, never stored here
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
expires_at DATETIME NOT NULL, -- ~10 min (one redirect round-trip incl. TOTP)
|
|
used_at DATETIME NULL, -- stamped at exchange
|
|
CONSTRAINT fk_mas_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
INDEX idx_mas_expires (expires_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
CREATE TABLE IF NOT EXISTS mobile_auth_codes (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
code_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque >=128-bit code
|
|
user_id INT NOT NULL,
|
|
session_id CHAR(36) NOT NULL, -- owning mobile_auth_sessions.session_id (ties code→PKCE challenge)
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
expires_at DATETIME NOT NULL, -- very short (~5 min)
|
|
used_at DATETIME NULL, -- set on first successful exchange (single use)
|
|
CONSTRAINT fk_mac_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
INDEX idx_mac_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;
|
|
|
|
-- Trusted devices for MFA (opt-in "Trust this device"). A trusted device lets a
|
|
-- browser/app SKIP the TOTP step at login — never the password. Pattern-identical
|
|
-- to mobile_refresh_tokens: the opaque trust token lives client-side (the rg_trust
|
|
-- cookie on web, EncryptedSharedPreferences on mobile) and only its sha256 hash is
|
|
-- stored here (token_hash UNIQUE, so the login path can look a device up in O(1)).
|
|
-- sha256 (not bcrypt) because the token is a 256-bit random value looked up BY its
|
|
-- hash — a per-row salt would break the index lookup. Trust is consulted only at
|
|
-- the login/password step, never at token refresh, and is revoked on untrust /
|
|
-- password change/reset / TOTP disable. Capped at 10 rows per user (enforced in
|
|
-- application code — no silent pruning). See docs/website/TRUSTED_DEVICES_MFA.md.
|
|
CREATE TABLE IF NOT EXISTS trusted_devices (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
user_id INT NOT NULL,
|
|
token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque trust token
|
|
platform ENUM('web','mobile') NOT NULL DEFAULT 'web',
|
|
device_name VARCHAR(100) NULL, -- friendly label for the Trusted Devices list
|
|
device_hash VARCHAR(32) NULL, -- best-effort UA+IP (sessionMeta) — display only
|
|
user_agent VARCHAR(255) NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
last_used_at DATETIME NULL, -- stamped when trust is honored at login
|
|
expires_at DATETIME NOT NULL, -- created_at + 30d
|
|
revoked_at DATETIME NULL,
|
|
CONSTRAINT fk_td_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
INDEX idx_td_user (user_id),
|
|
INDEX idx_td_expires (expires_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Single-use recovery (backup) codes for MFA. Generated at TOTP enrollment (10 at a
|
|
-- time, shown to the user ONCE) so a user who loses their authenticator can complete
|
|
-- login without an admin reset. code_hash is a BCRYPT hash (not sha256): a recovery
|
|
-- code is a human-typed, lower-entropy fallback credential — the closest analogue to
|
|
-- a password — and there is no hash-lookup constraint (we fetch the user's <=10 rows
|
|
-- and bcrypt.compare each, exactly like password verification). Cleared wholesale on
|
|
-- TOTP disable / password change/reset. See docs/website/TRUSTED_DEVICES_MFA.md.
|
|
CREATE TABLE IF NOT EXISTS recovery_codes (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
user_id INT NOT NULL,
|
|
code_hash VARCHAR(72) NOT NULL, -- bcrypt hash of one recovery code
|
|
used_at DATETIME NULL, -- single-use marker
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
CONSTRAINT fk_rc_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
INDEX idx_rc_user (user_id)
|
|
) 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;
|
|
|
|
-- 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);
|
|
|
|
-- Admin email invites (Protocol 2.0 provisioning). A staff member invites someone
|
|
-- by email at a pre-chosen access level; the invitee accepts via a tokened link,
|
|
-- which creates their website user at that role (and optionally a linked game
|
|
-- account). Only the sha256 hash of the opaque token is stored — a DB read never
|
|
-- yields a usable invite link, same as mobile_refresh_tokens. status tracks the
|
|
-- lifecycle; accepted_user_id back-points at the created user. Single-use +
|
|
-- expiring (enforced in the model on top of expires_at).
|
|
CREATE TABLE IF NOT EXISTS user_invites (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque token
|
|
email VARCHAR(255) NOT NULL,
|
|
role ENUM('admin','editor','moderator','player') NOT NULL DEFAULT 'player',
|
|
status ENUM('pending','accepted','revoked') NOT NULL DEFAULT 'pending',
|
|
invited_by INT NULL, -- staff user who sent it
|
|
accepted_user_id INT NULL, -- the user created on accept
|
|
expires_at DATETIME NOT NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
accepted_at DATETIME NULL,
|
|
CONSTRAINT fk_user_invites_inviter FOREIGN KEY (invited_by) REFERENCES users(id) ON DELETE SET NULL,
|
|
CONSTRAINT fk_user_invites_user FOREIGN KEY (accepted_user_id) REFERENCES users(id) ON DELETE SET NULL,
|
|
INDEX idx_user_invites_email (email),
|
|
INDEX idx_user_invites_status (status, expires_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Self-service password resets. A user requests a reset by email; a tokened link
|
|
-- is emailed to every active account on that address. Opening the link and setting
|
|
-- a new password rotates the hash and revokes all sessions (web + mobile). Only the
|
|
-- sha256 hash of the opaque token is stored — a DB read never yields a usable link,
|
|
-- same as user_invites / mobile_refresh_tokens. Single-use + short-lived (1h,
|
|
-- enforced in the model on top of expires_at). Also serves SSO-only accounts (null
|
|
-- password_hash) as their "set an initial password" path.
|
|
CREATE TABLE IF NOT EXISTS password_resets (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque token
|
|
user_id INT NOT NULL, -- the account this reset targets
|
|
status ENUM('pending','used') NOT NULL DEFAULT 'pending',
|
|
requested_ip VARCHAR(64) NULL, -- who asked (audit only)
|
|
expires_at DATETIME NOT NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
used_at DATETIME NULL,
|
|
CONSTRAINT fk_password_resets_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
INDEX idx_password_resets_user (user_id),
|
|
INDEX idx_password_resets_status (status, expires_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- ── Push notifications (opt-in) ─────────────────────────────────────────────
|
|
-- One row per registered push endpoint (Android/UnifiedPush v1; FCM later). The
|
|
-- `endpoint` is the UnifiedPush distributor URL the app's ntfy topic was handed —
|
|
-- unguessable but NOT a secret (the security model treats ntfy as an untrusted
|
|
-- relay and only ever pushes content-free tickles), so it is stored in the clear,
|
|
-- unlike mobile_refresh_tokens. A device belongs to one user; re-registering the
|
|
-- same endpoint for the same user is an idempotent upsert (UNIQUE user_id+endpoint).
|
|
CREATE TABLE IF NOT EXISTS push_devices (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
user_id INT NOT NULL,
|
|
transport ENUM('unifiedpush','fcm') NOT NULL DEFAULT 'unifiedpush',
|
|
endpoint VARCHAR(512) NOT NULL, -- distributor URL (or FCM token)
|
|
platform VARCHAR(40) NULL, -- e.g. 'android' (free-form label)
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
CONSTRAINT fk_push_devices_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
UNIQUE KEY uq_push_devices_user_endpoint (user_id, endpoint),
|
|
INDEX idx_push_devices_user (user_id)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Which notification streams a user has opted into. Subscriptions are per-user
|
|
-- (applied to every device the user has registered), not per-device. stream_id is
|
|
-- an id from the notification catalog (config/notificationStreams.js), validated
|
|
-- in the model on write. One row per (user, stream); PUT replaces the whole set.
|
|
CREATE TABLE IF NOT EXISTS notification_subscriptions (
|
|
user_id INT NOT NULL,
|
|
stream_id VARCHAR(64) NOT NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (user_id, stream_id),
|
|
CONSTRAINT fk_notif_subs_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
INDEX idx_notif_subs_stream (stream_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;
|
|
|
|
-- Player-submitted moderation appeals (Phase 6c). Unlike mod_actions above, this
|
|
-- table is SERVER-owned — written and read only by the main site (the player
|
|
-- appeals controller and the admin moderation queue), never by the bot. A player
|
|
-- appeals one of their own ban/mute mod_actions; staff triage the queue, and an
|
|
-- approval optionally triggers an automatic Discord reversal (Phase 6d) whose
|
|
-- outcome is recorded in reversal_status. mod_action_id is a plain column with NO
|
|
-- hard FK to the bot-owned mod_actions table (cross-owner FK avoided on purpose,
|
|
-- matching posts.announce_job_id) — existence is validated in app code. user_id
|
|
-- is the appealing site account; discord_user_id is the snowflake the appeal is
|
|
-- for (snapshotted from mod_actions.target_user_id at submit time).
|
|
CREATE TABLE IF NOT EXISTS appeals (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
mod_action_id INT NOT NULL,
|
|
discord_user_id VARCHAR(32) NOT NULL,
|
|
action_type ENUM('ban','mute') NOT NULL,
|
|
user_id INT NULL,
|
|
status ENUM('pending','under_review','approved','denied','withdrawn') NOT NULL DEFAULT 'pending',
|
|
submitted_text TEXT NOT NULL,
|
|
staff_response TEXT NULL,
|
|
handled_by_user_id INT NULL,
|
|
handled_by_tag VARCHAR(120) NULL,
|
|
reversal_status ENUM('none','done','failed') NOT NULL DEFAULT 'none',
|
|
submitted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
resolved_at DATETIME NULL,
|
|
CONSTRAINT fk_appeal_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
|
INDEX idx_appeals_status (status, submitted_at),
|
|
INDEX idx_appeals_action (mod_action_id)
|
|
) 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). `status` is a derived rollup of the legs (see
|
|
-- announceJobs.logic.js): done when every leg is done, failed when every leg is
|
|
-- exhausted, partial in between. 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',
|
|
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
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- One row per delivery leg per job. INDEPENDENT by design: a Discord outage never
|
|
-- blocks or retries another leg, and each leg tracks its own attempt count, last
|
|
-- error and next-due time for exponential backoff.
|
|
--
|
|
-- This is a child table rather than a pair of leg-prefixed column groups on
|
|
-- announce_jobs because the leg set is DATA now, not schema: core registers
|
|
-- `discord`, module-uo registers `towncrier`, and a module for another game
|
|
-- registers its own — through modules/registries.js's registerAnnounceLeg
|
|
-- (MODULE_SYSTEM.md §1.8). A module cannot ALTER a core table, so a leg that
|
|
-- needed its own columns could never come from a module at all. `leg` is a plain
|
|
-- VARCHAR and not an ENUM for the same reason.
|
|
CREATE TABLE IF NOT EXISTS announce_job_legs (
|
|
job_id INT NOT NULL,
|
|
leg VARCHAR(64) NOT NULL,
|
|
status ENUM('pending','done','failed') NOT NULL DEFAULT 'pending',
|
|
attempts SMALLINT NOT NULL DEFAULT 0,
|
|
last_error TEXT NULL,
|
|
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,
|
|
PRIMARY KEY (job_id, leg),
|
|
CONSTRAINT fk_announce_job_legs_job FOREIGN KEY (job_id) REFERENCES announce_jobs(id) ON DELETE CASCADE,
|
|
INDEX idx_announce_leg_due (status, next_attempt_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Carry the two hardcoded leg column groups over to the child table, once. Guarded
|
|
-- on the OLD columns still existing (via information_schema, since a plain SELECT
|
|
-- of a dropped column is a parse error, not a runtime one) and on there being no
|
|
-- row already, so replaying this file on every boot is a no-op after the first.
|
|
-- Deleting this block once every deployment has booted it is safe.
|
|
SET @has_legacy_legs := (
|
|
SELECT COUNT(*) FROM information_schema.COLUMNS
|
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'announce_jobs'
|
|
AND COLUMN_NAME = 'towncrier_status'
|
|
);
|
|
SET @sql := IF(@has_legacy_legs > 0,
|
|
'INSERT IGNORE INTO announce_job_legs (job_id, leg, status, attempts, last_error, next_attempt_at)
|
|
SELECT id, ''towncrier'', towncrier_status, towncrier_attempts, towncrier_last_error, towncrier_next_attempt_at FROM announce_jobs
|
|
UNION ALL
|
|
SELECT id, ''discord'', discord_status, discord_attempts, discord_last_error, discord_next_attempt_at FROM announce_jobs',
|
|
'DO 0');
|
|
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
|
|
|
-- MariaDB's IF EXISTS makes this idempotent, so it replays cleanly like the rest
|
|
-- of the file. It is the one DROP in core's schema, and it is deliberate: leaving
|
|
-- the columns would leave `towncrier` in a core file, which Phase 3's acceptance
|
|
-- grep forbids (MODULE_SYSTEM.md §2.7).
|
|
ALTER TABLE announce_jobs
|
|
DROP COLUMN IF EXISTS towncrier_status,
|
|
DROP COLUMN IF EXISTS towncrier_attempts,
|
|
DROP COLUMN IF EXISTS towncrier_last_error,
|
|
DROP COLUMN IF EXISTS towncrier_next_attempt_at,
|
|
DROP COLUMN IF EXISTS discord_status,
|
|
DROP COLUMN IF EXISTS discord_attempts,
|
|
DROP COLUMN IF EXISTS discord_last_error,
|
|
DROP COLUMN IF EXISTS discord_next_attempt_at,
|
|
DROP INDEX IF EXISTS idx_announce_due,
|
|
DROP INDEX IF EXISTS idx_announce_due_discord;
|
|
|
|
-- Installed modules (module system, docs/website/MODULE_SYSTEM.md §2.4). One row
|
|
-- per module the operator has installed onto the modules volume, keyed by the
|
|
-- module id from its module.json — the same id that names the directory, the URL
|
|
-- segment and the client registry key.
|
|
--
|
|
-- This table is a RECORD of what happened, never the source of truth for what is
|
|
-- mounted: the loader scans the filesystem at require time, before the database is
|
|
-- reachable (MODULE_API.md §4.1), so the URL surface is a property of the volume
|
|
-- and not of a row here. What the row decides is whether a mounted module answers
|
|
-- (`disabled` ⇒ its guard 404s, §4.5) and what the admin panel shows after a
|
|
-- failure.
|
|
--
|
|
-- `state` is the §2.4 machine in one column: installed → enabled → started, with
|
|
-- disabled and startup_failed as the recoverable states. `installed` is the
|
|
-- transient state between an install writing the row and the restart that starts
|
|
-- it. On every boot each non-disabled row is reset to `enabled` and re-attempted
|
|
-- (so a fixed module recovers on restart, with no panel visit needed), then the
|
|
-- load outcome writes `started` or `startup_failed`. Only `disabled` survives a
|
|
-- boot untouched — it is the operator's decision, not an outcome.
|
|
--
|
|
-- failure_stage/failure_reason are §4.4's recorded reason, one of the seven
|
|
-- validation steps of §4.3 plus `boot`. Both are cleared by every transition that
|
|
-- is not a failure, so a stale reason can never be shown against a running module.
|
|
--
|
|
-- source/sha256 are install provenance (§2.5): the release the bundle came from and
|
|
-- the digest that was verified before unpacking. Both NULL for a directory placed
|
|
-- on the volume by hand, which stays supported.
|
|
CREATE TABLE IF NOT EXISTS installed_modules (
|
|
id VARCHAR(32) NOT NULL PRIMARY KEY, -- module.json id; names the directory
|
|
name VARCHAR(128) NOT NULL, -- human label for the admin Modules screen
|
|
version VARCHAR(32) NOT NULL, -- module.json version (semver)
|
|
state ENUM('installed','enabled','disabled','started','startup_failed')
|
|
NOT NULL DEFAULT 'installed',
|
|
failure_stage VARCHAR(32) NULL, -- manifest|core_api|mounts|extensions|schema|require|register|boot
|
|
failure_reason TEXT NULL, -- the recorded reason, shown in the admin panel
|
|
source VARCHAR(255) NULL, -- release URL the bundle came from
|
|
sha256 CHAR(64) NULL, -- verified bundle digest
|
|
installed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
started_at DATETIME NULL, -- last successful start
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
INDEX idx_installed_modules_state (state)
|
|
) 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;
|
|
|
|
-- Mobile device sessions (M9): a friendly label the app may send at login, and
|
|
-- the last time this session token was issued/used, for the "Active Devices"
|
|
-- self-service list. Both nullable and additive; existing rows get them here.
|
|
ALTER TABLE mobile_refresh_tokens ADD COLUMN IF NOT EXISTS device_name VARCHAR(100) NULL;
|
|
ALTER TABLE mobile_refresh_tokens ADD COLUMN IF NOT EXISTS last_used_at DATETIME NULL;
|
|
|
|
-- SSO trusted devices: records that the user ticked "trust this device" on the
|
|
-- Custom Tab TOTP form, so /auth/mobile/sso/exchange knows to mint the app's own
|
|
-- trust token. A boolean only — the token is returned over that app→server call
|
|
-- and never persisted here (only its sha256 lands in trusted_devices).
|
|
ALTER TABLE mobile_auth_sessions ADD COLUMN IF NOT EXISTS trust_device TINYINT(1) NOT NULL DEFAULT 0;
|