From b89039142ed6a747dedf0b2f8ea4ae363fb50e94 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 19 Oct 2026 14:02:53 -0500 Subject: [PATCH 1/2] feat(rust): a Steam account linked on the site is a Rust link (D392, D396) Registers core's MODULE_API 1.17.0 identity hooks (coreApi ^1.17.0): onIdentityLinking refuses a Steam id that is another website account's Rust link (D396); onIdentityLinked writes the Rust link with the PRIMARY KEY as the race guard, reconciles Teams and raises the linked notice, as /link does. Unlinking Steam leaves the Rust link (D393); the Rust account page says so. ci/core-ref.json still pins the 1.15.0 core and must move to the website branch head carrying 1.17.0 before CI can pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- client/src/routes/player/Account.jsx | 9 ++++ engagement-triggers.json | 2 +- module.json | 2 +- server/index.js | 10 +++++ server/model/links/links.model.js | 67 ++++++++++++++++++++++++++++ server/test/_fakes.js | 3 ++ server/test/entry.test.js | 7 +++ server/test/links.test.js | 50 +++++++++++++++++++++ 8 files changed, 148 insertions(+), 2 deletions(-) diff --git a/client/src/routes/player/Account.jsx b/client/src/routes/player/Account.jsx index 6f9ab3b..cc71497 100644 --- a/client/src/routes/player/Account.jsx +++ b/client/src/routes/player/Account.jsx @@ -377,6 +377,15 @@ export default function Account() {
  • Type it below. It works once.
  • + {/* D392: core's "Link Steam" is a second way to make this link. Unlinking + Steam there removes only the sign-in (D393), so this page says where a + Rust link is removed. */} +

    + If this site offers Steam sign-in, linking Steam on your account + page links that Steam account here too. Unlinking it there only stops you signing in with it; remove a + Rust link here. +

    + {loading && } diff --git a/engagement-triggers.json b/engagement-triggers.json index b27617d..5d7fe3e 100644 --- a/engagement-triggers.json +++ b/engagement-triggers.json @@ -1,6 +1,6 @@ { "_comment": "Generated freeze of module-rust's engagement contract (docs/modules/rust/PLAN.md §25). Regenerate with `npm run engagement:manifest` in server/. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal.", - "coreApi": "^1.15.0", + "coreApi": "^1.17.0", "triggers": [ { "id": "rust.base.destroyed", diff --git a/module.json b/module.json index c403713..4aba531 100644 --- a/module.json +++ b/module.json @@ -2,7 +2,7 @@ "id": "rust", "name": "Rust", "version": "0.1.0", - "coreApi": "^1.15.0", + "coreApi": "^1.17.0", "server": "server/index.js", "client": { "entry": "client/dist/entry.js" }, "schema": "server/db/schema.sql", diff --git a/server/index.js b/server/index.js index c7dde29..0fa5026 100644 --- a/server/index.js +++ b/server/index.js @@ -158,6 +158,16 @@ module.exports = function register(ctx, api) { api.onBoot(boot.onBoot) api.onShutdown(boot.onShutdown) + // Sign in with Steam (MODULE_API 1.17.0; PLAN_REDESIGNS §15). A Steam account + // linked on the site is a linked Rust player (D392): core asks before it writes + // the sign-in identity, and this module refuses a Steam id that is another + // account's Rust link (D396); once it is written, the Rust link is made. Both + // are in the links model beside `/link`'s own write, and neither runs at + // registration — core calls them from a signed-in user's link flow. + const links = require('./model/links/links.model') // eslint-disable-line global-require + api.onIdentityLinking(links.checkSteamSignin) + api.onIdentityLinked(links.linkFromSteamSignin) + // The leases (PLAN.md §27, protocol 8): what an event may BORROW on a server // and must give back. Core's `core.lease` is the verb; these are the values it // may name and the four callables each ships. Every lease is targeted and the diff --git a/server/model/links/links.model.js b/server/model/links/links.model.js index eb43e8a..4e3ba44 100644 --- a/server/model/links/links.model.js +++ b/server/model/links/links.model.js @@ -286,6 +286,71 @@ async function unlinkFromGame(steamId) { return removed } +// ── Steam's own sign-in as a second way to link (D392, D393, D396) ───────── +// +// Core's "Link Steam" (STEAM_SIGNIN.md) proves the account with Steam's OpenID +// `check_authentication`, which is at least as strong as a code typed in game: +// so a Steam account linked on the site IS a linked Rust player, beside `/link`. +// Core asks this module twice through MODULE_API 1.17.0 — before it writes the +// sign-in identity, and after. Unlinking Steam on the site removes only the +// sign-in (D393), so there is no third call: the Rust link stays until it is +// removed on the Rust page or by `/unlink` in game, exactly as one made by code. + +const STEAM_ID = /^\d{17}$/ + +/** + * Core's pre-link check (`api.onIdentityLinking`). Refuses a Steam id that is + * already ANOTHER website account's Rust link (D396) — the same rule as D23 for + * a code, for the same reason: a link here is a privilege path, and moving it + * would hand that account's grants to whoever signed in. Core shows "already + * linked to another account" and nothing is written; staff remove the old link + * if the claim is real. Our own words go to core's log, never to the page. + */ +async function checkSteamSignin({ userId, provider, subject }) { + if (provider !== 'steam') return { ok: true } + const held = await db.getBySteamId(String(subject)) + if (held && Number(held.userId) !== Number(userId)) { + return { ok: false, reason: 'in_use', message: `steam ${subject} is the Rust link of user ${held.userId}` } + } + return { ok: true } +} + +/** + * Core's linked hook (`api.onIdentityLinked`): the Steam account becomes this + * user's Rust link (D392). + * + * The same write as `confirmOne`, with the PRIMARY KEY as the race guard: if + * somebody else's `/link` landed between core's check and this, the key refuses + * ours and their link stands — the sign-in identity core already wrote is only a + * way to sign in. `name` is the Steam persona name, which is what a Rust player is + * called in game until they rename. No `server_id`: no server minted anything. + */ +async function linkFromSteamSignin({ userId, provider, subject, displayName }) { + if (provider !== 'steam') return + const steamId = String(subject) + if (!STEAM_ID.test(steamId)) return + + const held = await db.getBySteamId(steamId) + if (held) { + if (Number(held.userId) !== Number(userId)) { + log.warn('steam sign-in linked an id that is another user\'s Rust link; the Rust link stands', { steamId, userId }) + } + return + } + try { + await db.insert({ steamId, userId, name: displayName || null, serverId: null }) + } catch (err) { + if (err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062)) { + log.warn('steam sign-in link lost a race for this id; the other link stands', { steamId, userId }) + return + } + throw err + } + log.info('steam account linked through steam sign-in', { steamId, userId }) + linksChanged('rust account linked via steam sign-in') + engagement.linked({ userId, steamId, name: displayName || null }) +} + /** The admin panel's read: every link this user holds, with per-server totals. */ async function forAdmin(userId) { const links = await db.listForUserWithPlayer(userId) @@ -326,5 +391,7 @@ module.exports = { unlinkOwned, unlinkAnyOwner, unlinkFromGame, + checkSteamSignin, + linkFromSteamSignin, forAdmin, } diff --git a/server/test/_fakes.js b/server/test/_fakes.js index 94f28bf..0f022c7 100644 --- a/server/test/_fakes.js +++ b/server/test/_fakes.js @@ -166,6 +166,9 @@ function fakeApi() { registerSlashCommands(commands) { once('registerSlashCommands'); record.slashCommands = commands }, onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn }, onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn }, + // MODULE_API 1.17.0 (Steam sign-in, PLAN_REDESIGNS §15). + onIdentityLinking(fn) { once('onIdentityLinking'); record.hooks.onIdentityLinking = fn }, + onIdentityLinked(fn) { once('onIdentityLinked'); record.hooks.onIdentityLinked = fn }, } api.record = record return api diff --git a/server/test/entry.test.js b/server/test/entry.test.js index a98d339..b4806c2 100644 --- a/server/test/entry.test.js +++ b/server/test/entry.test.js @@ -85,6 +85,13 @@ test('registers both lifecycle hooks', () => { assert.strictEqual(typeof api.record.hooks.onShutdown, 'function') }) +test('registers both identity hooks, so a Steam sign-in link is a Rust link (D392, D396)', () => { + const { api } = register() + const links = require('../model/links/links.model') + assert.strictEqual(api.record.hooks.onIdentityLinking, links.checkSteamSignin) + assert.strictEqual(api.record.hooks.onIdentityLinked, links.linkFromSteamSignin) +}) + test('the manifest declares what the loader requires', () => { assert.match(manifest.id, /^[a-z][a-z0-9-]{1,31}$/) assert.match(manifest.version, /^\d+\.\d+\.\d+/) diff --git a/server/test/links.test.js b/server/test/links.test.js index 1eb427b..a029783 100644 --- a/server/test/links.test.js +++ b/server/test/links.test.js @@ -429,3 +429,53 @@ test('a state read that fails asks everybody, which is slow and never wrong', as assert.equal((await links.redeem({ code: 'ZZZZZZ', userId: 4 })).reason, 'rejected') assert.equal(asked.length, 2) }) + + +// ── Steam's own sign-in as a second way to link (D392, D393, D396) ───────── + +const STEAM = '76561197960287930' + +test('core’s pre-link check refuses a Steam id that is another account’s Rust link (D396)', async () => { + withCore({ select: [[{ steamId: STEAM, userId: 9, username: 'bob' }]] }) + const links = require('../model/links/links.model') + const answer = await links.checkSteamSignin({ userId: 4, provider: 'steam', subject: STEAM }) + assert.equal(answer.ok, false) + assert.equal(answer.reason, 'in_use') +}) + +test('the pre-link check passes a free Steam id, one already yours, and every other provider', async () => { + withCore({ select: [[], [{ steamId: STEAM, userId: 4 }]] }) + const links = require('../model/links/links.model') + assert.deepEqual(await links.checkSteamSignin({ userId: 4, provider: 'steam', subject: STEAM }), { ok: true }) + assert.deepEqual(await links.checkSteamSignin({ userId: 4, provider: 'steam', subject: STEAM }), { ok: true }) + assert.deepEqual(await links.checkSteamSignin({ userId: 4, provider: 'google', subject: 'x' }), { ok: true }) +}) + +test('a Steam account linked on the site becomes a linked Rust player (D392)', async () => { + let inserted = null + const { ctx } = withCore({ select: [[]], onInsert: (params) => { inserted = params; return Promise.resolve({ affectedRows: 1 }) } }) + const links = require('../model/links/links.model') + await links.linkFromSteamSignin({ userId: 4, provider: 'steam', subject: STEAM, displayName: 'CoolGuy99' }) + assert.deepEqual(inserted, [STEAM, 4, 'CoolGuy99', null]) + assert.equal(ctx.teams.reconcile.calls.length, 1) + assert.equal(ctx.events.emit.calls.length, 1, 'the player hears about the new link, as with /link') +}) + +test('the linked hook writes nothing for a link that is already there, or for another provider', async () => { + let inserts = 0 + const { ctx } = withCore({ select: [[{ steamId: STEAM, userId: 4 }]], onInsert: () => { inserts += 1; return Promise.resolve({}) } }) + const links = require('../model/links/links.model') + await links.linkFromSteamSignin({ userId: 4, provider: 'steam', subject: STEAM, displayName: 'x' }) + await links.linkFromSteamSignin({ userId: 4, provider: 'discord', subject: '123', displayName: 'x' }) + await links.linkFromSteamSignin({ userId: 4, provider: 'steam', subject: 'not-an-id', displayName: 'x' }) + assert.equal(inserts, 0) + assert.equal(ctx.teams.reconcile.calls.length, 0) +}) + +test('a /link that lands between core’s check and the hook keeps its link; the hook does not throw', async () => { + const dup = Object.assign(new Error('dup'), { code: 'ER_DUP_ENTRY' }) + const { ctx } = withCore({ select: [[]], onInsert: () => Promise.reject(dup) }) + const links = require('../model/links/links.model') + await links.linkFromSteamSignin({ userId: 4, provider: 'steam', subject: STEAM, displayName: 'x' }) + assert.equal(ctx.teams.reconcile.calls.length, 0) +}) -- 2.49.1 From 81724546831b27d2bc58bfd258688724ff7dcd12 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 19 Oct 2026 20:28:25 -0500 Subject: [PATCH 2/2] ci(rust): pin core to website feat/steam-signin (MODULE_API 1.17.0) coreApi is ^1.17.0, which no core on main offers yet, so the frozen-manifest job is proved against the head of the open website PR. Moves to the sha it merges as. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- ci/core-ref.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ci/core-ref.json b/ci/core-ref.json index cbe8047..8847e0e 100644 --- a/ci/core-ref.json +++ b/ci/core-ref.json @@ -1,6 +1,6 @@ { - "$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/rust and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves, because a mount prefix is a string in server/index.js and a documented path is a string in a JSON file, and whether those name the same URL is a fact about a running core. It also answers the blind spot phase 1 had to check by hand: core mounts several routes at the TIER ROOT (/status, /version), which the loader's collision probe cannot see, so /rust being free is asserted here by a core rather than by a reading. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. This module needs MODULE_API 1.10.0 (module.json's coreApi is ^1.10.0), which the Event System cutover put on `main` — so unlike Module-uo, which spent the Event System window pinned to `edge`, this repo starts pinned to `main` and should stay there unless it comes to depend on a contract member that has not shipped yet. **2026-09-27, protocol 13 step 2:** this module calls ctx.events.expired (MODULE_API 1.11.0, PLAN_FIXES D183), so coreApi is ^1.11.0. It was pinned to website#209's branch head while that PR was open — a 1.10.0 core refuses to load it — and is back on `main` at the sha #209 merged as. **2026-10-12, protocol 14 (PLAN_REDESIGNS §2):** this module registers an event template and declares param durations, a lease value example and option notes (MODULE_API 1.13.0), so coreApi is ^1.13.0. It was pinned to website#212's branch head while that PR was open, and is back on `main` at the sha #212 merged as (the same tree). **2026-10-12, PLAN_REDESIGNS §12 (permission rewards):** this module declares a param with multiple: true (MODULE_API 1.14.0), so coreApi is ^1.14.0. It was pinned to website#213's branch head while that PR was open, and is back on `main` at the sha #213 merged as (the same tree). **2026-10-15, PLAN_REDESIGNS §14.3.4 (Dangerous Treasures):** the Start a Dangerous Treasures event step answers DT's warm-up with retryAfterMs (MODULE_API 1.15.0, D371), so coreApi is ^1.15.0, pinned to `main` at the sha website#215 merged as.", + "$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/rust and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves, because a mount prefix is a string in server/index.js and a documented path is a string in a JSON file, and whether those name the same URL is a fact about a running core. It also answers the blind spot phase 1 had to check by hand: core mounts several routes at the TIER ROOT (/status, /version), which the loader's collision probe cannot see, so /rust being free is asserted here by a core rather than by a reading. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. This module needs MODULE_API 1.10.0 (module.json's coreApi is ^1.10.0), which the Event System cutover put on `main` — so unlike Module-uo, which spent the Event System window pinned to `edge`, this repo starts pinned to `main` and should stay there unless it comes to depend on a contract member that has not shipped yet. **2026-09-27, protocol 13 step 2:** this module calls ctx.events.expired (MODULE_API 1.11.0, PLAN_FIXES D183), so coreApi is ^1.11.0. It was pinned to website#209's branch head while that PR was open — a 1.10.0 core refuses to load it — and is back on `main` at the sha #209 merged as. **2026-10-12, protocol 14 (PLAN_REDESIGNS §2):** this module registers an event template and declares param durations, a lease value example and option notes (MODULE_API 1.13.0), so coreApi is ^1.13.0. It was pinned to website#212's branch head while that PR was open, and is back on `main` at the sha #212 merged as (the same tree). **2026-10-12, PLAN_REDESIGNS §12 (permission rewards):** this module declares a param with multiple: true (MODULE_API 1.14.0), so coreApi is ^1.14.0. It was pinned to website#213's branch head while that PR was open, and is back on `main` at the sha #213 merged as (the same tree). **2026-10-15, PLAN_REDESIGNS §14.3.4 (Dangerous Treasures):** the Start a Dangerous Treasures event step answers DT's warm-up with retryAfterMs (MODULE_API 1.15.0, D371), so coreApi is ^1.15.0, pinned to `main` at the sha website#215 merged as. **2026-10-19, Steam sign-in (PLAN_REDESIGNS §15, D392/D396):** this module registers onIdentityLinking and onIdentityLinked (MODULE_API 1.17.0), so coreApi is ^1.17.0. Pinned to the head of website feat/steam-signin while that PR is open — a 1.16.0 core refuses to load it — and moves to the sha it merges as.", "repo": "https://gitea.whitlocktech.com/RunicGateway/website.git", - "ref": "eb773693d32781f2ec717f9be07816b6afefd162", - "refName": "main @ MODULE_API 1.15.0 (website#215 merged)" + "ref": "5e398d8acdce90cb003a023e5520d6329624aecb", + "refName": "feat/steam-signin @ MODULE_API 1.17.0 (website PR open)" } -- 2.49.1