8 Commits

Author SHA1 Message Date
1fb685fa0f Merge pull request 'chore: remove a stray empty file committed by #70' (#71) from fix/stray-file into edge
Reviewed-on: #71
2026-10-30 09:30:40 +00:00
bd13f197c5 chore: remove a stray empty file committed by #70
All checks were successful
PR Checks / client-build (pull_request) Successful in 51s
PR Checks / frozen-manifest (pull_request) Successful in 1m19s
PR Checks / server-tests (pull_request) Successful in 35s
`Promise.reject(dup)` is a zero-byte file a tool hook wrote into the working
tree; #70 committed it by accident. Nothing reads it. Found in the account
deletion walk (docs/website/ACCOUNT_DELETION.md step 6, W1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-30 03:56:42 -05:00
e30c7d591d Merge pull request 'feat(links): unlink every Steam id before an account is deleted (MODULE_API 1.20.0)' (#70) from feature/account-deletion-hook into edge
Reviewed-on: #70
2026-10-30 04:48:54 +00:00
b5573d9ae3 feat(links): unlink every Steam id before an account is deleted (MODULE_API 1.20.0)
All checks were successful
PR Checks / server-tests (pull_request) Successful in 1m1s
PR Checks / frozen-manifest (pull_request) Successful in 1m28s
PR Checks / client-build (pull_request) Successful in 43s
ACCOUNT_DELETION.md §4.4 (AD10). api.onUserDeleting calls links.unlinkForDeletion,
which runs the player's own unlinkOwned per link, logs rust.account.unlink with
reason 'account deleted', and marks every server's permission sync dirty so the
withdrawn grants reach the game on the next tick. coreApi ^1.20.0; core-ref pinned
to website#232's merge (7bd93b8); engagement manifest regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-29 20:38:30 -05:00
ec90d2d5ae Merge pull request 'fix(demo): lock removing a Steam link on the demo (DM27)' (#67) from fix/demo-lock-steam-unlink into edge
All checks were successful
PR Checks / server-tests (pull_request) Successful in 42s
PR Checks / client-build (pull_request) Successful in 58s
PR Checks / frozen-manifest (pull_request) Successful in 1m7s
Reviewed-on: #67
2026-10-28 09:47:44 +00:00
fb7511d700 fix(demo): lock removing a Steam link on the demo (DM27)
All checks were successful
PR Checks / server-tests (pull_request) Successful in 54s
PR Checks / client-build (pull_request) Successful in 1m5s
PR Checks / frozen-manifest (pull_request) Successful in 1m36s
The step-5 walk found demo-player could remove its stand-in Steam link from
its Rust page, and staff could remove it from Admin -> Users. That link is
what the player tour's Rust pages, rewards and grants hang on.

- module.json demoLocks gains DELETE /player/rust/links/:steamId.
- The staff route, DELETE /admin/users/:id/rust/links/:steamId, serves under
  core's users mount, which a module's demoLocks may not name, so its handler
  refuses with DEMO_LOCKED behind ctx.demoMode before the link is touched.

Off the demo both behave as before. Checked on a real core with the demo
seed: both answer 403 DEMO_LOCKED and the link stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-28 04:44:28 -05:00
5c9cefdbc2 Merge pull request 'fix(demo): seed NPC profiles, factions and chat styles (DM26)' (#65) from feat/demo-seed-npcs-chat into edge
All checks were successful
PR Checks / server-tests (pull_request) Successful in 38s
PR Checks / client-build (pull_request) Successful in 55s
PR Checks / frozen-manifest (pull_request) Successful in 59s
Reviewed-on: #65
2026-10-28 01:22:44 +00:00
1f4ad25edb fix(demo): seed NPC profiles, factions and chat styles (DM26)
All checks were successful
PR Checks / server-tests (pull_request) Successful in 55s
PR Checks / client-build (pull_request) Successful in 1m7s
PR Checks / frozen-manifest (pull_request) Successful in 1m36s
The demo's first restored wipes brought the default set's groups and
permissions back, but not its NPCs or chat titles. The restored rig's
RunicNPC says it is already managed (by the site before this one), so the
fresh site adopts nothing (D244) and its first push empties the profiles;
the placements then wait on missing profiles. The import never adopts a
group's chat style either.

onDemoSeed now also takes:
- npcs.factions: the faction table, through the NPC model's setFactions;
- npcs.profiles: each profile through the model's create/update, so it is
  checked exactly as the admin page checks it (an existing one is updated);
- chat: a BetterChat style per server and group, the defaults filled in. A
  group the rig's import has not brought in yet is created for that server,
  and the import then adopts the rig's title, rank and permissions into it.

The section's new parts are checked before anything is written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-27 20:15:32 -05:00
12 changed files with 315 additions and 15 deletions

View File

@@ -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. **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 is now on `edge` at the sha website#218 merged as. That is `edge`, not `main`, because #218 went into `edge` for the in-progress cutover. **2026-10-22, the cutover (PLAN_LAST_FIXES stage 6):** website#220 put `edge` on `main`, so this is back on `main` at the sha #220 merged as. That tree adds the last fixes' core half (website#219, no MODULE_API bump), whose only route changes are core's own event routes, so routes.manifest.json does not change. **2026-10-26, the public demo (DEMO_MODE.md §3.4, §3.5, DM18):** this module declares demoLocks, reads ctx.demoMode, registers onDemoSeed (MODULE_API 1.18.0) and gives its two console-command actions demoRefuses (1.19.0), so coreApi is ^1.19.0. It was pinned to the head of website feature/demo-step-refusal (website#224) while that PR was open — a 1.18.0 core never asks demoRefuses — and is back on `main` at the sha #224 merged as (the same tree, so routes.manifest.json does not change).", "$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 is now on `edge` at the sha website#218 merged as. That is `edge`, not `main`, because #218 went into `edge` for the in-progress cutover. **2026-10-22, the cutover (PLAN_LAST_FIXES stage 6):** website#220 put `edge` on `main`, so this is back on `main` at the sha #220 merged as. That tree adds the last fixes' core half (website#219, no MODULE_API bump), whose only route changes are core's own event routes, so routes.manifest.json does not change. **2026-10-26, the public demo (DEMO_MODE.md §3.4, §3.5, DM18):** this module declares demoLocks, reads ctx.demoMode, registers onDemoSeed (MODULE_API 1.18.0) and gives its two console-command actions demoRefuses (1.19.0), so coreApi is ^1.19.0. It was pinned to the head of website feature/demo-step-refusal (website#224) while that PR was open — a 1.18.0 core never asks demoRefuses — and is back on `main` at the sha #224 merged as (the same tree, so routes.manifest.json does not change). **2026-10-30, account deletion (ACCOUNT_DELETION.md §4.4):** this module registers onUserDeleting (MODULE_API 1.20.0), so coreApi is ^1.20.0, pinned to `main` at the sha website#232 merged as.",
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git", "repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
"ref": "69998ec54854d46681def92985a2b299ee3582a6", "ref": "7bd93b8bb0da734a642587f256d10c523ca39bea",
"refName": "website main @ website#224 merge, MODULE_API 1.19.0" "refName": "website main @ website#232 merge, MODULE_API 1.20.0"
} }

View File

@@ -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.", "_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.19.0", "coreApi": "^1.20.0",
"triggers": [ "triggers": [
{ {
"id": "rust.base.destroyed", "id": "rust.base.destroyed",

View File

@@ -2,7 +2,7 @@
"id": "rust", "id": "rust",
"name": "Rust", "name": "Rust",
"version": "0.1.0", "version": "0.1.0",
"coreApi": "^1.19.0", "coreApi": "^1.20.0",
"server": "server/index.js", "server": "server/index.js",
"client": { "entry": "client/dist/entry.js" }, "client": { "entry": "client/dist/entry.js" },
"schema": "server/db/schema.sql", "schema": "server/db/schema.sql",
@@ -16,6 +16,7 @@
"capabilities": ["rust", "servers", "killfeed", "leaderboard", "presence", "wipes", "identity", "map"], "capabilities": ["rust", "servers", "killfeed", "leaderboard", "presence", "wipes", "identity", "map"],
"demoLocks": [ "demoLocks": [
{ "method": "PUT", "path": "/admin/rust/servers/:id" }, { "method": "PUT", "path": "/admin/rust/servers/:id" },
{ "method": "DELETE", "path": "/admin/rust/servers/:id" } { "method": "DELETE", "path": "/admin/rust/servers/:id" },
{ "method": "DELETE", "path": "/player/rust/links/:steamId" }
] ]
} }

View File

@@ -1,9 +1,13 @@
// ── The public demo's module-side locks (DEMO_MODE.md §3.4, §3.5) ────────── // ── The public demo's module-side locks (DEMO_MODE.md §3.4, §3.5) ──────────
// //
// Core locks this module's two link routes by `demoLocks` in module.json. What a // Core locks this module's two rig-link routes and the player's Steam unlink by
// route list cannot express lives here, behind `ctx.demoMode` (MODULE_API // `demoLocks` in module.json. What a route list cannot express lives here, behind
// 1.18.0), and every handler that enforces one of these asks this file: // `ctx.demoMode` (MODULE_API 1.18.0), and every handler that enforces one of these
// asks this file:
// //
// the staff Steam unlink on Admin → Users (step-5 walk, W4): it serves under
// core's users mount, which a module's demoLocks may not name, so its handler
// refuses instead. The demo player's seeded link stays put either way;
// the console-command deny list (§3.4, DM11): the commands that would break // the console-command deny list (§3.4, DM11): the commands that would break
// the rig for every visitor until the next wipe. Asked at save, through // the rig for every visitor until the next wipe. Asked at save, through
// core's `demoRefuses` hook (1.19.0, DM18), and again at run; // core's `demoRefuses` hook (1.19.0, DM18), and again at run;

View File

@@ -9,18 +9,44 @@
// .env, so the token is never in the repo // .env, so the token is never in the repo
// links demo-player's stand-in Steam id (DM6), so the player screens have // links demo-player's stand-in Steam id (DM6), so the player screens have
// somebody to show // somebody to show
// npcs the NPC faction table and profiles of the demo default set (DM26)
// chat a BetterChat style per group (DM26)
// //
// { "servers": [{ "id": "main", "name": "Demo", "sidecarBaseUrl": "$env:RUST_SIDECAR_URL", // { "servers": [{ "id": "main", "name": "Demo", "sidecarBaseUrl": "$env:RUST_SIDECAR_URL",
// "sidecarToken": "$env:RUST_SIDECAR_TOKEN" }], // "sidecarToken": "$env:RUST_SIDECAR_TOKEN" }],
// "links": [{ "username": "demo-player", "steamId": "7656119…", "serverId": "main" }] } // "links": [{ "username": "demo-player", "steamId": "7656119…", "serverId": "main" }],
// "npcs": { "factions": [{ "a": "bandits", "b": "guards", "relation": "allied" }],
// "profiles": [{ "name": "bandit", "servers": ["main"], "body": { … } }] },
// "chat": [{ "serverId": "main", "group": "vip", "style": { "Title": "[VIP]", … } }] }
//
// **Why NPCs and chat come from here, not the rig's baseline (DM26).** The rig is
// restored before the site is wiped, and a fresh site imports what it finds
// (§7.1). That holds for permissions, but not for these two. The restored rig's
// RunicNPC says it is already managed, by the site before this one, so the new
// site adopts nothing (D244) and its first push empties the profiles. The import
// never adopts a group's chat style. So the site holds them from the start and
// pushes them, and the placements in the baseline spawn again.
//
// A chat style names a group the rig's import has not brought in yet, because
// the seed runs first. The group is created for that server, and the import
// then adopts the rig's title, rank and permissions into it. A style lists only
// the fields that differ from BetterChat's defaults.
// //
// **It runs on every seed, so it upserts.** Core seeds content on a database's // **It runs on every seed, so it upserts.** Core seeds content on a database's
// first run only, but calls this every time. Everything is checked before // first run only, but calls this every time. The section's shape is checked
// anything is written, and a problem throws, which fails the seed. // before anything is written, and a problem throws, which fails the seed. A
// profile is checked by the NPC model as it is saved, as the admin page saves
// one, so a profile the model refuses fails the seed too.
const linksDb = require('./model/links/links.db') const linksDb = require('./model/links/links.db')
const servers = require('./model/servers/servers.model') const servers = require('./model/servers/servers.model')
const serversDb = require('./model/servers/servers.db') const serversDb = require('./model/servers/servers.db')
const npcs = require('./model/npcs/npcs.model')
const npcsDb = require('./model/npcs/npcs.db')
const npcShape = require('./model/npcs/npcProfile')
const permApply = require('./model/permissions/permissions.apply')
const permDb = require('./model/permissions/permissions.db')
const chatStyle = require('./model/permissions/chatStyle')
const sidecar = require('./sidecarClient') const sidecar = require('./sidecarClient')
const core = require('./core') const core = require('./core')
@@ -28,6 +54,13 @@ const log = core.logger('demo')
const SERVER_ID = /^[a-z0-9][a-z0-9-]{0,63}$/ const SERVER_ID = /^[a-z0-9][a-z0-9-]{0,63}$/
const STEAM_ID = /^7656119\d{10}$/ const STEAM_ID = /^7656119\d{10}$/
// Oxide group names as the permission model keeps them: trimmed and lower-cased.
const GROUP_NAME = /^[a-z0-9_.-]{1,64}$/
/** BetterChat's defaults for the group, with the fixture's fields over them. */
function fullStyle(group, style) {
return { ...chatStyle.defaults(String(group || '')), ...(style && typeof style === 'object' ? style : {}) }
}
/** Every problem with the section, as sentences. Empty when it is good to write. */ /** Every problem with the section, as sentences. Empty when it is good to write. */
function checkSection(data, accounts = {}) { function checkSection(data, accounts = {}) {
@@ -35,11 +68,41 @@ function checkSection(data, accounts = {}) {
if (!data || typeof data !== 'object' || Array.isArray(data)) return ['modules.rust must be an object'] if (!data || typeof data !== 'object' || Array.isArray(data)) return ['modules.rust must be an object']
const list = (key) => (data[key] === undefined ? [] : data[key]) const list = (key) => (data[key] === undefined ? [] : data[key])
for (const key of ['servers', 'links']) { for (const key of ['servers', 'links', 'chat']) {
if (data[key] !== undefined && !Array.isArray(data[key])) problems.push(`modules.rust.${key} must be an array`) if (data[key] !== undefined && !Array.isArray(data[key])) problems.push(`modules.rust.${key} must be an array`)
} }
const n = data.npcs
if (n !== undefined) {
if (!n || typeof n !== 'object' || Array.isArray(n)) problems.push('modules.rust.npcs must be an object')
else {
if (n.factions !== undefined && !Array.isArray(n.factions)) problems.push('modules.rust.npcs.factions must be an array')
if (n.profiles !== undefined && !Array.isArray(n.profiles)) problems.push('modules.rust.npcs.profiles must be an array')
}
}
if (problems.length) return problems if (problems.length) return problems
if (n && n.factions !== undefined) {
const checked = npcShape.checkFactions(n.factions)
if (!checked.ok) problems.push(`modules.rust.npcs.${checked.error}`)
}
;((n && n.profiles) || []).forEach((p, i) => {
const at = `modules.rust.npcs.profiles[${i}]`
if (!p || typeof p !== 'object') return problems.push(`${at} must be an object`)
if (!npcShape.NAME_RULE.test(String(p.name || ''))) problems.push(`${at}.name must be 1 to 40 of a-z, 0-9, _ and -`)
if (!p.body || typeof p.body !== 'object' || Array.isArray(p.body)) problems.push(`${at}.body must be an object`)
return undefined
})
list('chat').forEach((c, i) => {
const at = `modules.rust.chat[${i}]`
if (!c || typeof c !== 'object') return problems.push(`${at} must be an object`)
if (!SERVER_ID.test(String(c.serverId || ''))) problems.push(`${at}.serverId must be a server id`)
if (!GROUP_NAME.test(String(c.group || ''))) problems.push(`${at}.group must be a group name`)
const checked = chatStyle.validateStyle(fullStyle(c.group, c.style))
if (!checked.ok) problems.push(`${at}.style: ${checked.errors.join(' ')}`)
return undefined
})
list('servers').forEach((s, i) => { list('servers').forEach((s, i) => {
const at = `modules.rust.servers[${i}]` const at = `modules.rust.servers[${i}]`
if (!s || typeof s !== 'object') return problems.push(`${at} must be an object`) if (!s || typeof s !== 'object') return problems.push(`${at} must be an object`)
@@ -92,6 +155,36 @@ async function onDemoSeed(data, { accounts = {} } = {}) {
await linksDb.insert({ steamId, userId, name: l.name || null, serverId: l.serverId || null }) await linksDb.insert({ steamId, userId, name: l.name || null, serverId: l.serverId || null })
log.info('demo link ready', { username: l.username }) log.info('demo link ready', { username: l.username })
} }
// After the servers: a profile is refused for a server the site does not have.
const n = data.npcs || {}
if (n.factions !== undefined) {
await npcs.setFactions(n.factions)
log.info('demo NPC factions ready', { pairs: n.factions.length })
}
if (n.profiles && n.profiles.length) {
const existing = await npcsDb.listProfiles()
for (const p of n.profiles) {
const input = { name: p.name, body: p.body, servers: p.servers, allServers: p.allServers, killsScope: p.killsScope }
const same = existing.find((e) => e.name === p.name && !e.replaced)
if (same) await npcs.update(same.id, input)
else await npcs.create(input)
log.info('demo NPC profile ready', { profile: p.name })
}
}
for (const c of data.chat || []) {
const name = String(c.group)
let group = await permApply.groupOn(name, c.serverId)
if (!group) {
const id = await permDb.insertGroup({ name, title: name })
await permDb.setGroupServers(id, { allServers: false, servers: [c.serverId] })
group = { id }
}
await permDb.setGroupChat(group.id, chatStyle.validateStyle(fullStyle(name, c.style)).fields)
await permDb.markDirty(c.serverId)
log.info('demo chat style ready', { server: c.serverId, group: name })
}
} }
module.exports = { checkSection, onDemoSeed } module.exports = { checkSection, onDemoSeed }

View File

@@ -168,6 +168,13 @@ module.exports = function register(ctx, api) {
api.onIdentityLinking(links.checkSteamSignin) api.onIdentityLinking(links.checkSteamSignin)
api.onIdentityLinked(links.linkFromSteamSignin) api.onIdentityLinked(links.linkFromSteamSignin)
// Account deletion (MODULE_API 1.20.0; ACCOUNT_DELETION.md §4.4, AD10). Core
// asks before the users row goes: every Steam id the account holds is unlinked
// through the player's own unlink, and the permission sync is marked dirty so
// the withdrawn grants reach the game on the next tick. It cannot stop the
// deletion, and never waits on a game.
api.onUserDeleting(links.unlinkForDeletion)
// The public demo's seed (MODULE_API 1.18.0, DM15): the rig's server row and // The public demo's seed (MODULE_API 1.18.0, DM15): the rig's server row and
// demo-player's Steam link, written by this module from its part of the // demo-player's Steam link, written by this module from its part of the
// fixture. Called only by `npm run seed:demo` on a DEMO_MODE deployment. The // fixture. Called only by `npm run seed:demo` on a DEMO_MODE deployment. The

View File

@@ -19,6 +19,7 @@
const core = require('../../core') const core = require('../../core')
const db = require('./links.db') const db = require('./links.db')
const engagement = require('../../engagement/emit') const engagement = require('../../engagement/emit')
const permissionsDb = require('../permissions/permissions.db')
const servers = require('../servers/servers.model') const servers = require('../servers/servers.model')
const serversDb = require('../servers/servers.db') const serversDb = require('../servers/servers.db')
const sidecar = require('../../sidecarClient') const sidecar = require('../../sidecarClient')
@@ -351,6 +352,47 @@ async function linkFromSteamSignin({ userId, provider, subject, displayName }) {
engagement.linked({ userId, steamId, name: displayName || null }) engagement.linked({ userId, steamId, name: displayName || null })
} }
// ── The website account is being deleted (MODULE_API 1.20.0) ──────────────
//
// ACCOUNT_DELETION.md §4.4. Core asks before the users row goes, while the
// person's links can still be read (AD10). The cascade on `rust_account_links`
// would remove the rows anyway; calling the real unlink keeps what a cascade
// skips — the `rust.account.unlink` entry staff read and the prompt Teams
// re-sync — so the game sees exactly what a player's own unlink produces.
/**
* Core's `api.onUserDeleting`: unlink every Steam id this account holds.
*
* The same `unlinkOwned` the player's Rust page calls, one link at a time, each
* logged as `rust.account.unlink` with `reason: 'account deleted'`. Then every
* server's permission sync is marked dirty: the desired set reaches a grant's
* Steam id through the link, so with the links gone the grants have already
* left it, and the next tick pushes the withdrawal instead of waiting for the
* digest check.
*
* Nothing here waits on a game. The link is the site's own record and the plugin
* keeps none (the header above), so there is never pending work: what this does
* not finish, the cascade does.
*/
async function unlinkForDeletion({ userId, deletedBy }) {
const held = await db.listForUser(userId)
let removed = 0
for (const row of held) {
// eslint-disable-next-line no-await-in-loop
if (await unlinkOwned(row.steamId, userId)) {
removed += 1
// eslint-disable-next-line no-await-in-loop
await core.activity.log({
userId,
action: 'rust.account.unlink',
detail: { steamId: row.steamId, reason: 'account deleted', deletedBy },
})
}
}
if (removed > 0) await permissionsDb.markDirty(null)
log.info('rust links removed before an account was deleted', { userId, deletedBy, removed })
}
/** The admin panel's read: every link this user holds, with per-server totals. */ /** The admin panel's read: every link this user holds, with per-server totals. */
async function forAdmin(userId) { async function forAdmin(userId) {
const links = await db.listForUserWithPlayer(userId) const links = await db.listForUserWithPlayer(userId)
@@ -393,5 +435,6 @@ module.exports = {
unlinkFromGame, unlinkFromGame,
checkSteamSignin, checkSteamSignin,
linkFromSteamSignin, linkFromSteamSignin,
unlinkForDeletion,
forAdmin, forAdmin,
} }

View File

@@ -8,6 +8,7 @@
const crypto = require('node:crypto') const crypto = require('node:crypto')
const core = require('../../core') const core = require('../../core')
const demo = require('../../demo')
const backpacks = require('../../model/backpacks/backpacks.model') const backpacks = require('../../model/backpacks/backpacks.model')
const economics = require('../../model/economics/economics.model') const economics = require('../../model/economics/economics.model')
@@ -54,6 +55,10 @@ async function removeLink(req, res) {
const { steamId } = req.params const { steamId } = req.params
const userId = req.params.id const userId = req.params.id
// On the demo the player's own unlink is locked by demoLocks; this route serves
// under core's users mount, which a module may not lock by route, so it asks here.
if (demo.isDemo()) return demo.refuse(res)
try { try {
const removed = await links.unlinkOwned(steamId, userId) const removed = await links.unlinkOwned(steamId, userId)

View File

@@ -173,6 +173,8 @@ function fakeApi() {
onIdentityLinked(fn) { once('onIdentityLinked'); record.hooks.onIdentityLinked = fn }, onIdentityLinked(fn) { once('onIdentityLinked'); record.hooks.onIdentityLinked = fn },
// MODULE_API 1.18.0 (the public demo's seed, DM15). // MODULE_API 1.18.0 (the public demo's seed, DM15).
onDemoSeed(fn) { once('onDemoSeed'); record.hooks.onDemoSeed = fn }, onDemoSeed(fn) { once('onDemoSeed'); record.hooks.onDemoSeed = fn },
// MODULE_API 1.20.0 (account deletion, ACCOUNT_DELETION.md §4.4).
onUserDeleting(fn) { once('onUserDeleting'); record.hooks.onUserDeleting = fn },
} }
api.record = record api.record = record
return api return api

View File

@@ -375,13 +375,116 @@ test('a re-seed leaves a link the right owner holds, and moves one held by someo
assert.deepEqual(writes, ['DELETE', 'INSERT']) assert.deepEqual(writes, ['DELETE', 'INSERT'])
}) })
// ── NPCs and chat styles from the seed (DM26) ──────────────────────────────
const NPC_SECTION = {
npcs: {
factions: [{ a: 'bandits', b: 'guards', relation: 'allied' }],
profiles: [
{ name: 'bandit', servers: ['main'], body: { kits: ['rnpc_raider'], role: 'roamer' } },
{ name: 'guard', servers: ['main'], body: { kits: ['rnpc_campguard'], role: 'sentry' } },
],
},
chat: [
{ serverId: 'main', group: 'vip', style: { Title: '[VIP]', TitleColor: '#ffaa00' } },
{ serverId: 'main', group: 'moderator', style: { Title: '[MOD]' } },
],
}
/** Stubs the models the seed writes NPCs and chat styles through; returns the calls. */
function stubSeedModels({ profiles = [], groups = {} } = {}) {
const calls = []
const npcs = require('../model/npcs/npcs.model')
const npcsDb = require('../model/npcs/npcs.db')
const apply = require('../model/permissions/permissions.apply')
const permDb = require('../model/permissions/permissions.db')
npcs.setFactions = async (f) => calls.push(['setFactions', f])
npcs.create = async (input) => calls.push(['create', input.name, input.servers])
npcs.update = async (id, input) => calls.push(['update', id, input.name])
npcsDb.listProfiles = async () => profiles
apply.groupOn = async (name) => groups[name] || null
permDb.insertGroup = async ({ name }) => {
calls.push(['insertGroup', name])
return 50
}
permDb.setGroupServers = async (id, s) => calls.push(['setGroupServers', id, s.servers])
permDb.setGroupChat = async (id, style) => calls.push(['setGroupChat', id, style.Title, style.TitleColor, Object.keys(style).length])
permDb.markDirty = async (scope) => calls.push(['markDirty', scope])
return calls
}
test('the NPC and chat parts of the seed section are checked before anything is written', async () => {
withCore()
const seed = require('../demoSeed')
assert.deepEqual(seed.checkSection(NPC_SECTION, {}), [])
const problems = seed.checkSection({
npcs: { factions: [{ a: 'scientists', b: 'animals', relation: 'hostile' }], profiles: [{ name: 'Bad Name', body: [] }] },
chat: [{ serverId: 'Main', group: '', style: { Title: '[VIP]', Nope: 1 } }],
}, {})
assert.equal(problems.length, 6, problems.join(' | '))
assert.deepEqual(seed.checkSection({ npcs: [] }, {}), ['modules.rust.npcs must be an object'])
const calls = stubSeedModels()
await assert.rejects(seed.onDemoSeed({ chat: [{ serverId: 'main', group: 'vip', style: { Priority: 'x' } }] }, { accounts: {} }), /style/)
assert.deepEqual(calls, [])
})
test('the seed sets the faction table, saves each profile, and styles each group', async () => {
withCore()
const calls = stubSeedModels({ profiles: [{ id: 7, name: 'bandit', replaced: false }], groups: { vip: { id: 13 } } })
await require('../demoSeed').onDemoSeed(NPC_SECTION, { accounts: {} })
assert.deepEqual(calls, [
['setFactions', NPC_SECTION.npcs.factions],
['update', 7, 'bandit'], // already there: updated, not created twice
['create', 'guard', ['main']],
['setGroupChat', 13, '[VIP]', '#ffaa00', 12], // all twelve fields, the defaults filled in
['markDirty', 'main'],
['insertGroup', 'moderator'], // the rig's import has not brought it in yet
['setGroupServers', 50, ['main']],
['setGroupChat', 50, '[MOD]', '#55aaff', 12],
['markDirty', 'main'],
])
})
// ── The manifest (§3.2) ──────────────────────────────────────────────────── // ── The manifest (§3.2) ────────────────────────────────────────────────────
test('module.json locks the two link routes and needs a core that asks demoRefuses', () => { test('module.json locks the two rig-link routes and the player Steam unlink, and needs a core that asks demoRefuses', () => {
const manifest = require('../../module.json') const manifest = require('../../module.json')
assert.deepEqual(manifest.demoLocks, [ assert.deepEqual(manifest.demoLocks, [
{ method: 'PUT', path: '/admin/rust/servers/:id' }, { method: 'PUT', path: '/admin/rust/servers/:id' },
{ method: 'DELETE', path: '/admin/rust/servers/:id' }, { method: 'DELETE', path: '/admin/rust/servers/:id' },
{ method: 'DELETE', path: '/player/rust/links/:steamId' },
]) ])
assert.equal(manifest.coreApi, '^1.19.0') // A floor, not an exact value: later contract members raise it (1.20.0 for
// account deletion), and what this test holds is that it never drops below the
// core that asks demoRefuses.
const [major, minor] = manifest.coreApi.replace(/^\^/, '').split('.').map(Number)
assert.ok(major === 1 && minor >= 19, `coreApi ${manifest.coreApi} is at least ^1.19.0`)
})
// ── The staff Steam unlink (W4) ────────────────────────────────────────────
test('staff cannot sever a Steam link on the demo, and the link is never touched', async () => {
for (const demoMode of [true, false]) {
withCore({ demo: demoMode })
const links = require('../model/links/links.model')
const asked = []
links.unlinkOwned = async (steamId, userId) => {
asked.push([steamId, userId])
return true
}
const res = fakeRes()
await require('../router/admin/usersRust.controller').removeLink(
{ params: { id: '3', steamId: '76561190000000001' }, user: { id: 1 } },
res,
)
if (demoMode) {
assert.equal(res.statusCode, 403)
assert.equal(res.body.code, 'DEMO_LOCKED')
assert.deepEqual(asked, [])
} else {
assert.equal(res.statusCode, 200)
assert.deepEqual(res.body, { unlinked: true })
assert.deepEqual(asked, [['76561190000000001', '3']])
}
}
}) })

View File

@@ -92,6 +92,12 @@ test('registers both identity hooks, so a Steam sign-in link is a Rust link (D39
assert.strictEqual(api.record.hooks.onIdentityLinked, links.linkFromSteamSignin) assert.strictEqual(api.record.hooks.onIdentityLinked, links.linkFromSteamSignin)
}) })
test('registers the account-deletion hook, so a deleted account is unlinked first (AD10)', () => {
const { api } = register()
const links = require('../model/links/links.model')
assert.strictEqual(api.record.hooks.onUserDeleting, links.unlinkForDeletion)
})
test('the manifest declares what the loader requires', () => { test('the manifest declares what the loader requires', () => {
assert.match(manifest.id, /^[a-z][a-z0-9-]{1,31}$/) assert.match(manifest.id, /^[a-z][a-z0-9-]{1,31}$/)
assert.match(manifest.version, /^\d+\.\d+\.\d+/) assert.match(manifest.version, /^\d+\.\d+\.\d+/)

View File

@@ -472,6 +472,42 @@ test('the linked hook writes nothing for a link that is already there, or for an
assert.equal(ctx.teams.reconcile.calls.length, 0) assert.equal(ctx.teams.reconcile.calls.length, 0)
}) })
// ── The website account is being deleted (MODULE_API 1.20.0, AD10) ─────────
test('a deleted account loses every Steam link through the player’s own unlink, each logged', async () => {
const { ctx, queries } = withCore({ select: [[{ steamId: '7656', userId: 4 }, { steamId: STEAM, userId: 4 }]] })
const links = require('../model/links/links.model')
await links.unlinkForDeletion({ userId: 4, deletedBy: 'self' })
// The same statement `unlinkOwned` uses, scoped by user, once per link.
const dels = queries.filter((q) => q.sql.trim().toUpperCase().startsWith('DELETE'))
assert.deepEqual(dels.map((q) => q.params), [['7656', 4], [STEAM, 4]])
// What a cascade would skip: one entry per link, and the Teams re-sync.
assert.deepEqual(ctx.activity.log.calls.map((c) => c[0]), [
{ userId: 4, action: 'rust.account.unlink', detail: { steamId: '7656', reason: 'account deleted', deletedBy: 'self' } },
{ userId: 4, action: 'rust.account.unlink', detail: { steamId: STEAM, reason: 'account deleted', deletedBy: 'self' } },
])
assert.equal(ctx.teams.reconcile.calls.length, 2)
// Every server is marked dirty, so the withdrawn grants go on the next tick.
const dirty = queries.filter((q) => /UPDATE rust_perm_sync SET dirty = 1/.test(q.sql))
assert.equal(dirty.length, 1)
assert.ok(!/WHERE/.test(dirty[0].sql), 'the whole fleet, not one server')
})
test('an account with no Steam link deletes without touching the game or the sync', async () => {
const { ctx, queries } = withCore({ select: [[]] })
const links = require('../model/links/links.model')
await links.unlinkForDeletion({ userId: 4, deletedBy: 'staff' })
assert.equal(queries.filter((q) => !q.sql.trim().toUpperCase().startsWith('SELECT')).length, 0)
assert.equal(ctx.activity.log.calls.length, 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 () => { 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 dup = Object.assign(new Error('dup'), { code: 'ER_DUP_ENTRY' })
const { ctx } = withCore({ select: [[]], onInsert: () => Promise.reject(dup) }) const { ctx } = withCore({ select: [[]], onInsert: () => Promise.reject(dup) })