From b5573d9ae340795931b1c3c621699fc572564fc2 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 29 Oct 2026 20:38:30 -0500 Subject: [PATCH 1/3] feat(links): unlink every Steam id before an account is deleted (MODULE_API 1.20.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- Promise.reject(dup) | 0 ci/core-ref.json | 6 ++--- engagement-triggers.json | 2 +- module.json | 2 +- server/index.js | 7 +++++ server/model/links/links.model.js | 43 +++++++++++++++++++++++++++++++ server/test/_fakes.js | 2 ++ server/test/demo.test.js | 6 ++++- server/test/entry.test.js | 6 +++++ server/test/links.test.js | 36 ++++++++++++++++++++++++++ 10 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 Promise.reject(dup) 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) }) -- 2.49.1 From bd13f197c50e01a2dfaf27c5f43157c19e6f2a49 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 30 Oct 2026 03:56:42 -0500 Subject: [PATCH 2/3] chore: remove a stray empty file committed by #70 `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 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- Promise.reject(dup) | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 Promise.reject(dup) diff --git a/Promise.reject(dup) b/Promise.reject(dup) deleted file mode 100644 index e69de29..0000000 -- 2.49.1 From dd9b8b271130725a094ffb2ec1e03bf0c320cbf3 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 30 Oct 2026 09:04:08 -0500 Subject: [PATCH 3/3] feat(safety): report, block and hide chat lines (MODULE_API 1.21.0) USER_SAFETY.md step 3, the module half of report, block and hide. - server/safety.js registers the rust.chat report target (snapshot for one reader, null when they could not have seen the line; staff's hide and unhide) and the steam game identity (ownersOf from rust_account_links). - The events feed filters chat per reader, in the query: lines from a blocked account's Steam ids, the reader's unlinked Steam-ID blocks and the lines they hid. Signed-in readers get canBlock on each chat line (false for their own and for staff-linked players). Lines staff hid for everyone are left out for everyone but staff, who get them with hidden: true. - rust_events gains hidden_at and hidden_by (ALTER ... IF NOT EXISTS). - POST /admin/rust/chat/:id/hide and /unhide (admin, moderator), logged. - The feed's chat lines get a menu: Report, Hide for me, Block player, and for staff Hide for everyone / Show to everyone again. - coreApi ^1.21.0; ci/core-ref.json pinned to the website#233 merge; routes.manifest.json and swagger-fragment.json regenerated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- ci/bundle.json | 1 + ci/core-ref.json | 6 +- client/src/api.js | 16 + client/src/components/ChatMenu.jsx | 198 +++++++++++ client/src/components/Feed.jsx | 17 +- client/src/lib/safety.js | 56 ++++ client/test/safety.test.js | 45 +++ module.json | 2 +- routes.manifest.json | 10 + server/core.js | 12 + server/db/schema.sql | 12 + server/index.js | 11 + server/model/events/events.db.js | 48 ++- server/model/events/events.model.js | 14 +- server/model/links/links.db.js | 13 + server/model/visibility/visibility.model.js | 41 ++- server/router/admin/rust.controller.js | 34 ++ server/router/admin/rust.router.js | 33 ++ server/router/public/rust.controller.js | 27 +- server/router/public/rust.router.js | 2 +- server/safety.js | 193 +++++++++++ server/test/_fakes.js | 9 + server/test/safety.test.js | 349 ++++++++++++++++++++ swagger-fragment.json | 58 +++- 24 files changed, 1182 insertions(+), 25 deletions(-) create mode 100644 client/src/components/ChatMenu.jsx create mode 100644 client/src/lib/safety.js create mode 100644 client/test/safety.test.js create mode 100644 server/safety.js create mode 100644 server/test/safety.test.js diff --git a/ci/bundle.json b/ci/bundle.json index d82dba8..f7c49ac 100644 --- a/ci/bundle.json +++ b/ci/bundle.json @@ -61,6 +61,7 @@ "permSync.js", "raidableBases.js", "router", + "safety.js", "sidecarClient.js", "titleSync.js", "worldEvents.js" diff --git a/ci/core-ref.json b/ci/core-ref.json index 01a79f3..6c6f1a3 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). **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.", + "$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. **2026-10-30, report, block and hide (USER_SAFETY.md §4.2):** this module registers a rust.chat report target and the steam game identity, and reads ctx.safety (MODULE_API 1.21.0), so coreApi is ^1.21.0, pinned to `main` at the sha website#233 merged as.", "repo": "https://gitea.whitlocktech.com/RunicGateway/website.git", - "ref": "7bd93b8bb0da734a642587f256d10c523ca39bea", - "refName": "website main @ website#232 merge, MODULE_API 1.20.0" + "ref": "f13b8fe1fec9dc330a985430b8bd4f67c9ec26c5", + "refName": "website main @ website#233 merge, MODULE_API 1.21.0" } diff --git a/client/src/api.js b/client/src/api.js index 66cf202..40d9a19 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -350,6 +350,21 @@ export const adminUserBackpacks = { req(`/admin/users/${encodeURIComponent(userId)}/rust/backpacks${query({ serverId, steamId })}`), } +// ── report, block and hide (USER_SAFETY.md §4.2) ─────────────────────────── +// Core's routes, not this module's: the Reports queue, a member's blocks and +// their "Hide for me" list are core's and hold for every module and the forum. +// Each names an ITEM — `{ targetType: 'rust.chat', targetId }` — never a person; +// core asks `server/safety.js` who said it. The last two are this module's own: +// staff's hide for everyone, set and cleared where the line lives. +export const safety = { + report: ({ targetType, targetId, reason, detail }) => + req('/player/reports', { method: 'POST', body: { targetType, targetId, reason, detail } }), + block: ({ targetType, targetId }) => req('/auth/me/blocks', { method: 'POST', body: { targetType, targetId } }), + hide: ({ targetType, targetId }) => req('/auth/me/hidden', { method: 'POST', body: { targetType, targetId } }), + hideForEveryone: (lineId) => req(`/admin/rust/chat/${encodeURIComponent(lineId)}/hide`, { method: 'POST' }), + unhideForEveryone: (lineId) => req(`/admin/rust/chat/${encodeURIComponent(lineId)}/unhide`, { method: 'POST' }), +} + // ── core's public event calendar ─────────────────────────────────────────── // The one CORE route this module reads, and core published `runId` on it for // exactly this: a map marker holds a run id and nothing else, and the calendar is @@ -382,6 +397,7 @@ export default { adminUserPermissions, adminUserEconomics, adminUserBackpacks, + safety, coreEvents, BASE, } diff --git a/client/src/components/ChatMenu.jsx b/client/src/components/ChatMenu.jsx new file mode 100644 index 0000000..7f2816d --- /dev/null +++ b/client/src/components/ChatMenu.jsx @@ -0,0 +1,198 @@ +// ── A chat line's menu: report, hide, block (USER_SAFETY.md §4.2) ────────── +// +// One small button at the end of each chat line in the feed, opening a row of +// actions under it. What it offers is `lib/safety.js`'s `chatActions`; what it +// does is core's (report, block, Hide for me) or this module's admin route +// (staff's hide for everyone). After anything that changes what the reader +// sees, the feed is asked again: the server is what filters it (S11), so the +// honest way to show a block is to re-read the feed, never to drop the line here. +// +// The wording follows core's forum menu, so a member reads one set of sentences +// wherever they report or block from. + +import { useState } from 'react' +import { Link } from 'react-router-dom' +import { useAuth } from '../core.js' +import api from '../api.js' +import { CHAT_TARGET, REPORT_REASONS, chatActions } from '../lib/safety.js' + +export default function ChatMenu({ line, onChanged }) { + const { user } = useAuth() + const actions = chatActions(line, user) + const [mode, setMode] = useState('closed') // closed | open | report | block | done + const [reason, setReason] = useState('abuse') + const [detail, setDetail] = useState('') + const [message, setMessage] = useState(null) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + + if (!actions) return null + + // Every move between the menu's states drops the last error: a refusal from + // one action ("you have already reported this") is not about the next. + const go = (next) => { setError(null); setMode(next) } + + const target = { targetType: CHAT_TARGET, targetId: line.id } + const name = (line.frame && line.frame.name) || 'this player' + + const run = async (call, { refetch = true, done = null } = {}) => { + setBusy(true) + setError(null) + try { + await call() + if (done) { + setMessage(done) + setMode('done') + } else { + setMode('closed') + } + if (refetch) await onChanged() + } catch (err) { + setError(err.message || 'Could not do that') + } finally { + setBusy(false) + } + } + + const toggle = ( + + ) + + if (mode === 'closed') return toggle + + return ( + <> + {toggle} +
+ {mode === 'done' &&

{message}

} + + {mode === 'open' && actions.signIn && ( +

+ Sign in + {' '}to report or block. +

+ )} + + {mode === 'open' && !actions.signIn && ( +
+ {actions.report && ( + + )} + {actions.hide && ( + + )} + {actions.block && ( + + )} + {actions.staff === 'hide' && ( + + )} + {actions.staff === 'unhide' && ( + + )} +
+ )} + + {mode === 'report' && ( +
{ + e.preventDefault() + run(() => api.safety.report({ ...target, reason, detail: detail || undefined }), { + refetch: false, + done: 'Reported to site staff.', + }) + }} + style={{ display: 'grid', gap: 8 }} + > + {/* A report changes nothing about the line, and says so: a member who + expects it to vanish and watches it stay will report it again. */} +

+ This goes to site staff. Reporting does not hide or change anything — it asks a + staffer to look. +

+ +