docs: plan Rust's world events, six optional plugins, and Steam sign-in (D349–D360) #345

Merged
whitlocktech merged 2 commits from docs/rust-native-events-optional-plugins into main 2026-10-14 10:10:50 +00:00
4 changed files with 338 additions and 3 deletions

View File

@@ -36,6 +36,7 @@ sidecar as a service, and hands you the values the website needs.
| [WIKI_UPGRADE.md](website/WIKI_UPGRADE.md) | Wiki subsystem upgrade notes |
| [SHARD_VISIBILITY.md](website/SHARD_VISIBILITY.md) | Who sees which shard data — the admin-configurable audience framework |
| [TRUSTED_DEVICES_MFA.md](website/TRUSTED_DEVICES_MFA.md) | TOTP two-factor, trusted devices and recovery codes |
| [STEAM_SIGNIN.md](website/STEAM_SIGNIN.md) | **Plan** — Sign in with Steam as a built-in provider beside Google and Discord (OpenID 2.0), and a Steam id for modules |
| [MODERATION_APPEALS.md](website/MODERATION_APPEALS.md) | Moderation actions, content reports and the appeals flow |
| [SPAWN_ATLAS.md](website/SPAWN_ATLAS.md) | The bestiary / spawn atlas: what the shard contains, parsed from the shard's own ServUO files — served over the bridge since Protocol 8, so no shared filesystem |
| [CLILOCS.md](website/CLILOCS.md) | UO's id → name table, so items have names. The shard decompresses and serves it over the bridge; the desktop conversion it replaced is gone |

View File

@@ -374,6 +374,9 @@ a display name. The goal the org lead named is server customisation — NPCs wit
### 4.8 Steam sign-in (moved to the website's plan, D171)
**Planned 2026-10-14 in [`../../website/STEAM_SIGNIN.md`](../../website/STEAM_SIGNIN.md)** (PLAN_REDESIGNS D360),
on the roadmap as the `website` milestone *Steam sign-in*.
Steam as a sign-in provider beside Google and Discord is part of the base website and is planned there, not
here. What this module needs from it: a way to learn a signed-in account's verified Steam id, so linking by
Steam can stand beside the in-game code (R1). Steam is OpenID 2.0, not OAuth2/OIDC, so the website needs its

View File

@@ -11,7 +11,7 @@ chose neither route: Runic Gateway writes its own NPC plugin, RunicNPC, planned
[`../../runicnpc/PLAN.md`](../../runicnpc/PLAN.md) (D214–D314), and the rest of this plan waits for it.** RunicNPC v1.0.0 shipped on 2026-10-09, and the plan
resumed with §2 the same day (D331–D334); §2 was built on 2026-10-12, and §11's Economics spike the same day
raised D335–D336 (§11.2.1). Economics was built on 2026-10-12 too (§11.2.4). The Backpacks spike followed and raised D337–D339 (§11.3.1), and Backpacks was built the same day (D340, D341, §11.3.4). The org lead then added permission rewards (§12,
D342–D346), planned and built the same day. On 2026-10-14 RaidableBases was deferred until the current paid copy arrives (D347), with both editions to be supported (D348, §11.4.2). **Every item left is built, tested and PR'd first; every walk —
D342–D346), planned and built the same day. On 2026-10-14 RaidableBases was deferred until the current paid copy arrives (D347), with both editions to be supported (D348, §11.4.2). The same day the org lead added Rust's own world events to the base module (§13, D349) and six more first-class optional plugins (§14, D350–D352), to be tested together in one phase before their integration plan. Their questions were answered the same day (D353–D359, the end walk now waiting for RaidableBases), and Steam sign-in stays on the website's plan, linked as §15 (D360). **Every item left is built, tested and PR'd first; every walk —
theirs and the ones still owed by §3 and §5 — happens together at the end (D331, §9).**
This is a companion to [`PLAN_FIXES.md`](PLAN_FIXES.md) and [`PLAN.md`](PLAN.md). Where they disagree, this
@@ -27,6 +27,9 @@ document is later and wins. Its decisions continue PLAN_FIXES' numbering at **D1
| 6 | NPCs — now RunicNPC, its own plan ([`runicnpc/PLAN.md`](../../runicnpc/PLAN.md)) | §4.7 | **runicnpc-rust**, Rust-Plugins, Rust-Link, Module-Rust, installer |
| 11 | First-class optional plugins: Economics, Backpacks, RaidableBases (Kits stays required) | — (added 2026-09-28) | Rust-Plugins, Module-Rust, installer, Android-app |
| 12 | Permissions as event rewards: until the event ends, or for good | — (added 2026-10-12) | **core** (website `main`, MODULE_API 1.14.0), Module-Rust |
| 13 | Rust's own world events (helicopter, Bradley, cargo, Chinook, airdrops, locked crates) in the base module | — (added 2026-10-14) | Rust-Plugins, Rust-Link, Module-Rust |
| 14 | Six more optional plugins: TruePVE, Dynamic PVP, Dangerous Treasures, Guarded Crate, ZLevels, Server Rewards | — (added 2026-10-14) | Rust-Plugins, Rust-Link, Module-Rust, Android-app |
| 15 | Steam sign-in: a link to the website's own plan, [`website/STEAM_SIGNIN.md`](../../website/STEAM_SIGNIN.md) | §4.8, D171 | **core** (website), Android-app, Module-Rust |
**Protocol.** §1, §3, §4 and §5 joined **protocol 13**, which shipped on 2026-10-09 with RunicNPC v1.0.0
(bundle 2026.10.09). The wire changes still to come, §2's and §11's, **open protocol 14** on `edge` and are
@@ -921,10 +924,16 @@ pulls) is research, not a walk, so it still comes first:
7. **The first-class optional plugins** (§11): Economics, then Backpacks, then RaidableBases, each starting
with its source pull and rig spike. Economics and Backpacks were built on 2026-10-12. **RaidableBases waits
for the current paid copy (D347)** and is built for both editions (D348).
8. **Permissions as event rewards** (§12), built on 2026-10-12.
9. **Rust's own world events and six more optional plugins** (§13, §14). First comes one test phase covering
all of them on both rigs (§14.2, D352). Then one integration plan, answered before code (§14.3). Then the
build, §13 first.
**Then one walk, on Oxide and Carbon, of everything still owed (D331):** §2.7; §11's rows; the in-game rows
**Then one walk, on Oxide and Carbon, of everything still owed (D331):** §2.7; §11's rows; §12.9; §13's and
§14's rows; the in-game rows
of §5.7 and §3.4; PLAN_FIXES §8's re-walk, including the Carbon pass of the first player walk. What it finds
is fixed, protocol 14 is released, and the cutover follows.
is fixed, protocol 14 is released, and the cutover follows. **The walk waits for RaidableBases (D359)**, so it
runs only once §11.4 is built from the current paid copy (D347).
## 10. Decisions of record
@@ -1027,6 +1036,28 @@ Reading the paid edition (§11.4.2) raised two more, answered on 2026-10-14:
| **D347** | **RaidableBases waits for the current paid copy.** No §11.4 code is written until the org lead supplies it and its spike (§11.4.2) has run. The old 3.0.5 copy was read for drift only. | Building §11.4 on the free edition now and fitting the paid edition in later; spiking the old 3.0.5 copy on the rigs. | 11.4.2 |
| **D348** | **Runic Gateway and all its systems support either edition, free or paid, as far as each allows.** Every part detects what the installed copy offers and uses the best route it has, and says so where there is none (§11.4.2). This widens D202: both editions must get each part, not only the paid edition's extras. | Building on the free edition's API and treating the paid edition as extras on top. | 11.4.2 |
The plugin survey the same day led to four more, decided by the org lead on 2026-10-14:
| # | Decision | Rejected | § |
|---|---|---|---|
| **D349** | **Rust's own world events are part of the base module:** the patrol helicopter, Bradley, the cargo ship, the Chinook, airdrops and locked crates reach the feed and the event system on every server, with no plugin. | Leaving them on the map only; supporting them through a plugin such as Inbound or Automated Events. | 13 |
| **D350** | **TruePVE, Dynamic PVP, Dangerous Treasures, Guarded Crate, ZLevels Remastered and Server Rewards become first-class optional plugins**, as D199 defines them. **Duelist is left out entirely.** | Duelist; SmoothRestarter and PlayerRanks (not wanted); Event Manager, Quests and Bounty (no result hooks). | 14 |
| **D351** | **As few helper plugins as possible, and nothing that runs afoul of a licence.** Runic Gateway never copies, modifies, bundles or redistributes a third-party plugin; it uses only their public hooks, methods and console commands, and operators install them from uMod. | A helper per plugin by default; shipping plugins with the installer or egg. | 14 |
| **D352** | **One test phase covers §13 and all six plugins together, on both rigs; then one integration plan, answered before code.** | A spike and a plan per plugin, as §11 did. | 14.2, 14.3 |
The org lead answered §13's and §14's questions the same day, and added Steam sign-in:
| # | Decision | Rejected | § |
|---|---|---|---|
| **D353** | **The public feed shows a world event with its grid and the player's name; the staff feed adds the Steam id** (Q12). | Staff only. | 13.4 |
| **D354** | **An event can call a vanilla event** (patrol helicopter, cargo ship, Chinook, airdrop at a grid or position) through a new step whose result is kept (`reversible: 'none'`): nothing is removed when the run ends. A new verb, not an exception to D88 (Q13). | No such step; removing the called event at teardown. | 13.4 |
| **D355** | **Every world event in §13.2 is an event-system trigger** (Q14). | Feed only. | 13.4 |
| **D356** | **Two new counts with titles: locked crates hacked (*Safecracker*) and airdrops looted first (*Scavenger*)** (Q15). Barrels broken, K/D ratio and time played are not added. | All of PlayerRanks' extra counts. | 13.4 |
| **D357** | **§13 and §14 join protocol 14** (D332), released with the rest after the end walk (Q16). | A protocol of their own. | 13.4 |
| **D358** | **BotReSpawn stays out** (Q17). | Feed and kill-count support for its bots. | 14.4 |
| **D359** | **The end walk waits for RaidableBases** (Q18). D331's one walk runs once every item is coded, §11.4 included, and §11.4 waits for the current paid copy (D347). | Walking the rest without it and giving RaidableBases its own walk later. | 9, 14.4 |
| **D360** | **Steam sign-in is supported in the same manner as the other providers:** a built-in provider beside Google and Discord, link-only, through the one session service, configured on Admin → Auth, on the website and in the Android app. **It stays on the website's plan (D171)**, now written as [`website/STEAM_SIGNIN.md`](../../website/STEAM_SIGNIN.md) and on the roadmap as the `website` milestone *Steam sign-in*. §15 links it. | Planning it inside this document. | 15 |
## 11. First-class optional plugins (D199–D202)
The org lead named four plugins the module supports as first-class integrations. Kits already is one. The other
@@ -1911,3 +1942,196 @@ steps. These points the design left open were settled in the code, and are named
- **The permission manager marks a for-good grant** with a chip, *given by event run N, step M*, from the
grant's note. It is turned off like any staff grant. A lent permission keeps its *from an event* chip and its
locked toggle, because the run's teardown takes it back.
---
## 13. Rust's own world events (D349)
On 2026-10-14 the org lead asked for a survey of further plugins worth supporting, "especially considering
events". The survey found that Rust's own world events already reach the site, but only as dots on the map.
The org lead decided that they become part of the **base module** (D349): no plugin, nothing optional, on every
server. They are the patrol helicopter, Bradley, the cargo ship, the Chinook, airdrops and locked crates.
### 13.1 What already exists
| What | Where | What it does |
|---|---|---|
| The live map's `world` layer | PROTOCOL §17.3 | Draws `cargo`, `heli`, `chinook`, `bradley`, `supply` and `crate` (a locked crate adds `hackLeftSec` or `hacked`), kept by `OnEntitySpawned` / `OnEntityKill` |
| Kill credit for the APC and both helicopters | the bridge's `CreditVehicle` (D174) | Credits the killing blow's player, or the last player to hurt the vehicle within 60 s when a helicopter crashes with nobody named. It counts `apcKills` / `heliKills` in `player.tally` for *Tankbreaker* and *Rotorbane* (§5.1) |
| Places the step verbs may use | `WorldPrefabs` (D88) | Crates and NPCs only. **Vehicles are kept out** because a player can take one, and a revert would kill something a person may be sitting in |
Nothing about these events reaches the feed, the event system's triggers, the run log or a leaderboard, apart
from the two kill counts.
### 13.2 The events, as proposed
The test phase (§14.2) confirms each one on the current Rust, Oxide and Carbon builds before the integration
plan names a hook. The column says what each should carry.
| Event | Carries |
|---|---|
| Patrol helicopter arrives; leaves without dying | grid, position |
| **Patrol helicopter downed** | who (the D174 credit), grid, position |
| Bradley spawns at Launch Site; **Bradley destroyed** | who, grid |
| Cargo ship arrives; docks at a harbour; leaves | grid, harbour |
| Chinook arrives; **drops its locked crate** | the monument it dropped at |
| Airdrop called (supply signal or plane); lands; **first looted** | who called it, grid, who looted it |
| **Locked crate: hack started; unlocked; looted** | who, where (small or large oil rig, cargo, Chinook drop, other), grid |
| Oil rig: heavy scientists called | which rig |
| Travelling Vendor arrives; leaves | grid |
| Anything newer in the current build | found by scanning Assembly-CSharp for event prefabs, and Oxide and Carbon for hooks, as §5's mission hook was found |
**On the wire** they would be one new frame kind with a `type` field. That keeps the public feed's allowlist
one entry wide, and it is filtered per type. They are rare, a few an hour, so one frame per event is fine.
Kill credit reuses D174's rule, so the feed and *Rotorbane* never disagree about who downed a helicopter.
### 13.3 What the site would do with them
- **The feed.** Each event is a feed line, e.g. *"Patrol helicopter downed by Alice at G7"*. Who may see which
line is Q12.
- **Triggers.** Each event is an event-system trigger, like `rust.boss.killed` today. Q14.
- **Steps that call a vanilla event.** For example a patrol helicopter, a cargo ship, a Chinook, or an airdrop at
a grid or position, offered as a step. Q13.
- **Titles and columns.** New counts from these events. Q15.
- **The map** already shows them, and nothing changes there.
### 13.4 Questions for the org lead
**Answered on 2026-10-14: every proposal below was accepted (D353–D357).**
- **Q12 — Who sees each line in the feed?** For example, *"Patrol helicopter downed by Alice at G7"* and
*"Bob started hacking the locked crate on Large Oil Rig"*. Everyone, or staff only? **Proposed:** the public feed
shows the event, the grid and the player's name; the staff feed adds the Steam id. The map already shows
these things publicly (D114 switches apply).
- **Q13 — Can an event call one?** For example, a *Saturday Air Raid* event at 20:00 calls a patrol
helicopter and three airdrops onto Launch Site. **Proposed:** yes, as a step whose result is kept
(`reversible: 'none'`). When the run ends, nothing is removed, because a cargo ship or an airdrop may already
carry players or their loot. This is why D88 kept vehicles out of the place verbs. It is a new verb, not an
exception to D88.
- **Q14 — Are they triggers?** For example, *"when anyone destroys Bradley, start the Launch Site Lockdown event"*,
or *"when the cargo ship docks, announce it on Discord"*. **Proposed:** every event in §13.2 is a trigger.
- **Q15 — Which new counts?** For example, *Safecracker* for 10 locked crates hacked this wipe, or *Scavenger* for
10 airdrops looted first. The PlayerRanks plugin the org lead set aside also counts barrels broken, K/D ratio
and time played, which §5 does not. **Proposed:** locked crates hacked and airdrops looted first. The others
only if the org lead wants them.
- **Q16 — Which protocol?** **Proposed:** protocol 14, which is unreleased (D332), released with the rest after
the end walk.
---
## 14. More first-class optional plugins (D350–D352)
The same survey read the org lead's two old plugin folders and uMod's Rust catalogue (446 plugins, by downloads
and 57 searches). The org lead chose **six free plugins as first-class optional integrations** (D350):
**TruePVE, Dynamic PVP, Dangerous Treasures, Guarded Crate, ZLevels Remastered and Server Rewards.** *Optional*
means D199's pattern: each is detected at hello, a step or screen that needs one refuses with a reason when it is
absent, and the installer never requires it.
Two rules from the org lead govern every one of them (D351):
- **As few helper plugins as possible.** The source reading below finds a public route for every proposed use,
so the target is **no new helper**. If the test phase finds one is needed, the integration plan says why and
asks first.
- **Nothing that runs afoul of a licence.** Runic Gateway never copies, modifies, bundles or redistributes a
third-party plugin. The installer and the egg do not ship one, and operators install them from uMod. The
bridge uses only their public hooks, their callable methods and their console commands (R2). Paid copies
(RaidableBases' paid edition, D347) are never committed to any repository or scratch area that is shared.
**And one way to test them (D352):** a **single test phase** covering all six plugins and §13's native events
together, on both rigs. Then **one integration plan**, written from what it found, before any code. This
replaces §11's pattern of a spike and a plan per plugin.
### 14.1 The six, as read from their source (2026-10-14)
Each line was read from the plugin's current `umod.org/plugins/<Name>.cs`. The test phase confirms each one on
the rigs.
| Plugin | Version, release, licence | What integrating it would give | The route the source shows |
|---|---|---|---|
| **TruePVE** (nivex) | 2.4.6, 2026-10-02, **none listed on uMod** | §3's zone step gains *"PvP rules in this zone"*: a ruleset for the run's zone, removed at teardown | `AddOrUpdateMapping(zoneId, ruleset)` / `RemoveMapping(zoneId)`; it raises `OnUpdatedMappings` / `OnRemovedMappings` |
| **Dynamic PVP** (HunterZ) | 5.2.0, 2026-08-31, MIT | Its temporary PvP zones around a downed helicopter, Bradley, a locked crate, a supply drop or cargo appear on the live map and in the feed | `OnCreatedDynamicPVP` / `OnDeletedDynamicPVP` (zone id, event name), `OnPlayerEnterPVP` / `ExitPVP`. It **requires ZoneManager and TruePVE** |
| **Dangerous Treasures** (nivex) | 3.0.0, 2026-10-07, GPL-3 | Feed and map for a treasure event, a *treasures won* column and title, and a step that starts one | Hooks `OnDangerousEventStarted(pos)`, `Won(looter, invaders)`, `Ended(pos)`, `OnPlayerEntered/ExitedDangerousEvent`. Console `dtevent [difficulty] [count]`; **it picks the spot** |
| **Guarded Crate** (Bazz3l) | 2.0.17, 2026-08-09, MIT | Feed and map, and a step that places a guarded crate at a spot | Hooks `OnGuardedCrateEventStart(pos)`, `Ended(player, instance)`, `Failed(pos)`. Console `gcrate start <event>` and `gcrate position <event> x y z`. **`gcrate stop` clears every one**, and the handler checks `arg.IsRcon` |
| **ZLevels Remastered** (nivex) | 3.3.1, 2026-09-10, MIT | Skill levels as leaderboard columns and title conditions. **XP boosts need no code:** its VIP XP multipliers are keyed by permission name, so §12's `rust.perm.lend` already lends one | `GetLevel(userId, skill)`, `api_GetPlayerInfo(userId)`. **No level-up hook**, so the level is read at tally time. Its only write, `api_SetPlayerInfo`, replaces the whole record and is not used |
| **Server Rewards** (k1lly0u) | 2.0.9, 2026-09-27, MIT | The second common currency (RP). It mirrors §11.2: a pay step, balances on the site and app, staff adjust, and a column | `AddPoints` / `TakePoints` / `CheckPoints` (by Steam id), and `OnPointsUpdated(userId, balance)` |
**Not included:**
- **Duelist:** left out entirely by the org lead. Its name also clashes with §5's blade-kill title.
- **SmoothRestarter:** not wanted.
- **PlayerRanks:** not wanted, because the site's leaderboard and titles already do its job. Its stat list fed Q15.
- **BotReSpawn:** paid ($40 on Codefling), with its own NPC AI overlapping RunicNPC (Q17).
- **Event Manager and its modes, Quests, Bounty:** they raise no result hooks, so the site could only poll
their data files, and most of Event Manager's modes were last released in 2021–2024.
### 14.2 The single test phase
One phase covers everything in §13 and §14, on **both rigs (Oxide and Carbon)**. It is research, so it runs now,
not with the deferred walks (D331).
**Setup.**
- Bring Rust, Oxide and Carbon up to date on both rigs, and record the builds.
- Back up each rig's plugin, config and data directories.
- Install the six from uMod at their current versions, recording each version and checksum.
- Load one throwaway spike plugin that subscribes to every hook in §13.2 and §14.1, logs each call with its
arguments, and calls each route in §14.1. It and the six are removed afterwards, and the rigs are restored from
the backups.
**What it records, per plugin:**
| Plugin | Questions the phase answers |
|---|---|
| **Native events** | For each row of §13.2, forced from the rig console: the hook that fires on each framework, its arguments, and who it names. Does a downed helicopter's credit agree with D174? |
| **TruePVE** | Do mapping a zone made by §3's zone step, and removing the mapping, change damage inside it as expected? Is the mapping saved across a TruePVE reload and a server restart? Can the ruleset names be read for the step's picker without the bridge reading the config file? |
| **Dynamic PVP** | For each vanilla event it reacts to: the zone id and name it creates, the radius, and how long the zone lasts. **Does §1/§3's zone handling see its zones as foreign ones, and leave them alone?** Do its TruePVE mappings and ours coexist? |
| **Dangerous Treasures** | `dtevent` per difficulty: where the event lands, every hook's arguments, the despawn timing, its map marker, and its own TruePVE and ZoneManager options. Boot cost |
| **Guarded Crate** | Does `gcrate position` work from the bridge's server-side console run, given the `IsRcon` check? Are the hook arguments as read? Is there any way to end one instance? How do RunicNPC's factions (D254, D255) treat its guards, which are Rust's scientists? |
| **ZLevels** | What does a level read cost for one player, and for a full server at tally time? Does a lent VIP permission change the XP rate at once? What happens at wipe, and with the no-wipe permission? |
| **Server Rewards** | Do Add, Take and Check work for an offline player? `OnPointsUpdated`'s arguments. Its wipe behaviour |
| **All together** | All six plus RunicNPC, ZoneDomes, Economics and Backpacks loaded at once: boot time, any errors, and hook overhead during a busy minute |
**The phase ends with a findings section per item**, as §11.4.1 and §11.4.4 did, plus the questions it raises.
It ships as one docs PR.
### 14.3 The integration plan
Written after §14.2, in one docs PR, and answered by the org lead before any code. For §13 and for each of the
six it says:
- what hello reports (`integrations` gains each plugin's `{ loaded, version }`);
- the wire additions, in protocol 14 unless Q16 says otherwise;
- what the site does: feed, map, steps, triggers, titles, columns, configuration;
- any helper, and why, which D351 makes the exception;
- the tests;
- the walk rows, which join the end-of-plan walk (D331).
It also proposes a build order. The working assumption is §13 first (no dependencies), then TruePVE, then Dynamic
PVP, then the other four.
### 14.4 Questions for the org lead
**Answered on 2026-10-14:** Q17 as proposed, out (D358). **Q18 against the proposal: the end walk waits for
RaidableBases (D359).**
- **Q17 — Is BotReSpawn in or out?** It is paid ($40) and runs its own NPC AI, profiles and monument spawns,
which RunicNPC already does. It cannot drive RunicNPC. The most it could add is its bots named in the feed
and kill counts. **Proposed:** out. It can be added later under D348's rules, with a licensed copy for the rigs.
- **Q18 — Does the end walk wait for RaidableBases?** D331 holds one walk until every item is coded, and
RaidableBases now waits for a copy with no date (D347). For example, if the paid copy arrives in a month, do §2,
§11.2, §11.3, §12, §13 and §14 wait a month for their walk? **Proposed:** no. The walk runs when §13 and §14
are coded, and RaidableBases gets its own walk after its build.
---
## 15. Steam sign-in (D360): on the website's plan
The org lead asked on 2026-10-14 for Steam sign-in **in the same manner as the other providers**, and for it to
stay on the website's plan (D171). It is planned in **[`website/STEAM_SIGNIN.md`](../../website/STEAM_SIGNIN.md)**
and is on the roadmap as the `website` milestone *Steam sign-in*. It is core work, not an item of this plan, and
it does not hold the end walk (D359).
What this module takes from it:
- **A verified Steam id for a signed-in account**, read through the module contract (MODULE_API 1.15.0 there).
- **A Steam identity as a linked Rust player**, beside the in-game `/link` code (R1). Whether it counts as a link
is that plan's Q1, and what unlinking removes is its Q2.
- When it lands, Module-Rust's account screens and the link flow are updated in their own PR. Rust's grants
already follow the website user to every linked Steam id (D28), so nothing else changes.

107
website/STEAM_SIGNIN.md Normal file
View File

@@ -0,0 +1,107 @@
# Sign in with Steam — plan
> **Status: plan, written 2026-10-14. Nothing is built.** Its four questions (§7) are for the org lead before code.
> It is the website's own plan for Steam sign-in, as Rust's
> [`PLAN_FIXES.md`](../modules/rust/PLAN_FIXES.md) D171 set out (§4.8 there), and it is on the roadmap as the
> `website` milestone *Steam sign-in*, issue website#214. Rust's [`PLAN_REDESIGNS.md`](../modules/rust/PLAN_REDESIGNS.md) §15 links
> here for what the Rust module needs from it.
## 1. Goal
**Steam becomes a sign-in provider in the same manner as the others** (the org lead, 2026-10-14; D360 in
PLAN_REDESIGNS). That means:
- It is a **built-in provider beside Google and Discord**. An admin turns it on in Admin → Auth.
- It follows the **same policy**: link-only, with opt-in sign-up when `player_registration` allows SSO.
- It creates sessions through the **same session service**, with the TOTP gate and the trusted-device skip.
- It is linked and unlinked from a player's account page, as the others are.
- It works **in the Android app** through the existing mobile SSO bridge (M9), like the others.
What it adds beyond that: **modules can learn a signed-in account's verified Steam ids**. That is what the Rust
module has needed since its dry run (`modules/rust-dryrun.md` finding 1, MODULE_SYSTEM.md's "a module cannot
register an identity provider").
Out of scope: a module registering its own identity provider. Steam is core's, so session creation stays a part of
core that no module can weaken (MODULE_SYSTEM.md).
## 2. What exists
| Piece | Where | Shape |
|---|---|---|
| Providers | `server/src/auth/providers/` | `registry.js` maps a `kind` to a class. Built-ins `google` and `discord` always exist as defaults; `oidc`/`oauth2` are custom. Each class implements `getAuthorizationUrl(state, options)` and `handleCallback(params)` → `{ subject, email, emailVerified, name }` |
| Provider config | `auth_providers` | `kind ENUM('google','discord','oidc','oauth2')`, `client_id`, `client_secret_enc` (AES-256-GCM via `secretBox`), URLs, scopes |
| Identities | `user_identities` | `(provider, subject)` unique, `user_id` indexed. **Several identities per provider per user are already allowed**, which several Steam accounts need |
| The flow | `router/v1/auth/sso.controller.js` | `beginFlow` (modes `login`, `link`, `mobile`) signs a single-use `sso_tx` cookie. `callback` reads `code` and `state` from the query, verifies the tx, calls `handleCallback`, then `finishLogin` / `finishLink` / `finishMobileLogin`. `provisionSsoPlayer` creates a player when registration allows SSO sign-up |
| Android | `/auth/mobile/sso/*` (M9) | A Custom Tab runs the same `/auth/sso/:provider/*` flow; the app receives a one-time code and exchanges it with PKCE |
## 3. How Steam differs: OpenID 2.0, not OAuth2
| OAuth2 / OIDC (today) | Steam |
|---|---|
| Redirect to an authorize URL with `client_id`, `state`, PKCE | Redirect to `https://steamcommunity.com/openid/login` with `openid.mode=checkid_setup`, `openid.return_to`, `openid.realm` and the identifier-select fields. **No client id, no secret, no `state` parameter** |
| Callback carries `code` and `state` | Callback carries `openid.*` fields. **Our `state` must ride inside `return_to`**, and the tx cookie verifies it as today |
| Exchange the code at the token endpoint | **Verify the assertion** by POSTing the same fields back to Steam with `openid.mode=check_authentication`, and accepting only `is_valid:true` |
| `subject` from the ID token or userinfo | `subject` is the 17-digit SteamID64 at the end of `openid.claimed_id` (`https://steamcommunity.com/openid/id/<id>`) |
| Email and name from the IdP | **No email.** No name either, unless the site holds a Steam Web API key (Q3) |
The checks the adapter must make: `openid.op_endpoint` is Steam's, `return_to` matches the URL we sent, the
claimed id matches `^https://steamcommunity\.com/openid/id/\d{17}$`, and `check_authentication` says valid. Each
assertion is single-use, which the existing single-use tx cookie already enforces.
## 4. The build
**Core (`website`):**
- `steam.provider.js`, a `BaseProvider` with `getAuthorizationUrl` and `handleCallback` doing §3. It is a built-in in
`registry.js` (`{ id: 'steam', kind: 'steam', name: 'Steam' }`), and its config validates with no client id or secret.
- `auth_providers.kind` gains `steam`. The schema change is additive.
- `sso.controller.js`'s `callback` accepts Steam's `openid.*` fields where it now requires `code`, for `kind: steam`
only, and passes them to `handleCallback`. Everything after `handleCallback` is unchanged: `finishLogin`,
`finishLink`, `finishMobileLogin`, the TOTP gate and provisioning.
- Admin → Auth shows Steam with an enable switch and, per Q3, an optional Web API key stored like a client secret.
- The account page lists Steam identities with a *Link Steam* button, and each linked Steam account is shown with its
id (or name, Q3) and an *Unlink* button.
- **Module contract, MODULE_API 1.15.0 (additions only):** a read of a user's verified identities for one provider,
and a hook when one is linked or unlinked. Names are settled at build. Module-uo uses neither, and runs its usual
frozen-manifest proof against the new core.
- OpenAPI annotations and the regenerated spec for anything new or changed; `BACKEND_DESIGN.md` §3/§4/§6.
**Android (`Android-app`):** the provider list comes from `/auth/providers`, so Steam appears where the app lists
providers. The work is the button's mark and a check of the M9 bridge with Steam's callback. Confirmed in the build.
**Module-Rust:** a Steam identity on the account becomes a linked Rust player (Q1), beside the in-game `/link` code
(R1). Unlinking follows Q2.
## 5. Security
- Nothing secret ships to a client. The optional Web API key is server-side and write-only in the API, like client
secrets.
- The `check_authentication` call is the only proof of a Steam assertion. It is made server to server, and never
skipped.
- **Link-only stays the rule:** a Steam id that is not linked signs nobody in, unless registration opts into SSO
sign-up (Q4).
- Login hardening (rate limits, bot score, honeypot) applies to the Steam routes as to the others.
## 6. Tests
- Unit: the claimed-id parser, `return_to` matching, a `check_authentication` reply of `is_valid:false`, a replayed
assertion, a wrong `op_endpoint`.
- Flow: login, link and mobile modes against a stubbed Steam endpoint, including the TOTP gate and provisioning.
- Module-uo proof against the new core.
- A live walk: sign in with a real Steam account on the local review stack, link, unlink, and the same in the app.
## 7. Questions for the org lead
- **Q1 — Does a Steam sign-in count as a linked Rust player?** For example, Alice clicks *Link Steam* on her account
page and approves it on steamcommunity.com. Does the Rust module then treat her as owning that Steam account, so
her kit rewards and permissions reach it, without the in-game `/link` code? **Proposed:** yes. Steam's own sign-in
proves the account at least as well as the code does, and both routes stay.
- **Q2 — What does unlinking remove?** For example, Alice unlinks Steam on her account page. Does her Rust link go
too, and with it the grants D28 resolves to that Steam id? **Proposed:** the two are one link with two ways to
make it, so removing either removes the Rust link. The site says so before it does.
- **Q3 — Names and avatars.** Steam's sign-in returns only the Steam id. Showing *"Steam: CoolGuy99"* with an avatar
needs a Steam Web API key, which an admin would paste into Admin → Auth like a client secret. **Proposed:** an
optional key. Without it the account page shows the Steam id, and a provisioned username is `steam_` plus the
last digits of the id.
- **Q4 — Sign-up through Steam.** When registration allows SSO sign-up, a new account from Steam has **no email**.
**Proposed:** allowed, as for any provider that sends no email. The player adds and verifies an address through
the ordinary flow later.