Protocol 3.0 order 3 (Part C), first of two website PRs. This half is the data
pipeline only — parsers, the build/import CLI, and the tables. No routes and no
client, so nothing is user-visible yet; the API and pages follow in PR 2.
Part C is website-only: no plugin, no sidecar, no new event kinds, no wire
change.
## Parsing
`src/utils/spawnAtlasParse.js` is pure and fs-free so CI covers it with no
ServUO tree. Zero new dependencies — `Regions.xml` genuinely nests, so it gets a
small hand-rolled subset tokenizer rather than a new XML package. The 10.5 MB of
`Spawns/*.xml` never touches it: those records are flat and get a streaming
regex sweep instead.
The high-value transform is point-in-rect placement — highest region priority
wins, ties break to the smaller rect, then a nearest-landmark fallback within
200 tiles, else "Wilderness". That is what turns "lizardman at 5411,1234" into
"Despise, Felucca", and it resolves 83.2% of points (5,369 of 6,455).
Three things the real data forced, none of which were in the design:
- **Only 6 facets, not 13.** `Eodon.xml`, `GravewaterLake.xml` and the other
named-area files carry TerMur/Trammel points, so the facet comes from each
record's own `<Map>` and the artifact shards 6 ways.
- **Facet names disagree across sources.** `Data/Locations/*.xml` spells them
`Ter Mur` and `Tokuno Islands`; `<Map>` and `<Facet name>` say `TerMur` and
`Tokuno`. Unreconciled this is silent — the landmark fallback simply never
fires on those facets and every unregioned spawn there reads "Wilderness".
- **Spawn type tokens carry XmlSpawner directives**: `Fairy,{RND,4,8}`,
`alchemist/z/-50`, `Agralem/Name/Agralem`. Taken literally these invent
creatures that do not exist AND split real ones in two, since `Fairy` and
`Fairy,{RND,4,8}` slug apart. 71 of 845 entries were affected; stripping at
the first `/` or `,` leaves 800 clean ones.
## Artifact
`npm run atlas:build -- --servuo <path>` writes `db/data/spawnAtlas.*.json`:
6 facet shards + a compact index + a small indented `meta`. 1.41 MB committed,
down from 4.40 MB by dropping `facet` per record, omitting defaulted fields, and
tuple-encoding the ~24,000 type entries. `encodePoint()` and the importer's
`readPoint()` are exact inverses and are round-tripped in tests.
Display spelling is chosen deterministically (most common, ties to the
capitalised form) because the spawn files are inconsistent about case and the
name would otherwise depend on file read order — a spurious diff on every
unrelated rebuild.
## Import
`npm run atlas:import` needs no ServUO tree, which is the whole reason build and
import are separate: the container has the artifact but not the tree. It
reloads all six tables in one transaction (DELETE, not TRUNCATE, which is DDL
and would implicitly commit), so a failed import leaves the previous atlas
intact.
## No artwork, by design
The repo ships no creature art and no extraction tooling. Sprites live in the
operator's own client `.mul`/`.uop` files and are theirs, not ours to
redistribute. `shard_spawn_creatures.art` is nullable and NULL on every fresh
import; an operator who wants art extracts it themselves, drops it under
`server/uploads/atlas/` (already gitignored) and maps slugs in a gitignored
`spawnAtlas.art.json`. Text-only is the normal, fully supported state.
## Verification
- **544 server tests pass**, 57 new across `spawnAtlas.parse.test.js` (the
`:OBJ=` split, directive stripping, nested-region priority inheritance,
half-open rects, the facet reconciliation, tokenizer edge cases) and
`spawnAtlas.build.test.js` (aggregation, deterministic naming, and the
encode/decode round trip).
- Built and imported for real against the local MariaDB and the ServUO tree at
`C:\Users\colby\Desktop\ServUO`: 6,455 points, 800 creatures, 23,927
point/type rows, 387 regions, 558 landmarks, 25 champion altars.
- "Where does a lizardman spawn?" answers Shrines / Isamu-Jima / Yew across
Felucca, Trammel and Tokuno.
No routes changed, so the OpenAPI spec and route manifest are untouched.
---
- [x] AI-assisted: written with **Claude Code** (Claude Opus 5), reviewed before opening.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
1230 lines
70 KiB
SQL
1230 lines
70 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)
|
|
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;
|
|
|
|
-- ── 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 = sha256(kind + t + stable-json(payload)) truncated to 40 hex chars
|
|
-- (fits CHAR(40)); 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;
|
|
|
|
-- Per-feature visibility for every shard-derived surface (Protocol 3.0). One row
|
|
-- per feature; an absent row means "use the compiled default", and the compiled
|
|
-- defaults reproduce the behavior that shipped before v3 — so an empty table is
|
|
-- a no-op. See utils/shardVisibility.js for the catalog and the ladder, and
|
|
-- docs/link/v3.md §3 for the contract.
|
|
--
|
|
-- audience the minimum rung on anonymous < logged_in < player < staff < admin
|
|
-- stream whether this feature's kinds fan out over SSE at all (the market
|
|
-- index ships with this off: no page needs a live firehose of
|
|
-- whole vendor inventories)
|
|
-- field_rules {"<field>": "<rung>"} for SENSITIVE fields only. `acct` and
|
|
-- `webId` are admin-only always and are rejected here — they are
|
|
-- not in-game visible and are deliberately not configurable.
|
|
CREATE TABLE IF NOT EXISTS shard_feature_visibility (
|
|
feature VARCHAR(48) NOT NULL PRIMARY KEY,
|
|
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
|
audience VARCHAR(20) NOT NULL DEFAULT 'anonymous',
|
|
stream TINYINT(1) NOT NULL DEFAULT 1,
|
|
field_rules JSON NULL,
|
|
updated_by INT NULL,
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|
) 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;
|
|
|
|
-- ── 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). 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;
|
|
|
|
-- ── Spawn atlas (Protocol 3.0 Part C) ───────────────────────────────────────
|
|
-- Static shard CONTENT, not live shard state: what spawns where, which regions
|
|
-- and landmarks exist, and which champion altars are configured. Nothing here
|
|
-- comes from the sidecar — it is imported from a committed artifact built off a
|
|
-- ServUO tree by `npm run atlas:build` (see docs/website/SPAWN_ATLAS.md), so
|
|
-- these tables stay populated whether the shard is up or not.
|
|
--
|
|
-- Every table is import-owned: `npm run atlas:import` TRUNCATEs and reloads them
|
|
-- in one transaction. Nothing else may write here, and nothing else may hold a
|
|
-- foreign key to them. No FKs at all, consistent with every other shard_* table.
|
|
|
|
-- One row per spawnable type, aggregated across the world. `total` is the sum of
|
|
-- each type's own MX across every point that spawns it (how many exist at once);
|
|
-- `facets` is a per-facet point count, so the facet filter and "where does this
|
|
-- live" both answer without touching shard_spawn_points.
|
|
CREATE TABLE IF NOT EXISTS shard_spawn_creatures (
|
|
slug VARCHAR(120) NOT NULL PRIMARY KEY, -- slugified class name; the /atlas/:slug key
|
|
name VARCHAR(120) NOT NULL, -- display spelling chosen by the build
|
|
total INT NOT NULL DEFAULT 0,
|
|
points INT NOT NULL DEFAULT 0,
|
|
facets JSON NULL, -- { "Felucca": 171, "Trammel": 160, ... }
|
|
-- Operator-supplied artwork, always NULL on a fresh import. The repo ships no
|
|
-- creature art: sprites live in the operator's own client .mul/.uop files and
|
|
-- are theirs to extract and place under uploads/atlas/. The UI renders without
|
|
-- art when this is NULL, which is the normal case.
|
|
art VARCHAR(255) NULL,
|
|
-- Plain INDEX, deliberately NOT FULLTEXT: ~800 rows makes a LIKE scan free,
|
|
-- and FULLTEXT's min-token-length would break searches for names like "orc".
|
|
INDEX idx_shard_spawn_creatures_name (name)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- One row per spawner. `region`/`landmark` are the resolved place name — the
|
|
-- point-in-rect transform that turns "5411,1234" into "Despise" — and `label` is
|
|
-- the resolved display string (region, else landmark, else 'Wilderness').
|
|
CREATE TABLE IF NOT EXISTS shard_spawn_points (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
facet VARCHAR(40) NOT NULL,
|
|
name VARCHAR(120) NULL, -- the ServUO spawner's own name
|
|
x INT NOT NULL,
|
|
y INT NOT NULL,
|
|
width INT NOT NULL DEFAULT 0,
|
|
height INT NOT NULL DEFAULT 0,
|
|
spawn_range INT NOT NULL DEFAULT 0, -- `range` is reserved in MariaDB
|
|
max_count INT NOT NULL DEFAULT 0,
|
|
min_delay INT NOT NULL DEFAULT 0,
|
|
max_delay INT NOT NULL DEFAULT 0,
|
|
tod_start INT NOT NULL DEFAULT 0, -- meaningless unless tod_mode <> 0
|
|
tod_end INT NOT NULL DEFAULT 0,
|
|
tod_mode INT NOT NULL DEFAULT 0,
|
|
region VARCHAR(120) NULL,
|
|
landmark VARCHAR(120) NULL,
|
|
label VARCHAR(120) NOT NULL DEFAULT 'Wilderness',
|
|
INDEX idx_shard_spawn_points_facet (facet),
|
|
INDEX idx_shard_spawn_points_label (label)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- The many-to-many between the two above: one spawner commonly carries several
|
|
-- types (a single Trammel point spawns six), each with its own max. This is how
|
|
-- /atlas/creatures/:slug finds the places a creature appears.
|
|
CREATE TABLE IF NOT EXISTS shard_spawn_point_types (
|
|
point_id INT NOT NULL,
|
|
slug VARCHAR(120) NOT NULL, -- → shard_spawn_creatures.slug (no FK)
|
|
max_count INT NOT NULL DEFAULT 1,
|
|
PRIMARY KEY (point_id, slug),
|
|
INDEX idx_shard_spawn_point_types_slug (slug)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Named regions from Data/Regions.xml, flattened out of their nesting. `rects`
|
|
-- holds the region's rectangles; `priority` and rect area are what resolved each
|
|
-- spawn point at build time, kept here so the admin drift check can re-derive.
|
|
CREATE TABLE IF NOT EXISTS shard_regions (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
facet VARCHAR(40) NOT NULL,
|
|
name VARCHAR(120) NOT NULL,
|
|
type VARCHAR(80) NULL, -- ServUO region class
|
|
priority INT NOT NULL DEFAULT 0,
|
|
parent VARCHAR(120) NULL, -- enclosing named region, if any
|
|
rects JSON NULL,
|
|
INDEX idx_shard_regions_facet (facet),
|
|
INDEX idx_shard_regions_name (name)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Points of interest from Data/Locations/*.xml. `grp` is the innermost enclosing
|
|
-- parent ("Covetous"), which is the label worth showing — "Covetous" reads
|
|
-- better than the individual marker "Level 1". (`group` is reserved in SQL.)
|
|
CREATE TABLE IF NOT EXISTS shard_landmarks (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
facet VARCHAR(40) NOT NULL,
|
|
name VARCHAR(120) NOT NULL,
|
|
grp VARCHAR(120) NULL,
|
|
x INT NOT NULL,
|
|
y INT NOT NULL,
|
|
z INT NOT NULL DEFAULT 0,
|
|
INDEX idx_shard_landmarks_facet (facet),
|
|
INDEX idx_shard_landmarks_name (name)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Configured champion altars from Config/ChampionSpawns.xml. This is static
|
|
-- roster data ("there is an Unholy Terror altar in Deceit") and is distinct from
|
|
-- the live champ.update feed in shard_champs ("it is on level 3 right now").
|
|
CREATE TABLE IF NOT EXISTS shard_champion_spawns (
|
|
slug VARCHAR(160) NOT NULL PRIMARY KEY, -- facet-name, e.g. "felucca-deceit"
|
|
name VARCHAR(120) NOT NULL,
|
|
grp VARCHAR(80) NULL, -- spawn group; one active per group
|
|
type VARCHAR(80) NULL, -- '' when randomised per activation
|
|
random_type TINYINT(1) NOT NULL DEFAULT 0,
|
|
facet VARCHAR(40) NOT NULL,
|
|
x INT NOT NULL,
|
|
y INT NOT NULL,
|
|
z INT NOT NULL DEFAULT 0,
|
|
radius INT NOT NULL DEFAULT 0,
|
|
label VARCHAR(120) NULL, -- resolved place name
|
|
INDEX idx_shard_champion_spawns_facet (facet)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Singleton (id = 1) describing the artifact currently loaded: when it was
|
|
-- built, its counts, and a sha256 per ServUO source file. The admin drift check
|
|
-- compares this against db/data/spawnAtlas.meta.json to report when the database
|
|
-- is behind the committed artifact.
|
|
CREATE TABLE IF NOT EXISTS shard_atlas_meta (
|
|
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
|
|
payload JSON NOT NULL,
|
|
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
CONSTRAINT chk_shard_atlas_meta_singleton CHECK (id = 1)
|
|
) 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;
|
|
|
|
-- 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;
|