TEAMS.md Part 4. `team_activity` takes items from two sources and treats them
identically on the read path: core writes its own membership and rename items
with source='core', and a module pushes game items through
`ctx.teams.activity.push`, which stops throwing and starts working.
Core writing here too is deliberate — the rendering path is exercised by core's
own content from day one, so the feed is never empty on a deployment whose
module pushes nothing.
Three rules shape the model:
- core never composes a summary. It arrives already rendered and is stored
verbatim; core cannot phrase "gained 15,000 gold" for a game whose
vocabulary it does not know.
- visibility fails closed. An item with no stated visibility is `members`.
- a push never throws at its call site. It is called from inside a game-event
handler, and a storage problem of core's must not become the module's
control flow.
Core emits four of the five kinds §4.2 names — `core.forum.thread` has nothing
to emit it until the forum lands in phase 4 — and emits none of them for a
Team's FIRST roster: importing a 155-member guild is one Team arriving, not 155
people joining, and a join per member would bury every real event under the
import and reach the row cap on day one.
Retention ships with the feed rather than after someone notices. A nightly
worker applies an age horizon and a per-Team row cap, both settings; either
alone has a hole, since age lets one busy guild write a million rows inside the
window and a cap keeps a dead Team's feed forever.
The sync now reads member ROWS rather than keys, replacing the `memberKeys`
call rather than adding to it: the feed needs each changing member's display
name and prior `is_leader`, and the upsert is about to overwrite both.
Co-Authored-By: Claude <noreply@anthropic.com>
1152 lines
67 KiB
SQL
1152 lines
67 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;
|
|
|
|
-- ── Teams (docs/website/TEAMS.md Part 2, phase 2) ─────────────────────────────
|
|
--
|
|
-- A Team is a core platform entity POPULATED by a module and owned by core. The
|
|
-- module answers "what teams exist and who is in them" through the team provider
|
|
-- (MODULE_API.md — registerTeamProvider); core stores the answer, gates it and
|
|
-- displays it. Every table below is core-internal (TEAMS.md §10.3): a module must
|
|
-- never read or write one, even though a module is what fills them.
|
|
--
|
|
-- Note the tables carry no `<moduleId>_` prefix, correctly — MODULE_API.md §2.6's
|
|
-- prefix rule binds modules, and these are core's.
|
|
|
|
-- The Team itself. `external_id` is the module's own stable identity for it
|
|
-- (module-uo sends the persistent ServUO Guild.Id) and is opaque to core.
|
|
--
|
|
-- `name` is IMMUTABLE for the life of the row (§2.2): a rename archives this row
|
|
-- with archived_reason='renamed' and creates a new one, so the old Team keeps its
|
|
-- activity, its grants and its forum as a read-only record. What staff can change
|
|
-- is display_name_override, which changes what is RENDERED and never what the row
|
|
-- IS — identity and display are different things and only identity is frozen.
|
|
CREATE TABLE IF NOT EXISTS teams (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
module_id VARCHAR(32) NOT NULL, -- which module is authoritative
|
|
external_id VARCHAR(191) NOT NULL, -- opaque to core
|
|
name VARCHAR(160) NOT NULL,
|
|
abbr VARCHAR(32) NULL,
|
|
slug VARCHAR(191) NOT NULL, -- derived from name, unique among ACTIVE teams
|
|
status ENUM('active','archived') NOT NULL DEFAULT 'active',
|
|
meta JSON NULL, -- module-supplied, opaque (alliance, crest, …)
|
|
member_count INT NOT NULL DEFAULT 0, -- denormalised from team_members
|
|
linked_count INT NOT NULL DEFAULT 0, -- members whose user_id is not null
|
|
online_count INT NOT NULL DEFAULT 0, -- last known; refreshed by sync
|
|
-- Public suppression, independent of status. A hidden Team still works
|
|
-- completely for its own members; it is absent from public surfaces (§2.8).
|
|
hidden TINYINT(1) NOT NULL DEFAULT 0,
|
|
hidden_reason ENUM('reserved_name','staff') NULL,
|
|
hidden_term VARCHAR(64) NULL, -- which reserved term matched, for the review queue
|
|
-- Set once staff have made an explicit decision about the name. Re-screening
|
|
-- runs on every sync, and this is what stops it re-hiding a Team a human has
|
|
-- already allowed — without it the override would be undone every 15 minutes.
|
|
name_reviewed_at DATETIME NULL,
|
|
-- PER-TEAM freshness, which team_sync_state cannot express: it holds one row per
|
|
-- MODULE, and §2.4 gate 3 leaves one Team's roster untouched while the others
|
|
-- sync normally. Without a per-Team stamp that Team's page would claim the
|
|
-- module's last success as its own, which is precisely the staleness the rule
|
|
-- exists to surface. Bumped only when a roster is actually applied.
|
|
roster_synced_at DATETIME NULL,
|
|
-- §2.4 gate 4's per-Team quarantine, the twin of team_sync_state.pending_empty_
|
|
-- since: an authoritative-but-empty ROSTER for a Team that currently has members
|
|
-- is remembered here and applied only if the next answer agrees.
|
|
members_empty_since DATETIME NULL,
|
|
-- Staff may change what is DISPLAYED without touching identity (§2.8.3).
|
|
display_name_override VARCHAR(160) NULL,
|
|
-- The successor row written at archive time when this Team was renamed, so the
|
|
-- old slug can still resolve and explain itself rather than 404 (§2.2).
|
|
succeeded_by INT NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
archived_at DATETIME NULL,
|
|
archived_reason VARCHAR(64) NULL, -- 'disbanded' | 'renamed' | 'staff'
|
|
-- A generated column is how "unique among ACTIVE rows only" is expressed without
|
|
-- a partial index (MariaDB has none): NULL never collides in a UNIQUE key, so
|
|
-- any number of archived rows may share an external_id.
|
|
active_key VARCHAR(191) AS (IF(status='active', external_id, NULL)) STORED,
|
|
active_slug VARCHAR(191) AS (IF(status='active', slug, NULL)) STORED,
|
|
UNIQUE KEY uq_teams_active (module_id, active_key),
|
|
UNIQUE KEY uq_teams_active_slug (active_slug),
|
|
INDEX idx_teams_status (status),
|
|
INDEX idx_teams_slug (slug),
|
|
INDEX idx_teams_review (hidden, hidden_reason),
|
|
-- Self-referential and deliberately SET NULL: a successor may itself be archived
|
|
-- and eventually pruned, and losing the pointer must not take the old row with it.
|
|
CONSTRAINT fk_teams_succeeded_by FOREIGN KEY (succeeded_by) REFERENCES teams(id) ON DELETE SET NULL
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- The membership PROJECTION. Module-authoritative; core only mirrors it, and the
|
|
-- sync is the ONLY writer (§2.5 path 1). Rows are soft-departed rather than
|
|
-- deleted so history and rejoin detection survive, and so the activity feed can
|
|
-- still name a departed member.
|
|
--
|
|
-- user_id is resolved BY THE MODULE (it owns the game↔site link table); core never
|
|
-- resolves it, because doing so would be core reading a module's table by name.
|
|
CREATE TABLE IF NOT EXISTS team_members (
|
|
team_id INT NOT NULL,
|
|
member_key VARCHAR(191) NOT NULL, -- module's stable member id (UO: character serial)
|
|
display_name VARCHAR(160) NULL, -- in-game name
|
|
user_id INT NULL, -- resolved by the MODULE; NULL = unlinked
|
|
is_leader TINYINT(1) NOT NULL DEFAULT 0,
|
|
rank_label VARCHAR(48) NULL, -- module vocabulary, opaque to core
|
|
online TINYINT(1) NOT NULL DEFAULT 0,
|
|
status ENUM('active','departed') NOT NULL DEFAULT 'active',
|
|
first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
departed_at DATETIME NULL,
|
|
PRIMARY KEY (team_id, member_key),
|
|
-- SET NULL, not CASCADE (§2.10): deleting a site account does not remove the
|
|
-- character from the guild — only the link to the site goes.
|
|
CONSTRAINT fk_team_members_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
|
CONSTRAINT fk_team_members_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
|
INDEX idx_team_members_user (user_id),
|
|
INDEX idx_team_members_status (team_id, status)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Freshness of the module's answer. One row per module. THE table invariant 1
|
|
-- ("module unavailability is staleness, never emptiness") is enforced against.
|
|
CREATE TABLE IF NOT EXISTS team_sync_state (
|
|
module_id VARCHAR(32) NOT NULL PRIMARY KEY,
|
|
last_attempt_at DATETIME NULL,
|
|
last_success_at DATETIME NULL,
|
|
consecutive_failures INT NOT NULL DEFAULT 0,
|
|
last_error VARCHAR(500) NULL,
|
|
-- The quarantine for §2.4's mass-deletion guard: an authoritative-but-empty
|
|
-- answer is remembered here and applied only if the NEXT one agrees.
|
|
pending_empty_since DATETIME NULL,
|
|
INDEX idx_team_sync_success (last_success_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Staff leadership overrides (§2.5.1), applied ON TOP of the synced value at read
|
|
-- time. The projection is never mutated: the sync keeps writing what the game
|
|
-- says and this keeps saying what staff decided, which is the whole point — an
|
|
-- override the sync clobbered every 15 minutes would be useless.
|
|
CREATE TABLE IF NOT EXISTS team_leader_overrides (
|
|
team_id INT NOT NULL,
|
|
member_key VARCHAR(191) NOT NULL,
|
|
effect ENUM('grant','deny') NOT NULL,
|
|
actor_user_id INT NULL,
|
|
actor_username VARCHAR(32) NULL, -- snapshot, so the record survives the account
|
|
reason VARCHAR(255) NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (team_id, member_key),
|
|
CONSTRAINT fk_tlo_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
|
CONSTRAINT fk_tlo_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Forum access grants (§2.5 path 3) — an append-only grant/revoke ledger that is
|
|
-- ALSO the current state. An active grant is one with revoked_at IS NULL, and a
|
|
-- generated column is how "one active grant per (team,user)" is expressed without
|
|
-- a partial index (MariaDB has none): NULL never collides in a UNIQUE key.
|
|
--
|
|
-- The table lands here, in the phase that builds the resolver, so forumAccess() is
|
|
-- written once and its non-contamination tests are real. The grant/revoke FLOW,
|
|
-- the per-Team cap and the leader UI are phase 4's; nothing writes this table yet.
|
|
--
|
|
-- user_id is NULLABLE and SET NULL, which contradicts the sketch in TEAMS.md §2.5
|
|
-- and follows §2.10, which settled it deliberately: CASCADE would delete the audit
|
|
-- trail of who granted whom, which is exactly what an audit exists to survive. The
|
|
-- username snapshots keep the record readable after the account is gone.
|
|
--
|
|
-- THE TWO CANNOT BOTH BE HAD AS §2.5 WROTE THEM, and this is why the marker below
|
|
-- is a bare flag rather than §2.5's `active_user AS (IF(revoked_at IS NULL,
|
|
-- user_id, NULL))`. MariaDB refuses `ON DELETE SET NULL` on a foreign key whose
|
|
-- column is a base column of a STORED generated column (ER_GENERATED_COLUMN_
|
|
-- FUNCTION_IS_NOT_ALLOWED, 1901) — so §2.5's generated column forces §2.10's
|
|
-- CASCADE, and the audit trail with it. Deriving the marker from `revoked_at`
|
|
-- ALONE and putting user_id in the KEY instead gives identical semantics: at most
|
|
-- one active row per (team_id, user_id), unlimited revoked rows, and user_id free
|
|
-- to be a SET NULL foreign key. Verified against MariaDB 11 both ways.
|
|
CREATE TABLE IF NOT EXISTS team_forum_grants (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
team_id INT NOT NULL,
|
|
user_id INT NULL,
|
|
username VARCHAR(32) NULL, -- snapshot of the grantee at grant time
|
|
granted_by INT NULL, -- NULL for a system grant, or a deleted actor
|
|
granted_username VARCHAR(32) NULL, -- snapshot of the actor
|
|
granted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
reason VARCHAR(255) NULL,
|
|
revoked_by INT NULL,
|
|
revoked_username VARCHAR(32) NULL,
|
|
revoked_at DATETIME NULL,
|
|
revoke_reason VARCHAR(255) NULL,
|
|
active_marker TINYINT(1) AS (IF(revoked_at IS NULL, 1, NULL)) STORED,
|
|
UNIQUE KEY uq_team_forum_grant_active (team_id, user_id, active_marker),
|
|
CONSTRAINT fk_tfg_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
|
CONSTRAINT fk_tfg_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
|
CONSTRAINT fk_tfg_granted_by FOREIGN KEY (granted_by) REFERENCES users(id) ON DELETE SET NULL,
|
|
CONSTRAINT fk_tfg_revoked_by FOREIGN KEY (revoked_by) REFERENCES users(id) ON DELETE SET NULL,
|
|
INDEX idx_tfg_user (user_id)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- The §2.9 approval queue. A MODERATOR performing one of the three actions that
|
|
-- publish untrusted game-sourced strings creates a pending row here; an ADMIN
|
|
-- performing one applies it immediately. Rows are kept after a decision — "a
|
|
-- moderator asked to publish this name and an admin refused" is the record worth
|
|
-- having.
|
|
--
|
|
-- `action` + `payload` means a fourth gated action is an enum value rather than a
|
|
-- schema change. That is room to extend, not an invitation: nothing else is gated
|
|
-- today, and nothing should be without asking §2.9's question first.
|
|
CREATE TABLE IF NOT EXISTS team_moderation_requests (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
team_id INT NOT NULL,
|
|
action ENUM('unhide','display_name_override','clear_display_name_override') NOT NULL,
|
|
payload JSON NULL, -- e.g. { "displayName": "…" }
|
|
reason VARCHAR(255) NULL,
|
|
requested_by INT NULL,
|
|
requested_username VARCHAR(32) NULL, -- snapshot (§2.10)
|
|
requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
status ENUM('pending','approved','rejected','withdrawn') NOT NULL DEFAULT 'pending',
|
|
decided_by INT NULL,
|
|
decided_username VARCHAR(32) NULL,
|
|
decided_at DATETIME NULL,
|
|
decision_note VARCHAR(255) NULL,
|
|
CONSTRAINT fk_tmr_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
|
CONSTRAINT fk_tmr_requested_by FOREIGN KEY (requested_by) REFERENCES users(id) ON DELETE SET NULL,
|
|
CONSTRAINT fk_tmr_decided_by FOREIGN KEY (decided_by) REFERENCES users(id) ON DELETE SET NULL,
|
|
INDEX idx_tmr_queue (status, requested_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- The per-Team activity feed (TEAMS.md §4.2, phase 3). Two writers, one table:
|
|
-- core writes its own membership and rename items with source='core', and a module
|
|
-- pushes game items through ctx.teams.activity.push with source=<moduleId>. That
|
|
-- core writes here too is deliberate — the rendering path is exercised by core's
|
|
-- own content from day one, so the feed is never empty on a deployment whose
|
|
-- module pushes nothing.
|
|
--
|
|
-- `summary` is ALREADY-RENDERED text and core never composes one (§4.1). Core
|
|
-- cannot phrase "gained 15,000 gold" for a game whose vocabulary it does not know,
|
|
-- and a core that templated it would have re-acquired exactly the game semantics
|
|
-- the module system exists to remove. `kind` and `payload` are likewise opaque:
|
|
-- core stores and filters them, and only the module's `team.overview` slot renders
|
|
-- anything richer than the text.
|
|
CREATE TABLE IF NOT EXISTS team_activity (
|
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
team_id INT NOT NULL,
|
|
source VARCHAR(32) NOT NULL, -- 'core' or a module id
|
|
kind VARCHAR(64) NOT NULL, -- namespaced <source>.<name>, opaque to core
|
|
summary VARCHAR(255) NOT NULL, -- module-rendered; core never composes one
|
|
-- Defaults to 'members' — fail closed. The module CHOOSES visibility per item;
|
|
-- core ENFORCES it on the read path. Same shape as a module owning the
|
|
-- public-safety filter for its push streams (MODULE_API.md §2.4).
|
|
visibility ENUM('public','members') NOT NULL DEFAULT 'members',
|
|
actor_member_key VARCHAR(191) NULL,
|
|
actor_user_id INT NULL,
|
|
payload JSON NULL, -- opaque; rendered only by the module's slot
|
|
occurred_at DATETIME NOT NULL, -- when it happened in the game, not when it arrived
|
|
-- Optional idempotence key. INSERT IGNORE against this unique index is the same
|
|
-- trick shard_events already uses, and it is what makes a sidecar reconnect
|
|
-- backfill safe: replaying a window of events re-posts nothing.
|
|
dedupe_key CHAR(40) NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
CONSTRAINT fk_team_activity_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
|
-- Actor is SET NULL, not CASCADE (§2.10): deleting an account must not delete the
|
|
-- Team's history of what happened, only the attribution.
|
|
CONSTRAINT fk_team_activity_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL,
|
|
UNIQUE KEY uq_team_activity_dedupe (team_id, dedupe_key),
|
|
INDEX idx_team_activity_feed (team_id, occurred_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Migrations for databases created before the wiki upgrade. Each statement uses
|
|
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
|
|
-- these columns from the CREATE TABLE above; existing installs get them here.
|
|
-- (The category foreign key is only added on fresh installs; on upgraded databases
|
|
-- referential integrity for category_id is enforced in application code.)
|
|
-- Opt-in TOTP two-factor columns for databases created before login hardening.
|
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret VARCHAR(64) NULL;
|
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled TINYINT(1) NOT NULL DEFAULT 0;
|
|
-- Session-revocation cutoff for databases created before token revocation landed.
|
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS tokens_valid_after DATETIME NULL;
|
|
-- Moderation dashboard (Phase 6): add the 'moderator' role to databases created
|
|
-- before it. MODIFY has no IF NOT EXISTS form, but re-declaring the same ENUM is
|
|
-- an idempotent no-op, so it is safe to run on every boot.
|
|
-- Player accounts: widen the enum again to include 'player' (self-service public
|
|
-- accounts). Same idempotent-MODIFY pattern.
|
|
ALTER TABLE users MODIFY COLUMN role ENUM('admin','editor','moderator','player') NOT NULL DEFAULT 'admin';
|
|
-- Player accounts: make password_hash nullable (SSO-only players), pin the
|
|
-- username collation (case-insensitive uniqueness backstop), and add the player
|
|
-- columns to databases created before this. MODIFY is an idempotent no-op when
|
|
-- the column already matches; ADD COLUMN IF NOT EXISTS is safe to re-run.
|
|
ALTER TABLE users MODIFY COLUMN password_hash VARCHAR(72) NULL;
|
|
ALTER TABLE users MODIFY COLUMN username VARCHAR(32) NOT NULL COLLATE utf8mb4_general_ci;
|
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255) NULL;
|
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified TINYINT(1) NOT NULL DEFAULT 0;
|
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS status ENUM('active','pending','disabled','banned') NOT NULL DEFAULT 'active';
|
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_ip VARCHAR(45) NULL;
|
|
-- Player self-registration mode: disabled | password | sso | both. Default off,
|
|
-- so the system behaves exactly as today until an admin opts in.
|
|
INSERT IGNORE INTO settings (`key`, value) VALUES ('player_registration', 'disabled');
|
|
|
|
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL;
|
|
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL;
|
|
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published TINYINT(1) NOT NULL DEFAULT 1;
|
|
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS sort_order INT NOT NULL DEFAULT 0;
|
|
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published_at DATETIME NULL;
|
|
ALTER TABLE wiki_pages ADD FULLTEXT INDEX IF NOT EXISTS idx_wiki_search (title, body);
|
|
|
|
-- News → town-crier + Discord announcement pipeline. Add the announcement-state
|
|
-- columns to posts on databases created before the pipeline landed. announced_at
|
|
-- is stamped once both legs deliver; announce_job_id points at the announce_jobs
|
|
-- row for the post's admin status panel. Kept as a plain column (not a hard FK)
|
|
-- so the idempotent boot migration never trips over a re-added constraint — the
|
|
-- pointer is resolved in application code and the CASCADE on announce_jobs.post_id
|
|
-- already keeps the two tables consistent.
|
|
ALTER TABLE posts ADD COLUMN IF NOT EXISTS announced_at DATETIME NULL;
|
|
ALTER TABLE posts ADD COLUMN IF NOT EXISTS announce_job_id INT NULL;
|
|
|
|
-- 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;
|