Web sessions were stateless JWTs with no server-side store: the revocation hooks in session.service were stubs that only logged. As a result web logout was client-side only (a copied cookie stayed valid until natural JWT expiry) and a password change never invalidated existing sessions. The mobile bearer flow already had revocable, DB-stored tokens; this brings the web/cookie flow to parity. Two-layer revocation, both enforced in requireAuth (which already loads the fresh user row each request): - Per-session denylist: new `revoked_sessions` table keyed on the JWT `jti` (already minted per session). A single logout adds this session's jti; rows self-expire at the token's own exp and are pruned on boot. New model `revokedSessions` mirrors the `mobileSessions` db/model split. - Per-user cutoff: new `users.tokens_valid_after` column. A password change (and the new `invalidateSessions` helper) bumps it to NOW(); any token whose iat is at or before the cutoff is rejected. The comparison is inclusive so a token minted in the same wall-clock second as the change is still revoked. Wiring: - session.service: revokeSession / invalidateSession / invalidateAllUserSessions now delegate to the stores; sessions carry `expiresAt` (JWT exp) so logout can set a self-pruning denylist row. - /logout gains best-effort attachSession so the controller can revoke this session's jti and log auth.logout; stays a no-op for anonymous callers. - users.model.update bumps the cutoff whenever the password hash is rotated. - schema.sql: revoked_sessions table + tokens_valid_after column, added to the CREATE and to the idempotent migration block (ensureSchema on boot). Verified end-to-end against the local dev DB: a captured cookie is rejected after logout, and an existing session is rejected after a password change while re-login with the new password succeeds. Full server test suite green (96). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
388 lines
20 KiB
SQL
388 lines
20 KiB
SQL
-- UOMysticmoon database schema (MariaDB)
|
|
-- Run automatically by the MariaDB container (docker-entrypoint-initdb.d) on a
|
|
-- fresh volume, and idempotently by ensureSchema() on every server boot.
|
|
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
username VARCHAR(32) NOT NULL UNIQUE,
|
|
password_hash VARCHAR(72) NOT NULL,
|
|
role ENUM('admin','editor') NOT NULL DEFAULT 'admin',
|
|
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
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
CREATE TABLE IF NOT EXISTS posts (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
category ENUM('news','five_on_friday','newsletter','screenshot') NOT NULL,
|
|
title VARCHAR(200) NOT NULL,
|
|
slug VARCHAR(220) NULL,
|
|
excerpt VARCHAR(400) NULL,
|
|
body MEDIUMTEXT NULL,
|
|
image_url VARCHAR(500) NULL,
|
|
published TINYINT(1) NOT NULL DEFAULT 0,
|
|
author_id INT NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
published_at DATETIME NULL,
|
|
CONSTRAINT fk_posts_author FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL,
|
|
INDEX idx_posts_feed (category, published, published_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Wiki categories / sections. Defined before wiki_pages so the FK resolves on a
|
|
-- fresh install. Pages reference a category (nullable = "Uncategorized").
|
|
CREATE TABLE IF NOT EXISTS wiki_categories (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
slug VARCHAR(120) NOT NULL UNIQUE,
|
|
title VARCHAR(200) NOT NULL,
|
|
description VARCHAR(400) NULL,
|
|
sort_order INT NOT NULL DEFAULT 0,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
CREATE TABLE IF NOT EXISTS wiki_pages (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
slug VARCHAR(120) NOT NULL UNIQUE,
|
|
title VARCHAR(200) NOT NULL,
|
|
body MEDIUMTEXT NULL,
|
|
excerpt VARCHAR(400) NULL,
|
|
category_id INT NULL,
|
|
published TINYINT(1) NOT NULL DEFAULT 1,
|
|
sort_order INT NOT NULL DEFAULT 0,
|
|
updated_by INT NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
published_at DATETIME NULL,
|
|
CONSTRAINT fk_wiki_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
|
|
CONSTRAINT fk_wiki_category FOREIGN KEY (category_id) REFERENCES wiki_categories(id) ON DELETE SET NULL,
|
|
FULLTEXT INDEX idx_wiki_search (title, body)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Wiki tags (many-to-many with pages).
|
|
CREATE TABLE IF NOT EXISTS wiki_tags (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
slug VARCHAR(120) NOT NULL UNIQUE,
|
|
label VARCHAR(120) NOT NULL
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
CREATE TABLE IF NOT EXISTS wiki_page_tags (
|
|
page_id INT NOT NULL,
|
|
tag_id INT NOT NULL,
|
|
PRIMARY KEY (page_id, tag_id),
|
|
CONSTRAINT fk_wpt_page FOREIGN KEY (page_id) REFERENCES wiki_pages(id) ON DELETE CASCADE,
|
|
CONSTRAINT fk_wpt_tag FOREIGN KEY (tag_id) REFERENCES wiki_tags(id) ON DELETE CASCADE
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Internal-link index, rebuilt on each save. target_slug may point at a page
|
|
-- that does not exist yet (a "red link").
|
|
CREATE TABLE IF NOT EXISTS wiki_links (
|
|
source_page_id INT NOT NULL,
|
|
target_slug VARCHAR(120) NOT NULL,
|
|
CONSTRAINT fk_wiki_links_src FOREIGN KEY (source_page_id) REFERENCES wiki_pages(id) ON DELETE CASCADE,
|
|
INDEX idx_wiki_links_target (target_slug)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Per-save content snapshots for history / diff / restore.
|
|
CREATE TABLE IF NOT EXISTS wiki_revisions (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
page_id INT NOT NULL,
|
|
title VARCHAR(200) NOT NULL,
|
|
body MEDIUMTEXT NULL,
|
|
excerpt VARCHAR(400) NULL,
|
|
category_id INT NULL,
|
|
editor_id INT NULL,
|
|
change_note VARCHAR(280) NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
CONSTRAINT fk_wiki_rev_page FOREIGN KEY (page_id) REFERENCES wiki_pages(id) ON DELETE CASCADE,
|
|
CONSTRAINT fk_wiki_rev_editor FOREIGN KEY (editor_id) REFERENCES users(id) ON DELETE SET NULL,
|
|
INDEX idx_wiki_rev_page (page_id, id)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
CREATE TABLE IF NOT EXISTS settings (
|
|
`key` VARCHAR(64) PRIMARY KEY,
|
|
value TEXT NULL,
|
|
updated_by INT NULL,
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
CONSTRAINT fk_settings_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
CREATE TABLE IF NOT EXISTS activity_log (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
user_id INT NULL,
|
|
action VARCHAR(64) NOT NULL,
|
|
detail TEXT NULL,
|
|
ip VARCHAR(45) NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
CONSTRAINT fk_activity_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
|
INDEX idx_activity_created (created_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Pluggable SSO / OAuth2 provider configuration. Rows exist for the built-in
|
|
-- providers ('google', 'discord') once an admin configures them, plus any custom
|
|
-- OIDC/OAuth2 providers (id = a slug). Client secrets are stored ENCRYPTED
|
|
-- (client_secret_enc) and are never returned to a client. Built-in providers
|
|
-- hardcode their endpoint URLs in code; the *_url columns are used only by
|
|
-- custom (oidc/oauth2) providers.
|
|
CREATE TABLE IF NOT EXISTS auth_providers (
|
|
id VARCHAR(64) PRIMARY KEY, -- 'google' | 'discord' | custom slug
|
|
kind ENUM('google','discord','oidc','oauth2') NOT NULL,
|
|
name VARCHAR(80) NOT NULL,
|
|
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
|
client_id VARCHAR(255) NULL,
|
|
client_secret_enc TEXT NULL, -- AES-256-GCM ciphertext, never exposed
|
|
authorize_url VARCHAR(500) NULL, -- custom providers only
|
|
token_url VARCHAR(500) NULL,
|
|
userinfo_url VARCHAR(500) NULL,
|
|
scopes VARCHAR(500) NULL,
|
|
priority INT NOT NULL DEFAULT 100,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Account linking: maps an external SSO identity to an internal user. A login via
|
|
-- SSO succeeds only if a matching (provider, subject) row exists (link-only —
|
|
-- external identities are never auto-provisioned into accounts). UNIQUE(provider,
|
|
-- subject) guarantees one external identity maps to exactly one internal user.
|
|
CREATE TABLE IF NOT EXISTS user_identities (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
user_id INT NOT NULL,
|
|
provider VARCHAR(64) NOT NULL, -- matches auth_providers.id
|
|
subject VARCHAR(191) NOT NULL, -- external stable user id (sub / discord id)
|
|
email VARCHAR(255) NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
CONSTRAINT fk_identity_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
UNIQUE KEY uq_identity_provider_subject (provider, subject),
|
|
INDEX idx_identity_user (user_id)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Long-lived, revocable refresh tokens for mobile (Android) bearer-token auth.
|
|
-- The opaque refresh token is never stored in the clear — only its sha256 hash —
|
|
-- so a DB read does not leak usable tokens. Rows are rotated on every refresh
|
|
-- (old row revoked, new row inserted) and revoked on logout. Web cookie sessions
|
|
-- do NOT use this table; it is purely for the mobile bearer flow.
|
|
CREATE TABLE IF NOT EXISTS mobile_refresh_tokens (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
user_id INT NOT NULL,
|
|
token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque refresh token
|
|
device_hash VARCHAR(32) NULL, -- from sessionService.sessionMeta (best-effort)
|
|
user_agent VARCHAR(255) NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
expires_at DATETIME NOT NULL,
|
|
revoked_at DATETIME NULL,
|
|
CONSTRAINT fk_mrt_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
INDEX idx_mrt_user (user_id),
|
|
INDEX idx_mrt_expires (expires_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Denylist of revoked web/cookie session tokens, keyed on the JWT `jti` minted
|
|
-- per session in createSession. A single logout adds this session's jti here;
|
|
-- requireAuth rejects any token whose jti is present. Rows self-expire: expires_at
|
|
-- mirrors the token's own exp, after which the JWT fails verification anyway, so
|
|
-- the row is dead weight and gets pruned. "Log out everywhere" / password change
|
|
-- do NOT use this table — they bump users.tokens_valid_after instead (one row vs.
|
|
-- one-per-session). This is the web/cookie analogue of mobile_refresh_tokens.
|
|
CREATE TABLE IF NOT EXISTS revoked_sessions (
|
|
jti CHAR(36) PRIMARY KEY, -- the session's JWT jti (uuid v4)
|
|
user_id INT NULL,
|
|
expires_at DATETIME NOT NULL, -- mirrors the token exp (prune after)
|
|
revoked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
CONSTRAINT fk_revoked_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
INDEX idx_revoked_sessions_expires (expires_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Discord bot control (Phase 1). Singleton row (id = 1) holding the bot's
|
|
-- config — the token is encrypted at rest (bot_token_enc) the same way OAuth
|
|
-- client secrets are, and is only ever decrypted server-side to push to the
|
|
-- bot process over the internal API; it is never returned to the admin UI
|
|
-- and the bot process never reads this table directly. `status`/`status_detail`
|
|
-- /`last_connected_at` are last-known-state mirrors of what the bot reported,
|
|
-- shown in the admin panel between polls.
|
|
CREATE TABLE IF NOT EXISTS bot_config (
|
|
id INT PRIMARY KEY DEFAULT 1,
|
|
guild_id VARCHAR(32) NULL,
|
|
bot_token_enc TEXT NULL,
|
|
application_id VARCHAR(32) NULL,
|
|
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
|
status VARCHAR(20) NOT NULL DEFAULT 'disconnected',
|
|
status_detail VARCHAR(500) NULL,
|
|
last_connected_at DATETIME NULL,
|
|
updated_by INT NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
CONSTRAINT fk_bot_config_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
|
|
CONSTRAINT chk_bot_config_singleton CHECK (id = 1)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Discord bot moderation core (Phase 2). These tables are owned by the bot
|
|
-- process (its own DB pool, bot/src/db.js) — the main server never reads or
|
|
-- writes them. They live in the same physical database as everything else
|
|
-- (per the spec's "shared instance, clearly prefixed where needed" option)
|
|
-- purely because there's no separate migration tooling to stand up a second
|
|
-- database for a single-guild v1 bot.
|
|
|
|
-- Per-guild key/value config the bot needs at runtime (currently just the
|
|
-- mod-log channel; filters/schedules/role-menu config lands here in later
|
|
-- phases). Set via the `/modlog set` slash command, not the admin panel —
|
|
-- unlike bot_config (identity/connection secrets), this is routine Discord
|
|
-- server administration staff already do inside Discord.
|
|
CREATE TABLE IF NOT EXISTS guild_config (
|
|
guild_id VARCHAR(32) NOT NULL,
|
|
`key` VARCHAR(64) NOT NULL,
|
|
value VARCHAR(500) NULL,
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (guild_id, `key`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Audit trail + mod-log source of truth for ban/kick/mute/warn actions.
|
|
-- duration_seconds is only set for timed mutes; NULL for permanent
|
|
-- ban/kick/warn actions.
|
|
CREATE TABLE IF NOT EXISTS mod_actions (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
guild_id VARCHAR(32) NOT NULL,
|
|
action_type ENUM('ban','kick','mute','warn') NOT NULL,
|
|
target_user_id VARCHAR(32) NOT NULL,
|
|
target_tag VARCHAR(120) NULL,
|
|
staff_user_id VARCHAR(32) NOT NULL,
|
|
staff_tag VARCHAR(120) NULL,
|
|
reason VARCHAR(500) NULL,
|
|
duration_seconds INT NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
INDEX idx_mod_actions_target (guild_id, target_user_id, created_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Standing warnings, separate from mod_actions so /warnings can list active
|
|
-- warnings per user. expires_at is unused in Phase 2 (no decay/escalation
|
|
-- yet — deferred, see mute/warn command comments) but the column is cheap to
|
|
-- add now rather than migrate in later.
|
|
CREATE TABLE IF NOT EXISTS warnings (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
guild_id VARCHAR(32) NOT NULL,
|
|
target_user_id VARCHAR(32) NOT NULL,
|
|
target_tag VARCHAR(120) NULL,
|
|
staff_user_id VARCHAR(32) NOT NULL,
|
|
staff_tag VARCHAR(120) NULL,
|
|
reason VARCHAR(500) NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
expires_at DATETIME NULL,
|
|
INDEX idx_warnings_target (guild_id, target_user_id, created_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Banned-word list (Phase 3). `word` is stored as the admin typed it; matching
|
|
-- normalizes both sides at runtime (case, leetspeak, repeated chars — see
|
|
-- bot/src/filter/normalize.js), so the stored value doesn't need every
|
|
-- obfuscated variant. severity drives the auto-action: delete-only, delete +
|
|
-- warn, or delete + mute (see messageFilter.js). The role/channel allowlist
|
|
-- that bypasses filtering entirely lives in guild_config (keys
|
|
-- filter_allow_roles / filter_allow_channels, CSV of snowflake ids) rather
|
|
-- than a separate table — it's a short, rarely-changed list.
|
|
CREATE TABLE IF NOT EXISTS filter_words (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
guild_id VARCHAR(32) NOT NULL,
|
|
word VARCHAR(200) NOT NULL,
|
|
severity ENUM('delete','warn','mute') NOT NULL DEFAULT 'delete',
|
|
added_by VARCHAR(32) NULL,
|
|
added_by_tag VARCHAR(120) NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE KEY uq_filter_words_guild_word (guild_id, word)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Scheduled/recurring messages (Phase 4). A row is EITHER recurring
|
|
-- (cron_expression set, run_at NULL — reposts on the node-cron schedule
|
|
-- forever until disabled/removed) OR one-off (run_at set, cron_expression
|
|
-- NULL — posted once, then sent_at is stamped so the scheduler's due-message
|
|
-- sweep never reposts it). content is plain text for now — the original spec
|
|
-- allows richer embed JSON here, deferred since authoring embed JSON through a
|
|
-- single slash-command string option isn't practical without a modal/admin UI.
|
|
CREATE TABLE IF NOT EXISTS scheduled_messages (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
guild_id VARCHAR(32) NOT NULL,
|
|
channel_id VARCHAR(32) NOT NULL,
|
|
content VARCHAR(2000) NOT NULL,
|
|
cron_expression VARCHAR(100) NULL,
|
|
run_at DATETIME NULL,
|
|
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
|
sent_at DATETIME NULL,
|
|
created_by VARCHAR(32) NULL,
|
|
created_by_tag VARCHAR(120) NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
CONSTRAINT chk_schedule_kind CHECK (
|
|
(cron_expression IS NOT NULL AND run_at IS NULL) OR
|
|
(cron_expression IS NULL AND run_at IS NOT NULL)
|
|
),
|
|
INDEX idx_scheduled_due (run_at, sent_at, enabled)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Self-assignable role menus (Phase 5). Button-based, not reaction-based —
|
|
-- avoids needing the messageReactionAdd/Remove events and their own intent.
|
|
-- `mapping` is a JSON array of {roleId, label}, validated against at click
|
|
-- time (see bot/src/discord/roleMenuHandler.js) so a stale/foreign button
|
|
-- customId can't toggle an untracked role. Auto-role-on-join is simpler and
|
|
-- reuses guild_config (key auto_role_id) rather than a table of its own.
|
|
CREATE TABLE IF NOT EXISTS role_menus (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
guild_id VARCHAR(32) NOT NULL,
|
|
channel_id VARCHAR(32) NOT NULL,
|
|
message_id VARCHAR(32) NOT NULL,
|
|
mapping TEXT NOT NULL,
|
|
created_by VARCHAR(32) NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE KEY uq_role_menus_message (message_id)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Timed role assignments (temp-mute-equivalent roles, timed event roles).
|
|
-- Swept once a minute (bot/src/roles/tempRoleSweeper.js) — expired rows have
|
|
-- their Discord role removed and the row deleted. UNIQUE(guild,user,role) so
|
|
-- re-granting the same temp role just refreshes its expiry via ON DUPLICATE
|
|
-- KEY UPDATE rather than stacking duplicate rows.
|
|
CREATE TABLE IF NOT EXISTS temp_roles (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
guild_id VARCHAR(32) NOT NULL,
|
|
user_id VARCHAR(32) NOT NULL,
|
|
role_id VARCHAR(32) NOT NULL,
|
|
expires_at DATETIME NOT NULL,
|
|
created_by VARCHAR(32) NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE KEY uq_temp_roles_user_role (guild_id, user_id, role_id),
|
|
INDEX idx_temp_roles_expires (expires_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Audit trail for the auto-rotating primary invite (Phase 6). triggered_by
|
|
-- NULL means the weekly scheduled rotation did it, not a staff member — see
|
|
-- bot/src/invites/inviteRotator.js, shared by both /invite rotate and the
|
|
-- cron job so both paths log identically. The channel invites are created in
|
|
-- is configured separately in guild_config (key invite_channel_id).
|
|
CREATE TABLE IF NOT EXISTS invite_log (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
guild_id VARCHAR(32) NOT NULL,
|
|
channel_id VARCHAR(32) NOT NULL,
|
|
invite_code VARCHAR(20) NOT NULL,
|
|
triggered_by VARCHAR(32) NULL,
|
|
triggered_by_tag VARCHAR(120) NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
revoked_at DATETIME NULL,
|
|
INDEX idx_invite_log_guild (guild_id, created_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- 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;
|
|
|
|
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);
|