A first-party Rust clan is a Team (R5). This module becomes the site's Team provider and answers core from the plugin's `clans` board. Design of record: docs/modules/rust/PLAN.md §24, D47-D58. - The store: rust_clans, rust_clan_members and rust_clan_boards. A clan's identity is <serverId>:<clanId>:<createdMs> (D52), because the game restarts clan ids whenever its clan database version changes. - The provider (D53): getTeams is complete only when every server's board is fresh, supported and untruncated. It is partial when some are, and refuses when none are. Freshness is judged by the website's clock, from when the board's `t` last advanced. - Only a complete board may mark a clan gone. A board at the game's 100-clan ceiling (D55), or one with an unreadable row, proves nothing about what it leaves out. - Leadership is diffed board to board and published (D54). The five clan events are published as team.* kinds, and written to the Team feed as members-only lines (D49). - Core only writes feed items for a Team it already holds. So the last 10 minutes of clan events are re-offered on each board refresh, deduped by a sha1 key: core clamps a dedupeKey to 40 characters, and a readable key would be truncated into collisions. - projectRoster and the clan page share one audience rule (D48): the clan's linked members and staff by default, re-read from the users row. The setting lives on Admin > Rust visibility, which also warns about uMod Clans (D47) and the ceiling. - Public: GET servers/:id/clans (the list is public, D58) and GET clans/:externalId. The client adds a Clans tab and /rust/clans/:externalId, with three module slots for core's notify, activity and forum contributions (D56). - Linking and unlinking an account ask core to reconcile Teams (D57). - The clan kinds are staff-class in the public feed allowlist. - PROTOCOL_VERSION is now 6. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
795 lines
42 KiB
SQL
795 lines
42 KiB
SQL
-- ── The schema fragment ───────────────────────────────────────────────────
|
||
--
|
||
-- Core replays this file on EVERY boot, statement by statement, immediately
|
||
-- after its own schema.sql and before it seeds defaults (MODULE_API.md §2.6).
|
||
--
|
||
-- There is no migration runner anywhere in this project. A module's schema is
|
||
-- not a sequence of changes to apply once — it is a statement of what the tables
|
||
-- should look like, written so that running it against a database that already
|
||
-- matches does nothing. Every CREATE carries IF NOT EXISTS; **changing a table
|
||
-- is an ALTER below the CREATE, never an edit to the CREATE**, because
|
||
-- `CREATE TABLE IF NOT EXISTS` does nothing at all when the table is already
|
||
-- there and an edited column would reach fresh installs only.
|
||
--
|
||
-- Every table here is prefixed `rust_`, which is this module's id and the only
|
||
-- prefix it may create under.
|
||
--
|
||
-- ── Four kinds of table, and the split between them is the whole design ───
|
||
--
|
||
-- CONFIGURATION `rust_servers` — rows an operator writes, from Admin → Rust.
|
||
-- OBSERVED STATE `rust_server_state`, `rust_presence` — what a sidecar last
|
||
-- reported, replaced rather than appended.
|
||
-- THE RECORD `rust_wipes`, `rust_players`, `rust_player_wipe_stats`,
|
||
-- `rust_gather_totals` — permanent, and the reason a wipe does
|
||
-- not erase a player's history.
|
||
-- THE WINDOW `rust_events` — recent detail, bounded by a sweep.
|
||
--
|
||
-- They are separate tables rather than columns on one because they have
|
||
-- different writers, different lifetimes and different audiences — and because
|
||
-- a purge of observed state while keeping the configuration is a thing an
|
||
-- operator will eventually want.
|
||
--
|
||
-- Teardown is `purge.sql`, which no boot ever runs.
|
||
|
||
|
||
-- ── The configured servers ────────────────────────────────────────────────
|
||
--
|
||
-- One row per Rust game server, and therefore one row per sidecar: the bridge is
|
||
-- one server to one sidecar, on that server's own host (R8). A community running
|
||
-- six servers has six rows here, each with its own base URL and its own token.
|
||
--
|
||
-- `id` is the operator's own slug and is what every URL under `/rust/servers/`
|
||
-- carries. It is deliberately NOT auto-increment: it appears in links people
|
||
-- share, and a row rebuilt after a mistake should be able to keep its address.
|
||
--
|
||
-- `sidecar_token_enc` holds the sidecar's shared secret **encrypted at rest**
|
||
-- through `ctx.secretBox` (MODULE_API.md §2.3), like every other secret this
|
||
-- platform stores. It is write-only in the API: the admin surface accepts a new
|
||
-- value and never returns the stored one, so a compromised admin session cannot
|
||
-- read back the credential that reaches the game host.
|
||
--
|
||
-- `protocol` records the wire version this row was configured against. It is
|
||
-- stored rather than assumed because a fleet is upgraded one host at a time, and
|
||
-- an operator needs to see WHICH server disagrees rather than that one does.
|
||
CREATE TABLE IF NOT EXISTS rust_servers (
|
||
id VARCHAR(64) NOT NULL PRIMARY KEY,
|
||
name VARCHAR(120) NOT NULL,
|
||
sidecar_base_url VARCHAR(255) NOT NULL,
|
||
sidecar_token_enc TEXT NULL,
|
||
protocol INT UNSIGNED NOT NULL DEFAULT 1,
|
||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||
sort_order INT NOT NULL DEFAULT 0,
|
||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||
);
|
||
|
||
|
||
-- ── What each server last said about itself ───────────────────────────────
|
||
--
|
||
-- One row per configured server, replaced whole each time this module reads a
|
||
-- sidecar. It is the table that lets the site render while every game server is
|
||
-- off, which is the point of the sidecar holding a store at all.
|
||
--
|
||
-- `updated_at` carries no `ON UPDATE CURRENT_TIMESTAMP`, deliberately. That
|
||
-- clause fires only when an UPDATE actually CHANGES a value, so a writer sending
|
||
-- the same numbers back — which is exactly what a quiet server looks like —
|
||
-- would leave the timestamp frozen at the first write and the row would look
|
||
-- stale while nothing was wrong. The writer sets the column explicitly instead.
|
||
--
|
||
-- `boot_id` is the game process's own identity, not the sidecar's and not the
|
||
-- plugin's. It changes when the world started over and at no other time, which
|
||
-- is what makes it the thing to watch: a reconnect of either bridge component
|
||
-- loses nothing, and a game restart loses everything an event put in the world.
|
||
--
|
||
-- `raw` keeps the whole frame. This module indexes the columns it serves and
|
||
-- stores the rest verbatim, so a protocol version that adds a field needs no
|
||
-- migration here — the same dumb-forwarder property the sidecar has, one hop
|
||
-- further along.
|
||
CREATE TABLE IF NOT EXISTS rust_server_state (
|
||
server_id VARCHAR(64) NOT NULL PRIMARY KEY,
|
||
reachable TINYINT(1) NOT NULL DEFAULT 0,
|
||
online TINYINT(1) NOT NULL DEFAULT 0,
|
||
players INT UNSIGNED NOT NULL DEFAULT 0,
|
||
max_players INT UNSIGNED NOT NULL DEFAULT 0,
|
||
hostname VARCHAR(191) NULL,
|
||
level VARCHAR(120) NULL,
|
||
seed BIGINT NULL,
|
||
world_size INT UNSIGNED NULL,
|
||
boot_id VARCHAR(64) NULL,
|
||
save_created_at VARCHAR(32) NULL,
|
||
protocol INT UNSIGNED NULL,
|
||
raw LONGTEXT NULL,
|
||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
CONSTRAINT fk_rust_server_state_server
|
||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||
);
|
||
|
||
|
||
-- ── The read path ─────────────────────────────────────────────────────────
|
||
--
|
||
-- Protocol 2 turned the bridge from a greeting into a catalogue, and these are
|
||
-- the tables that hold it. They divide on one line, and it is the line R12 drew:
|
||
--
|
||
-- PERMANENT `rust_wipes`, `rust_players`, `rust_player_wipe_stats`,
|
||
-- `rust_gather_totals` — a player's record, kept for ever. All-time
|
||
-- is a SUM across wipes rather than a second set of counters, so
|
||
-- there is no second number that can disagree with the first.
|
||
--
|
||
-- BOUNDED `rust_events` — the recent raw window the killfeed reads, pruned
|
||
-- on a sweep. It is detail, not record: losing last month's
|
||
-- individual deaths costs a scroll-back, losing last month's
|
||
-- totals costs a player their history.
|
||
--
|
||
-- DERIVED `rust_presence` — who is on right now, replaced wholesale from
|
||
-- the `players.online` board. Never a history, never appended.
|
||
--
|
||
-- The sidecar keeps its own bounded copy of the same events (default 14 days),
|
||
-- so shortening either window loses recent detail and neither loses a total.
|
||
|
||
|
||
-- ── Wipes ─────────────────────────────────────────────────────────────────
|
||
--
|
||
-- One row per (server, wipe). The id is the plugin's, derived from the save's
|
||
-- creation time and stamped on every frame (PROTOCOL.md §8.2) — this module
|
||
-- never derives one, because two derivations of one fact eventually disagree
|
||
-- about a boundary.
|
||
--
|
||
-- Rows appear by being MENTIONED: the first frame carrying a wipe id this module
|
||
-- has not seen creates it. There is no "start a wipe" call and there must not be
|
||
-- one, because the website is not present when a wipe happens — a wipe is a fact
|
||
-- about a world that was restarted while nobody was watching.
|
||
CREATE TABLE IF NOT EXISTS rust_wipes (
|
||
server_id VARCHAR(64) NOT NULL,
|
||
wipe_id VARCHAR(48) NOT NULL,
|
||
save_created_at VARCHAR(32) NULL,
|
||
first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
PRIMARY KEY (server_id, wipe_id),
|
||
CONSTRAINT fk_rust_wipes_server
|
||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||
);
|
||
|
||
|
||
-- ── Players ───────────────────────────────────────────────────────────────
|
||
--
|
||
-- Identity, and deliberately nothing else. It is keyed on the Steam id alone
|
||
-- and carries no server: a player is the same person on all six of a community's
|
||
-- servers, and everything that is per-server lives in the stats table.
|
||
--
|
||
-- `user_id` is NOT here. Linking a Steam id to a website account is phase 6's
|
||
-- work (R1), and a column waiting for it would be a column every read has to
|
||
-- remember is always null.
|
||
CREATE TABLE IF NOT EXISTS rust_players (
|
||
steam_id VARCHAR(32) NOT NULL PRIMARY KEY,
|
||
name VARCHAR(191) NULL,
|
||
first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||
);
|
||
|
||
|
||
-- ── The permanent record ──────────────────────────────────────────────────
|
||
--
|
||
-- One row per player per wipe per server, and the only counters this module
|
||
-- keeps. R12's "per-wipe detail plus all-time rollups" is satisfied by SUMming
|
||
-- this rather than by maintaining a second all-time row, because two counters
|
||
-- for one fact drift the first time an ingest is replayed.
|
||
--
|
||
-- Every column is a COUNT that only ever goes up within a wipe, which is what
|
||
-- makes ingest idempotent-ish in the only way that matters: the cursor advances
|
||
-- only after the batch commits, so a crash re-reads a batch it has not counted.
|
||
--
|
||
-- `playtime_sec` comes from `sessionSec` on a disconnect, and a session whose
|
||
-- start this module never saw contributes NOTHING rather than zero — the plugin
|
||
-- omits the field, the ingest skips it, and the number stays honestly short
|
||
-- instead of quietly wrong.
|
||
CREATE TABLE IF NOT EXISTS rust_player_wipe_stats (
|
||
server_id VARCHAR(64) NOT NULL,
|
||
wipe_id VARCHAR(48) NOT NULL,
|
||
steam_id VARCHAR(32) NOT NULL,
|
||
kills INT UNSIGNED NOT NULL DEFAULT 0,
|
||
deaths INT UNSIGNED NOT NULL DEFAULT 0,
|
||
suicides INT UNSIGNED NOT NULL DEFAULT 0,
|
||
npc_kills INT UNSIGNED NOT NULL DEFAULT 0,
|
||
structures INT UNSIGNED NOT NULL DEFAULT 0,
|
||
sessions INT UNSIGNED NOT NULL DEFAULT 0,
|
||
playtime_sec BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||
last_seen DATETIME NULL,
|
||
PRIMARY KEY (server_id, wipe_id, steam_id),
|
||
KEY idx_rust_stats_kills (server_id, wipe_id, kills DESC),
|
||
KEY idx_rust_stats_player (steam_id)
|
||
);
|
||
|
||
|
||
-- ── What they gathered ────────────────────────────────────────────────────
|
||
--
|
||
-- A row per resource rather than a JSON blob on the stats row, for one reason:
|
||
-- the leaderboard question is "who gathered the most sulfur this wipe", and that
|
||
-- is an ORDER BY over a column in every SQL engine and a JSON function call in
|
||
-- exactly one. The resource name is the game's own shortname, unknown in advance
|
||
-- and not worth a lookup table.
|
||
CREATE TABLE IF NOT EXISTS rust_gather_totals (
|
||
server_id VARCHAR(64) NOT NULL,
|
||
wipe_id VARCHAR(48) NOT NULL,
|
||
steam_id VARCHAR(32) NOT NULL,
|
||
resource VARCHAR(64) NOT NULL,
|
||
amount BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (server_id, wipe_id, steam_id, resource),
|
||
KEY idx_rust_gather_top (server_id, wipe_id, resource, amount DESC)
|
||
);
|
||
|
||
|
||
-- ── The recent raw window ─────────────────────────────────────────────────
|
||
--
|
||
-- Every ingested event, whole, for as long as the retention sweep keeps it. The
|
||
-- killfeed reads this; so does an admin looking at what happened.
|
||
--
|
||
-- `raw` holds the entire frame and the columns beside it are only what a query
|
||
-- needs to reach — the same rule the sidecar's own store follows, one hop along:
|
||
-- a protocol version that adds a field needs no migration here.
|
||
--
|
||
-- **`kind` is a security boundary, not a label.** Some kinds carry IP addresses
|
||
-- and player reports (PROTOCOL.md §8.4), and what makes them safe is that the
|
||
-- public read is filtered by an allowlist this module holds, default-deny. The
|
||
-- rows are stored either way, because an operator chasing ban evasion needs them.
|
||
CREATE TABLE IF NOT EXISTS rust_events (
|
||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||
server_id VARCHAR(64) NOT NULL,
|
||
wipe_id VARCHAR(48) NULL,
|
||
kind VARCHAR(64) NOT NULL,
|
||
t BIGINT NOT NULL,
|
||
steam_id VARCHAR(32) NULL,
|
||
raw LONGTEXT NOT NULL,
|
||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
KEY idx_rust_events_server (server_id, id DESC),
|
||
KEY idx_rust_events_kind (server_id, kind, id DESC),
|
||
KEY idx_rust_events_wipe (server_id, wipe_id, id DESC),
|
||
KEY idx_rust_events_created (created_at)
|
||
);
|
||
|
||
|
||
-- ── Who is on right now ───────────────────────────────────────────────────
|
||
--
|
||
-- Replaced wholesale every time the `players.online` board arrives, which is on
|
||
-- every bridge connect and every 60 seconds. It is a BOARD, and the reason it is
|
||
-- its own table rather than rows in `rust_events` is that a board answers "now"
|
||
-- and an event answers "then"; storing a board as history is the mistake the
|
||
-- wire's `type` field exists to prevent, and it would be a shame to make it here
|
||
-- after the sidecar went to the trouble of not making it there.
|
||
CREATE TABLE IF NOT EXISTS rust_presence (
|
||
server_id VARCHAR(64) NOT NULL,
|
||
steam_id VARCHAR(32) NOT NULL,
|
||
name VARCHAR(191) NULL,
|
||
sleeping TINYINT(1) NOT NULL DEFAULT 0,
|
||
connected_at DATETIME NULL,
|
||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
PRIMARY KEY (server_id, steam_id)
|
||
);
|
||
|
||
|
||
-- ── The ingest cursor ─────────────────────────────────────────────────────
|
||
--
|
||
-- Where this module has read up to in each sidecar's feed. One row per server.
|
||
--
|
||
-- It is persisted rather than held in memory because the alternative is a module
|
||
-- that re-reads everything on every boot or nothing at all, and both are wrong in
|
||
-- a way that only shows up in production. The cursor advances **after** the batch
|
||
-- is written, never before: a crash mid-batch re-reads rows it has not counted,
|
||
-- which is the safe direction to be wrong in.
|
||
--
|
||
-- A NEW server starts at the sidecar's current end rather than at zero (see
|
||
-- `GET /feed` with no `since`). A module installed today against a sidecar that
|
||
-- has been running a month wants what happens next — replaying a fortnight of
|
||
-- deaths into stats whose wipes it never saw is not a catch-up, it is a
|
||
-- fabrication of history it was not present for.
|
||
CREATE TABLE IF NOT EXISTS rust_ingest_cursor (
|
||
server_id VARCHAR(64) NOT NULL PRIMARY KEY,
|
||
last_event_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||
events_seen BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
CONSTRAINT fk_rust_cursor_server
|
||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||
);
|
||
|
||
|
||
-- ── Who owns which Steam account ──────────────────────────────────────────
|
||
--
|
||
-- R1's identity link, and the reason it is a table rather than a column on
|
||
-- `rust_players`: a link is a fact about a WEBSITE USER that happens to be keyed
|
||
-- by a Steam id, and it outlives every row this module writes about play. A
|
||
-- column here would be null for the overwhelming majority of players and would
|
||
-- be deleted by any sweep that pruned inactive ones.
|
||
--
|
||
-- **Keyed on `steam_id` alone, fleet-wide.** `rust_players` already made that
|
||
-- call in protocol 2 and it is the truth of the thing: a Steam account is one
|
||
-- person across every server an operator runs, where stats are per server and
|
||
-- per wipe. Linking on one server links for the fleet, because there is nothing
|
||
-- else it could honestly mean.
|
||
--
|
||
-- **One Steam id, at most one user** — that is what the primary key buys, and it
|
||
-- is load-bearing rather than tidy. Phase 7 makes the site the author of who may
|
||
-- do what in game and phase 13 makes it the thing that hands out loot; both are
|
||
-- grants against a Steam id, and both assume the question "whose is this?" has
|
||
-- exactly one answer.
|
||
--
|
||
-- The reverse is deliberately NOT constrained: one website user may hold several
|
||
-- Steam accounts. People have a second account, or a family shares a site login,
|
||
-- and refusing that would be inventing a rule the game does not have.
|
||
--
|
||
-- `ON DELETE CASCADE` from `users`: a deleted account's links go with it. The
|
||
-- alternative is a row naming a user id that resolves to nobody, which every
|
||
-- read would then have to defend against.
|
||
CREATE TABLE IF NOT EXISTS rust_account_links (
|
||
steam_id VARCHAR(32) NOT NULL PRIMARY KEY,
|
||
user_id INT NOT NULL,
|
||
-- What the player was called in game when they linked. A display name, kept
|
||
-- so an operator reading the admin panel sees a person rather than a number;
|
||
-- never used to identify anybody, because a Rust name changes on a whim.
|
||
name VARCHAR(191) NULL,
|
||
-- Which server minted the code. Not part of the identity — the link is
|
||
-- fleet-wide — but an operator asking "where did this come from" has no other
|
||
-- way to find out, and a support conversation starts there.
|
||
server_id VARCHAR(64) NULL,
|
||
linked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
CONSTRAINT fk_rust_links_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||
KEY idx_rust_links_user (user_id)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||
|
||
|
||
-- ── Site-owned permissions (phase 7, R2) ──────────────────────────────────
|
||
--
|
||
-- The website is the author of record for who may do what in game, and the
|
||
-- framework's own permission store is an ENFORCEMENT CACHE. That is one
|
||
-- sentence with three consequences, and the tables below are shaped by them:
|
||
--
|
||
-- • Every third-party plugin honours a site grant with no adapter, because
|
||
-- they all already call `UserHasPermission`. Nothing here is read by the
|
||
-- game directly; it is pushed into the store the game already consults.
|
||
-- • A wipe stops being a data-loss event. The game forgets and the site does
|
||
-- not, so the next sync puts it all back.
|
||
-- • A hand edit is REPORTED, never silently overwritten (D31). Which means
|
||
-- the site has to be able to tell a grant it made from one somebody typed
|
||
-- at a console — and that is a fact only the site can hold, because the
|
||
-- store records who granted a permission nowhere.
|
||
--
|
||
-- ── A grant is against a WEBSITE USER (D28) ───────────────────────────────
|
||
--
|
||
-- Not against a Steam id, though a Steam id is what reaches the game. The site
|
||
-- authors privilege for a PERSON: phase 13's earned entitlements follow whoever
|
||
-- earned them, and an account unlinked from a person takes their privileges
|
||
-- with it. The Steam ids are resolved from `rust_account_links` at push time,
|
||
-- so a player who links a second account gets what they hold on both — which is
|
||
-- the honest reading of "this person may do this".
|
||
--
|
||
-- A user with no linked account is authored against perfectly well and simply
|
||
-- reaches nobody until they link. That is visible on the admin screen rather
|
||
-- than silent, because a grant that reaches nothing looks identical to a grant
|
||
-- that worked from every other angle.
|
||
--
|
||
-- ── Scope (D29) ───────────────────────────────────────────────────────────
|
||
--
|
||
-- Every authored row carries one: a server id, or `*` for the whole fleet. The
|
||
-- game stores permissions per server (each has its own store), an operator
|
||
-- running a modded server and a vanilla one will not want one set on both, and
|
||
-- a single-server community never has to think about it.
|
||
|
||
|
||
-- ── Groups ────────────────────────────────────────────────────────────────
|
||
--
|
||
-- Mirrored into the game as REAL groups (D30) rather than flattened into
|
||
-- per-player grants. Third-party plugins read group membership, BetterChat's
|
||
-- group API (R15, phase 17) has something to hang on, and an operator reading
|
||
-- `oxide.show groups` sees what the website shows.
|
||
--
|
||
-- The cost of that fidelity is written down in PLAN.md §12.2 rule 4 and does
|
||
-- not go away: **a player the store has never seen cannot be put in a group**,
|
||
-- while a direct grant to the same id works immediately. The sync reports those
|
||
-- members as pending and the membership lands on their first connection.
|
||
--
|
||
-- The name is the primary key, fleet-wide, even though the row carries a scope:
|
||
-- one `vip` on the site is one `vip` in the game, pushed to the servers its
|
||
-- scope names. Two groups of the same name with different scopes would be two
|
||
-- definitions of one name in every store that received both.
|
||
CREATE TABLE IF NOT EXISTS rust_perm_groups (
|
||
name VARCHAR(64) NOT NULL PRIMARY KEY,
|
||
title VARCHAR(120) NOT NULL DEFAULT '',
|
||
rank INT NOT NULL DEFAULT 0,
|
||
scope VARCHAR(64) NOT NULL DEFAULT '*',
|
||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||
);
|
||
|
||
|
||
-- What each group carries. A row per permission rather than a list on the group
|
||
-- for the ordinary reason: "which groups grant kits.vip" is the question an
|
||
-- operator asks when they are about to remove a plugin, and that is a WHERE
|
||
-- clause here and a scan of every row in the other shape.
|
||
CREATE TABLE IF NOT EXISTS rust_perm_group_permissions (
|
||
group_name VARCHAR(64) NOT NULL,
|
||
permission VARCHAR(128) NOT NULL,
|
||
PRIMARY KEY (group_name, permission),
|
||
CONSTRAINT fk_rust_perm_group_permissions_group
|
||
FOREIGN KEY (group_name) REFERENCES rust_perm_groups (name) ON DELETE CASCADE
|
||
);
|
||
|
||
|
||
-- Who is in each group — by website user, like every other authored row.
|
||
--
|
||
-- `added_by` is an admin's user id and deliberately carries NO foreign key: a
|
||
-- staff member's account being deleted must not delete the record of what they
|
||
-- did, and `ON DELETE SET NULL` would quietly rewrite history to "nobody".
|
||
-- The activity log is the audit trail; this column is a convenience beside it.
|
||
CREATE TABLE IF NOT EXISTS rust_perm_group_members (
|
||
group_name VARCHAR(64) NOT NULL,
|
||
user_id INT NOT NULL,
|
||
added_by INT NULL,
|
||
added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
PRIMARY KEY (group_name, user_id),
|
||
KEY idx_rust_perm_members_user (user_id),
|
||
CONSTRAINT fk_rust_perm_members_group
|
||
FOREIGN KEY (group_name) REFERENCES rust_perm_groups (name) ON DELETE CASCADE,
|
||
CONSTRAINT fk_rust_perm_members_user
|
||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||
);
|
||
|
||
|
||
-- ── Direct grants ─────────────────────────────────────────────────────────
|
||
--
|
||
-- A permission held by one person, without a group. It is not a lesser version
|
||
-- of membership: it is the shape that reaches a player who has never connected
|
||
-- to that server, which is exactly what an entitlement earned on the website at
|
||
-- three in the morning has to do (R16).
|
||
--
|
||
-- `source` is why this table does not need changing in phase 13. Every later
|
||
-- author — an event action granting the right to redeem a kit, a lease handing
|
||
-- out a weekend group — writes a row here with its own source rather than a
|
||
-- store of its own, so there is one answer to "why does this player have this"
|
||
-- and one place the push reads.
|
||
CREATE TABLE IF NOT EXISTS rust_perm_grants (
|
||
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||
user_id INT NOT NULL,
|
||
permission VARCHAR(128) NOT NULL,
|
||
scope VARCHAR(64) NOT NULL DEFAULT '*',
|
||
source VARCHAR(32) NOT NULL DEFAULT 'admin',
|
||
note VARCHAR(255) NULL,
|
||
granted_by INT NULL,
|
||
granted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
UNIQUE KEY uq_rust_perm_grant (user_id, permission, scope),
|
||
KEY idx_rust_perm_grant_user (user_id),
|
||
CONSTRAINT fk_rust_perm_grants_user
|
||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||
);
|
||
|
||
|
||
-- ── What this site has actually put in each game ──────────────────────────
|
||
--
|
||
-- The site's memory of its own authorship, one row per thing it has confirmed
|
||
-- into one server's store. It is the table that makes D31 possible at all.
|
||
--
|
||
-- Three sets, and every interesting question is the difference between two of
|
||
-- them:
|
||
--
|
||
-- desired − pushed what to apply
|
||
-- pushed − desired what to RETIRE, because the site put it there and has
|
||
-- since withdrawn it
|
||
-- present − desired drift: somebody else put it there
|
||
--
|
||
-- Without the middle row a withdrawn grant is indistinguishable from a hand
|
||
-- edit, and those two have opposite correct answers. Inferring it from absence
|
||
-- is the mistake this table exists to prevent.
|
||
--
|
||
-- It is keyed by Steam id rather than by user, because it records what is in the
|
||
-- GAME, and the game has never heard of a website account. Unlinking an account
|
||
-- therefore leaves its row here until the next sync retires it — which is the
|
||
-- correct behaviour and would be impossible to express keyed the other way.
|
||
CREATE TABLE IF NOT EXISTS rust_perm_pushed (
|
||
server_id VARCHAR(64) NOT NULL,
|
||
-- `grant` | `member` | `group-permission` | `group`
|
||
kind VARCHAR(24) NOT NULL,
|
||
-- a Steam id, or a group name
|
||
subject VARCHAR(64) NOT NULL,
|
||
-- a permission, a group name, or '' for the existence of a group
|
||
object VARCHAR(128) NOT NULL,
|
||
pushed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
PRIMARY KEY (server_id, kind, subject, object),
|
||
CONSTRAINT fk_rust_perm_pushed_server
|
||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||
);
|
||
|
||
|
||
-- ── Drift ─────────────────────────────────────────────────────────────────
|
||
--
|
||
-- What a sync found in a server's store that the site did not author, within
|
||
-- the namespace the site claims. Rows appear and disappear with the report:
|
||
-- this is the CURRENT difference, not a history of differences, and a hand edit
|
||
-- that somebody has since removed should stop being on the screen.
|
||
--
|
||
-- Nothing here is ever removed from the game by the sync itself. An operator
|
||
-- typing `oxide.grant` during an incident is drift, not an error, and the two
|
||
-- answers offered to them — adopt it, or revoke it — are both a person's
|
||
-- decision.
|
||
CREATE TABLE IF NOT EXISTS rust_perm_drift (
|
||
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||
server_id VARCHAR(64) NOT NULL,
|
||
kind VARCHAR(24) NOT NULL,
|
||
subject VARCHAR(64) NOT NULL,
|
||
object VARCHAR(128) NOT NULL,
|
||
first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
UNIQUE KEY uq_rust_perm_drift (server_id, kind, subject, object),
|
||
CONSTRAINT fk_rust_perm_drift_server
|
||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||
);
|
||
|
||
|
||
-- ── Removing something the site never put there ───────────────────────────
|
||
--
|
||
-- Revoking a drift row cannot go through `rust_perm_pushed`, because the whole
|
||
-- point of a drift row is that it was never pushed. It cannot go through the
|
||
-- authored tables either: a foreign grant often names a Steam id that belongs
|
||
-- to no website account at all, and there is no user to author it against.
|
||
--
|
||
-- So a revoke is its own instruction with its own lifetime: queued by a person,
|
||
-- carried in the next sync's retire list, and deleted once a report says the
|
||
-- game no longer has it. A server that is offline keeps the instruction until
|
||
-- it comes back, which is the behaviour an operator expects from a website that
|
||
-- claims to be the author of record.
|
||
CREATE TABLE IF NOT EXISTS rust_perm_revocations (
|
||
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||
server_id VARCHAR(64) NOT NULL,
|
||
kind VARCHAR(24) NOT NULL,
|
||
subject VARCHAR(64) NOT NULL,
|
||
object VARCHAR(128) NOT NULL,
|
||
requested_by INT NULL,
|
||
requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
UNIQUE KEY uq_rust_perm_revocation (server_id, kind, subject, object),
|
||
CONSTRAINT fk_rust_perm_revocations_server
|
||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||
);
|
||
|
||
|
||
-- ── The state of the mirror, per server ───────────────────────────────────
|
||
--
|
||
-- One row per configured server: whether its store currently matches what the
|
||
-- site authors, when that was last true, and what the last report said.
|
||
--
|
||
-- `dirty` is how everything that should provoke a sync says so without knowing
|
||
-- anything about syncing: an admin writing a grant, a drift hook firing in the
|
||
-- game, a server reporting a new boot id or a new wipe. The loop owns WHEN, and
|
||
-- every other part of the module owns WHETHER.
|
||
--
|
||
-- `desired_hash` and `synced_hash` are the cheap half of that question. A loop
|
||
-- that pushed the whole set every tick would work and would also write to six
|
||
-- game servers every thirty seconds for ever; comparing a hash costs one query
|
||
-- and skips the round trip when nothing has changed. The periodic audit below
|
||
-- is what keeps that from being a way to never notice drift.
|
||
CREATE TABLE IF NOT EXISTS rust_perm_sync (
|
||
server_id VARCHAR(64) NOT NULL PRIMARY KEY,
|
||
-- `pending` | `ok` | `failed`
|
||
state VARCHAR(24) NOT NULL DEFAULT 'pending',
|
||
dirty TINYINT(1) NOT NULL DEFAULT 1,
|
||
desired_hash VARCHAR(64) NULL,
|
||
synced_hash VARCHAR(64) NULL,
|
||
boot_id VARCHAR(64) NULL,
|
||
wipe_id VARCHAR(48) NULL,
|
||
last_attempt_at DATETIME NULL,
|
||
last_ok_at DATETIME NULL,
|
||
report LONGTEXT NULL,
|
||
error VARCHAR(191) NULL,
|
||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
CONSTRAINT fk_rust_perm_sync_server
|
||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||
);
|
||
|
||
|
||
-- ── What each server's plugins have registered ────────────────────────────
|
||
--
|
||
-- The option source the authoring form offers (D33), cached from the live read
|
||
-- so that opening the form is not six round trips to six game hosts.
|
||
--
|
||
-- It is a cache of a fact that changes when an operator loads a plugin, and it
|
||
-- is refreshed on every sync — which is also why a name that has stopped being
|
||
-- registered disappears from the form rather than lingering as a choice that
|
||
-- silently does nothing.
|
||
CREATE TABLE IF NOT EXISTS rust_perm_catalogue (
|
||
server_id VARCHAR(64) NOT NULL,
|
||
permission VARCHAR(128) NOT NULL,
|
||
seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
PRIMARY KEY (server_id, permission),
|
||
CONSTRAINT fk_rust_perm_catalogue_server
|
||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||
);
|
||
|
||
|
||
-- ── Changes to tables that already shipped ────────────────────────────────
|
||
--
|
||
-- An ALTER below the CREATE, never an edit to it: `CREATE TABLE IF NOT EXISTS`
|
||
-- does nothing against a database that already has the table, so an edited column
|
||
-- would reach fresh installs only — which is the worst possible distribution for
|
||
-- a schema change, because it works everywhere it is tested.
|
||
ALTER TABLE rust_server_state ADD COLUMN IF NOT EXISTS wipe_id VARCHAR(48) NULL;
|
||
|
||
-- Phase 4. `updated_at` is when THIS module last wrote the row, which is not the
|
||
-- same fact as when the server last said something — and the pages were reading
|
||
-- the first as if it were the second, so a server that had been down for three
|
||
-- days rendered "last reported just now" on every failed poll.
|
||
--
|
||
-- They are genuinely two facts and both are wanted: `updated_at` decides whether
|
||
-- the row is stale (a module that stopped polling must not leave a page claiming
|
||
-- a server is up), and `last_seen_at` is when a `server.hello` last arrived. Only
|
||
-- a successful refresh moves it.
|
||
ALTER TABLE rust_server_state ADD COLUMN IF NOT EXISTS last_seen_at DATETIME NULL;
|
||
|
||
|
||
-- ── Configuration written from the site (phase 7b, R18) ───────────────────
|
||
--
|
||
-- The audit trail for the most powerful thing this website can do to somebody's
|
||
-- game host: write a file on it. One row per save attempt, including the ones
|
||
-- that were refused and the ones the plugin rolled back — a write that did not
|
||
-- land is exactly the row an operator asking "why is ZoneManager down" needs to
|
||
-- find.
|
||
--
|
||
-- **No file bodies.** `changes` holds the fields that changed and their before
|
||
-- and after LITERALS, which is what a person reading this wants, and secrets are
|
||
-- redacted on the way in (`configEdit.redactChange`). D37 lets an admin read a
|
||
-- credential on the page they opened deliberately; this table is read by more
|
||
-- people, for longer, and usually by somebody who was not there.
|
||
--
|
||
-- The versions bracket the write: `version_before` is what the plugin said the
|
||
-- file was when it was read, `version_after` what it is now. They are the
|
||
-- plugin's own hashes, echoed — this module never computes one.
|
||
CREATE TABLE IF NOT EXISTS rust_config_writes (
|
||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||
server_id VARCHAR(64) NOT NULL,
|
||
path VARCHAR(255) NOT NULL,
|
||
plugin VARCHAR(128) NULL,
|
||
-- What the admin asked us to reload. NULL is an honest value: a file whose
|
||
-- plugin is not loaded is written and not reloaded, and saying so is the
|
||
-- difference between "saved" and "in effect".
|
||
reload_target VARCHAR(128) NULL,
|
||
-- `form` or `raw`. Which tier an edit came through changes how it should be
|
||
-- read: a form edit is type-preserving and narrow, a raw edit replaced the
|
||
-- whole document.
|
||
tier VARCHAR(16) NOT NULL DEFAULT 'form',
|
||
user_id INT NULL,
|
||
-- `applied` | `rolled-back` | `refused` | `unreachable`
|
||
outcome VARCHAR(24) NOT NULL,
|
||
reloaded TINYINT(1) NOT NULL DEFAULT 0,
|
||
changes LONGTEXT NULL,
|
||
version_before VARCHAR(64) NULL,
|
||
version_after VARCHAR(64) NULL,
|
||
detail VARCHAR(500) NULL,
|
||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
CONSTRAINT fk_rust_config_writes_server
|
||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE,
|
||
-- A deleted account must not delete the record that they changed a setting.
|
||
-- The row stays and the name goes; the alternative is an audit trail that a
|
||
-- person can erase by closing their account.
|
||
CONSTRAINT fk_rust_config_writes_user
|
||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL,
|
||
KEY idx_rust_config_writes_server (server_id, created_at)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||
|
||
|
||
-- ── Who may see who is online (the presence fix, 2026-09-22) ──────────────
|
||
--
|
||
-- The org lead's rule: **nothing tells who is online by default.** The Online
|
||
-- list, the killfeed, chat and every other frame that says a named player was on
|
||
-- the server reach STAFF unless an operator deliberately widens them. A count is
|
||
-- not a name and stays public.
|
||
--
|
||
-- Two places, because the decision has two shapes:
|
||
--
|
||
-- • `rust_settings` holds the FLEET default — one row per key. A key/value
|
||
-- table rather than a column per setting, because phase 9's clan-roster
|
||
-- audience is the next key and a table that grows a column per setting grows
|
||
-- an ALTER per setting.
|
||
-- • `rust_servers.presence_audience` is an optional PER-SERVER override. NULL
|
||
-- means "inherit the fleet default", which is not the same as any audience —
|
||
-- an operator who later narrows the fleet must narrow every server that never
|
||
-- chose otherwise.
|
||
--
|
||
-- The stored value is a word (`staff` · `signed_in` · `public`) and an unknown
|
||
-- word reads as `staff` (`model/visibility`): a typo in a row must narrow, never
|
||
-- widen.
|
||
CREATE TABLE IF NOT EXISTS rust_settings (
|
||
setting_key VARCHAR(64) NOT NULL PRIMARY KEY,
|
||
value VARCHAR(255) NOT NULL,
|
||
updated_by INT NULL,
|
||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
CONSTRAINT fk_rust_settings_user
|
||
FOREIGN KEY (updated_by) REFERENCES users (id) ON DELETE SET NULL
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||
|
||
ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS presence_audience VARCHAR(16) NULL;
|
||
|
||
|
||
-- ── Clans (phase 9, protocol 6) ───────────────────────────────────────────
|
||
--
|
||
-- Rust's FIRST-PARTY clans, which this module answers core's Team questions
|
||
-- from (R5, PLAN.md §24). Three tables, and the split is the same one the rest
|
||
-- of this file makes: what a board said (`rust_clans`, `rust_clan_members`),
|
||
-- and what this module knows about the board itself (`rust_clan_boards`).
|
||
--
|
||
-- **`external_id` is the Team's identity, and it is NOT the game's clan id.**
|
||
-- It is `<serverId>:<clanId>:<createdMs>` (D52). The game keeps clans in
|
||
-- `clans.<version>.db` with the version hard-coded, so a game update that bumps
|
||
-- it starts a fresh file whose ids restart at 1. Keyed on the id alone, the new
|
||
-- clan #1 would inherit the old clan #1's Team — its forum, its members-only
|
||
-- history — and core would read the swap as a rename.
|
||
--
|
||
-- **A clan that leaves the board is marked gone, not deleted.** `gone_at` is set
|
||
-- only when a board that is COMPLETE for its server no longer carries it: a
|
||
-- board truncated at the game's 100-clan ceiling (D55) proves nothing about a
|
||
-- clan it does not list. A gone clan is not offered to core, which is what lets
|
||
-- core archive its Team.
|
||
CREATE TABLE IF NOT EXISTS rust_clans (
|
||
external_id VARCHAR(160) NOT NULL PRIMARY KEY,
|
||
server_id VARCHAR(64) NOT NULL,
|
||
clan_id BIGINT NOT NULL,
|
||
created_ms BIGINT NOT NULL,
|
||
name VARCHAR(191) NOT NULL,
|
||
-- `#rrggbb`, as the plugin spells it. Stored as sent rather than parsed, and
|
||
-- re-checked on the way out (`model/clans`), because it ends up in a style.
|
||
color VARCHAR(16) NULL,
|
||
score BIGINT NOT NULL DEFAULT 0,
|
||
member_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||
max_members INT UNSIGNED NULL,
|
||
first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
gone_at DATETIME NULL,
|
||
CONSTRAINT fk_rust_clans_server
|
||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE,
|
||
KEY idx_rust_clans_server (server_id, gone_at),
|
||
KEY idx_rust_clans_game_id (server_id, clan_id)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||
|
||
-- One row per member per clan, replaced whole from each board.
|
||
--
|
||
-- `rank` is the role's rank, and **rank 1 is leader** — the game's own rule, and
|
||
-- several members may hold it. It is NULL when the member's role id matched no
|
||
-- role on the board: "not known" must never be read as "leads this clan".
|
||
--
|
||
-- There is deliberately no `last_seen`. The game has one; the plugin does not
|
||
-- send it, because when somebody was last on is presence (PLAN.md §23).
|
||
CREATE TABLE IF NOT EXISTS rust_clan_members (
|
||
external_id VARCHAR(160) NOT NULL,
|
||
steam_id VARCHAR(32) NOT NULL,
|
||
name VARCHAR(191) NULL,
|
||
role_rank INT NULL,
|
||
role_name VARCHAR(64) NULL,
|
||
joined_ms BIGINT NULL,
|
||
PRIMARY KEY (external_id, steam_id),
|
||
CONSTRAINT fk_rust_clan_members_clan
|
||
FOREIGN KEY (external_id) REFERENCES rust_clans (external_id) ON DELETE CASCADE,
|
||
KEY idx_rust_clan_members_steam (steam_id)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||
|
||
-- What this module knows about each server's clan board, as opposed to what the
|
||
-- board said.
|
||
--
|
||
-- **Freshness is judged by THIS side's clock.** `board_t` is the plugin's own
|
||
-- timestamp on the board; `seen_at` is when this module first saw that value.
|
||
-- A board whose `t` stops advancing is a game that stopped talking, and the
|
||
-- age of `seen_at` is how long ago that was — comparing `board_t` to the
|
||
-- website's clock instead would let a game host whose clock runs ahead make a
|
||
-- stale board look current for as long as the skew lasts.
|
||
--
|
||
-- `umod_clans` is whether the optional uMod Clans plugin is loaded on that
|
||
-- server (D47): its clans are a separate system and never Teams, and the admin
|
||
-- page says so.
|
||
CREATE TABLE IF NOT EXISTS rust_clan_boards (
|
||
server_id VARCHAR(64) NOT NULL PRIMARY KEY,
|
||
board_t BIGINT NULL,
|
||
seen_at DATETIME NULL,
|
||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||
supported TINYINT(1) NOT NULL DEFAULT 0,
|
||
truncated TINYINT(1) NOT NULL DEFAULT 0,
|
||
backend VARCHAR(64) NULL,
|
||
reason VARCHAR(255) NULL,
|
||
umod_clans TINYINT(1) NOT NULL DEFAULT 0,
|
||
clan_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
CONSTRAINT fk_rust_clan_boards_server
|
||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|