Compare commits
6 Commits
v0.6.0
...
e30c7d591d
| Author | SHA1 | Date | |
|---|---|---|---|
| e30c7d591d | |||
| b5573d9ae3 | |||
| ec90d2d5ae | |||
| fb7511d700 | |||
| 5c9cefdbc2 | |||
| 1f4ad25edb |
0
Promise.reject(dup)
Normal file
0
Promise.reject(dup)
Normal 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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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']])
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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+/)
|
||||||
|
|||||||
@@ -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) })
|
||||||
|
|||||||
Reference in New Issue
Block a user