diff --git a/Promise.reject(dup) b/Promise.reject(dup) new file mode 100644 index 0000000..e69de29 diff --git a/ci/core-ref.json b/ci/core-ref.json index 6db419d..01a79f3 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. **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", - "ref": "69998ec54854d46681def92985a2b299ee3582a6", - "refName": "website main @ website#224 merge, MODULE_API 1.19.0" + "ref": "7bd93b8bb0da734a642587f256d10c523ca39bea", + "refName": "website main @ website#232 merge, MODULE_API 1.20.0" } diff --git a/engagement-triggers.json b/engagement-triggers.json index 07e5a04..423c067 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.19.0", + "coreApi": "^1.20.0", "triggers": [ { "id": "rust.base.destroyed", diff --git a/module.json b/module.json index df76427..45d57fa 100644 --- a/module.json +++ b/module.json @@ -2,7 +2,7 @@ "id": "rust", "name": "Rust", "version": "0.1.0", - "coreApi": "^1.19.0", + "coreApi": "^1.20.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 ebbc350..ba86f63 100644 --- a/server/index.js +++ b/server/index.js @@ -168,6 +168,13 @@ module.exports = function register(ctx, api) { api.onIdentityLinking(links.checkSteamSignin) 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 // 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 diff --git a/server/model/links/links.model.js b/server/model/links/links.model.js index 4e3ba44..31f6cca 100644 --- a/server/model/links/links.model.js +++ b/server/model/links/links.model.js @@ -19,6 +19,7 @@ const core = require('../../core') const db = require('./links.db') const engagement = require('../../engagement/emit') +const permissionsDb = require('../permissions/permissions.db') const servers = require('../servers/servers.model') const serversDb = require('../servers/servers.db') const sidecar = require('../../sidecarClient') @@ -351,6 +352,47 @@ async function linkFromSteamSignin({ userId, provider, subject, displayName }) { 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. */ async function forAdmin(userId) { const links = await db.listForUserWithPlayer(userId) @@ -393,5 +435,6 @@ module.exports = { unlinkFromGame, checkSteamSignin, linkFromSteamSignin, + unlinkForDeletion, forAdmin, } diff --git a/server/test/_fakes.js b/server/test/_fakes.js index 031311e..ecd9dbc 100644 --- a/server/test/_fakes.js +++ b/server/test/_fakes.js @@ -173,6 +173,8 @@ function fakeApi() { onIdentityLinked(fn) { once('onIdentityLinked'); record.hooks.onIdentityLinked = fn }, // MODULE_API 1.18.0 (the public demo's seed, DM15). 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 return api diff --git a/server/test/demo.test.js b/server/test/demo.test.js index 7c7bf22..307c0f6 100644 --- a/server/test/demo.test.js +++ b/server/test/demo.test.js @@ -454,7 +454,11 @@ test('module.json locks the two rig-link routes and the player Steam unlink, and { 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) ──────────────────────────────────────────── diff --git a/server/test/entry.test.js b/server/test/entry.test.js index b4806c2..d17a277 100644 --- a/server/test/entry.test.js +++ b/server/test/entry.test.js @@ -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) }) +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', () => { 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 a029783..5a9592d 100644 --- a/server/test/links.test.js +++ b/server/test/links.test.js @@ -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) }) +// ── 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 () => { const dup = Object.assign(new Error('dup'), { code: 'ER_DUP_ENTRY' }) const { ctx } = withCore({ select: [[]], onInsert: () => Promise.reject(dup) })