Add a full password-reset flow — the prerequisite for the Android app (docs/android/PLAN.md §8.2), which hands off to the website for reset rather than shipping a native screen. Backend: - password_resets table: stores only the sha256 hash of an opaque 32-byte token (mirrors user_invites / mobile_refresh_tokens), single-use, ~1h TTL. - model/passwordResets + users.getActiveByEmail (email is non-unique, so a request can match several accounts, each emailed its own link). - mailer.sendPasswordReset (fails soft when email is unconfigured). - Endpoints: POST /auth/password/forgot (always a generic 200 — no account enumeration), GET|POST /auth/password/reset/:token. Confirming rotates the hash and revokes every session (web cutoff + mobile refresh tokens); it does not auto-login, so a 2FA account still passes TOTP next sign-in. Also serves SSO-only accounts (null hash) as their set-initial-password path. - Dedicated request/confirm rate limiters. Swagger regenerated. Web: - ForgotPassword + ResetPassword pages, routes /account/forgot and /account/reset/:token, and a "Forgot your password?" link on the login page. Tests: test/passwordResets.test.js (5). All server tests pass; client builds; end-to-end smoketest against MariaDB passes (no-enumeration, single-use, hash rotation, session revoke, login with the new password). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
922 lines
51 KiB
SQL
922 lines
51 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.
|
|
|
|
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;
|
|
|
|
-- Current champion-spawn board, upserted on champ.update and removed on
|
|
-- champ.remove. Mirrors the sidecar's /champs projection into our own store so
|
|
-- the public Champions page (and its live deltas) survive a shard outage, the
|
|
-- same way shard_online / shard_houses do. Three families share one table, told
|
|
-- apart by `category` (champion | mini | sea); category-specific fields (level,
|
|
-- kills, boss, restartAt, hits, …) live in the JSON `payload` so the schema does
|
|
-- not have to model every variant.
|
|
CREATE TABLE IF NOT EXISTS shard_champs (
|
|
serial VARCHAR(20) NOT NULL PRIMARY KEY, -- controller/mobile serial (opaque hex)
|
|
category VARCHAR(16) NULL, -- champion | mini | sea
|
|
type VARCHAR(80) NULL,
|
|
name VARCHAR(120) NULL,
|
|
status VARCHAR(16) NULL, -- active | cooldown | dormant
|
|
active TINYINT(1) NOT NULL DEFAULT 0,
|
|
map VARCHAR(40) NULL,
|
|
x INT NULL,
|
|
y INT NULL,
|
|
z INT NULL,
|
|
boss_up TINYINT(1) NOT NULL DEFAULT 0,
|
|
payload JSON NOT NULL, -- the full champ.update object
|
|
t BIGINT NULL, -- event time, epoch ms
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
INDEX idx_shard_champs_category (category)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Current open help-page (support ticket) queue, upserted on page.new/page.updated
|
|
-- and removed on page.closed. Snapshotted authoritatively from the sidecar's
|
|
-- GET /pages on every (re)connect. page_id is the sender's serial (one page per
|
|
-- player). Staff-only data — served on the admin channel, never public.
|
|
CREATE TABLE IF NOT EXISTS shard_pages (
|
|
page_id VARCHAR(20) NOT NULL PRIMARY KEY, -- sender serial (one page per player)
|
|
type VARCHAR(40) NULL, -- Bug | Stuck | Account | Question | ...
|
|
sender_name VARCHAR(120) NULL,
|
|
sender_acct VARCHAR(120) NULL,
|
|
web_id INT NULL, -- linked website user id, if any
|
|
message TEXT NULL,
|
|
map VARCHAR(40) NULL,
|
|
x INT NULL,
|
|
y INT NULL,
|
|
z INT NULL,
|
|
sent_ms BIGINT NULL, -- when the page was opened, epoch ms
|
|
handled TINYINT(1) NOT NULL DEFAULT 0, -- a staffer claimed it in game
|
|
handler VARCHAR(120) NULL,
|
|
payload JSON NOT NULL, -- the full page.new/updated object
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
INDEX idx_shard_pages_handled (handled)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Guild roster board (Protocol 2.0). Upserted on guild.update (a full-state
|
|
-- snapshot emitted only on change) and removed on guild.remove. The leader is an
|
|
-- actor object flattened into leader_* columns; the full event is kept in
|
|
-- `payload` for anything not hoisted. Mirrors the sidecar's GET /guilds
|
|
-- projection into our store so the public Guilds page survives a shard outage.
|
|
CREATE TABLE IF NOT EXISTS shard_guilds (
|
|
id INT NOT NULL PRIMARY KEY, -- in-game guild id
|
|
name VARCHAR(120) NULL,
|
|
abbr VARCHAR(24) NULL,
|
|
members INT NULL,
|
|
online INT NULL,
|
|
alliance VARCHAR(120) NULL,
|
|
leader_serial VARCHAR(20) NULL,
|
|
leader_name VARCHAR(120) NULL,
|
|
leader_acct VARCHAR(120) NULL,
|
|
leader_web_id INT NULL,
|
|
payload JSON NOT NULL, -- the full guild.update object
|
|
t BIGINT NULL, -- event time, epoch ms
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
INDEX idx_shard_guilds_name (name)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Town-governor board (Protocol 2.0, City Loyalty). One row per city, upserted on
|
|
-- city.update (full-state, emitted only on change; there is no remove event since
|
|
-- the set of cities is fixed). governor / governorElect are actor objects
|
|
-- flattened into columns; the full event is kept in `payload`. Empty on shards
|
|
-- that do not run the City Loyalty system.
|
|
CREATE TABLE IF NOT EXISTS shard_governors (
|
|
city VARCHAR(40) NOT NULL PRIMARY KEY, -- Britain | Moonglow | ...
|
|
governor_serial VARCHAR(20) NULL,
|
|
governor_name VARCHAR(120) NULL,
|
|
governor_acct VARCHAR(120) NULL,
|
|
governor_web_id INT NULL,
|
|
elect_serial VARCHAR(20) NULL,
|
|
elect_name VARCHAR(120) NULL,
|
|
elect_acct VARCHAR(120) NULL,
|
|
election_phase VARCHAR(16) NULL, -- none | nominate | vote | pending
|
|
candidates INT NULL,
|
|
auto_pick_at DATETIME NULL,
|
|
payload JSON NOT NULL, -- the full city.update object
|
|
t BIGINT NULL, -- event time, epoch ms
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Governor term history — the "who governed when" ledger behind the Governors
|
|
-- board. Captured from day one (history cannot be backfilled) on every observed
|
|
-- governor CHANGE: the open term (ended_at IS NULL) is closed and a new one
|
|
-- opened. `votes` stays NULL — the city.update feed exposes only the candidate
|
|
-- COUNT and election phase, not per-candidate tallies, so we record who governed
|
|
-- and when (reliable) and never fabricate vote numbers. The look-back UI ("who
|
|
-- were all the governors of Britain?") reads this table.
|
|
CREATE TABLE IF NOT EXISTS shard_governor_terms (
|
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
city VARCHAR(40) NOT NULL,
|
|
governor_serial VARCHAR(20) NULL,
|
|
governor_name VARCHAR(120) NULL,
|
|
governor_acct VARCHAR(120) NULL,
|
|
governor_web_id INT NULL,
|
|
started_at BIGINT NOT NULL, -- term start, epoch ms
|
|
ended_at BIGINT NULL, -- term end epoch ms (NULL = current)
|
|
votes INT NULL, -- not in the feed (reserved)
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
INDEX idx_shard_gov_terms_city (city, started_at),
|
|
INDEX idx_shard_gov_terms_open (city, ended_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Online-population snapshot (Protocol 2.0). Singleton row (id = 1) holding the
|
|
-- latest presence.online aggregate: total count plus per-facet and per-region
|
|
-- breakdown maps (stored as JSON). Distinct from shard_online (per-player) — this
|
|
-- is the rolled-up headcount the public "Players Online" widget renders. The
|
|
-- time series, if ever needed, is available from GET /history?kind=presence.online.
|
|
CREATE TABLE IF NOT EXISTS shard_presence (
|
|
id INT PRIMARY KEY DEFAULT 1,
|
|
count INT NOT NULL DEFAULT 0,
|
|
by_facet JSON NULL, -- { "Felucca": 12, "Trammel": 30 }
|
|
by_region JSON NULL, -- { "Britain": 18, "Wilderness": 9 }
|
|
t BIGINT NULL, -- snapshot time, epoch ms
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
CONSTRAINT chk_shard_presence_singleton CHECK (id = 1)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- 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;
|
|
|
|
-- 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');
|
|
-- Game-account signup (Protocol 2.0 hybrid mode): whether a signed-in website user
|
|
-- may provision a linked game account from the site. Default off; the shard's own
|
|
-- signup mode still has the final say (a 'game'-mode shard refuses regardless).
|
|
INSERT IGNORE INTO settings (`key`, value) VALUES ('game_account_signup', '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;
|
|
|
|
-- House registry (Protocol 2.0). The house.update full-state feed carries richer
|
|
-- fields than the house.decay transition feed shard_houses was built for. Rather
|
|
-- than a second table for one entity, extend shard_houses: house.update writes the
|
|
-- registry columns below (owner display name, co-owner/friend counts, placement
|
|
-- price, decay level name) while house.decay keeps owning `stage`/`is_idoc`. Each
|
|
-- upsert only touches its own columns, so the two feeds never clobber each other.
|
|
-- `price` is the placement value, NOT a "for sale" flag (stock ServUO has none).
|
|
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS owner_name VARCHAR(120) NULL;
|
|
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS co_owners INT NULL;
|
|
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS friends INT NULL;
|
|
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS price BIGINT NULL;
|
|
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS decay VARCHAR(24) NULL;
|
|
-- Distinguishes a full registry row (seen via house.update) from a decay-only row,
|
|
-- so the public Houses browser can list registered houses without pulling in rows
|
|
-- we only ever saw an IDOC transition for.
|
|
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS in_registry TINYINT(1) NOT NULL DEFAULT 0;
|