Files
Module-Rust/server/db/schema.sql
wtclaude e54ae3afb9
All checks were successful
PR Checks / server-tests (pull_request) Successful in 18s
PR Checks / frozen-manifest (pull_request) Successful in 51s
PR Checks / client-build (pull_request) Successful in 7m56s
feat(rust): mod configuration from the site, and an editor that will not rewrite a float
R18's two tiers: a form generated from a config file's own values, and raw JSON
for what a form cannot express. Admin → Rust mod config, one live round trip per
action, nothing cached between a browser and a game host's disk.

`configEdit.js` is the part that could not be done naively. JavaScript cannot
tell `1` from `1.0`, and both mod frameworks deserialize a config into typed C#
classes — so a read-modify-write silently rewrites every whole-numbered float as
an integer on fields nobody touched, and a plugin that then throws at load does
not come back. It never parses, mutates and re-serialises: it records the SOURCE
SPAN of every value and splices literals into them, so an untouched `1.0` is
still `1.0` and a number an admin types travels as text the whole way (D35/D36).

The bridge's own config is editable with `Host`, `Port` and `ServerId` locked,
in the form and in the raw tier, because either would cut the link carrying the
edit or strand every row this site holds (D38). Credentials render masked with a
reveal; the raw tier shows them (D37) and the audit trail never does.

`rust_config_writes` records every save including the refused and the rolled
back — an operator asking why a setting is not what they set needs to see that
somebody tried.

Three defects a browser walk found that 179 green tests did not:

* every save of the bridge's own config was refused while the page said the
  opposite — a `<select>` whose value matches no `<option>` shows the first one,
  so the reload guess `RunicGateway` was on the wire and "nothing" was on the
  screen;
* `btn ghost` is not a class this platform defines (`.btn-ghost` is), so every
  secondary button in this module has rendered as a primary one since phase 7 —
  here it made the open file and the active tier indistinguishable;
* a save's refusal rendered at the top of a long form, far from the button.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-22 08:55:28 -05:00

671 lines
36 KiB
SQL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- ── 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;