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)
+})