R2, and the first phase where this module WRITES to a game. Groups and grants are authored on the website and pushed into each server's own permission store, so every plugin that already calls `UserHasPermission` honours them with no adapter, and a wipe stops being a data-loss event. **Seven org-lead decisions (D28-D34).** A grant is keyed to the website USER and resolved to every Steam id they have linked at push time (D28); every authored row carries a scope — a server or `*` (D29); groups are mirrored as real groups rather than flattened (D30); a holder the site did not author is REPORTED, never undone, with adopt and revoke offered (D31); one verb, with the plugin diffing locally (D32); a permission no server has registered is reported unresolved and never self-registered (D33); authoring is people and groups by hand, with rules deferred (D34). **Three sets, and every interesting question is a difference between two.** `desired − pushed` is what to apply; `pushed − desired` is what to RETIRE, because the site put it there and has since withdrawn it; `present − desired` is drift. The middle one is why `rust_perm_pushed` exists: a name in the store that is not in the desired set is either something the site retired or something a human granted, and those two have opposite correct answers. **What lands is not what was sent.** A grant naming a permission the server has not registered did not land — `GrantUserPermission` no-ops silently — and a member the store has never seen could not be placed. Neither is recorded as pushed, so the site never believes it gave a privilege it did not. The loop asks a cheap question every thirty seconds — does the digest of the desired set still equal what this server last confirmed — and syncs on a change, a restart, a wipe, a drift hook, a failed attempt past its backoff, or the fifteen-minute audit that finds drift on a server nobody has touched. **This module's first admin page**, because a permission model is the first thing here that has to be composed rather than configured. What is on it is decided by what an operator can get wrong: four states are invisible from the game and from a list of grants, and each is a sentence rather than a number. Walked end to end against a real core at the pinned ref, the real sidecar, and a stand-in speaking protocol 4 — including a restart that emptied the store and was fully re-pushed. Four defects the browser found that 133 green tests did not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
621 lines
33 KiB
SQL
621 lines
33 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;
|