30 Commits

Author SHA1 Message Date
28d0b76f13 fix(events): core.lease keeps a module's retry: false on read/apply refusals
All checks were successful
PR Checks / client-build (pull_request) Successful in 59s
PR Checks / bot-tests (pull_request) Successful in 1m16s
PR Checks / server-tests (pull_request) Successful in 6m3s
core.lease rebuilt both failure envelopes as { ok: false, error }, so a
module's retry: false was dropped and the dispatcher retried a permanent
refusal (e.g. module-rust's events-disabled) three times. Both returns now
pass retry: false through and leave retry undefined otherwise, so the
dispatcher's default still applies. Three tests in eventModuleContract cover
read, apply (reservation still released) and the default.

Closes #206

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-11-01 02:53:17 -06:00
f13b8fe1fe Merge pull request 'feat(safety): report, block and hide, MODULE_API 1.21.0' (#233) from feature/user-safety into main
All checks were successful
sync-project-tree / sync (push) Successful in 19s
Build container images / build (push) Successful in 1m34s
Build container images / deploy (push) Successful in 39s
SonarQube / analysis (push) Successful in 10m40s
Demo wipe / wipe (push) Successful in 7m47s
Reviewed-on: #233
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-30 12:24:36 +00:00
41a1e84b51 feat(safety): report, block and hide, MODULE_API 1.21.0
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 36s
PR Checks / server-tests (pull_request) Successful in 6m11s
PR Checks / client-build (pull_request) Successful in 49s
USER_SAFETY.md step 2 (core), with S11-S14 decided while building it.

- Reports: POST /player/reports takes any target type, core's forum
  types or a module's registered ones. One resolver
  (model/safety/targets.js) answers "what is this item, for this
  reader" and 404s an item the reader cannot see. Every report stores
  content_reports.snapshot; the queue shows a module item from it.
- Hide for everyone: POST /admin/moderation/reports/:id/hide hides the
  item through its own moderation (forum hide as staff, or the module's
  hide) and actions the report with actioned_with = 'hide'.
- Blocks (user_blocks) name an item, never a person, and land on the
  author's account first (S12); an unlinked game identity is blocked as
  itself and becomes an account block once linked (S14). Staff cannot
  be blocked; a block on someone later promoted stops applying (S8).
- Hide for me (user_hidden_items), /auth/me/blocks and /auth/me/hidden.
- Forum reads filter blocked authors and hidden items on the server and
  carry canBlock per thread and post.
- MODULE_API 1.21.0: api.registerReportTargets,
  api.registerGameIdentities, ctx.safety.forViewer and
  ctx.safety.staffAmong, so a module filters its own per-viewer feeds on
  the server (S11) and marks lines canBlock (S13).
- Web: Hide for me / Block author in the forum menus, a Blocked section
  on both account pages, Hide for everyone in the Reports queue.
- Tests, OpenAPI, route/guard/engagement manifests regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-30 05:41:00 -05:00
7bd93b8bb0 Merge pull request 'feat(account): self-service account deletion, MODULE_API 1.20.0' (#232) from feature/account-deletion into main
Some checks failed
sync-project-tree / sync (push) Successful in 26s
SonarQube / analysis (push) Successful in 11m44s
Build container images / build (push) Failing after -9s
Build container images / deploy (push) Has been skipped
Demo wipe / wipe (push) Successful in 7m8s
Reviewed-on: #232
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-30 01:34:52 +00:00
d1ae8f943f feat(account): self-service account deletion, MODULE_API 1.20.0
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 37s
PR Checks / client-build (pull_request) Successful in 49s
PR Checks / server-tests (pull_request) Successful in 6m14s
ACCOUNT_DELETION.md phase 2 (core): a member deletes their own account on
the website, and the admin route runs the same procedure.

- DELETE /api/v1/auth/me/account: typed username, password always (AD7),
  and a 2FA or recovery code when 2FA is on. A wrong password or code is
  400, not 401 (AD12), so the app's refresh-on-401 never replays it.
  409 password_required and last_admin; the demo's shared logins are
  locked.
- auth/accountDeletion.js: module hooks first, then one transaction that
  reserves a staff username (reserved_usernames, AD9) and deletes the row,
  then a Teams reconcile, the audit entry (no username) and a notice to
  the old address (new protected template auth.account-deleted).
- MODULE_API 1.20.0: api.onUserDeleting({ userId, deletedBy }), before the
  delete, 10 s limit, cannot veto (AD10, AD11).
- utils/displayAuthor.js: a deleted account's username snapshots read
  "[deleted player]" to members and the stored name, marked, to staff
  (AD5, AD6). Used by the forum, the Reports queue, the guest list and
  the Team digest. A deleted account drops off Forum guests (AD13).
- Reserved names refuse at every username write, answered as "taken".
- Web: Delete my account on the player and staff Account pages; staff
  views mark "(deleted account)".
- OpenAPI regenerated; route manifest and engagement manifest regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-29 18:32:19 -05:00
222bcb02fc Merge pull request 'fix(demo): switch on the actions the seeded events use (DM28)' (#231) from fix/demo-seed-enables-actions into main
Some checks failed
sync-project-tree / sync (push) Successful in -2m16s
Build container images / build (push) Successful in 1m39s
Build container images / deploy (push) Successful in 39s
SonarQube / analysis (push) Failing after 6m19s
Demo wipe / wipe (push) Failing after 12m53s
Reviewed-on: #231
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-28 09:55:24 +00:00
ebedee146f fix(demo): switch on the actions the seeded events use (DM28)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 54s
PR Checks / server-tests (pull_request) Successful in 6m6s
PR Checks / client-build (pull_request) Successful in 7m33s
The step-5 walk found the demo's three seeded events (Server greeting,
Treasure drop, Payday) showing as ready but failing when run: a fresh
database has no switchboard rows, so every change and irreversible action
starts disabled ("not enabled on this deployment").

seed:demo now switches on, with the events, every action their steps use,
uncapped, as an admin flipping the switch without filling the cap boxes
would. Nothing else is switched on. The deny list (DM11) still keeps a
visitor's own command step harmless, and the wipe drops the rows with the
rest of the database.

Checked on a scratch database with the v0.6.1 module: the seed leaves
rust.command.run, rust.dt.start and rust.coins.pay enabled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-28 04:44:25 -05:00
47a79d841c Merge pull request 'feat(demo): seed the default set's events, NPCs and chat titles' (#230) from feature/demo-default-events into main
Some checks failed
sync-project-tree / sync (push) Successful in 14s
Build container images / build (push) Successful in 1m14s
Build container images / deploy (push) Successful in 59s
SonarQube / analysis (push) Failing after 6m13s
Demo wipe / wipe (push) Successful in 7m7s
Reviewed-on: #230
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-28 02:21:04 +00:00
62eaa7693d Merge branch 'main' into feature/demo-default-events
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 40s
PR Checks / client-build (pull_request) Successful in 1m8s
PR Checks / server-tests (pull_request) Successful in 12m26s
2026-10-28 02:05:42 +00:00
0f05098d8f Merge pull request 'feat(demo): refuse a data: URI in any write body (DM25)' (#229) from feature/demo-data-uri-lock into main
Some checks failed
sync-project-tree / sync (push) Successful in 15s
Build container images / build (push) Successful in 1m34s
Build container images / deploy (push) Successful in 41s
SonarQube / analysis (push) Failing after 6m18s
Demo wipe / wipe (push) Successful in 7m15s
Reviewed-on: #229
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-28 01:28:28 +00:00
8ec7f399a7 Merge branch 'main' into feature/demo-data-uri-lock
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 41s
PR Checks / client-build (pull_request) Successful in 55s
PR Checks / server-tests (pull_request) Successful in 6m25s
2026-10-28 01:21:50 +00:00
38aef5964e feat(demo): seed the default set's NPC profiles, factions and chat titles (DM26)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 47s
PR Checks / client-build (pull_request) Successful in 1m8s
PR Checks / server-tests (pull_request) Successful in 12m27s
The restored rig's RunicNPC says it is already managed, so a fresh site
adopts none of its profiles and its first push empties them; chat styles
are never adopted either. They come from the fixture now, through
Module-Rust's onDemoSeed (needs the release carrying DM26; an older module
ignores these keys):

- npcs.factions: bandits and guards, allied;
- npcs.profiles: bandit (roamer, rnpc_raider) and guard (sentry, heavy,
  rnpc_campguard, heals), both on the demo server. Only the fields that
  differ from RunicNPC's defaults are listed;
- chat: [VIP] in gold and [MOD] in green.

The baseline's placements (bandit-1, bandit-2, guard-1) spawn again once
the site pushes these.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-27 20:16:27 -05:00
9408fee3c1 feat(demo): seed two showcase events, Treasure drop and Payday
All checks were successful
PR Checks / client-build (pull_request) Successful in 1m35s
PR Checks / server-tests (pull_request) Successful in 6m41s
PR Checks / bot-tests (pull_request) Successful in 7m14s
The demo default set (org lead, 2026-10-27: curated showcase) puts its
permissions and NPCs on the rig, where the baseline restores them; events
live in the site's database, which every wipe drops, so they belong in the
fixture. Both are manual, published, on the demo server:

- Treasure drop: rust.dt.start, Easy.
- Payday: rust.coins.pay, 500 coins to every linked player, said in chat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-27 13:35:55 -05:00
c18250eb07 Merge pull request 'fix(demo): chown modules/ and logs/ from a root container in the wipe' (#228) from fix/demo-wipe-chown into main
Some checks failed
sync-project-tree / sync (push) Successful in 12s
Build container images / build (push) Successful in 2m22s
Build container images / deploy (push) Successful in 47s
SonarQube / analysis (push) Failing after 8m32s
Demo wipe / wipe (push) Successful in 7m22s
Reviewed-on: #228
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-27 18:15:24 +00:00
61feca50a8 feat(demo): refuse a data: URI in any write body (DM25)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 45s
PR Checks / client-build (pull_request) Successful in 1m2s
PR Checks / server-tests (pull_request) Successful in 6m4s
Uploads are locked hard on the demo, but an image pasted into rich text
does not use multipart: it is saved as a data: URI inside the JSON body.
refuseDataUri, mounted beside refuseMultipart, refuses any POST/PUT/PATCH/
DELETE whose body has a data: URI in any string, on any route, core's or
a module's, with the usual 403 DEMO_LOCKED.

Each string is first read the way a browser reads an attribute (HTML
character references decoded, tabs and newlines removed), so &#100;ata: and
a data: split by a newline are refused too. "metadata:" and prose such as
"Data: none, yet" pass. Reads are never checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-27 13:13:51 -05:00
7aafd0dd74 fix(demo): chown modules/ and logs/ from a root container in the wipe
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 40s
PR Checks / server-tests (pull_request) Successful in 6m1s
PR Checks / client-build (pull_request) Successful in 7m27s
The runner user is not root, so the host chown failed and `|| true` hid it.
The app (uid 1000) then could not unpack the declared module into ./modules,
came up healthy with no module, and seed:demo refused the fixture's
rust.command.run event (website run 358, the first scheduled wipe).

A throwaway container runs as root and the runner is in the docker group, so
the chown now happens there, and a failure stops the run at that step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-27 13:04:44 -05:00
3b9998539f Merge pull request 'fix(docker): move the app and bot images to Node 22.23.4 (Node2Shell)' (#226) from fix/node22-node2shell into main
Some checks failed
sync-project-tree / sync (push) Successful in 15s
Build container images / build (push) Successful in 4m56s
Build container images / deploy (push) Successful in 1m3s
SonarQube / analysis (push) Failing after 7m37s
Demo wipe / wipe (push) Failing after 1m52s
Reviewed-on: #226
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-27 17:40:01 +00:00
3c1c174337 Merge branch 'main' into fix/node22-node2shell
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 45s
PR Checks / client-build (pull_request) Successful in 1m5s
PR Checks / server-tests (pull_request) Successful in 6m35s
2026-10-27 17:33:14 +00:00
247323ff05 Merge pull request 'feat(demo): deploy/demo and the wipe + baseline workflows (step 5)' (#227) from feature/demo-deploy into main
Some checks failed
Demo wipe / wipe (push) Failing after 25s
sync-project-tree / sync (push) Successful in 33s
Build container images / build (push) Successful in 2m18s
Build container images / deploy (push) Successful in 51s
SonarQube / analysis (push) Has been cancelled
Reviewed-on: #227
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-27 17:31:45 +00:00
d48d8c473c feat(demo): deploy/demo and the wipe + baseline workflows (step 5)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 50s
PR Checks / client-build (pull_request) Successful in 57s
PR Checks / server-tests (pull_request) Successful in 12m25s
The public demo's deployment (docs/website/DEMO_MODE.md §7, §10 step 5):

- deploy/demo/docker-compose.yml: the app and its database only, DEMO_MODE
  on, a /status healthcheck, the fixture mounted for seed:demo, MODULES read
  from state/modules.env so the workflow never edits the .env with secrets.
- deploy/demo/.env.example: every variable the host needs, including
  BOT_INTERNAL_KEY (the app refuses to start in production without it) and
  MODULE_SOURCE_HOSTS (a fresh database's install allowlist).
- deploy/demo/seed.json: the four logins, settings, two posts, a wiki page,
  a published "Server greeting" event on a Rust console step, the rig's
  server link and demo-player's Steam link; secrets as "$env:NAME".
- deploy/demo/rig.mjs: plan (latest Rust bundle + newest Module-Rust on the
  same protocol, keeping the previous set when none matches), restore (kill,
  write a changed bundle, delete + unpack demo-baseline.tar.gz, start),
  baseline (power stop saves the world, DM22; compress, rename, start) and
  smoke (the site, then the rig reporting in), on the Pterodactyl client API.
- .gitea/workflows/demo-wipe.yml: every 2 h on the hour UTC, on a push to
  deploy/demo/, and by hand; rig first, then down -v / pull / up / seed.
- .gitea/workflows/demo-baseline.yml: by hand only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-26 12:23:52 -05:00
2a6e307f16 fix(docker): move the app and bot images to Node 22.23.4 (Node2Shell)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 1m1s
PR Checks / client-build (pull_request) Successful in 2m13s
PR Checks / server-tests (pull_request) Successful in 7m19s
CVE-2026-111497 ("Node2Shell") is an out-of-bounds write in the Brotli
decoder Node bundles; it was fixed on 2026-10-21 in 22.23.4, 24.22.1 and
26.12.1. Both images were on node:20-alpine, and Node 20 is past end of
life, so it gets no fix.

The site is not reachable through the main path (Express 4 / body-parser
1.20 refuse a `br` request body with 415, and nothing calls the zlib
Brotli API), but fetch() decodes Brotli responses and the runtime itself
was unpatched. Pin the exact patched version so a floating tag cannot
hold an old build, and run CI on the same version the image ships.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-26 10:00:02 -05:00
c7a24a186c Merge pull request 'fix(demo): refuse publishing the homepage hero (DM21)' (#225) from fix/demo-hero-publish-lock into main
Some checks failed
sync-project-tree / sync (push) Successful in 25s
Build container images / build (push) Successful in -13s
Build container images / deploy (push) Successful in 47s
SonarQube / analysis (push) Failing after 6m25s
Reviewed-on: #225
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-26 14:50:55 +00:00
c87563bc32 fix(demo): refuse publishing the homepage hero (DM21)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 47s
PR Checks / client-build (pull_request) Successful in 54s
PR Checks / server-tests (pull_request) Successful in 12m42s
Settings stay open on the demo (DM2), so the hero editor's Publish wrote
hero_layout, the hero every visitor sees. hero_layout joins the protected
settings keys: PUT /admin/settings naming it answers 403 DEMO_LOCKED with
keys: ['hero_layout']. The editor's draft autosave and Preview write only
hero_layout_draft and stay open; its uploads were already refused by the
multipart lock. Server lock only, as decided: the editor needs no change,
since its status line and the toast already show the demo sentence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-26 09:35:03 -05:00
69998ec548 Merge pull request 'feat(demo): refuse an event step at save with demoRefuses (MODULE_API 1.19.0)' (#224) from feature/demo-step-refusal into main
Some checks failed
sync-project-tree / sync (push) Successful in 25s
Build container images / build (push) Successful in 3m17s
Build container images / deploy (push) Successful in 45s
SonarQube / analysis (push) Failing after 8m36s
Reviewed-on: #224
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-26 10:52:08 +00:00
cee4ab8aea feat(demo): refuse an event step at save with demoRefuses (MODULE_API 1.19.0)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 46s
PR Checks / server-tests (pull_request) Successful in 7m0s
PR Checks / client-build (pull_request) Successful in 7m29s
DEMO_MODE.md §3.4 refuses a denied console command when the event is
saved and when it runs. Saving is core's route, and core never asked a
module about a step's params, so the save-time half had no seam (DM18).

An event action may now declare demoRefuses(params), returning a
sentence or null. With DEMO_MODE on, create, save and publish ask it
for every step; the first sentence refuses the write 403 DEMO_LOCKED
with that sentence as the message (and in errors). A hook that throws
refuses too. Off the demo it is validated and never called, and the
catalog strips it with the other callables.

The events controller passes code and message through on create,
update and publish, so the client's one DEMO_LOCKED toast fires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-26 03:39:42 -05:00
c6f53a1509 Merge pull request 'feat(demo): DEMO_MODE lock, seed:demo and the demo banner (MODULE_API 1.18.0)' (#223) from feature/demo-mode-core into main
Some checks failed
sync-project-tree / sync (push) Successful in 26s
Build container images / build (push) Successful in 2m46s
Build container images / deploy (push) Successful in 50s
SonarQube / analysis (push) Failing after 6m26s
Reviewed-on: #223
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-24 00:05:08 +00:00
4ad746c43e feat(demo): DEMO_MODE lock, seed:demo and the demo banner (MODULE_API 1.18.0)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 43s
PR Checks / client-build (pull_request) Successful in 1m3s
PR Checks / server-tests (pull_request) Successful in 8m37s
Core half of the public demo (docs/website/DEMO_MODE.md §10 step 1, website#222).
Nothing here runs unless DEMO_MODE=true.

- server/src/demo/: the lock at /api/v1, ahead of every router. It covers every
  Tier A row of §3.3, the shared-login conditions, POST /auth/register (DM16),
  DELETE /auth/me/sessions/:id on a demo account (DM17), and the PUT
  /admin/settings keys that reach a Tier A lock. Any multipart request is refused
  app-wide. noindex and robots.txt. A condition that cannot decide refuses.
- Loader: demoLocks in module.json (writes only, under the module's own mounts),
  ctx.demoMode, and api.onDemoSeed (DM15). MODULE_API 1.18.0 on both halves.
- GET /public/settings carries demo: { resetsAt, logins } on the demo only.
- npm run seed:demo -- <fixture>: upserts the four accounts, sets the site live
  with registration disabled, seeds content on the first run only, calls each
  module's onDemoSeed, and stamps demo_state. It resolves $env: strings and
  refuses to run off the demo.
- Client: the banner, one DEMO_LOCKED toast fed from the request primitive (so
  module screens get it too), and the published logins on both sign-in pages.
  Dashboard and register say the demo message instead of blaming a role.
- OpenAPI: DemoLocked schema, the demo block, and a note that any write may
  answer DEMO_LOCKED on the demo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-23 18:52:09 -05:00
b5e0d01a91 Merge pull request 'fix(teams): say "game server", not "shard", on the never-synced Teams panel' (#221) from fix/teams-panel-neutral-wording into main
All checks were successful
sync-project-tree / sync (push) Successful in 19s
Build container images / build (push) Successful in 1m55s
Build container images / deploy (push) Successful in 41s
SonarQube / analysis (push) Successful in 10m49s
Reviewed-on: #221
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-22 19:59:38 +00:00
8012a457fc fix(teams): say "game server", not "shard", on the never-synced Teams panel
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 39s
PR Checks / client-build (pull_request) Successful in 58s
PR Checks / server-tests (pull_request) Successful in 12m43s
Admin → Teams told an operator that an unsynced projection "is not a
confirmed empty shard". Teams is a core primitive and core's word for the
host is the game server; "shard" is Ultima Online's. The detail now reads
"What is shown below does not confirm that the game server has no Teams."

The matching test, and the four comments that said "shard" about the same
state, follow it. Found while making runicgateway.com's core docs
game-neutral (runicgateway.com#34), which had to paraphrase this line
rather than quote it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-22 14:43:37 -05:00
6890a74c7c Merge pull request 'chore(release): cut edge over to main — Steam sign-in (MODULE_API 1.17.0) and the last fixes' core half' (#220) from edge into main
All checks were successful
Build container images / build (push) Successful in -21s
sync-project-tree / sync (push) Successful in -2m6s
Build container images / deploy (push) Successful in 39s
SonarQube / analysis (push) Successful in 12m57s
Reviewed-on: #220
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-22 10:29:20 +00:00
105 changed files with 9385 additions and 149 deletions

View File

@@ -0,0 +1,42 @@
# Capture the demo rig's baseline (docs/website/DEMO_MODE.md §7.2). Run by hand,
# after setting the demo default set up through the demo site: the permission
# groups, a few NPCs, a clan for the demo's Team. It stops the rig (a power stop
# runs `quit`, which saves the world, DM22), compresses oxide/data, oxide/config
# and server/<identity> into demo-baseline.tar.gz at the server root, and starts
# it again. Every wipe from then on restores that archive.
#
# Run it right after a wipe has finished and the default set is in place, not
# while visitors are mid-change: whatever is on the rig is what every later
# cycle returns to.
name: Demo baseline
on:
workflow_dispatch: {}
concurrency:
group: demo-rig
cancel-in-progress: false
env:
DEMO_DIR: ${{ vars.DEMO_DIR || '/opt/demo.runicgateway.com' }}
PTERODACTYL_URL: ${{ vars.PTERODACTYL_URL || 'http://192.168.0.12' }}
PTERODACTYL_SERVER: ${{ vars.PTERODACTYL_SERVER || '6eb9d621' }}
NODE_IMAGE: node:22.23.4-alpine
jobs:
baseline:
runs-on: demo-site
if: github.ref == 'refs/heads/main'
steps:
- name: Check out the deploy files
uses: actions/checkout@v4
- name: Capture the baseline
env:
PTERODACTYL_CLIENT_KEY: ${{ secrets.PTERODACTYL_CLIENT_KEY }}
run: |
set -euo pipefail
docker run --rm -v "$PWD/deploy/demo:/demo:ro" \
-e PTERODACTYL_URL -e PTERODACTYL_SERVER -e PTERODACTYL_CLIENT_KEY \
"$NODE_IMAGE" node /demo/rig.mjs baseline

View File

@@ -0,0 +1,117 @@
# The public demo's wipe (docs/website/DEMO_MODE.md §7). Every 2 hours on the
# hour, UTC (DM7), it puts demo.runicgateway.com and its Rust server back to the
# demo's starting point:
#
# 1. versions the latest Rust bundle and the newest Module-Rust release on the
# same protocol (§7.5, DM12)
# 2. the rig FIRST: kill, write the new bundle if it changed, restore
# demo-baseline.tar.gz, start (§7.2). A fresh site imports what is
# on the rig, so the rig must be back before the site talks to it (§7.1)
# 3. the site down -v, pull, up, seed:demo (§7.3)
# 4. smoke the site answers, then the rig reports in (bounded)
#
# A redeploy is the same path: a push to main that changes deploy/demo/ runs it,
# and so does workflow_dispatch (§7.4).
#
# Runs on the `demo-site` runner, in host mode, on the demo host itself. The host
# needs Docker with Compose, and node for actions/checkout; everything else runs
# in containers. One-time host setup: deploy/demo/README.md.
#
# Secret: PTERODACTYL_CLIENT_KEY the demo subuser's client key (file + power)
# Variables: PTERODACTYL_URL, PTERODACTYL_SERVER (defaults below), DEMO_DIR
name: Demo wipe
on:
schedule:
- cron: '0 */2 * * *'
workflow_dispatch: {}
push:
branches: [main]
paths:
- 'deploy/demo/**'
- '.gitea/workflows/demo-wipe.yml'
# A wipe and a baseline capture must never overlap: both stop the rig.
concurrency:
group: demo-rig
cancel-in-progress: false
env:
DEMO_DIR: ${{ vars.DEMO_DIR || '/opt/demo.runicgateway.com' }}
PTERODACTYL_URL: ${{ vars.PTERODACTYL_URL || 'http://192.168.0.12' }}
PTERODACTYL_SERVER: ${{ vars.PTERODACTYL_SERVER || '6eb9d621' }}
NODE_IMAGE: node:22.23.4-alpine
jobs:
wipe:
runs-on: demo-site
if: github.ref == 'refs/heads/main'
steps:
- name: Check out the deploy files
uses: actions/checkout@v4
- name: Install the deploy files
# Everything but .env, which only the host holds, and state/, which the
# wipe itself writes.
run: |
set -euo pipefail
test -f "$DEMO_DIR/.env" || { echo "::error::$DEMO_DIR/.env is missing; see deploy/demo/README.md"; exit 1; }
mkdir -p "$DEMO_DIR/state" "$DEMO_DIR/logs" "$DEMO_DIR/modules"
# The app runs as uid 1000 and unpacks the declared module into ./modules.
# The runner is not root, so a host chown fails; a container is root and
# the runner may start one. A failure here stops the run: without it the
# app comes up healthy with no module and the seed fails much later.
docker run --rm -v "$DEMO_DIR:/demo" "$NODE_IMAGE" chown -R 1000:1000 /demo/modules /demo/logs
cp deploy/demo/docker-compose.yml deploy/demo/seed.json deploy/demo/rig.mjs "$DEMO_DIR/"
- name: Decide the versions
run: |
set -euo pipefail
docker run --rm -v "$DEMO_DIR:/demo" -e STATE_DIR=/demo/state "$NODE_IMAGE" node /demo/rig.mjs plan
- name: Restore the rig
id: rig
# The site is wiped even when this fails: a demo left un-wiped is worse
# than one whose rig kept a visitor's changes for a cycle. The last step
# fails the run so the failure is seen.
continue-on-error: true
env:
PTERODACTYL_CLIENT_KEY: ${{ secrets.PTERODACTYL_CLIENT_KEY }}
run: |
set -euo pipefail
docker run --rm -v "$DEMO_DIR:/demo" -e STATE_DIR=/demo/state \
-e PTERODACTYL_URL -e PTERODACTYL_SERVER -e PTERODACTYL_CLIENT_KEY \
"$NODE_IMAGE" node /demo/rig.mjs restore
- name: Wipe and reseed the site
run: |
set -euo pipefail
cd "$DEMO_DIR"
docker compose pull
docker compose down -v --remove-orphans
docker compose up -d
for attempt in $(seq 1 60); do
STATUS="$(docker inspect -f '{{.State.Health.Status}}' rg-demo-app-1 2>/dev/null || echo starting)"
[ "$STATUS" = healthy ] && break
sleep 5
done
if [ "$STATUS" != healthy ]; then
echo "::error::the app did not become healthy within 300s"
docker compose logs --tail 100 app
exit 1
fi
docker compose exec -T app npm run --silent --prefix server seed:demo -- /app/deploy/demo/seed.json
docker compose ps
- name: Smoke check
run: |
set -euo pipefail
docker run --rm --network rg-demo_default -v "$DEMO_DIR:/demo" \
-e SITE_URL=http://app:3000 "$NODE_IMAGE" node /demo/rig.mjs smoke
- name: Report the rig restore
if: steps.rig.outcome == 'failure'
run: |
echo "::error::the rig restore failed (see its step); the site was wiped and reseeded anyway"
exit 1

View File

@@ -44,7 +44,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22.23.4
cache: npm
cache-dependency-path: server/package-lock.json
- name: Check core names no module identifier
@@ -90,7 +90,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22.23.4
cache: npm
cache-dependency-path: client/package-lock.json
- name: Install client deps
@@ -113,7 +113,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22.23.4
cache: npm
cache-dependency-path: bot/package-lock.json
- name: Install bot deps

View File

@@ -1,4 +1,4 @@
FROM node:20-alpine
FROM node:22.23.4-alpine
WORKDIR /app

View File

@@ -147,7 +147,7 @@ flowchart TB
| Layer | Tech |
|---|---|
| Backend | Node.js 20+, Express 4, `mariadb` driver (parameterized SQL, no ORM) |
| Backend | Node.js 22 (images pin 22.23.4), Express 4, `mariadb` driver (parameterized SQL, no ORM) |
| Auth | Session service over JWT: httpOnly cookie (web) + bearer access/refresh tokens (mobile), bcrypt hashing, optional TOTP 2FA (`speakeasy` + `qrcode`), pluggable OAuth2/OIDC SSO (built-in Google & Discord + generic) |
| Database | MariaDB 11 (own container) |
| Frontend | React 18, Vite 5, React Router 6 |
@@ -196,7 +196,8 @@ website/
## Prerequisites
- **Node.js 20+** and npm (Node 22/24 are fine).
- **Node.js 22.23.4+** (or 24.22.1+) and npm. Older builds carry the Node2Shell Brotli bug
(CVE-2026-111497), and Node 20 is past end of life.
- **Docker Desktop** (for MariaDB, and for the full Compose deploy).
---

View File

@@ -1,4 +1,4 @@
FROM node:20-alpine
FROM node:22.23.4-alpine
WORKDIR /app/bot

View File

@@ -2,6 +2,8 @@ import { Routes, Route, Navigate, Outlet } from 'react-router-dom'
import { AuthProvider } from './contexts/AuthContext.jsx'
import { SiteProvider } from './contexts/SiteContext.jsx'
import MaintenanceGate from './components/MaintenanceGate.jsx'
import DemoBanner from './components/DemoBanner.jsx'
import DemoToast from './components/DemoToast.jsx'
import RequireAuth from './components/RequireAuth.jsx'
import RequirePlayer from './components/RequirePlayer.jsx'
import RoleGate from './components/RoleGate.jsx'
@@ -90,6 +92,10 @@ export default function App() {
the provider hooks are called once for the whole app rather than
once per screen. */}
<ModuleFeaturesProvider>
{/* The public demo's strip and refusal toast (DEMO_MODE.md §3.1, §6).
Both render nothing unless the server says this is the demo. */}
<DemoBanner />
<DemoToast />
<Routes>
{/* Landing hero — always public, even in maintenance mode. The hero is
itself the pre-launch "coming soon" page, so it sits outside the

View File

@@ -1,5 +1,7 @@
// Thin fetch wrapper. Always sends cookies (httpOnly JWT) and talks to the
// same-origin API (/api/v1) — proxied to the Express server in dev.
import { reportIfDemoLocked } from '../lib/demoNotice.js'
const BASE = '/api/v1'
// Prefix a non-empty query string with "?" (and nothing when it is empty), so
@@ -28,6 +30,9 @@ async function req(path, { method = 'GET', body, headers, raw } = {}) {
const text = await res.text()
const data = text ? safeParse(text) : null
if (!res.ok) {
// The public demo's lock (DEMO_MODE.md §3.1): shown once, as a toast, for
// every caller. The ApiError below still goes to the form as usual.
reportIfDemoLocked(data)
const message = (data && data.message) || res.statusText || 'Request failed'
throw new ApiError(res.status, message, data)
}
@@ -112,6 +117,10 @@ export const api = {
// The change endpoints re-issue the session cookie server-side, so the caller
// stays signed in.
myAccount: () => req('/auth/me/account'),
// Delete the signed-in account (ACCOUNT_DELETION.md §4.1). The password always,
// and `code` or `recoveryCode` when two-factor is on; refusals carry a `code`.
deleteMyAccount: ({ username, password, code, recoveryCode }) =>
req('/auth/me/account', { method: 'DELETE', body: { username, password, code, recoveryCode } }),
changeUsername: (username) =>
req('/auth/me/account/username', { method: 'PATCH', body: { username } }),
changePassword: (newPassword, currentPassword) =>
@@ -145,6 +154,20 @@ export const api = {
generateRecoveryCodes: (currentPassword) =>
req('/auth/me/account/recovery-codes/generate', { method: 'POST', body: { currentPassword } }),
// Report, block and "Hide for me" (USER_SAFETY.md §4.1). A block and a report
// name an ITEM — `{ targetType, targetId }` — never a person: the server reads
// who wrote it. Saved on the account, so they apply on every device.
reportItem: ({ targetType, targetId, reason, detail }) =>
req('/player/reports', { method: 'POST', body: { targetType, targetId, reason, detail } }),
myBlocks: () => req('/auth/me/blocks'),
blockAuthor: ({ targetType, targetId }) => req('/auth/me/blocks', { method: 'POST', body: { targetType, targetId } }),
unblock: (id) => req(`/auth/me/blocks/${encodeURIComponent(id)}`, { method: 'DELETE' }),
myHidden: () => req('/auth/me/hidden'),
hideItem: ({ targetType, targetId }) => req('/auth/me/hidden', { method: 'POST', body: { targetType, targetId } }),
unhideItem: ({ targetType, targetId }) =>
req(`/auth/me/hidden/${encodeURIComponent(targetType)}/${encodeURIComponent(targetId)}`, { method: 'DELETE' }),
clearHidden: () => req('/auth/me/hidden', { method: 'DELETE' }),
// ----- settings (any authenticated account) -----
// Nav overrides for the layouts the caller's own role renders, and the theme
// catalog the appearance form is built from. A fifth group, not part of
@@ -647,6 +670,10 @@ export const api = {
},
handleContentReport: (id, body) =>
req(`/admin/moderation/reports/${id}/handle`, { method: 'POST', body }),
// "Hide for everyone" (USER_SAFETY.md §4.1): hides the item through its own
// moderation and actions the report.
hideReportedItem: (id, body = {}) =>
req(`/admin/moderation/reports/${id}/hide`, { method: 'POST', body }),
modRecent: (params = {}) => {
const qs = new URLSearchParams()
if (params.type) qs.set('type', params.type)

View File

@@ -0,0 +1,47 @@
import { Link } from 'react-router-dom'
import { useSite } from '../contexts/SiteContext.jsx'
import { formatResetTime } from '../lib/demoNotice.js'
// The public demo's strip, on every page (docs/website/DEMO_MODE.md §6).
// Renders nothing unless GET /public/settings carries a `demo` block, which the
// server sends only with DEMO_MODE on — so a real shard never draws it, and the
// banner needs no schedule of its own: `resetsAt` is stamped by every wipe.
export default function DemoBanner() {
const { settings } = useSite()
const demo = settings.demo
if (!demo) return null
const at = demo.resetsAt ? formatResetTime(demo.resetsAt) : null
const names = (demo.logins || []).map((l) => l.username)
return (
<div
role="note"
className="sans"
style={{
padding: '7px 16px',
textAlign: 'center',
fontSize: '0.82rem',
lineHeight: 1.45,
color: 'var(--head)',
background: 'var(--bg-deep)',
borderBottom: '1px solid var(--accent)',
}}
>
<strong style={{ color: 'var(--accent)' }}>Demo site</strong>
{' — anything you change is wiped '}
{at ? `at ${at}` : 'every two hours'}.
{names.length > 0 && (
<>
{' Sign-ins: '}
{names.join(' / ')}
{' — passwords on the '}
<Link to="/account/login" style={{ color: 'var(--accent)' }}>
sign-in page
</Link>
.
</>
)}
</div>
)
}

View File

@@ -0,0 +1,51 @@
import { useSite } from '../contexts/SiteContext.jsx'
// The published sign-ins, on the sign-in pages of the public demo
// (DEMO_MODE.md §4). They are public by design — printed here and on
// runicgateway.com/beta — so "Use" fills the form rather than making anyone
// copy a password. Renders nothing off the demo.
export default function DemoLogins({ onPick }) {
const { settings } = useSite()
const logins = settings.demo?.logins || []
if (logins.length === 0) return null
return (
<section
aria-label="Demo sign-ins"
className="sans"
style={{
marginTop: 18,
padding: '14px 16px',
border: '1px solid var(--line)',
borderRadius: 10,
fontSize: '0.84rem',
// Opaque: the admin sign-in page draws it over the hero image.
background: 'var(--bg-deep)',
}}
>
<p style={{ margin: '0 0 10px', color: 'var(--head)' }}>
<strong>Demo sign-ins</strong> <span style={{ color: 'var(--dim)' }}>— shared by everyone, reset every wipe</span>
</p>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<tbody>
{logins.map((l) => (
<tr key={l.username}>
<td style={{ padding: '5px 0', verticalAlign: 'middle' }}>
<div style={{ color: 'var(--head)', whiteSpace: 'nowrap' }}>{l.username}</div>
<div style={{ color: 'var(--dim)', fontFamily: 'monospace', fontSize: '0.78rem', wordBreak: 'break-all' }}>
{l.password}
</div>
</td>
<td style={{ padding: '5px 8px', color: 'var(--dim)', verticalAlign: 'middle' }}>{l.role}</td>
<td style={{ padding: '5px 0', textAlign: 'right', verticalAlign: 'middle' }}>
<button type="button" className="btn btn-ghost" style={{ padding: '3px 10px', fontSize: '0.8rem' }} onClick={() => onPick(l.username, l.password)}>
Use
</button>
</td>
</tr>
))}
</tbody>
</table>
</section>
)
}

View File

@@ -0,0 +1,51 @@
import { useEffect, useState } from 'react'
import { onDemoLocked } from '../lib/demoNotice.js'
import { onNotice } from '../lib/notice.js'
// The one toast for a write the public demo refused (DEMO_MODE.md §3.1). Fed
// by api/client.js through lib/demoNotice.js, so every screen — and every
// installed module's screens — gets it without handling the code itself. On a
// real shard nothing is ever refused that way, so it never appears.
const SHOW_MS = 5000
export default function DemoToast() {
const [message, setMessage] = useState(null)
useEffect(() => onDemoLocked((text) => setMessage(text)), [])
// Also the site's one passing notice (lib/notice.js), on any deployment: the
// account page's "Your account has been deleted." after it has navigated home.
useEffect(() => onNotice((text) => setMessage(text)), [])
useEffect(() => {
if (!message) return undefined
const t = setTimeout(() => setMessage(null), SHOW_MS)
return () => clearTimeout(t)
}, [message])
return (
<div
role="status"
aria-live="polite"
style={{ position: 'fixed', left: 16, right: 16, bottom: 20, zIndex: 1000, display: 'flex', justifyContent: 'center', pointerEvents: 'none' }}
>
{message && (
<div
className="sans"
style={{
maxWidth: 480,
padding: '11px 16px',
borderRadius: 10,
fontSize: '0.88rem',
color: 'var(--head)',
background: 'var(--bg-deep)',
border: '1px solid var(--accent)',
boxShadow: '0 12px 32px rgba(0,0,0,0.45)',
pointerEvents: 'auto',
}}
>
{message}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,119 @@
import { useCallback, useEffect, useState } from 'react'
import { api } from '../../api/client.js'
import { blockSubtitle } from '../../lib/userSafety.js'
// Account → Blocked (docs/website/USER_SAFETY.md §1, §4.1). The members and
// players this account has blocked, each with Unblock, and the items it hid for
// itself, with one "Show them again".
//
// Blocks and hides are saved on the account, so this list is the same one the
// app shows, and undoing one here undoes it everywhere. The role-agnostic
// /auth/me surface, so the same panel serves players and staff.
//
// Two kinds of block, and the panel names which: an account (a forum author, or
// a chat speaker whose game account is linked — S12) or a game identity nobody
// has linked yet (a Steam id). A block whose target has since become staff no
// longer applies (S8), and the row says so rather than the block vanishing.
export default function BlockedPanel() {
const [blocks, setBlocks] = useState(null)
const [hiddenCount, setHiddenCount] = useState(0)
const [error, setError] = useState('')
const [msg, setMsg] = useState('')
const [busy, setBusy] = useState(false)
const load = useCallback(async () => {
try {
const [b, h] = await Promise.all([api.myBlocks(), api.myHidden()])
setBlocks(b.blocks || [])
setHiddenCount((h.hidden || []).length)
} catch {
setError('Could not load your blocks.')
setBlocks([])
}
}, [])
useEffect(() => {
load()
}, [load])
async function unblock(block) {
setBusy(true)
setMsg('')
setError('')
try {
await api.unblock(block.id)
setMsg(block.name ? `${block.name} is unblocked.` : 'Unblocked.')
await load()
} catch {
setError('Could not unblock that.')
} finally {
setBusy(false)
}
}
async function showHidden() {
setBusy(true)
setMsg('')
setError('')
try {
await api.clearHidden()
setMsg('Everything you hid is shown again.')
await load()
} catch {
setError('Could not show those again.')
} finally {
setBusy(false)
}
}
if (!blocks) return null
return (
<div style={{ marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 28 }}>
<h2 className="display" style={{ marginTop: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
Blocked
</h2>
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
You don’t see anything a blocked member or player writes — forum posts and in-game chat — on this
site or in the app. They aren’t told. Staff can’t be blocked.
</p>
{blocks.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
{blocks.map((b) => (
<div key={b.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>{b.name || 'Unknown player'}</div>
<div className="sans dim" style={{ fontSize: '0.76rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{blockSubtitle(b)}
</div>
{!b.applies && b.reason === 'staff' && (
<div className="sans" style={{ fontSize: '0.76rem', color: '#d9b884', marginTop: 2 }}>
Not applied: this account is now a staff member, and staff can’t be blocked.
</div>
)}
</div>
<button onClick={() => unblock(b)} disabled={busy} className="pill">
Unblock
</button>
</div>
))}
</div>
) : (
<p className="sans dim" style={{ fontSize: '0.86rem', margin: '14px 0' }}>You haven’t blocked anyone.</p>
)}
{hiddenCount > 0 && (
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<span className="sans dim" style={{ fontSize: '0.86rem' }}>
You’ve hidden {hiddenCount} {hiddenCount === 1 ? 'item' : 'items'} for yourself.
</span>
<button onClick={showHidden} disabled={busy} className="pill">Show them again</button>
</div>
)}
{msg && <p className="sans" style={{ marginTop: 14, color: '#7fd0a4', fontSize: '0.86rem' }}>{msg}</p>}
{error && <p className="sans" style={{ marginTop: 14, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>}
</div>
)
}

View File

@@ -0,0 +1,178 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.jsx'
import { api } from '../../api/client.js'
import { showNotice } from '../../lib/notice.js'
import {
WHAT_GOES, WHAT_STAYS, STAFF_NAME_NOTE, isStaffRole, usernameMatches, isSharedDemoLogin, refusalMessage,
} from '../../lib/accountDeletion.js'
// "Delete my account" (docs/website/ACCOUNT_DELETION.md §4.2). Last on the
// Account page and set apart from the sections above it.
//
// The account goes at once — there is no grace period (AD4) — so the safeguard
// is the confirm step: the typed username, the password, and a two-factor code
// when two-factor is on. Delete stays off until the username matches.
//
// On success the server has already dropped every session. This screen sends
// the browser home, re-reads the session (which is now gone) and says so.
const DANGER = '#d98b84'
export default function DeleteAccountPanel({ account }) {
const { refresh } = useAuth()
const { settings } = useSite()
const navigate = useNavigate()
const [open, setOpen] = useState(false)
const [typed, setTyped] = useState('')
const [password, setPassword] = useState('')
const [code, setCode] = useState('')
const [useRecovery, setUseRecovery] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const demoLocked = isSharedDemoLogin(settings, account.username)
const noPassword = account.has_password === false
const needsCode = Boolean(account.totp_enabled)
const ready = usernameMatches(typed, account.username) && password.length > 0 && (!needsCode || code.trim().length > 0)
function close() {
setOpen(false)
setTyped('')
setPassword('')
setCode('')
setUseRecovery(false)
setError('')
}
async function confirm(e) {
e.preventDefault()
if (!ready || busy) return
setBusy(true)
setError('')
try {
await api.deleteMyAccount({
username: typed.trim(),
password,
...(needsCode ? (useRecovery ? { recoveryCode: code.trim() } : { code: code.trim() }) : {}),
})
navigate('/', { replace: true })
showNotice('Your account has been deleted.')
await refresh()
} catch (err) {
setError(refusalMessage(err))
setBusy(false)
}
}
return (
<section
aria-labelledby="delete-account-heading"
style={{ borderTop: `1px solid ${DANGER}`, paddingTop: 26, marginTop: 40 }}
>
<h2 id="delete-account-heading" className="display" style={{ marginTop: 0, fontSize: '1.15rem', color: DANGER }}>
Delete my account
</h2>
<div className="sans" style={{ color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6, maxWidth: 640 }}>
<p style={{ marginTop: 0 }}>
Your account is deleted straight away and cannot be restored. These go with it:
</p>
<ul style={{ margin: '0 0 12px', paddingLeft: 20 }}>
{WHAT_GOES.map((line) => <li key={line}>{line}</li>)}
</ul>
<p style={{ margin: '0 0 6px' }}>What stays:</p>
<ul style={{ margin: '0 0 12px', paddingLeft: 20 }}>
{WHAT_STAYS.map((line) => <li key={line}>{line}</li>)}
</ul>
{isStaffRole(account.role) && <p style={{ margin: '0 0 12px' }}>{STAFF_NAME_NOTE}</p>}
</div>
{noPassword && (
<p className="sans" style={{ color: DANGER, fontSize: '0.86rem' }}>
This account has no password yet. Set one in the Password section above first; the password is how you
confirm a deletion.
</p>
)}
{!open && (
<div>
<button
type="button"
onClick={() => setOpen(true)}
disabled={demoLocked || noPassword}
className="btn btn-sq"
style={{ borderColor: DANGER, color: DANGER }}
>
Delete my account…
</button>
{demoLocked && (
<p className="sans dim" style={{ fontSize: '0.82rem', margin: '8px 0 0' }}>
This is turned off on the demo site.
</p>
)}
</div>
)}
{open && (
<form
onSubmit={confirm}
style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 360, padding: 16, border: `1px solid ${DANGER}`, borderRadius: 10 }}
>
<label>
<span className="field-label">
Type <strong style={{ color: 'var(--head)' }}>{account.username}</strong> to confirm
</span>
<input
type="text"
value={typed}
onChange={(e) => setTyped(e.target.value)}
className="input"
autoComplete="off"
autoCapitalize="none"
spellCheck={false}
/>
</label>
<label>
<span className="field-label">Password</span>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="input" autoComplete="current-password" />
</label>
{needsCode && (
<label>
<span className="field-label">{useRecovery ? 'Recovery code' : 'Authenticator code'}</span>
<input
type="text"
inputMode={useRecovery ? 'text' : 'numeric'}
autoComplete="one-time-code"
value={code}
onChange={(e) => setCode(e.target.value)}
className="input"
/>
<button
type="button"
className="pill"
style={{ marginTop: 8 }}
onClick={() => { setUseRecovery((v) => !v); setCode('') }}
>
{useRecovery ? 'Use an authenticator code instead' : 'Use a recovery code instead'}
</button>
</label>
)}
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button
type="submit"
disabled={!ready || busy}
className="btn btn-sq"
style={{ background: ready ? DANGER : undefined, borderColor: DANGER, color: ready ? '#1a1a1a' : DANGER }}
>
{busy ? 'Deleting…' : 'Delete my account'}
</button>
<button type="button" onClick={close} disabled={busy} className="pill">Cancel</button>
</div>
{error && <p role="alert" className="sans" style={{ margin: 0, color: DANGER, fontSize: '0.85rem' }}>{error}</p>}
</form>
)}
</section>
)
}

View File

@@ -0,0 +1,69 @@
// What the Account page's "Delete my account" section says and decides
// (docs/website/ACCOUNT_DELETION.md §4.1, §4.2). Plain JS with tests, so the
// words and the confirm rule can be checked without a browser.
//
// The server is the guard: it checks the username, the password and the code
// again whatever this file decides. What is here only keeps a mis-tap from
// reaching it and turns its refusal codes into sentences.
/** What goes with the account. The same list as BACKEND_DESIGN.md's. */
export const WHAT_GOES = [
'your sign-in, password, email address, two-factor settings and recovery codes',
'every signed-in device, trusted device and push registration',
'your linked sign-ins, such as Google, Discord or Steam',
'your game-account links, and anything this site granted in the game through them',
'your notification settings and inbox',
]
/** What stays, and who sees what of it. */
export const WHAT_STAYS = [
'What you wrote where others read it, such as forum posts, stays. Other members see it as written by “[deleted player]”.',
'Reports you filed and appeals you made stay as moderation records. Staff can still see who it was, and the activity log keeps its IP addresses.',
'Event results stay with the score and the in-game name, with no site account behind them.',
'A game account stays on the game server. Only its link to this site goes.',
]
/** Said only to staff: their name is never free again (AD9). */
export const STAFF_NAME_NOTE =
'You are staff, so your username can never be used again, by anyone, once this account is deleted.'
/** True for every role but player. */
export const isStaffRole = (role) => Boolean(role) && role !== 'player'
/** The confirm button is live once the typed name matches, ignoring case. */
export function usernameMatches(typed, username) {
return typeof typed === 'string'
&& typeof username === 'string'
&& typed.trim().length > 0
&& typed.trim().toLowerCase() === username.toLowerCase()
}
/**
* True when the signed-in account is one of the demo's shared logins, which the
* server refuses to delete. The section still shows (Play's reviewer must find
* it); only its button is off.
*/
export function isSharedDemoLogin(settings, username) {
const logins = settings?.demo?.logins
if (!Array.isArray(logins) || typeof username !== 'string') return false
const name = username.toLowerCase()
return logins.some((l) => typeof l?.username === 'string' && l.username.toLowerCase() === name)
}
const BY_CODE = {
username_mismatch: 'Type your username exactly as it is shown.',
wrong_password: 'Your password is incorrect.',
code_required: 'Enter a code from your authenticator app, or a recovery code.',
wrong_code: 'That code is not valid. Try again.',
password_required: 'This account has no password yet. Set one in the Password section above, then delete the account.',
last_admin: 'You are the only admin. Make someone else an admin before you delete your account.',
DEMO_LOCKED: 'This is turned off on the demo site.',
}
/** A refusal from DELETE /auth/me/account, as one sentence. */
export function refusalMessage(err) {
const code = err?.body?.code
if (code && BY_CODE[code]) return BY_CODE[code]
if (err?.status === 429) return 'Too many attempts. Wait a few minutes and try again.'
return err?.message || 'Could not delete your account. Try again.'
}

View File

@@ -0,0 +1,25 @@
// How a deleted account's name is shown (ACCOUNT_DELETION.md AD5, AD6).
//
// The server decides WHAT name a viewer gets (server/src/utils/displayAuthor.js):
// a member is sent "[deleted player]", staff are sent the stored name and a
// `…Deleted` flag. All this file does is mark the staff case, so a moderator
// reading "Ravenna" knows that account no longer exists.
export const DELETED_PLAYER = '[deleted player]'
/** "Ravenna (deleted account)" for staff; the placeholder is left as it is. */
export function markDeleted(name, deleted) {
if (!name) return name
return deleted && name !== DELETED_PLAYER ? `${name} (deleted account)` : name
}
/**
* The same for an admin view that is handed raw ledger rows (`actor_user_id`,
* `actor_username`): an id that is NULL beside a snapshot that is not is a
* deleted account, and nothing else clears those ids. Only an explicit null
* counts — a row that does not carry the id column at all says nothing.
*/
export function rowName(userId, username, fallback = '—') {
if (!username) return fallback
return markDeleted(username, userId === null)
}

View File

@@ -0,0 +1,57 @@
// The public demo's refusal (docs/website/DEMO_MODE.md §3.1).
//
// With DEMO_MODE on, the server answers a locked write `403 { code:
// 'DEMO_LOCKED', message }`. Every request goes through api/client.js's req(),
// installed modules' included, so req() reports the refusal here and the one
// toast (components/DemoToast.jsx) shows it — wherever the action was, without
// each form learning the code. The form still gets its ApiError as usual.
//
// The lock is the server's (DM10). Nothing here decides what is allowed; it
// only explains what was refused.
export const DEMO_LOCKED = 'DEMO_LOCKED'
const listeners = new Set()
/** Subscribe to refusals. Returns the unsubscribe function. */
export function onDemoLocked(fn) {
listeners.add(fn)
return () => listeners.delete(fn)
}
/** Report a refusal, if this response body is one. Returns whether it was. */
export function reportIfDemoLocked(body) {
if (!body || typeof body !== 'object' || body.code !== DEMO_LOCKED) return false
const message = typeof body.message === 'string' && body.message ? body.message : 'This is turned off on the demo site.'
for (const fn of listeners) {
try {
fn(message)
} catch {
// A broken listener must not turn a refusal into a different error.
}
}
return true
}
/**
* Was this ApiError the demo's refusal? For the few screens that turn a 403
* into a sentence of their own ("only an administrator can…"), which would
* otherwise blame the role of somebody who is signed in as an admin.
*/
export function isDemoLocked(err) {
return Boolean(err && err.body && err.body.code === DEMO_LOCKED)
}
/**
* "14:00 UTC" for the banner. The wipe runs on the hour in UTC (DM7), so the
* banner says UTC rather than converting — a converted time in a reviewer's
* zone reads as a different schedule from the one published on /beta.
*/
export function formatResetTime(iso) {
const ms = Date.parse(iso)
if (Number.isNaN(ms)) return null
const d = new Date(ms)
const hh = String(d.getUTCHours()).padStart(2, '0')
const mm = String(d.getUTCMinutes()).padStart(2, '0')
return `${hh}:${mm} UTC`
}

27
client/src/lib/notice.js Normal file
View File

@@ -0,0 +1,27 @@
// A one-line notice for the site-wide toast (components/DemoToast.jsx), for the
// rare moment a screen has to say something after it has navigated away — the
// account page cannot show "Your account has been deleted." on itself, because
// the account it would be shown on no longer exists.
//
// The same shape as lib/demoNotice.js and drawn by the same toast, so the site
// has one place a passing message appears.
const listeners = new Set()
/** Subscribe to notices. Returns the unsubscribe function. */
export function onNotice(fn) {
listeners.add(fn)
return () => listeners.delete(fn)
}
/** Show one notice. */
export function showNotice(message) {
if (typeof message !== 'string' || !message) return
for (const fn of listeners) {
try {
fn(message)
} catch {
// A broken listener must not stop the caller.
}
}
}

View File

@@ -41,7 +41,7 @@ export function relativeTime(when, now = Date.now()) {
* Distinct from `teamAdmin.freshnessOf`, which is worded for an operator
* debugging a sync. A visitor needs one sentence about whether what they are
* looking at is current, and specifically must never be shown an unconfirmed
* empty projection as though it were a confirmed empty shard.
* empty projection as though it were a confirmed empty one.
*/
export function freshnessNote(sync = {}, now = Date.now()) {
// Nothing supplies Teams here, so there is nothing to be stale ABOUT. A
@@ -57,7 +57,7 @@ export function freshnessNote(sync = {}, now = Date.now()) {
* Group feed items into days, newest first, preserving order within a day (§4.3).
*
* Keyed by local calendar date rather than by a UTC slice: "yesterday" is a
* property of where the reader is sitting, and a shard's evening raid landing at
* property of where the reader is sitting, and a game server's evening raid landing at
* 00:30 UTC belongs on the day the players experienced it.
*/
export function groupByDay(items = [], locale = undefined) {

View File

@@ -3,10 +3,12 @@
//
// Plain JS with tests, following lib/moduleAdmin.js. The reason it is worth
// splitting here specifically: this screen's job is to tell an operator the
// difference between "the shard has no Teams" and "core has not been able to ask
// difference between "the game server has no Teams" and "core has not been able to ask
// for two hours", and those two produce almost the same page. Getting that
// wording right is logic, not markup.
import { markDeleted, rowName } from './deletedAccount.js'
/** Tones the screen uses. Names, not colours — the view maps them. */
export const TONE = { ok: 'ok', warn: 'warn', bad: 'bad', idle: 'idle' }
@@ -31,7 +33,7 @@ export function freshnessOf(sync = {}) {
return {
tone: TONE.bad,
label: 'Never synced',
detail: 'Core has never had an answer it could trust. What is shown below is not a confirmed empty shard.',
detail: 'Core has never had an answer it could trust. What is shown below does not confirm that the game server has no Teams.',
}
}
if (sync.stale) {
@@ -91,7 +93,7 @@ export const GATED_NOTE =
/** A one-line description of a queued request, for the approval queue. */
export function describeRequest(request = {}) {
const payload = parsePayload(request.payload)
const who = request.requested_username || 'a deleted user'
const who = rowName(request.requested_by, request.requested_username, 'a deleted user')
switch (request.action) {
case 'unhide':
return `${who} asks to publish “${request.team_name}”`
@@ -133,7 +135,7 @@ export function leadershipOf(member = {}) {
return {
isLeader: granted,
overridden: true,
note: `${granted ? 'Granted' : 'Denied'} by ${member.leaderOverride.by || 'a deleted user'}`
note: `${granted ? 'Granted' : 'Denied'} by ${markDeleted(member.leaderOverride.by, member.leaderOverride.byDeleted) || 'a deleted user'}`
+ `${member.leaderOverride.reason ? ` — ${member.leaderOverride.reason}` : ''}`
+ ` (the game says ${member.isLeaderSynced ? 'leader' : 'not a leader'})`,
}

View File

@@ -12,6 +12,8 @@
// `created_at` when the write arrives. A client that granted one would be
// deciding a time-bounded permission against the clock of the party it bounds.
import { markDeleted } from './deletedAccount.js'
export const REPORT_REASONS = [
['abuse', 'Abusive or harassing'],
['spam', 'Spam'],
@@ -75,7 +77,7 @@ export function stripToText(html) {
export function threadSummary(thread) {
const parts = []
if (thread.type === 'announcement') parts.push('Announcement')
parts.push(thread.author)
parts.push(markDeleted(thread.author, thread.authorDeleted))
if (thread.type === 'discussion' && thread.postCount > 1) {
const replies = thread.postCount - 1
parts.push(`${replies} ${replies === 1 ? 'reply' : 'replies'}`)

View File

@@ -0,0 +1,35 @@
// Report, block and hide — the parts of the screens that are rules rather than
// markup (docs/website/USER_SAFETY.md §1, §4.1). Plain JS so the client's
// node --test suite can hold them.
function fmtDate(s) {
if (!s) return ''
const d = new Date(s)
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
/**
* The second line of a row on Account → Blocked: what was blocked, and when.
* An account block is the normal one (S12); a game block names the identity
* and says it is not linked to an account yet, since that is why it is a Steam
* id and not a username.
*/
export function blockSubtitle(block) {
const parts = []
if (block.kind === 'game' && block.identity) {
parts.push(`${block.identity.label} ${block.identity.id}, not linked to an account`)
} else {
parts.push('Account')
}
const when = fmtDate(block.createdAt)
if (when) parts.push(`blocked ${when}`)
return parts.join(' · ')
}
/**
* May the Reports queue offer "Hide for everyone" on this report? Not on an
* upload — it is shown inside a post, and hiding that post is the act — and not
* once this report has already hidden its item.
*/
export const canHideForEveryone = (report) =>
report.targetType !== 'team_forum_upload' && report.actionedWith !== 'hide'

View File

@@ -5,6 +5,8 @@ import { api } from '../api/client.js'
import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx'
import { REPORT_REASONS, editOfferOpen, stripToText, threadSummary } from '../lib/teamForum.js'
// A member is sent "[deleted player]"; staff the stored name, which this marks.
import { markDeleted } from '../lib/deletedAccount.js'
// Core's Team forum, rendered into a second slot a MODULE declares
// (TEAMS.md Part 5, and the phase 3 amendment to §3.4).
@@ -124,6 +126,10 @@ export default function TeamForumPanel({ externalId, moduleId }) {
imageMode={imageMode}
onBack={() => openThread(null)}
onChanged={() => loadThread(team.slug, thread.id)}
onGone={async () => {
await loadThreads(team.slug)
openThread(null)
}}
onModerate={async (action) => {
await api.teamForumModerate(team.slug, thread.id, { action })
await loadThreads(team.slug)
@@ -309,7 +315,7 @@ function GuestManager({ slug }) {
)
}
function ThreadView({ slug, thread, canModerate, imageMode, onBack, onChanged, onModerate }) {
function ThreadView({ slug, thread, canModerate, imageMode, onBack, onChanged, onGone, onModerate }) {
// A clock that ticks, so an edit control whose deadline passed while the page
// sat open goes away instead of becoming a button that fails. It only ever
// REMOVES an offer — the server decides whether an edit happens, and re-derives
@@ -332,8 +338,7 @@ function ThreadView({ slug, thread, canModerate, imageMode, onBack, onChanged, o
</h2>
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 14px' }}>
{thread.type === 'announcement' ? 'Announcement · ' : ''}
{thread.author}
{thread.authorDeleted && ' (account removed)'}
{markDeleted(thread.author, thread.authorDeleted)}
{thread.locked && ' · locked'}
</p>
@@ -385,6 +390,15 @@ function ThreadView({ slug, thread, canModerate, imageMode, onBack, onChanged, o
targetId={thread.id}
label="Report this thread"
/>
{/* A thread this reader hides, or whose author they block, is gone
from their view, so the panel goes back to the list. */}
<SafetyControls
targetType="team_forum_thread"
targetId={thread.id}
author={thread.author}
canBlock={thread.canBlock}
onDone={onGone}
/>
{canModerate && (
<>
<button type="button" className="pill" onClick={() => onModerate(thread.pinned ? 'unpin' : 'pin')}>
@@ -448,8 +462,7 @@ function PostView({ slug, post, canModerate, now, onChanged }) {
return (
<article style={{ marginBottom: 16 }}>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 2px' }}>
{post.author}
{post.authorDeleted && ' (account removed)'}
{markDeleted(post.author, post.authorDeleted)}
{post.editedAt && ' · edited'}
{post.status === 'hidden' && ' · hidden'}
</p>
@@ -515,6 +528,15 @@ function PostView({ slug, post, canModerate, now, onChanged }) {
label="Report"
/>
)}
{!post.mine && (
<SafetyControls
targetType="team_forum_post"
targetId={post.id}
author={post.author}
canBlock={post.canBlock}
onDone={onChanged}
/>
)}
{canModerate && (
<>
<button type="button" className="pill" onClick={() => moderate(post.status === 'hidden' ? 'unhide' : 'hide')}>
@@ -529,6 +551,76 @@ function PostView({ slug, post, canModerate, now, onChanged }) {
)
}
/**
* "Hide for me" and "Block author" (USER_SAFETY.md §1).
*
* Both are the reader's own tools and both are saved on their account, so they
* hold on every device and in the app. Neither files a report or changes the
* item for anyone else, and the blocked person is not told (S9) — the
* confirmation says so, because a member deciding whether to block someone is
* usually deciding whether it will start something.
*
* `canBlock` is the server's answer (S8: never yourself, a deleted account or
* staff); this offers Block only when it is true. "Hide for me" works on any
* single item, a staff member's included.
*/
function SafetyControls({ targetType, targetId, author, canBlock, onDone }) {
const [confirming, setConfirming] = useState(false)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const run = async (call) => {
setBusy(true)
setError(null)
try {
await call({ targetType, targetId })
setConfirming(false)
await onDone()
} catch (err) {
setError(err.message || 'Could not do that')
} finally {
setBusy(false)
}
}
if (confirming) {
return (
<div
style={{
display: 'grid', gap: 8, marginTop: 8, padding: 12, width: '100%',
border: '1px solid var(--rule, #ccc)', borderRadius: 6,
}}
>
<p className="sans" style={{ fontSize: '0.85rem', margin: 0 }}>
Block <strong>{author}</strong>? You won&rsquo;t see anything they write, here or in the app.
They aren&rsquo;t told. You can undo it from <em>Account → Blocked</em>.
</p>
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem', margin: 0 }}>{error}</p>}
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={() => run(api.blockAuthor)}>
Block
</button>
<button type="button" className="pill" onClick={() => setConfirming(false)}>Cancel</button>
</div>
</div>
)
}
return (
<>
<button type="button" className="pill" disabled={busy} onClick={() => run(api.hideItem)}>
Hide for me
</button>
{canBlock && (
<button type="button" className="pill" onClick={() => setConfirming(true)}>
Block author
</button>
)}
{error && <span className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.8rem' }}>{error}</span>}
</>
)
}
/**
* The report control — the first user-facing report flow this site has ever had.
*

View File

@@ -11,6 +11,22 @@
// that the two files can drift, so a test asserts they agree
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
// both.
// 1.21.0 — report, block and hide reach a module's content (USER_SAFETY.md
// §4.1): `api.registerReportTargets`, `api.registerGameIdentities`,
// `ctx.safety`. Server-side: a module filters its own feeds per reader on the
// server (S11), and its client half calls core's ordinary `/player/reports` and
// `/auth/me/blocks` routes through the shared request primitive. This file bumps
// for the reason at the top.
// 1.20.0 — `api.onUserDeleting` (ACCOUNT_DELETION.md §4.1, AD10): a module
// unlinks a person's game links before their account is deleted. Server-side
// only. This file bumps for the reason at the top.
// 1.19.0 — an event action may declare `demoRefuses(params)` (DEMO_MODE.md §3.4,
// DM18): the demo refuses a step at save. Server-side only; the refusal reaches
// the browser as an ordinary DEMO_LOCKED answer. This file bumps for the reason at the top.
// 1.18.0 — the public demo (DEMO_MODE.md §3.2): `demoLocks` in module.json,
// `ctx.demoMode`, `api.onDemoSeed`. Server-side; the client half is core's own
// banner and refusal toast, which an installed module's requests reach through
// the shared request primitive. This file bumps for the reason at the top.
// 1.17.0 — a module may take part in linking a sign-in identity (STEAM_SIGNIN.md
// §4): `ctx.identities.list`, `api.onIdentityLinking`, `api.onIdentityLinked`.
// Server-side; the client half is core's own account pages showing Steam. This
@@ -98,4 +114,4 @@
// but the two halves state ONE version: a module declares a single coreApi range
// and is served one chunk, so a client that claimed 1.0.0 while the server
// answered 1.1.0 would be two answers to one question.
export const MODULE_API_VERSION = '1.17.0'
export const MODULE_API_VERSION = '1.21.0'

View File

@@ -4,6 +4,7 @@ import MoonDot from '../../components/MoonDot.jsx'
import BrandLogo from '../../components/BrandLogo.jsx'
import ProviderIcon from '../../components/ProviderIcon.jsx'
import TrustLimitModal from '../../components/security/TrustLimitModal.jsx'
import DemoLogins from '../../components/DemoLogins.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.jsx'
import { api } from '../../api/client.js'
@@ -342,6 +343,14 @@ export default function AdminLogin() {
Protected area — not indexed. Sessions expire after 1 day.
</p>
</form>
{stage === 'creds' && (
<DemoLogins
onPick={(u, p) => {
setUsername(u)
setPassword(p)
}}
/>
)}
<p style={{ textAlign: 'center', margin: '20px 0 0' }}>
<Link to="/" className="sans" style={{ color: 'var(--accent)', fontSize: '0.84rem', textDecoration: 'none' }}>
← Back to site

View File

@@ -5,6 +5,8 @@ import RecoveryCodesDisplay from '../../../components/security/RecoveryCodesDisp
import TrustedDevicesPanel from '../../../components/security/TrustedDevicesPanel.jsx'
import RecoveryCodesPanel from '../../../components/security/RecoveryCodesPanel.jsx'
import EmailAddressPanel from '../../../components/security/EmailAddressPanel.jsx'
import DeleteAccountPanel from '../../../components/security/DeleteAccountPanel.jsx'
import BlockedPanel from '../../../components/security/BlockedPanel.jsx'
import { IdentitySummary, linkBanner, linkableProviders, unlinkPrompt } from '../../../components/security/linkedIdentities.jsx'
import { api } from '../../../api/client.js'
@@ -315,6 +317,14 @@ export default function AccountAdmin() {
{account && <EmailAddressPanel account={account} reload={load} />}
<LinkedAccounts />
{/* Blocks and hides are the member's own (USER_SAFETY.md §1); staff are
members too, and the list is the one the app shows. */}
<BlockedPanel />
{/* The same section the player portal shows: every member can delete
themself, staff included (ACCOUNT_DELETION.md §1). */}
{account && <DeleteAccountPanel account={account} />}
</section>
)
}

View File

@@ -4,6 +4,8 @@ import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { ago, dateTime } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
import { markDeleted } from '../../../lib/deletedAccount.js'
import { canHideForEveryone } from '../../../lib/userSafety.js'
// The member-raised content-report queue (TEAMS.md §5.6).
//
@@ -20,10 +22,20 @@ import { api } from '../../../api/client.js'
// row here rather than as a new screen.
//
// **Handling a report is bookkeeping about the REPORT, not moderation of the
// content.** Acting on the content itself is the ordinary forum moderation
// control, or a site-wide sanction against the account. Keeping those separate is
// what stops "report" from becoming a way for any member to hide anything, so
// this screen deliberately offers no hide/delete button of its own.
// content.** Keeping those separate is what stops "report" from becoming a way
// for any member to hide anything.
//
// **The one exception is "Hide for everyone"** (USER_SAFETY.md §4.1, S3), and it
// is a separate button with its own words, not a status. It hides the item
// through the item's OWN moderation — the forum's hide, or a game module's for a
// chat line — so it is a staff moderation recorded where that content's
// moderation is recorded, and the report is actioned with that named. Undoing it
// happens where the item lives, where staff still see it marked (S10). Delete
// stays the forum's.
//
// Since 1.21.0 a report can be about a game module's item (a Rust chat line),
// and every new report carries a snapshot of what the item said, which is what
// this screen shows once the item itself is gone.
const STATUS_TABS = [
{ key: 'open_work', label: 'Open work', param: undefined },
@@ -70,12 +82,44 @@ const bytes = (n) => {
* was gone" is a fact worth seeing, and dropping it would hide the pattern of a
* member deleting their own content the moment it is reported.
*/
function SnapshotLine({ snapshot }) {
// An unlinked chat speaker is named by their in-game name and Steam id; the
// queue is staff-only, and the id is what a staffer acts on in game.
const who = markDeleted(snapshot.author, snapshot.authorDeleted) || 'unknown'
const idNote = snapshot.authorIdentity ? ` (${snapshot.authorIdentity.kind} ${snapshot.authorIdentity.id})` : ''
return (
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
{snapshot.label ? `${snapshot.label} · ` : ''}{who}{idNote}
{snapshot.where?.server ? ` on ${snapshot.where.server}` : ''}
{snapshot.at ? ` · ${dateTime(snapshot.at)}` : ''}
</span>
)
}
function TargetCell({ report }) {
const t = report.target
const snap = report.snapshot
if (!t) {
return (
<span style={{ color: 'var(--muted)' }}>
{report.targetType.replace('team_forum_', '')} #{report.targetId} — no longer exists
{/* What it said when it was reported, kept on the report (1.21.0). */}
{snap && (
<>
<span style={{ display: 'block', color: 'var(--text)' }}>{snap.text || <em>(no text)</em>}</span>
<SnapshotLine snapshot={snap} />
</>
)}
</span>
)
}
if (t.kind === 'module') {
return (
<span>
{t.text || <em className="dim">(no text)</em>}
{snap ? <SnapshotLine snapshot={snap} /> : (
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>{t.label}</span>
)}
</span>
)
}
@@ -84,7 +128,7 @@ function TargetCell({ report }) {
<span>
<a href={t.url} target="_blank" rel="noopener noreferrer" className="link-accent">{t.filename}</a>
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
{t.uploader || 'unknown'} · {t.mimetype} · {bytes(t.byteSize)}
{markDeleted(t.uploader, t.uploaderDeleted) || 'unknown'} · {t.mimetype} · {bytes(t.byteSize)}
{t.deleted && ' · removed'}
</span>
</span>
@@ -95,7 +139,7 @@ function TargetCell({ report }) {
<span>
<strong>{t.title}</strong>
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
{t.type} by {t.author || 'unknown'}
{t.type} by {markDeleted(t.author, t.authorDeleted) || 'unknown'}
{t.status !== 'visible' && ` · ${t.status}`}
</span>
</span>
@@ -105,7 +149,7 @@ function TargetCell({ report }) {
<span>
{t.excerpt || <em className="dim">(no text)</em>}
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
{t.author || 'unknown'} in “{t.threadTitle}”
{markDeleted(t.author, t.authorDeleted) || 'unknown'} in “{t.threadTitle}”
{t.status !== 'visible' && ` · ${t.status}`}
</span>
</span>
@@ -133,10 +177,11 @@ export default function ContentReports() {
return (
<section>
<p className="sans dim" style={{ margin: '0 0 14px', fontSize: '0.85rem', maxWidth: 720 }}>
Reports raised by members about Team forum content. They come to site staff and are not visible
to a Team&rsquo;s own leaders — a leader moderates their own forum, so a report about a leader
has to reach someone above them. Handling a report records a decision about the report; hiding
or removing the content itself is done from the forum, or as a sanction against the account.
Reports raised by members about Team forum content and in-game chat. They come to site staff and
are not visible to a Team&rsquo;s own leaders — a leader moderates their own forum, so a report
about a leader has to reach someone above them. Handling a report records a decision about the
report. <em>Hide for everyone</em> also hides the item, for everyone but staff; removing it is
done from the forum, or as a sanction against the account.
{typeof data?.openCount === 'number' && ` ${data.openCount} open.`}
</p>
@@ -192,13 +237,16 @@ export default function ContentReports() {
<span className="badge">{REASON_LABEL[r.reason] || r.reason}</span>
</td>
<td className="adm-td dim" style={{ maxWidth: 260 }}>{r.detail || '—'}</td>
<td className="adm-td dim">{r.reporter}</td>
<td className="adm-td dim">{markDeleted(r.reporter, r.reporterDeleted)}</td>
<td className="adm-td dim" title={dateTime(r.createdAt)}>{ago(r.createdAt)}</td>
<td className="adm-td">
<span className="badge" style={STATUS_STYLE[r.status]}>{STATUS_LABEL[r.status] || r.status}</span>
{r.actionedWith === 'hide' && (
<span className="dim" style={{ display: 'block', fontSize: '0.75rem' }}>Hidden for everyone</span>
)}
{r.handledBy && (
<span className="dim" style={{ display: 'block', fontSize: '0.75rem' }}>
{r.handledBy}
{markDeleted(r.handledBy, r.handledByDeleted)}
{r.handledNote ? ` — ${r.handledNote}` : ''}
</span>
)}
@@ -222,9 +270,9 @@ export default function ContentReports() {
<HandleModal
report={handling}
onCancel={() => setHandling(null)}
onDone={() => {
onDone={(text = 'Report updated.') => {
setHandling(null)
setNotice({ text: 'Report updated.', tone: 'ok' })
setNotice({ text, tone: 'ok' })
reload()
}}
onError={(message) => setNotice({ text: message, tone: 'error' })}
@@ -257,6 +305,17 @@ function HandleModal({ report, onCancel, onDone, onError }) {
}
}
const hideForEveryone = async () => {
setBusy(true)
try {
await api.admin.hideReportedItem(report.id, { note: note || undefined })
onDone('Hidden for everyone, and the report is actioned.')
} catch (err) {
onError(err.message || 'Could not hide that.')
setBusy(false)
}
}
return (
<Modal
title={`Report #${report.id}`}
@@ -264,6 +323,11 @@ function HandleModal({ report, onCancel, onDone, onError }) {
footer={(
<>
<button className="pill" onClick={onCancel}>Cancel</button>
{canHideForEveryone(report) && (
<button className="pill" onClick={hideForEveryone} disabled={busy} style={{ color: '#d98b84', borderColor: '#d98b84' }}>
Hide for everyone
</button>
)}
<button className="btn btn-primary btn-sq" onClick={submit} disabled={busy}>
{busy ? 'Saving…' : 'Save'}
</button>
@@ -272,8 +336,12 @@ function HandleModal({ report, onCancel, onDone, onError }) {
>
<div style={{ display: 'grid', gap: 12 }}>
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>
This records a decision about the report. It does not hide, delete or restore the content —
do that from the forum itself, or against the account.
<strong>Save</strong> records a decision about the report and changes nothing about the content.
{' '}
{canHideForEveryone(report)
? <><strong>Hide for everyone</strong> hides the item for everyone but staff, who still see it marked
where it is and can unhide it there, and actions this report.</>
: 'Deleting or restoring the content is done from the forum itself, or against the account.'}
</p>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>

View File

@@ -3,6 +3,7 @@ import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { ago, dateTime } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
import { isDemoLocked } from '../../../lib/demoNotice.js'
import { useSite } from '../../../contexts/SiteContext.jsx'
import { useAuth } from '../../../contexts/AuthContext.jsx'
@@ -49,11 +50,10 @@ export default function Dashboard() {
await refreshSite()
reload()
} catch (err) {
setModeError(
err.status === 403
? 'Only an administrator can change the site mode.'
: 'Could not change the site mode. Try again.',
)
let message = 'Could not change the site mode. Try again.'
if (isDemoLocked(err)) message = err.message
else if (err.status === 403) message = 'Only an administrator can change the site mode.'
setModeError(message)
} finally {
setBusy(false)
}

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { dateTime } from '../../../lib/format.js'
import { rowName } from '../../../lib/deletedAccount.js'
import {
freshnessOf, statusOf, gateLabelFor, describeRequest, leadershipOf, GATED_NOTE,
} from '../../../lib/teamAdmin.js'
@@ -14,7 +15,7 @@ import TeamVoice from './TeamVoice.jsx'
// Three panels, in the order an operator needs them:
//
// 1. **Sync state**, verbatim, including the last error. The screen's first job
// is to make "the shard has no Teams" and "core has not been able to ask for
// is to make "the game server has no Teams" and "core has not been able to ask for
// two hours" impossible to confuse — they render almost identically
// otherwise, and one is fine while the other is an outage.
// 2. **The review queue** — Teams auto-hidden because their name matched the
@@ -331,7 +332,7 @@ function ForumLedger({ team, onClose }) {
<td className="adm-td dim">{dateTime(r.created_at)}</td>
<td className="adm-td" style={{ color: 'var(--head)' }}>{r.action}</td>
<td className="adm-td dim">{r.target_type} #{r.target_id}</td>
<td className="adm-td">{r.actor_username || '—'}</td>
<td className="adm-td">{rowName(r.actor_user_id, r.actor_username)}</td>
<td className="adm-td">
{/* The distinction the whole ledger exists to preserve. */}
<Pill tone={r.actor_role === 'staff' ? 'warn' : 'ok'}>{r.actor_role}</Pill>

View File

@@ -5,6 +5,8 @@ import RecoveryCodesDisplay from '../../components/security/RecoveryCodesDisplay
import TrustedDevicesPanel from '../../components/security/TrustedDevicesPanel.jsx'
import RecoveryCodesPanel from '../../components/security/RecoveryCodesPanel.jsx'
import EmailAddressPanel from '../../components/security/EmailAddressPanel.jsx'
import DeleteAccountPanel from '../../components/security/DeleteAccountPanel.jsx'
import BlockedPanel from '../../components/security/BlockedPanel.jsx'
import { IdentitySummary, linkBanner, linkableProviders, unlinkPrompt } from '../../components/security/linkedIdentities.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { api } from '../../api/client.js'
@@ -422,6 +424,8 @@ export default function PlayerAccount() {
)}
<LinkedAccounts />
<ActiveDevices />
<BlockedPanel />
<DeleteAccountPanel account={account} />
</>
)}
</div>

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Link, useNavigate, useLocation } from 'react-router-dom'
import ProviderIcon from '../../components/ProviderIcon.jsx'
import TrustLimitModal from '../../components/security/TrustLimitModal.jsx'
import DemoLogins from '../../components/DemoLogins.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { api } from '../../api/client.js'
import PlayerShell, { honeypotStyle } from './PlayerShell.jsx'
@@ -272,6 +273,15 @@ export default function PlayerLogin() {
)}
</form>
{stage === 'creds' && (
<DemoLogins
onPick={(u, p) => {
setUsername(u)
setPassword(p)
}}
/>
)}
{trustLimit && (
<TrustLimitModal
devices={trustLimit.devices}

View File

@@ -3,6 +3,7 @@ import { Link, useNavigate } from 'react-router-dom'
import ProviderIcon from '../../components/ProviderIcon.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { api } from '../../api/client.js'
import { isDemoLocked } from '../../lib/demoNotice.js'
import PlayerShell, { honeypotStyle } from './PlayerShell.jsx'
export default function PlayerRegister() {
@@ -54,6 +55,7 @@ export default function PlayerRegister() {
navigate('/player', { replace: true })
} catch (err) {
if (err.status === 409) setError('That username is already taken.')
else if (isDemoLocked(err)) setError(err.message)
else if (err.status === 403) setError('Registration is not open right now.')
else if (err.status === 400) setError(err.message || 'Please check your details and try again.')
else setError('Could not create your account right now.')

View File

@@ -0,0 +1,80 @@
// The Account page's "Delete my account" section (client/src/lib/accountDeletion.js)
// and how a deleted account's name is marked for staff (lib/deletedAccount.js).
// docs/website/ACCOUNT_DELETION.md §4.1, §4.2, AD5, AD6.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
usernameMatches, isSharedDemoLogin, refusalMessage, isStaffRole, WHAT_GOES, WHAT_STAYS,
} from '../src/lib/accountDeletion.js'
import { DELETED_PLAYER, markDeleted, rowName } from '../src/lib/deletedAccount.js'
import { describeRequest, leadershipOf } from '../src/lib/teamAdmin.js'
import { threadSummary } from '../src/lib/teamForum.js'
// ── the confirm step ───────────────────────────────────────────────────────
test('Delete is live only once the typed username matches, ignoring case and spaces', () => {
assert.equal(usernameMatches('Thorne', 'Thorne'), true)
assert.equal(usernameMatches(' thorne ', 'Thorne'), true)
assert.equal(usernameMatches('Thorn', 'Thorne'), false)
assert.equal(usernameMatches('', 'Thorne'), false)
assert.equal(usernameMatches(' ', 'Thorne'), false)
assert.equal(usernameMatches(undefined, 'Thorne'), false)
})
test('the demo’s shared logins are recognised from the published list, and nothing else is', () => {
const settings = { demo: { logins: [{ username: 'demo-player' }, { username: 'play-review' }] } }
assert.equal(isSharedDemoLogin(settings, 'Demo-Player'), true)
assert.equal(isSharedDemoLogin(settings, 'alice'), false)
// Off the demo there is no `demo` block, so nobody is a shared login.
assert.equal(isSharedDemoLogin({}, 'demo-player'), false)
assert.equal(isSharedDemoLogin(undefined, 'demo-player'), false)
})
test('every refusal code the route answers has its own sentence', () => {
for (const code of ['username_mismatch', 'wrong_password', 'code_required', 'wrong_code', 'password_required', 'last_admin', 'DEMO_LOCKED']) {
const msg = refusalMessage({ status: 400, body: { code }, message: 'server words' })
assert.notEqual(msg, 'server words', code)
assert.ok(msg.length > 10, code)
}
assert.match(refusalMessage({ status: 429, body: null }), /Too many attempts/)
assert.equal(refusalMessage({ status: 500, message: 'Internal Server Error' }), 'Internal Server Error')
assert.match(refusalMessage(null), /Could not delete/)
})
test('the screen says what goes and what stays, and that staff names are kept forever', () => {
assert.ok(WHAT_GOES.some((l) => /game-account links/.test(l)))
assert.ok(WHAT_STAYS.some((l) => l.includes('[deleted player]')))
assert.equal(isStaffRole('moderator'), true)
assert.equal(isStaffRole('player'), false)
})
// ── staff see a deleted account's name, marked ─────────────────────────────
test('a deleted account’s stored name is marked; the placeholder is left alone', () => {
assert.equal(markDeleted('Ravenna', true), 'Ravenna (deleted account)')
assert.equal(markDeleted('Ravenna', false), 'Ravenna')
assert.equal(markDeleted(DELETED_PLAYER, true), DELETED_PLAYER)
assert.equal(markDeleted(null, true), null)
})
test('a raw ledger row is a deleted account only when its id is an explicit null', () => {
assert.equal(rowName(null, 'OldMod'), 'OldMod (deleted account)')
assert.equal(rowName(4, 'OldMod'), 'OldMod')
assert.equal(rowName(undefined, 'OldMod'), 'OldMod')
assert.equal(rowName(null, null), '—')
})
test('Admin → Teams marks a deleted requester and a deleted override author', () => {
assert.match(
describeRequest({ action: 'unhide', requested_by: null, requested_username: 'mod1', team_name: 'X' }),
/^mod1 \(deleted account\) asks/,
)
const l = leadershipOf({ isLeaderSynced: true, leaderOverride: { effect: 'deny', by: 'mod1', byDeleted: true } })
assert.match(l.note, /Denied by mod1 \(deleted account\)/)
})
test('a forum thread by a deleted account is marked in the list for staff', () => {
assert.match(threadSummary({ type: 'discussion', author: 'Ravenna', authorDeleted: true, postCount: 1 }), /^Ravenna \(deleted account\)/)
assert.equal(threadSummary({ type: 'discussion', author: DELETED_PLAYER, authorDeleted: true, postCount: 1 }), DELETED_PLAYER)
})

View File

@@ -247,6 +247,32 @@ test('the report queue defaults to the open work rather than to everything', asy
assert.equal(calls[0].url, '/api/v1/admin/moderation/reports')
})
// USER_SAFETY.md §4.1: a report, a block and a hide name an ITEM, never a
// person, and the server reads who wrote it.
test('report, block and hide name an item, on the one route for every target', async () => {
willReply({ body: { ok: true } })
await api.reportItem({ targetType: 'rust.chat', targetId: 4410, reason: 'abuse' })
assert.equal(calls[0].url, '/api/v1/player/reports')
assert.deepEqual(JSON.parse(calls[0].opts.body), { targetType: 'rust.chat', targetId: 4410, reason: 'abuse' })
calls = []
willReply({ body: { block: {} } })
await api.blockAuthor({ targetType: 'team_forum_post', targetId: 80 })
assert.equal(calls[0].url, '/api/v1/auth/me/blocks')
assert.deepEqual(JSON.parse(calls[0].opts.body), { targetType: 'team_forum_post', targetId: 80 })
calls = []
willReply({ body: { ok: true } })
await api.unhideItem({ targetType: 'rust.chat', targetId: 4410 })
assert.equal(calls[0].url, '/api/v1/auth/me/hidden/rust.chat/4410')
assert.equal(calls[0].opts.method, 'DELETE')
calls = []
willReply({ body: {} })
await api.admin.hideReportedItem(41, { note: 'x' })
assert.equal(calls[0].url, '/api/v1/admin/moderation/reports/41/hide')
})
test('a Team slug is URL-encoded on every forum path', async () => {
willReply({ body: { ok: true } })
await api.teamForumReport('a b/c', { targetType: 'team_forum_thread', targetId: 1, reason: 'spam' })

View File

@@ -0,0 +1,71 @@
import { test, afterEach } from 'node:test'
import assert from 'node:assert/strict'
import { api } from '../src/api/client.js'
import { onDemoLocked, reportIfDemoLocked, formatResetTime, isDemoLocked } from '../src/lib/demoNotice.js'
// The public demo's refusal reaches the one toast from req() itself, so every
// screen and every installed module gets it without handling the code — and
// the caller still gets its ApiError, so a form's own error line is unchanged.
const realFetch = global.fetch
afterEach(() => {
global.fetch = realFetch
})
function replyOnce(status, body) {
global.fetch = async () => ({
ok: status >= 200 && status < 300,
status,
statusText: 'Forbidden',
text: async () => JSON.stringify(body),
})
}
test('a DEMO_LOCKED answer is reported once and still thrown to the caller', async () => {
const heard = []
const off = onDemoLocked((m) => heard.push(m))
replyOnce(403, { code: 'DEMO_LOCKED', message: 'This is turned off on the demo site.' })
await assert.rejects(() => api.publicSettings(), (err) => err.status === 403 && err.message === 'This is turned off on the demo site.')
off()
assert.deepEqual(heard, ['This is turned off on the demo site.'])
})
test('any other refusal is not a demo refusal', async () => {
const heard = []
const off = onDemoLocked((m) => heard.push(m))
replyOnce(403, { message: 'Admin role required' })
await assert.rejects(() => api.publicSettings())
off()
assert.deepEqual(heard, [])
})
test('reportIfDemoLocked recognises the code alone and survives a broken listener', () => {
const off1 = onDemoLocked(() => {
throw new Error('boom')
})
const heard = []
const off2 = onDemoLocked((m) => heard.push(m))
assert.equal(reportIfDemoLocked({ code: 'DEMO_LOCKED' }), true)
assert.equal(reportIfDemoLocked({ code: 'OTHER' }), false)
assert.equal(reportIfDemoLocked('text'), false)
assert.equal(reportIfDemoLocked(null), false)
off1()
off2()
assert.deepEqual(heard, ['This is turned off on the demo site.'])
})
test('isDemoLocked tells the demo refusal from a role 403', async () => {
replyOnce(403, { code: 'DEMO_LOCKED', message: 'This is turned off on the demo site.' })
const locked = await api.publicSettings().catch((e) => e)
replyOnce(403, { message: 'Admin role required' })
const role = await api.publicSettings().catch((e) => e)
assert.equal(isDemoLocked(locked), true)
assert.equal(isDemoLocked(role), false)
assert.equal(isDemoLocked(null), false)
})
test('the reset time is said in UTC, as the wipe runs', () => {
assert.equal(formatResetTime('2026-10-24T14:00:00.000Z'), '14:00 UTC')
assert.equal(formatResetTime('2026-10-24T09:05:00+02:00'), '07:05 UTC')
assert.equal(formatResetTime('nope'), null)
})

View File

@@ -19,7 +19,7 @@ test('a deployment with no provider is not stale, it is uninvolved', () => {
assert.equal(freshnessNote({ configured: false }, NOW), null)
})
test('never synced is a warning, and never reads as a confirmed empty shard', () => {
test('never synced is a warning, and never reads as a confirmed empty roster', () => {
const note = freshnessNote({ configured: true, lastSyncAt: null }, NOW)
assert.equal(note.tone, 'warn')
assert.match(note.text, /Not yet confirmed/)

View File

@@ -21,13 +21,13 @@ test('no provider is idle, not a fault', () => {
assert.match(f.label, /No Team provider/)
})
test('never synced is reported as never synced, not as an empty shard', () => {
test('never synced is reported as never synced, not as a game server with no Teams', () => {
// The failure this prevents: an empty projection core has never confirmed,
// rendered as though the game genuinely has no Teams.
const f = freshnessOf({ configured: true, lastSyncAt: null })
assert.equal(f.tone, TONE.bad)
assert.equal(f.label, 'Never synced')
assert.match(f.detail, /not a confirmed empty shard/)
assert.match(f.detail, /does not confirm that the game server has no Teams/)
})
test('stale says how old it is', () => {

View File

@@ -0,0 +1,24 @@
// Account → Blocked and the Reports queue's "Hide for everyone"
// (client/src/lib/userSafety.js; docs/website/USER_SAFETY.md §1, §4.1).
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { blockSubtitle, canHideForEveryone } from '../src/lib/userSafety.js'
test('a game block says it is a Steam id with no account yet; an account block says Account', () => {
const game = blockSubtitle({
kind: 'game',
identity: { module: 'rust', kind: 'steam', label: 'Steam account', id: '76561198000000123' },
})
assert.equal(game, 'Steam account 76561198000000123, not linked to an account')
assert.equal(blockSubtitle({ kind: 'account', identity: null }), 'Account')
assert.match(blockSubtitle({ kind: 'account', createdAt: '2026-10-30T10:00:00Z' }), /^Account · blocked /)
})
test('hide for everyone is offered on a post, a thread and a chat line, never on an upload or twice', () => {
assert.equal(canHideForEveryone({ targetType: 'team_forum_post', actionedWith: null }), true)
assert.equal(canHideForEveryone({ targetType: 'team_forum_thread', actionedWith: null }), true)
assert.equal(canHideForEveryone({ targetType: 'rust.chat', actionedWith: null }), true)
assert.equal(canHideForEveryone({ targetType: 'team_forum_upload', actionedWith: null }), false)
assert.equal(canHideForEveryone({ targetType: 'rust.chat', actionedWith: 'hide' }), false)
})

61
deploy/demo/.env.example Normal file
View File

@@ -0,0 +1,61 @@
# ─── The demo site's environment (docs/website/DEMO_MODE.md) ───
# Copy to /opt/demo.runicgateway.com/.env on the `demo-site` host and fill in.
# NEVER commit the real file. The wipe workflow never writes it; it only reads it.
IMAGE_TAG=latest
NODE_ENV=production
PORT=3000
# The host port the external reverse proxy forwards demo.runicgateway.com to.
DEMO_HOST_PORT=3000
# How often the wipe runs, for the banner's "wiped at" time. Must match the cron
# in .gitea/workflows/demo-wipe.yml (every 2 hours on the hour, UTC, DM7).
DEMO_RESET_EVERY_HOURS=2
# Branding
BRAND_NAME=Runic Gateway Demo
BRAND_SHORT_NAME=RG Demo
BRAND_URL=https://demo.runicgateway.com
CLIENT_ORIGIN=https://demo.runicgateway.com
# Database. Recreated empty at every wipe; the passwords only need to be stable.
DB_HOST=db
DB_PORT=3306
DB_NAME=runic_gateway
DB_USER=runic
DB_PASSWORD=
DB_ROOT_PASSWORD=
# Long random strings, e.g. `openssl rand -hex 32`. SECRET_ENC_KEY is required in
# production. Every stored secret is re-seeded at each wipe, so rotating either
# one only signs people out.
JWT_SECRET=
SECRET_ENC_KEY=
COOKIE_SECURE=auto
# The guard on the app's unpublished internal port. The demo runs no bot, but the
# app refuses to start in production without it. Another long random string.
BOT_INTERNAL_KEY=
# One proxy hop: the external reverse proxy.
TRUST_PROXY=1
# Leave ADMIN_USERNAME unset: the boot seed must not create a fifth admin. The four
# demo accounts come from seed.json (§4).
ADMIN_USERNAME=
# Where modules may be installed from. A fresh database seeds the allowlist from
# this, and the declared module (state/modules.env) installs under it.
MODULE_SOURCE_HOSTS=gitea.whitlocktech.com
# ── seed.json reads these through "$env:NAME" ──
# The published logins (§4) are not secrets, but they live here so the repo does
# not pin them. play-review's is NOT published; it goes in the Play Console only.
DEMO_ADMIN_PASSWORD=
DEMO_MODERATOR_PASSWORD=
DEMO_PLAYER_PASSWORD=
PLAY_REVIEW_PASSWORD=
# A stand-in Steam id that has played on the rig, so demo-player's Rust screens
# have data (DM6).
DEMO_PLAYER_STEAM_ID=
# The rig's sidecar web API (rust-oxide's last allocation) and its web token: the
# RUSTLINK_WEB_TOKEN in the rig's startup command, or rust-link/sidecar.toml.
DEMO_RIG_SIDECAR_URL=http://192.168.0.12:21004
DEMO_RIG_SIDECAR_TOKEN=

53
deploy/demo/README.md Normal file
View File

@@ -0,0 +1,53 @@
# deploy/demo — demo.runicgateway.com
The public demo site: the normal image with `DEMO_MODE=true`, wiped every two hours together
with its Rust server. Design of record: `docs/website/DEMO_MODE.md`.
| File | What it is |
| --- | --- |
| `docker-compose.yml` | The app and its database. No bot and no ntfy: the demo sends nothing outward (§5) |
| `.env.example` | Every variable the host's `.env` needs, with what each one is for |
| `seed.json` | The fixture `seed:demo` writes after every wipe: the four logins, settings, content, and the Rust module's server link and Steam link (§7.3). Passwords and credentials are `"$env:NAME"` references to `.env` |
| `rig.mjs` | Versions (`plan`), the rig restore (`restore`), the baseline capture (`baseline`) and the smoke check (`smoke`), against the Pterodactyl client API (§7.2, §7.5) |
Two workflows drive it, both on the `demo-site` runner:
- **`demo-wipe.yml`**: every 2 hours on the hour (UTC), on a push to `main` that changes this
directory, and by hand. Versions, then the rig, then the site, then a smoke check.
- **`demo-baseline.yml`**: by hand only. It captures the rig's current state as
`demo-baseline.tar.gz`, which every wipe restores.
## One-time host setup
1. The `demo-site` runner in **host mode** on the demo host, with Docker and Compose, and `node`
on the `PATH` (for `actions/checkout`). The host must reach `gitea.whitlocktech.com`, the
Pterodactyl panel and the rig's sidecar port.
2. `sudo mkdir -p /opt/demo.runicgateway.com`, owned by the runner's user (or the runner runs as
root). To use another path, set the `DEMO_DIR` Actions variable.
3. Copy `.env.example` to `/opt/demo.runicgateway.com/.env` and fill every blank. Generate
secrets on the host, e.g. `openssl rand -hex 32`. The file never leaves the host.
4. Log the host in to the registry if the images are not public: `docker login gitea.whitlocktech.com`.
5. Point the external reverse proxy at `<host>:DEMO_HOST_PORT` (default 3000).
6. Run **Demo wipe** by hand. The first run finds no baseline, so it leaves the rig as it is and
starts it.
Then set the demo default set up through the site: the permission groups, a few NPCs, a clan. Run
**Demo baseline** while nobody else is changing anything. Every later wipe returns the rig to that.
The panel URL and the rig's server identifier default to `http://192.168.0.12` and `6eb9d621`
(`rust-oxide`). Override them with the `PTERODACTYL_URL` and `PTERODACTYL_SERVER` Actions
variables. `PTERODACTYL_CLIENT_KEY` is the demo subuser's key, an Actions secret.
## What the wipe keeps on the host
`state/` in the deploy directory:
- `bundle.json`: the Rust bundle last written onto the rig. A new bundle is written only when
this differs.
- `modules.env`: `MODULES=rust@<version>=<manifest>`, the newest Module-Rust release on the bundle's
protocol. When no release matches a new bundle yet, the wipe keeps this file and the rig's
bundle as they were and warns (§7.5).
- `plan.json`: this cycle's decision, from `plan` to `restore`.
`modules/` keeps the unpacked module between wipes. `down -v` drops the database and the uploads,
never this directory.

View File

@@ -0,0 +1,70 @@
# The public demo, demo.runicgateway.com (docs/website/DEMO_MODE.md).
#
# The same image as every other deployment, switched by DEMO_MODE=true (§2). Only
# the app and its database: no bot and no ntfy, because the demo sends nothing
# outward (§5). The wipe workflow (.gitea/workflows/demo-wipe.yml) copies this
# file into the deploy directory on the `demo-site` host and runs it from there;
# the host's own `.env` beside it holds every secret.
#
# The project name is fixed so the network is `rg-demo_default` whatever the
# directory is called: the workflow's smoke check joins it to reach the app.
name: rg-demo
services:
db:
image: mariadb:11
restart: unless-stopped
environment:
MARIADB_DATABASE: ${DB_NAME}
MARIADB_USER: ${DB_USER}
MARIADB_PASSWORD: ${DB_PASSWORD}
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
volumes:
# Dropped at every wipe (`down -v`) with the uploads volume: that is the wipe.
- dbdata:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 10
app:
image: gitea.whitlocktech.com/runicgateway/website-app:${IMAGE_TAG:-latest}
restart: unless-stopped
# modules.env is written by the wipe (rig.mjs plan): MODULES=rust@<version>=…,
# the newest Module-Rust release on the bundle's protocol (§7.5). Kept out of
# .env so the workflow never edits the file holding the secrets.
env_file:
- .env
- state/modules.env
environment:
DEMO_MODE: "true"
DB_HOST: db
UPLOAD_DIR: /app/uploads
LOG_DIR: /app/logs
MODULES_DIR: /app/modules
depends_on:
db:
condition: service_healthy
volumes:
- uploads:/app/uploads
- ./logs:/app/logs
# A bind mount, not a volume, so `down -v` keeps the unpacked module and a
# wipe does not download it again. Must be writable by uid 1000.
- ./modules:/app/modules
# The seed fixture (§7.3), read by `seed:demo` after every wipe.
- ./seed.json:/app/deploy/demo/seed.json:ro
# Busybox wget is in the Alpine image; `/status` answers once the server listens.
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:3000/api/v1/public/status"]
interval: 10s
timeout: 5s
start_period: 30s
retries: 30
# The external reverse proxy (DNS + HTTPS) forwards here.
ports:
- "${DEMO_HOST_PORT:-3000}:3000"
volumes:
dbdata:
uploads:

372
deploy/demo/rig.mjs Normal file
View File

@@ -0,0 +1,372 @@
// ── The demo's rig and versions (docs/website/DEMO_MODE.md §7.2, §7.5) ──────
//
// One script, four commands, run by the demo workflows on the `demo-site` host
// inside a stock Node container (so the host needs Docker and nothing else):
//
// node rig.mjs plan decide the versions for this cycle (§7.5)
// node rig.mjs restore put the rig back to the baseline, updating its plugins
// and sidecar first when the bundle changed (§7.2)
// node rig.mjs baseline capture the rig's current state as the baseline (§7.2)
// node rig.mjs smoke wait for the site, then for the rig to report in (§7.3.6)
//
// State lives in $STATE_DIR (the deploy directory's `state/`):
// bundle.json the bundle last written onto the rig
// modules.env MODULES=rust@<version>=<manifest>, read by docker-compose.yml
// plan.json this cycle's decision, written by `plan`, read by `restore`
//
// Environment:
// PTERODACTYL_URL the panel, e.g. http://192.168.0.12
// PTERODACTYL_SERVER the rig's short identifier, e.g. e6758c06
// PTERODACTYL_CLIENT_KEY the demo subuser's client key (file + power only)
// PTERODACTYL_IDENTITY optional: the folder under /server holding the save;
// found by listing /server when unset
// GITEA_URL default https://gitea.whitlocktech.com
// SITE_URL for `smoke`, e.g. http://app:3000
// SMOKE_TIMEOUT_SECONDS for `smoke`, default 900 (a Rust boot is ~9 minutes)
//
// Dependency-free on purpose: Node's own fetch and crypto, and the image's tar.
import { createHash } from 'node:crypto'
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
const GITEA = (process.env.GITEA_URL || 'https://gitea.whitlocktech.com').replace(/\/+$/, '')
const STATE_DIR = process.env.STATE_DIR || path.join(path.dirname(fileURLToPath(import.meta.url)), 'state')
const BASELINE = 'demo-baseline.tar.gz'
const MODULE_REPO = 'RunicGateway/Module-Rust'
const BUNDLE_PATH = 'RunicGateway/installer/contents/v2/rust/current.json?ref=bundles'
const log = (...a) => console.log(...a)
const warn = (msg) => console.log(`::warning::${msg}`)
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
function need(name) {
const v = process.env[name]
if (!v) throw new Error(`${name} is not set`)
return v
}
const statePath = (f) => path.join(STATE_DIR, f)
function readJson(file, fallback = null) {
try {
return JSON.parse(fs.readFileSync(file, 'utf8'))
} catch {
return fallback
}
}
function writeFile(file, text) {
fs.mkdirSync(path.dirname(file), { recursive: true })
fs.writeFileSync(file, text)
}
// ── Gitea ──────────────────────────────────────────────────────────────────
// The JSON API, never /raw/: /raw/ is CDN-cached for hours on this instance, and
// a stale current.json would pin the demo to an old bundle without a word.
async function gitea(apiPath) {
const res = await fetch(`${GITEA}/api/v1/repos/${apiPath}`, { headers: { Accept: 'application/json' } })
if (!res.ok) throw new Error(`GET ${apiPath}: HTTP ${res.status}`)
return res.json()
}
async function currentBundle() {
const file = await gitea(BUNDLE_PATH)
return JSON.parse(Buffer.from(file.content, 'base64').toString('utf8'))
}
async function download(url, sha256) {
const res = await fetch(url)
if (!res.ok) throw new Error(`GET ${url}: HTTP ${res.status}`)
const buf = Buffer.from(await res.arrayBuffer())
if (sha256) {
const got = createHash('sha256').update(buf).digest('hex')
if (got !== sha256) throw new Error(`${url}: sha256 ${got} does not match the bundle's ${sha256}`)
}
return buf
}
const semver = (v) => String(v).replace(/^v/, '').split('.').map((n) => Number.parseInt(n, 10) || 0)
function newerFirst(a, b) {
const x = semver(a)
const y = semver(b)
for (let i = 0; i < 3; i += 1) if (x[i] !== y[i]) return y[i] - x[i]
return 0
}
/** The newest Module-Rust release whose install manifest says `protocol` (DM12). */
async function moduleFor(protocol) {
const releases = (await gitea(`${MODULE_REPO}/releases?limit=50`))
.filter((r) => !r.draft && !r.prerelease)
.sort((a, b) => newerFirst(a.tag_name, b.tag_name))
for (const r of releases) {
const asset = (r.assets || []).find((a) => /^module-rust-.+\.json$/.test(a.name))
if (!asset) continue
const res = await fetch(asset.browser_download_url)
if (!res.ok) continue
const manifest = await res.json()
// Releases from before §7.5 carry no `protocol`; they never match.
if (manifest.protocol === protocol) {
return { version: manifest.version, manifestUrl: asset.browser_download_url }
}
}
return null
}
// ── Pterodactyl (client API: files + power only) ─────────────────────────
function ptero() {
const base = `${need('PTERODACTYL_URL').replace(/\/+$/, '')}/api/client/servers/${need('PTERODACTYL_SERVER')}`
const key = need('PTERODACTYL_CLIENT_KEY')
async function call(method, sub, { json, body, type, raw = false } = {}) {
const headers = { Authorization: `Bearer ${key}`, Accept: 'application/json' }
let payload
if (json !== undefined) {
headers['Content-Type'] = 'application/json'
payload = JSON.stringify(json)
} else if (body !== undefined) {
headers['Content-Type'] = type || 'application/octet-stream'
payload = body
}
const res = await fetch(`${base}${sub}`, { method, headers, body: payload })
const text = await res.text()
if (!res.ok) throw new Error(`${method} ${sub}: HTTP ${res.status} ${text.slice(0, 300)}`)
if (raw) return text
return text ? JSON.parse(text) : null
}
return {
state: async () => (await call('GET', '/resources')).attributes.current_state,
power: (signal) => call('POST', '/power', { json: { signal } }),
list: async (dir) =>
(await call('GET', `/files/list?directory=${encodeURIComponent(dir)}`)).data.map((f) => f.attributes),
remove: (files) => call('POST', '/files/delete', { json: { root: '/', files } }),
decompress: (file) => call('POST', '/files/decompress', { json: { root: '/', file } }),
compress: async (files) => (await call('POST', '/files/compress', { json: { root: '/', files } })).attributes,
rename: (from, to) => call('PUT', '/files/rename', { json: { root: '/', files: [{ from, to }] } }),
read: (file) => call('GET', `/files/contents?file=${encodeURIComponent(file)}`, { raw: true }),
write: (file, body, type) => call('POST', `/files/write?file=${encodeURIComponent(file)}`, { body, type }),
// One file per call: a chmod naming two applies only the first, silently.
chmod: (dir, file, mode) => call('POST', '/files/chmod', { json: { root: dir, files: [{ file, mode }] } }),
}
}
async function waitFor(p, wanted, seconds) {
const until = Date.now() + seconds * 1000
for (;;) {
const s = await p.state()
if (s === wanted) return
if (Date.now() > until) throw new Error(`the rig is "${s}" after ${seconds}s, expected "${wanted}"`)
await sleep(5000)
}
}
/** The folder under /server holding the world save and cfg/. */
async function identity(p) {
if (process.env.PTERODACTYL_IDENTITY) return process.env.PTERODACTYL_IDENTITY
const dirs = (await p.list('/server')).filter((f) => !f.is_file).map((f) => f.name)
if (dirs.length !== 1) {
throw new Error(`cannot tell the server identity: /server holds ${JSON.stringify(dirs)}; set PTERODACTYL_IDENTITY`)
}
return dirs[0]
}
const baselineDirs = (id) => ['oxide/data', 'oxide/config', `server/${id}`]
// ── plan ──────────────────────────────────────────────────────────────────
async function plan() {
const bundle = await currentBundle()
const applied = readJson(statePath('bundle.json'))
const previousModules = fs.existsSync(statePath('modules.env'))
log(`bundle ${bundle.bundle} (protocol ${bundle.protocol}); on the rig: ${applied ? applied.bundle : 'none recorded'}`)
const module = await moduleFor(bundle.protocol)
if (!module) {
// §7.5: a half-released protocol bump never takes the demo down.
if (!previousModules) {
throw new Error(`no Module-Rust release speaks protocol ${bundle.protocol}, and there is no previous set to keep`)
}
warn(`no Module-Rust release speaks protocol ${bundle.protocol} yet; keeping the previous bundle and module`)
writeFile(statePath('plan.json'), JSON.stringify({ bundle: null }, null, 2))
return
}
log(`module rust ${module.version} (protocol ${bundle.protocol})`)
writeFile(statePath('modules.env'), `MODULES=rust@${module.version}=${module.manifestUrl}\n`)
const changed = !applied || applied.bundle !== bundle.bundle
writeFile(statePath('plan.json'), JSON.stringify({ bundle: changed ? bundle : null }, null, 2))
log(changed ? 'the rig gets the new bundle at this wipe' : 'the rig is already on this bundle')
}
// ── restore ───────────────────────────────────────────────────────────────
async function writeBundle(p, bundle) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bundle-'))
try {
const plugins = []
for (const part of [bundle.payload, bundle.npc].filter(Boolean)) {
const tarball = path.join(tmp, part.asset.name)
fs.writeFileSync(tarball, await download(part.asset.url, part.asset.sha256))
const out = path.join(tmp, `${part.asset.name}.d`)
fs.mkdirSync(out)
execFileSync('tar', ['-xzf', tarball, '-C', out])
for (const dir of fs.readdirSync(out)) {
for (const f of fs.readdirSync(path.join(out, dir))) {
if (f.endsWith('.cs')) plugins.push({ name: f, text: fs.readFileSync(path.join(out, dir, f)) })
}
}
}
if (!plugins.length) throw new Error(`bundle ${bundle.bundle} carries no plugin files`)
const sidecar = bundle.sidecar.assets['linux-x86_64']
const binary = await download(sidecar.url, sidecar.sha256)
const launcher = await download(bundle.sidecar.launcher.url, bundle.sidecar.launcher.sha256)
for (const f of plugins) {
await p.write(`/oxide/plugins/${f.name}`, f.text, 'text/plain')
log(` wrote oxide/plugins/${f.name}`)
}
await p.write('/rust-link/rust-link-sidecar', binary)
await p.write('/rust-link/with-sidecar.sh', launcher, 'text/plain')
await p.chmod('/rust-link', 'rust-link-sidecar', '755')
await p.chmod('/rust-link', 'with-sidecar.sh', '755')
log(` wrote the sidecar ${bundle.sidecar.version} and its launcher`)
// The rig's own record of what it runs, in the shape the rig push writes, so
// anyone reading the rig sees the bundle the wipe put there.
const prior = JSON.parse((await p.read('/rust-link/bundle.json').catch(() => null)) || '{}')
const record = {
bundle: bundle.bundle,
protocol: bundle.protocol,
framework: prior.framework || 'oxide',
installed: new Date().toISOString().replace(/\.\d+Z$/, 'Z'),
sidecar: { tag: bundle.sidecar.tag },
plugin: { tag: bundle.payload.tag, commit: bundle.payload.commit },
...(bundle.npc ? { runicnpc: { tag: bundle.npc.tag, api: bundle.npc.api } } : {}),
}
await p.write('/rust-link/bundle.json', `${JSON.stringify(record, null, 2)}\n`, 'text/plain')
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
}
async function restore() {
const p = ptero()
const decided = readJson(statePath('plan.json'))
if (!decided) throw new Error('no plan.json; run `plan` first')
// 1. Kill: no save, the world is about to be replaced.
if ((await p.state()) !== 'offline') {
await p.power('kill')
await waitFor(p, 'offline', 120)
}
log('the rig is stopped')
// §7.5: the bundle goes on while the server is stopped.
if (decided.bundle) {
log(`writing bundle ${decided.bundle.bundle} onto the rig`)
await writeBundle(p, decided.bundle)
writeFile(
statePath('bundle.json'),
JSON.stringify({ bundle: decided.bundle.bundle, protocol: decided.bundle.protocol, applied: new Date().toISOString() }, null, 2),
)
}
// 2-3. Delete the three directories and unpack the baseline over them.
const root = (await p.list('/')).map((f) => f.name)
if (root.includes(BASELINE)) {
const id = await identity(p)
// Only what exists: a visitor cannot delete these, but a half-finished
// earlier run can have.
const oxide = new Set((await p.list('/oxide')).map((f) => `oxide/${f.name}`))
const dirs = baselineDirs(id).filter((d) => oxide.has(d) || d === `server/${id}`)
await p.remove(dirs)
log(` deleted ${dirs.join(', ')}`)
await p.decompress(BASELINE)
log(` unpacked ${BASELINE}`)
} else {
warn(`there is no ${BASELINE} on the rig yet; it starts as it is. Capture one with the "Demo baseline" workflow.`)
}
// 4. Start. Rust takes minutes to boot; the site does not wait for it.
await p.power('start')
log('the rig is starting')
}
// ── baseline ──────────────────────────────────────────────────────────────
async function baseline() {
const p = ptero()
// A power stop runs the egg's stop command, `quit`, which writes the save, so
// the subuser needs no console permission (DM22).
if ((await p.state()) !== 'offline') {
await p.power('stop')
await waitFor(p, 'offline', 300)
}
log('the rig is stopped and saved')
const id = await identity(p)
const made = await p.compress(baselineDirs(id))
log(` compressed ${baselineDirs(id).join(', ')} into ${made.name}`)
if ((await p.list('/')).some((f) => f.name === BASELINE)) await p.remove([BASELINE])
await p.rename(made.name, BASELINE)
log(` saved as ${BASELINE} (${made.size} bytes)`)
await p.power('start')
log('the rig is starting')
}
// ── smoke ─────────────────────────────────────────────────────────────────
async function smoke() {
const site = need('SITE_URL').replace(/\/+$/, '')
const until = Date.now() + (Number(process.env.SMOKE_TIMEOUT_SECONDS) || 900) * 1000
const get = async (p) => {
try {
const res = await fetch(`${site}${p}`)
return res.ok ? await res.json() : null
} catch {
return null
}
}
while (!(await get('/api/v1/public/status'))) {
if (Date.now() > until) throw new Error('the site never answered /api/v1/public/status')
await sleep(5000)
}
log('the site answers')
for (;;) {
const list = (await get('/api/v1/public/rust/servers'))?.servers || []
if (list.length && list.every((s) => s.online)) {
log(`the rig reports in: ${list.map((s) => s.name || s.id).join(', ')}`)
return
}
if (Date.now() > until) {
throw new Error(`the rig has not reported in (${JSON.stringify(list.map((s) => ({ id: s.id, online: s.online })))}); the site serves with it shown offline`)
}
await sleep(15000)
}
}
// ── main ──────────────────────────────────────────────────────────────────
export { ptero, identity, baselineDirs, writeBundle, moduleFor, newerFirst, currentBundle, BASELINE }
// Run only when invoked, so a test can import the pieces without running one.
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
const commands = { plan, restore, baseline, smoke }
const command = commands[process.argv[2]]
if (!command) {
console.error(`usage: node rig.mjs ${Object.keys(commands).join('|')}`)
process.exit(2)
}
command().catch((err) => {
console.log(`::error::${err.message}`)
process.exit(1)
})
}

158
deploy/demo/seed.json Normal file
View File

@@ -0,0 +1,158 @@
{
"accounts": {
"demo-admin": { "role": "admin", "password": "$env:DEMO_ADMIN_PASSWORD" },
"demo-moderator": { "role": "moderator", "password": "$env:DEMO_MODERATOR_PASSWORD" },
"demo-player": { "role": "player", "password": "$env:DEMO_PLAYER_PASSWORD" },
"play-review": { "role": "admin", "password": "$env:PLAY_REVIEW_PASSWORD", "published": false }
},
"settings": {
"site_title": "Runic Gateway Demo",
"status_message": "A public demo. Look around, change things — every change is wiped every two hours."
},
"content": {
"posts": [
{
"category": "news",
"title": "Welcome to the Runic Gateway demo",
"slug": "welcome-to-the-demo",
"body": "<p>This is a live Runic Gateway site, linked to a real Rust server. Sign in with one of the logins on the sign-in page and look around: the admin panel, the moderation queue, a player's own pages.</p><p>You can change most things. A few would break the demo for the next visitor — uploads, the server link, deleting accounts — and those answer <em>This is turned off on the demo site</em>. Everything is put back every two hours.</p>"
},
{
"category": "news",
"title": "What the Rust module adds",
"slug": "what-the-rust-module-adds",
"body": "<p>The server list, the kill feed and the leaderboard come from the game as it happens. Signed in as an admin you can manage permission groups, place NPCs, edit plugin configs and build events that run on the server.</p>"
}
],
"wiki": [
{
"slug": "about-this-demo",
"title": "About this demo",
"category": "guides",
"published": true,
"body": "<h2>The logins</h2><p>The sign-in page lists them, with a button that fills the form: an admin, a moderator and a player.</p><h2>The wipe</h2><p>The site and its Rust server go back to this starting point every two hours, on the hour, UTC. The banner says when the next one is. Anyone signed in is signed out by it.</p><h2>What is turned off</h2><p>Anything that would break the demo for the next visitor: uploads, the contact form, changing the shared logins, the site mode, the server link, and console commands that would stop the server.</p>"
}
],
"events": [
{
"title": "Server greeting",
"summary": "Says hello in the game chat. Run it from Admin → Events.",
"timezone": "UTC",
"spec": {
"schedule": { "kind": "manual" },
"phases": [
{
"key": "main",
"label": "Greet",
"steps": [
{
"actionId": "rust.command.run",
"params": {
"server": "demo",
"commands": ["say Hello from the Runic Gateway demo site!"],
"confirm": true
}
}
]
}
]
},
"publish": true
},
{
"title": "Treasure drop",
"summary": "Starts a Dangerous Treasures event: a guarded, burning chest somewhere on the map. Run it from Admin → Events.",
"timezone": "UTC",
"spec": {
"schedule": { "kind": "manual" },
"phases": [
{
"key": "main",
"label": "Drop the treasure",
"steps": [
{ "actionId": "rust.dt.start", "params": { "server": "demo", "level": 0 } }
]
}
]
},
"publish": true
},
{
"title": "Payday",
"summary": "Pays every linked player 500 coins through Economics. Run it from Admin → Events.",
"timezone": "UTC",
"spec": {
"schedule": { "kind": "manual" },
"phases": [
{
"key": "main",
"label": "Pay",
"steps": [
{
"actionId": "rust.coins.pay",
"params": {
"server": "demo",
"amount": 500,
"recipients": "linked",
"message": "Payday! You received {amount} coins.",
"delivery": "chat"
}
}
]
}
]
},
"publish": true
}
]
},
"modules": {
"rust": {
"servers": [
{
"id": "demo",
"name": "Demo server",
"sidecarBaseUrl": "$env:DEMO_RIG_SIDECAR_URL",
"sidecarToken": "$env:DEMO_RIG_SIDECAR_TOKEN"
}
],
"links": [
{ "username": "demo-player", "steamId": "$env:DEMO_PLAYER_STEAM_ID", "serverId": "demo" }
],
"npcs": {
"factions": [{ "a": "bandits", "b": "guards", "relation": "allied" }],
"profiles": [
{
"name": "bandit",
"servers": ["demo"],
"body": {
"kits": ["rnpc_raider"],
"names": ["Bandit", "Bandit Scout"],
"role": "roamer",
"movement": { "mode": "wander", "radius": 20 },
"health": 150,
"faction": "bandits"
}
},
{
"name": "guard",
"servers": ["demo"],
"body": {
"kits": ["rnpc_campguard"],
"names": ["Camp Guard"],
"prefab": "scientistnpc_heavy",
"role": "sentry",
"health": 300,
"faction": "guards",
"kitUse": { "heal": true, "grenades": false, "melee": false, "rockets": false, "flamethrower": false }
}
}
]
},
"chat": [
{ "serverId": "demo", "group": "vip", "style": { "Title": "[VIP]", "TitleColor": "#ffaa00", "UsernameColor": "#ffaa00", "Priority": "10" } },
{ "serverId": "demo", "group": "moderator", "style": { "Title": "[MOD]", "TitleColor": "#55ff55", "UsernameColor": "#55ff55", "Priority": "50" } }
]
}
}
}

View File

@@ -65,6 +65,22 @@ CREATE TABLE IF NOT EXISTS users (
UNIQUE KEY uq_users_email_norm (email_norm)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Usernames that can never be taken again (ACCOUNT_DELETION.md AD9). A deleted
-- STAFF account's name lands here in the same transaction that deletes the row,
-- so nobody can register "Ravenna" the day after the moderator Ravenna left and
-- speak with her name. A player's name is never added and is free at once.
--
-- There is no release path, by decision ("completely unavailable"). The column
-- carries the users.username collation so "reserved" means exactly what "taken"
-- means: the same case folding the UNIQUE index above applies. Registration, an
-- invite's acceptance, an admin creating or renaming an account and a username
-- change all refuse a reserved name with the answer they give a taken one.
CREATE TABLE IF NOT EXISTS reserved_usernames (
username_norm VARCHAR(32) NOT NULL COLLATE utf8mb4_general_ci PRIMARY KEY,
reason ENUM('deleted_staff') NOT NULL,
reserved_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS posts (
id INT AUTO_INCREMENT PRIMARY KEY,
category ENUM('news','five_on_friday','newsletter','screenshot') NOT NULL,
@@ -1153,7 +1169,7 @@ CREATE TABLE IF NOT EXISTS team_forum_posts (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
thread_id INT NOT NULL,
author_user_id INT NULL,
author_username VARCHAR(32) NULL, -- snapshot; renders as "[deleted account]" when both are gone
author_username VARCHAR(32) NULL, -- snapshot; with the id NULL it is a deleted account: "[deleted player]" to members, the name to staff (utils/displayAuthor.js)
body_html MEDIUMTEXT NOT NULL, -- sanitised on write via utils/forumHtml.js
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
edited_at DATETIME NULL,
@@ -1285,6 +1301,72 @@ CREATE TABLE IF NOT EXISTS content_reports (
INDEX idx_cr_team (team_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- What the reported item said when it was reported (USER_SAFETY.md §4.1). A chat
-- line is pruned with the rest of a module's event log, and a queue row must still
-- show what was said after that; a forum author editing a post after the report
-- must not change what staff read. NULL on reports filed before 1.21.0.
--
-- `actioned_with` names what the queue DID when a staffer actioned a report
-- through it ('hide' — hide for everyone). NULL for an "actioned" that only
-- records a decision, which is every report before 1.21.0.
ALTER TABLE content_reports ADD COLUMN IF NOT EXISTS snapshot JSON NULL;
ALTER TABLE content_reports ADD COLUMN IF NOT EXISTS actioned_with VARCHAR(16) NULL;
-- A member's blocks (USER_SAFETY.md §4.1, S2, S12, S14). Saved on the account,
-- so a block applies on the website and in the app, on every device.
--
-- Two kinds of row, never both at once:
-- • an ACCOUNT block names `blocked_user_id`. This is the normal block (S12):
-- a forum author, and a chat speaker whose game identity is linked to an
-- account, are blocked as the account.
-- • a GAME block names (`module_id`, `identity_kind`, `external_id`) — a
-- Steam id in a chat feed that no account has linked. When that identity is
-- later linked, the row is rewritten as an account block the next time it is
-- read (S14).
--
-- Staff cannot be blocked (S8). The refusal happens when the block is made; a
-- row whose target LATER became staff is kept and simply stops applying, so the
-- Blocked page can say why rather than the block vanishing.
--
-- Each kind has its own unique key, and MySQL's NULL-distinct rule keeps them
-- out of each other's way: an account row's game columns are NULL, and a game
-- row's `blocked_user_id` is.
--
-- `display_name` is a snapshot of what the member saw when they blocked: the
-- username, or the in-game name of an unlinked speaker.
CREATE TABLE IF NOT EXISTS user_blocks (
id INT AUTO_INCREMENT PRIMARY KEY,
blocker_user_id INT NOT NULL,
blocked_user_id INT NULL,
module_id VARCHAR(32) NULL,
identity_kind VARCHAR(32) NULL,
external_id VARCHAR(64) NULL,
display_name VARCHAR(64) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_ub_blocker FOREIGN KEY (blocker_user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_ub_blocked FOREIGN KEY (blocked_user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY uq_ub_account (blocker_user_id, blocked_user_id),
UNIQUE KEY uq_ub_game (blocker_user_id, module_id, identity_kind, external_id),
INDEX idx_ub_external (module_id, identity_kind, external_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- A member's "Hide for me" (USER_SAFETY.md §1, S3): one item gone from their own
-- view on every device. No report is filed and nothing about the item changes.
--
-- `target_type` is a report target type (`team_forum_thread`, `team_forum_post`,
-- or a module's, e.g. `rust.chat`) and `target_id` its id. Nothing checks the
-- item exists: answering "no such item" would tell a member whether a thread id
-- exists in a forum they cannot see, and a row for an item that is not there
-- hides nothing.
CREATE TABLE IF NOT EXISTS user_hidden_items (
user_id INT NOT NULL,
target_type VARCHAR(32) NOT NULL,
target_id BIGINT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, target_type, target_id),
CONSTRAINT fk_uhi_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- The §2.9 approval queue. A MODERATOR performing one of the three actions that
-- publish untrusted game-sourced strings creates a pending row here; an ADMIN
-- performing one applies it immediately. Rows are kept after a decision — "a

284
server/db/seedDemo.js Normal file
View File

@@ -0,0 +1,284 @@
// ── `npm run seed:demo` — the public demo's base (DEMO_MODE.md §7.3) ────────
//
// Run by the wipe workflow inside the app container, after `compose down -v` and
// a fresh `up` have left an empty database with the schema in place:
//
// docker compose exec app npm run seed:demo -- /app/deploy/demo/seed.json
//
// It writes, from one fixture file:
// 1. the four static logins (§4) — upserted, so a password a visitor somehow
// changed is back within one cycle and a re-run is harmless;
// 2. the site settings the demo starts from (live, registration disabled, and
// whatever the fixture adds);
// 3. the seeded content — posts, wiki pages, pages, events — on the first run
// against a database only, so a re-run never duplicates it;
// 4. each installed module's own rows, through the module's `onDemoSeed` hook
// (API 1.18.0, DM15): core never writes a module's tables;
// 5. `demo_state` — when the next wipe is due and the published logins, for
// the banner and the sign-in page (§6).
//
// With the events it also switches on, in the events switchboard, every action
// those events' steps use — otherwise a seeded event cannot run (§7.3).
//
// A string `"$env:NAME"` anywhere in the fixture is replaced with that variable,
// so the rig's sidecar token lives in the host's .env and never in the repo. An
// unset variable fails the run rather than seeding an empty credential.
//
// Refuses to run without DEMO_MODE=true: pointed at a real shard by mistake, it
// would reset four accounts' passwords and flip the site live.
require('dotenv').config()
const fs = require('fs')
const path = require('path')
const { isDemo, DEMO_ACCOUNTS } = require('../src/demo/mode')
const ROLES = ['admin', 'editor', 'moderator', 'player']
const log = require('../src/utils/logger')('seed-demo')
// ── Pure helpers (tested in test/seedDemo.test.js) ─────────────────────────
/** Replace every `"$env:NAME"` string, at any depth. Throws on an unset one. */
function resolveEnv(value, env = process.env, where = 'fixture') {
if (typeof value === 'string') {
const m = /^\$env:([A-Z_][A-Z0-9_]*)$/.exec(value)
if (!m) return value
const got = env[m[1]]
if (got === undefined || got === '') throw new Error(`${where}: environment variable ${m[1]} is not set`)
return got
}
if (Array.isArray(value)) return value.map((v, i) => resolveEnv(v, env, `${where}[${i}]`))
if (value && typeof value === 'object') {
const out = {}
for (const [k, v] of Object.entries(value)) out[k] = resolveEnv(v, env, `${where}.${k}`)
return out
}
return value
}
/**
* When the next wipe is due: the next whole multiple of `everyHours` past
* midnight UTC, strictly after `now` — the schedule the wipe workflow's cron
* runs on (DM7: every 2 hours on the hour). The seed runs a minute or two after
* a wipe starts, so "strictly after" lands on the following slot.
*/
function nextResetAt(now = new Date(), everyHours = 2) {
const step = everyHours * 60 * 60 * 1000
const midnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
const slots = Math.floor((now.getTime() - midnight) / step) + 1
return new Date(midnight + slots * step)
}
/** Check the fixture's shape before anything is written. Returns a list of problems. */
function checkFixture(fixture) {
const problems = []
if (!fixture || typeof fixture !== 'object' || Array.isArray(fixture)) return ['the fixture must be a JSON object']
const accounts = fixture.accounts || {}
for (const name of DEMO_ACCOUNTS) {
const a = accounts[name]
if (!a) {
problems.push(`accounts.${name} is missing`)
continue
}
if (typeof a.password !== 'string' || a.password.length < 8) {
problems.push(`accounts.${name}.password must be a string of at least 8 characters`)
}
if (!ROLES.includes(a.role)) problems.push(`accounts.${name}.role must be one of ${ROLES.join(', ')}`)
}
for (const name of Object.keys(accounts)) {
if (!DEMO_ACCOUNTS.includes(name)) {
problems.push(`accounts.${name} is not a demo account (the lock only knows ${DEMO_ACCOUNTS.join(', ')})`)
}
}
if (fixture.settings !== undefined) {
if (!fixture.settings || typeof fixture.settings !== 'object' || Array.isArray(fixture.settings)) {
problems.push('settings must be an object')
} else if ('demo_state' in fixture.settings) {
problems.push('settings.demo_state is written by the seed itself')
}
}
for (const key of ['posts', 'wiki', 'pages', 'events']) {
const list = fixture.content?.[key]
if (list !== undefined && !Array.isArray(list)) problems.push(`content.${key} must be an array`)
}
if (fixture.modules !== undefined && (typeof fixture.modules !== 'object' || Array.isArray(fixture.modules))) {
problems.push('modules must be an object keyed by module id')
}
return problems
}
/**
* Every action a seeded event's steps use, once each, in first-use order.
*
* A fresh database has no switchboard rows, so every `change` and `irreversible`
* action starts disabled (EVENTS.md §K) — and a seeded event that answers "not
* enabled on this deployment" when a visitor runs it is a showcase that does not
* show (step-5 walk, W1). The seed switches on exactly these and nothing else.
*/
function eventActionIds(events = []) {
const ids = []
for (const e of events) {
for (const phase of e?.spec?.phases || []) {
for (const step of phase?.steps || []) {
if (step?.actionId && !ids.includes(step.actionId)) ids.push(step.actionId)
}
}
}
return ids
}
// ── The run ────────────────────────────────────────────────────────────────
/* eslint-disable global-require */
async function upsertAccounts(fixture) {
const users = require('../src/model/users/users.model')
const ids = {}
for (const username of DEMO_ACCOUNTS) {
const { password, role } = fixture.accounts[username]
const existing = await users.getRawByUsername(username)
let id
if (existing) {
id = existing.id
// A password change bumps the session cutoff, which signs every visitor out
// of this account — the right outcome at a wipe, and on a fresh database
// (the normal case) there is nobody to sign out.
await users.update(id, { password, role, status: 'active', emailVerified: true })
await users.disableTotp(id)
} else {
const created = await users.createUser({ username, password, role, status: 'active', emailVerified: true })
id = created.id
}
ids[username] = id
log.info(`account ${username} (${role}) ready`, { id })
}
return ids
}
async function writeSettings(fixture) {
const settings = require('../src/model/settings/settings.model')
await settings.setMany({
site_mode: 'live',
site_mode_changed_at: new Date().toISOString(),
site_mode_changed_by: 'seed:demo',
// §4: registration is closed, and the route is locked too (DM16).
[settings.REGISTRATION_KEY]: 'disabled',
...(fixture.settings || {}),
})
}
async function writeContent(fixture, authorId) {
const content = fixture.content || {}
const posts = require('../src/model/posts/posts.model')
const wiki = require('../src/model/wiki/wiki.model')
const pages = require('../src/model/pages/pages.model')
const events = require('../src/model/events/eventDefinitions.model')
for (const p of content.posts || []) {
const category = posts.toDbCategory(p.category || 'news')
if (!category) throw new Error(`post "${p.title}": unknown category "${p.category}"`)
await posts.create({ ...p, category, published: p.published !== false, author_id: authorId })
}
for (const w of content.wiki || []) {
const category = w.category ? await wiki.getCategoryBySlug(w.category) : null
if (w.category && !category) throw new Error(`wiki "${w.slug}": unknown category "${w.category}"`)
await wiki.create({ ...w, categoryId: category ? category.id : null, updatedBy: authorId })
}
for (const pg of content.pages || []) {
await pages.create(pg, authorId)
}
for (const e of content.events || []) {
const { publish, ...input } = e
const made = await events.create(input, authorId, { role: 'admin' })
if (!made.ok) throw new Error(`event "${input.name || input.title}": ${(made.errors || []).join('; ')}`)
if (publish) {
const pub = await events.publish(made.id, authorId)
if (!pub.ok) throw new Error(`event "${input.name || input.title}" did not publish: ${(pub.errors || []).join('; ')}`)
}
}
// Uncapped, like a switch an admin flips without filling the cap boxes: the
// deny list (DM11) is what keeps a visitor's own command step harmless, and the
// wipe takes the row away with the rest of the database.
const actionSettings = require('../src/model/events/eventActionSettings.db')
const enabled = eventActionIds(content.events)
for (const actionId of enabled) await actionSettings.put(actionId, { enabled: true, caps: {} }, authorId)
log.info('content written', {
posts: (content.posts || []).length,
wiki: (content.wiki || []).length,
pages: (content.pages || []).length,
events: (content.events || []).length,
actionsEnabled: enabled,
})
}
async function seedModules(fixture, accounts) {
const modules = require('../src/modules/loader')
const hooks = modules.demoSeedHooks()
const wanted = fixture.modules || {}
for (const id of Object.keys(wanted)) {
if (!hooks.some((h) => h.id === id)) {
throw new Error(`modules.${id}: no installed module "${id}" takes a demo seed (onDemoSeed)`)
}
}
for (const { id, hook } of hooks) {
if (wanted[id] === undefined) continue
// The module gets its part of the fixture and the demo accounts' ids, so it
// can link one of them to a game identity without reading core's users table.
await hook(wanted[id], { accounts: { ...accounts } })
log.info(`module ${id} seeded`)
}
}
async function writeState(fixture, now = new Date()) {
const settings = require('../src/model/settings/settings.model')
const everyHours = Number(process.env.DEMO_RESET_EVERY_HOURS) || 2
const logins = DEMO_ACCOUNTS
.filter((name) => fixture.accounts[name].published !== false)
.map((name) => ({ username: name, password: fixture.accounts[name].password, role: fixture.accounts[name].role }))
const state = { resetsAt: nextResetAt(now, everyHours).toISOString(), logins }
await settings.set(settings.DEMO_STATE_KEY, JSON.stringify(state))
log.info('demo state stamped', { resetsAt: state.resetsAt, logins: logins.length })
}
async function run(file) {
if (!isDemo()) throw new Error('seed:demo runs only with DEMO_MODE=true')
if (!file) throw new Error('usage: npm run seed:demo -- <fixture.json> (or set DEMO_SEED_FILE)')
const fixture = resolveEnv(JSON.parse(fs.readFileSync(path.resolve(file), 'utf8')))
const problems = checkFixture(fixture)
if (problems.length) throw new Error(`the fixture has problems:\n - ${problems.join('\n - ')}`)
// The app itself, without listening: this is what loads the installed modules
// (and core's registries, which an event definition is validated against)
// exactly as the server does. Nothing here calls onBoot, so no module starts
// its workers in this process.
require('../src/app')
const settings = require('../src/model/settings/settings.model')
const firstRun = !(await settings.get(settings.DEMO_STATE_KEY))
const accounts = await upsertAccounts(fixture)
await writeSettings(fixture)
if (firstRun) await writeContent(fixture, accounts['demo-admin'])
else log.info('content already seeded on this database; left as is')
await seedModules(fixture, accounts)
await writeState(fixture)
}
/* eslint-enable global-require */
if (require.main === module) {
;(async () => {
const { close } = require('../src/utils/db') // eslint-disable-line global-require
try {
await run(process.argv[2] || process.env.DEMO_SEED_FILE)
log.info('demo seeded')
} catch (err) {
log.error(`seed:demo failed: ${err.message}`)
process.exitCode = 1
} finally {
await close()
}
})()
}
module.exports = { resolveEnv, nextResetAt, checkFixture, eventActionIds, run }

View File

@@ -1,6 +1,6 @@
{
"_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.",
"moduleApiVersion": "1.17.0",
"moduleApiVersion": "1.21.0",
"triggers": [
{
"id": "event.phase.changed",

View File

@@ -7,6 +7,7 @@
"start": "node src/server.js",
"dev": "nodemon src/server.js",
"seed": "node db/seed.js",
"seed:demo": "node db/seedDemo.js",
"swagger": "node swagger/swagger.js",
"routes:manifest": "node scripts/routeManifest.js",
"engagement:manifest": "node scripts/engagementManifest.js",

View File

@@ -818,6 +818,17 @@
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/admin/moderation/reports/:id/hide",
"handlers": 4,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/search",
@@ -1784,6 +1795,18 @@
"requireAuth"
]
},
{
"method": "DELETE",
"path": "/api/v1/auth/me/account",
"handlers": 9,
"gates": [
"noindex",
"requireAuth",
"backoffGuard",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/auth/me/account",
@@ -1916,6 +1939,37 @@
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/auth/me/blocks",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/auth/me/blocks",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "DELETE",
"path": "/api/v1/auth/me/blocks/:id",
"handlers": 4,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/auth/me/devices",
@@ -1947,6 +2001,46 @@
"validate"
]
},
{
"method": "DELETE",
"path": "/api/v1/auth/me/hidden",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/auth/me/hidden",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/auth/me/hidden",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "DELETE",
"path": "/api/v1/auth/me/hidden/:targetType/:targetId",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/auth/me/notifications",
@@ -2285,6 +2379,17 @@
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/player/reports",
"handlers": 7,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/player/teams",

View File

@@ -357,6 +357,10 @@
"method": "POST",
"path": "/api/v1/admin/moderation/reports/:id/handle"
},
{
"method": "POST",
"path": "/api/v1/admin/moderation/reports/:id/hide"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/search"
@@ -733,6 +737,10 @@
"method": "GET",
"path": "/api/v1/auth/me"
},
{
"method": "DELETE",
"path": "/api/v1/auth/me/account"
},
{
"method": "GET",
"path": "/api/v1/auth/me/account"
@@ -785,6 +793,18 @@
"method": "PATCH",
"path": "/api/v1/auth/me/account/username"
},
{
"method": "GET",
"path": "/api/v1/auth/me/blocks"
},
{
"method": "POST",
"path": "/api/v1/auth/me/blocks"
},
{
"method": "DELETE",
"path": "/api/v1/auth/me/blocks/:id"
},
{
"method": "GET",
"path": "/api/v1/auth/me/devices"
@@ -797,6 +817,22 @@
"method": "DELETE",
"path": "/api/v1/auth/me/devices/:id"
},
{
"method": "DELETE",
"path": "/api/v1/auth/me/hidden"
},
{
"method": "GET",
"path": "/api/v1/auth/me/hidden"
},
{
"method": "POST",
"path": "/api/v1/auth/me/hidden"
},
{
"method": "DELETE",
"path": "/api/v1/auth/me/hidden/:targetType/:targetId"
},
{
"method": "GET",
"path": "/api/v1/auth/me/notifications"
@@ -941,6 +977,10 @@
"method": "GET",
"path": "/api/v1/player/events/history"
},
{
"method": "POST",
"path": "/api/v1/player/reports"
},
{
"method": "GET",
"path": "/api/v1/player/teams"

View File

@@ -21,6 +21,8 @@ const createLogger = require('./utils/logger')
const htmlShell = require('./utils/htmlShell')
const { applyTrustProxy, trustProxyDebug } = require('./utils/trustProxy')
const botScore = require('./middleware/botScore')
const { isDemo } = require('./demo/mode')
const demoLock = require('./demo/locks')
const httpLog = createLogger('http')
const errLog = createLogger('error')
@@ -84,6 +86,24 @@ app.use(morgan(accessFormat, { stream: { write: (line) => httpLog.info(line.trim
app.use(express.json({ limit: '2mb' }))
app.use(cookieParser())
// ── The public demo ───────────────────────────────────────────────────
// DEMO_MODE=true turns this deployment into demo.runicgateway.com
// (docs/website/DEMO_MODE.md). Unset, nothing here is mounted, so a real shard
// runs none of it. After the body parser, because one lock reads the body of
// PUT /admin/settings; after cookieParser, because the shared-login locks read
// the session; ahead of `/api` and of the SPA's static files (robots.txt).
// The module locks are asked per request: the loader runs further down.
if (isDemo()) {
app.use(demoLock.noindex)
app.get('/robots.txt', (req, res) => {
// #swagger.ignore = true
demoLock.robots(req, res)
})
app.use(demoLock.refuseMultipart)
app.use(demoLock.refuseDataUri)
app.use('/api/v1', demoLock.createLock(() => modules.demoLocks()))
}
// ── Paths ─────────────────────────────────────────────────────────────
const SERVER_ROOT = path.join(__dirname, '..')
const REPO_ROOT = path.join(SERVER_ROOT, '..')

View File

@@ -0,0 +1,174 @@
// ── Deleting an account (docs/website/ACCOUNT_DELETION.md §4.1) ────────────
//
// One procedure for both ways an account goes: a member deleting their own
// (`DELETE /auth/me/account`, by: 'self') and staff deleting it from the admin
// panel (`DELETE /admin/users/:id`, by: 'staff'). The two routes check different
// things before they get here — the member proves who they are, the admin is not
// allowed to delete themself — and from here on they do the same thing.
//
// In order:
//
// 1. **Before the delete.** Read what is needed afterwards (the address for the
// notice, the username, the role), then ask every started module's
// `onUserDeleting` hook in turn (MODULE_API 1.20.0). That is where game links
// are unlinked through each module's own unlink (AD10): a cascade is not an
// unlink, and for UO the link lives on the shard.
// 2. **One transaction.** A staff name is added to `reserved_usernames` (AD9)
// and the users row is deleted. The foreign keys do the rest: credentials,
// sessions and settings cascade, authored content is detached. Snapshot
// columns, the content and the activity log are NOT touched (AD5, AD6) —
// who sees which name is utils/displayAuthor's rule, applied when read.
// 3. **After commit**, fire-and-forget, each step on its own: a Teams
// reconcile, the activity-log entry, and the notice to the old address.
//
// **Nothing stops a deletion once it starts** (AD11). A hook that throws, or runs
// past HOOK_TIMEOUT_MS, is logged and the deletion goes ahead: Play requires the
// request to be honoured, and a game server that is down must not hold a
// member's request open. Whatever a module could not finish is its own pending
// work. The refusals below all come BEFORE step 1.
const users = require('../model/users/users.model')
const activity = require('../model/activity/activity.model')
const teamSync = require('../model/teams/teamSync.model')
const loader = require('../modules/loader')
const mailer = require('../utils/mailer')
const { isDemo, isDemoUsername } = require('../demo/mode')
const log = require('../utils/logger')('account-deletion')
const HOOK_TIMEOUT_MS = 10_000
const BY = ['self', 'staff']
function withTimeout(fn, ms) {
let timer
const deadline = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`no answer within ${ms} ms`)), ms)
})
return Promise.race([Promise.resolve().then(fn), deadline]).finally(() => clearTimeout(timer))
}
/**
* Ask every started module's `onUserDeleting`, one at a time.
*
* In turn rather than at once so a module's log lines read in the order they ran,
* and because a site runs one module: there is nothing to parallelise. Never
* throws; returns what each module did, for the log.
*
* @returns {Promise<{module: string, ok: boolean, error?: string}[]>}
*/
async function runHooks(payload, timeoutMs) {
const frozen = Object.freeze({ userId: payload.userId, deletedBy: payload.deletedBy })
const outcomes = []
for (const { id, hook } of loader.userDeletingHooks()) {
try {
// eslint-disable-next-line no-await-in-loop
await withTimeout(() => hook(frozen), timeoutMs)
outcomes.push({ module: id, ok: true })
} catch (err) {
log.error('a module could not finish before an account was deleted; deleting anyway', {
module: id,
userId: frozen.userId,
message: err.message,
})
outcomes.push({ module: id, ok: false, error: err.message })
}
}
return outcomes
}
// "29 October 2026", in UTC so the date in the mail does not depend on the
// host's zone.
function formatDate(when) {
return when.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' })
}
/**
* Step 3. Each step in its own try, and the whole thing never rejects, so the
* caller may leave it un-awaited. Returned anyway, so a test has a moment at
* which the after-commit work has happened.
*/
async function afterCommit({ userId, by, req, email, username, reserved, when }) {
try {
teamSync.request({ reason: 'account deleted' })
} catch (err) {
log.warn('teams reconcile request failed after a deletion', { message: err.message })
}
// Never the username: the entry outlives the account, and the activity log is
// a record of what was done, not a second place the name is kept. A member
// deleting themself is no longer a user the row could name, so the entry
// carries no user id, only the IP the request came from (AD6).
try {
const action = by === 'self' ? 'user.self_delete' : 'user.delete'
const detail = { id: userId, ...(reserved ? { usernameReserved: true } : {}) }
if (by === 'self') {
await activity.log({ req: { ip: req ? req.ip : null }, action, detail })
} else {
await activity.log({ req, action, detail })
}
} catch (err) {
log.warn('activity entry failed after a deletion', { message: err.message })
}
if (email) {
try {
const sent = await mailer.sendAccountDeleted({ to: email, username, deletedOn: formatDate(when), by })
if (!sent.sent) log.info('deletion notice not sent (mail not configured)', { userId })
} catch (err) {
log.warn('deletion notice failed', { userId, message: err.message })
}
}
}
/**
* Delete one account.
*
* @param {number} userId
* @param {{by: 'self'|'staff', req?: object, hookTimeoutMs?: number}} opts
* @returns {Promise<
* | {ok: true, id: number, reserved: boolean, modules: object[], settled: Promise<void>}
* | {ok: false, reason: 'not_found' | 'demo' | 'last_admin'}
* >}
*/
async function deleteAccount(userId, { by, req = null, hookTimeoutMs = HOOK_TIMEOUT_MS } = {}) {
if (!BY.includes(by)) throw new Error(`deleteAccount: by must be one of ${BY.join(', ')}`)
const target = await users.getById(userId)
if (!target) return { ok: false, reason: 'not_found' }
// The demo's shared logins. The demo lock refuses both routes before they get
// here (demo/locks.js); this is the line behind it, so no future caller of the
// service can delete the account every visitor signs in as.
if (isDemo() && isDemoUsername(target.username)) return { ok: false, reason: 'demo' }
// A deployment with no admin left has nobody who can run it.
if (target.role === 'admin' && (await users.countAdmins()) <= 1) return { ok: false, reason: 'last_admin' }
// 1. Before the delete.
const modules = await runHooks({ userId, deletedBy: by }, hookTimeoutMs)
// 2. One transaction: reserve a staff name, delete the row.
const reserveName = target.role !== 'player' ? target.username : null
const removed = await users.removeReserving(userId, reserveName)
// Gone between the read above and now: somebody else's deletion won. Nothing
// of ours happened, so nothing after commit runs either.
if (!removed) return { ok: false, reason: 'not_found' }
log.info('account deleted', {
userId,
by,
reserved: Boolean(reserveName),
modules: modules.map((m) => `${m.module}:${m.ok ? 'ok' : 'failed'}`),
})
// 3. After commit.
const settled = afterCommit({
userId,
by,
req,
email: target.email || null,
username: target.username,
reserved: Boolean(reserveName),
when: new Date(),
})
return { ok: true, id: userId, reserved: Boolean(reserveName), modules, settled }
}
module.exports = { deleteAccount, HOOK_TIMEOUT_MS }

View File

@@ -442,8 +442,12 @@ const ACTIONS = [
const baseline = await lease.read({ target })
if (!baseline || baseline.ok !== true) {
// A module that says `retry: false` has said a retry cannot help, so that
// verdict is passed through; anything else stays undefined so the
// dispatcher's own default (retry) still applies.
return {
ok: false,
retry: baseline && baseline.retry === false ? false : undefined,
error: baseline && baseline.error
? String(baseline.error)
: `could not read the current value of ${lease.label}`,
@@ -494,7 +498,11 @@ const ACTIONS = [
}
if (!applied || applied.ok !== true) {
await resourcesDb.markReverted(reserved.id)
return { ok: false, error: applied && applied.error ? String(applied.error) : `${lease.label} refused the new value` }
return {
ok: false,
retry: applied && applied.retry === false ? false : undefined,
error: applied && applied.error ? String(applied.error) : `${lease.label} refused the new value`,
}
}
await resourcesDb.confirm(reserved.id)

250
server/src/demo/locks.js Normal file
View File

@@ -0,0 +1,250 @@
// ── The demo lock (docs/website/DEMO_MODE.md §3) ───────────────────────────
//
// With DEMO_MODE on, app.js mounts four middlewares from this file, and with it
// off none of them is mounted at all, so a real shard runs none of this code.
//
// • `refuseMultipart` — any request with a multipart body, anywhere, before a
// router sees it (§3.3 "Uploads — locked hard"). An upload route added later
// is locked without anyone remembering to list it, and a module cannot open one.
// • `lock` — each write request against core's list below plus every module's
// declared `demoLocks`, at `/api/v1`, ahead of every router.
// • `refuseDataUri` — any write whose JSON body carries a data: URI in any
// string (DM25): a pasted image is an upload that did not use multipart.
// • `noindex` + `robots` — nothing a visitor writes reaches a search engine.
//
// **The server is the lock (DM10).** Everything here is decided from the request
// alone, so `curl`, an old app build and a hand-edited page are refused exactly
// like a click. The client explains a refusal; it never enforces one.
//
// A refusal is always the same answer, so the web client and the app can each
// recognise it by `code` and show one sentence for it.
const { compile, match } = require('./paths')
const { isDemoUsername } = require('./mode')
const log = require('../utils/logger')('demo')
const CODE = 'DEMO_LOCKED'
const MESSAGE = 'This is turned off on the demo site.'
const WRITES = ['POST', 'PUT', 'PATCH', 'DELETE']
function refuse(res, extra = {}) {
return res.status(403).json({ message: MESSAGE, code: CODE, ...extra })
}
// ── Conditions ─────────────────────────────────────────────────────────────
// A few rows lock a route only for some requests. Each condition is asked only
// after the method and path have matched, so the DB is touched on a handful of
// rare admin and self-service writes and on nothing else.
// Required lazily: this file is required by app.js, and the users model would
// otherwise make the DB pool a startup dependency of the lock.
/* eslint-disable global-require */
const users = () => require('../model/users/users.model')
const sessions = () => require('../auth/session.service')
/* eslint-enable global-require */
async function isDemoUserId(id) {
const n = Number(id)
if (!Number.isInteger(n) || n < 1) return false
const user = await users().getById(n)
return Boolean(user && isDemoUsername(user.username))
}
// `PUT /admin/users/:id` and its kin: an admin acting on one of the shared logins.
const targetIsDemo = (req, params) => isDemoUserId(params.id)
// `/auth/me/*`: the caller signed in as one of the shared logins. The identity is
// read the way requireAuth reads it, and the account row decides — not the
// username claim in the token. No session is not a refusal: the route's own
// requireAuth answers that with its usual 401.
async function callerIsDemo(req) {
const session = sessions().validateSession(req)
return Boolean(session && (await isDemoUserId(session.userId)))
}
// `PUT /admin/settings` stays open (DM2), but that one route writes arbitrary
// keys, and a few keys are another door into something Tier A locks: `site_mode`
// is what `PUT /admin/site-mode` writes, `module_source_hosts` what the Modules
// screen's sources do, `teams_voice_*` the voice settings. `demo_*` is the
// banner and the published logins, which the seed owns. `hero_layout` is the
// live homepage hero (DM21): the editor's draft (`hero_layout_draft`) and its
// Preview stay open, its Publish is refused.
const PROTECTED_SETTINGS = [/^site_mode/, /^module_source_hosts$/, /^teams_voice_/, /^demo_/, /^hero_layout$/]
function protectedKeys(body) {
if (!body || typeof body !== 'object' || Array.isArray(body)) return []
return Object.keys(body).filter((key) => PROTECTED_SETTINGS.some((re) => re.test(key)))
}
// ── Core's list (§3.3) ─────────────────────────────────────────────────────
// Paths are relative to /api/v1 and written as the routers declare them;
// test/demoLocks.test.js holds every row against routes.manifest.json, so a
// row that stops naming a real route fails the build rather than reading as a lock.
const CORE_LOCKS = [
// Tier A — site configuration.
{ methods: ['PUT'], path: '/admin/site-mode' },
{ methods: WRITES, path: '/admin/modules/*' },
{ methods: WRITES, path: '/admin/auth/providers/*' },
{ methods: ['PUT'], path: '/admin/email/config' },
{ methods: ['POST'], path: '/admin/email/test' },
{ methods: ['POST'], path: '/admin/email/disconnect' },
{ methods: ['PUT'], path: '/admin/discord-bot/config' },
{ methods: WRITES, path: '/admin/teams/voice/*' },
{ methods: ['PUT'], path: '/admin/settings', when: (req) => protectedKeys(req.body).length > 0 },
// Tier A — the shared logins, by an admin.
{ methods: ['PUT', 'DELETE'], path: '/admin/users/:id', when: targetIsDemo },
{ methods: ['DELETE'], path: '/admin/users/:id/trusted-devices', when: targetIsDemo },
{ methods: ['DELETE'], path: '/admin/users/:id/trusted-devices/:deviceId', when: targetIsDemo },
{ methods: ['POST'], path: '/admin/users/:id/mfa/reset', when: targetIsDemo },
// Tier A — the shared logins, by whoever is signed in as one.
{ methods: ['PATCH'], path: '/auth/me/account/username', when: callerIsDemo },
{ methods: ['PATCH'], path: '/auth/me/account/password', when: callerIsDemo },
{ methods: ['PATCH'], path: '/auth/me/account/email', when: callerIsDemo },
{ methods: ['POST'], path: '/auth/me/account/totp/setup', when: callerIsDemo },
{ methods: ['POST'], path: '/auth/me/account/totp/enable', when: callerIsDemo },
{ methods: ['POST'], path: '/auth/me/account/totp/disable', when: callerIsDemo },
{ methods: ['POST'], path: '/auth/me/account/recovery-codes/generate', when: callerIsDemo },
{ methods: ['DELETE'], path: '/auth/me/account/identities/:provider', when: callerIsDemo },
// ACCOUNT_DELETION.md §4.3: the screen stays visible on the demo, because
// Play's reviewer must be able to find it, and the shared logins cannot be
// deleted. An account an admin created on the demo can be.
{ methods: ['DELETE'], path: '/auth/me/account', when: callerIsDemo },
// DM17: the whole route, because a mobile access token carries no refresh-row
// id and "a session that is not the caller's" cannot be decided. Signing out
// (POST /auth/mobile/logout) revokes the caller's own row and stays open.
{ methods: ['DELETE'], path: '/auth/me/sessions/:id', when: callerIsDemo },
// Tier A — credentials for everyone.
{ methods: ['POST'], path: '/auth/password/forgot' },
{ methods: ['POST'], path: '/auth/password/reset/:token' },
// DM16: settings are open, so `player_registration` can be switched on; the
// route is what keeps registration closed (§4).
{ methods: ['POST'], path: '/auth/register' },
// Tier C's one exception: the contact form mails a person.
{ methods: ['POST'], path: '/public/contact' },
// Uploads. `refuseMultipart` already stops every one of these; they are
// listed too so the table in §3.3 and this file say the same thing, and so a
// non-multipart body sent to an upload route is refused the same way.
{ methods: ['POST'], path: '/admin/uploads' },
{ methods: ['POST'], path: '/admin/posts/upload' },
{ methods: ['POST'], path: '/admin/settings/brand-asset/:slot' },
{ methods: ['POST'], path: '/player/teams/:slug/forum/uploads' },
].map((rule) => ({ ...rule, source: 'core', compiled: compile(rule.path) }))
/**
* Build the lock middleware.
*
* @param {() => object[]} moduleLocks returns the installed modules' compiled
* locks — asked per request, because the loader runs after app.js mounts this.
*/
function createLock(moduleLocks = () => []) {
return async function lock(req, res, next) {
if (!WRITES.includes(req.method)) return next()
const rules = CORE_LOCKS.concat(moduleLocks())
for (const rule of rules) {
if (!rule.methods.includes(req.method)) continue
const params = match(rule.compiled, req.path)
if (!params) continue
if (rule.when) {
let applies
try {
applies = await rule.when(req, params)
} catch (err) {
// Fail closed: a lock that cannot decide is a lock.
log.warn('lock condition failed; refusing', { path: rule.path, message: err.message })
applies = true
}
if (!applies) continue
}
log.info('refused', { method: req.method, path: req.originalUrl, rule: rule.path, source: rule.source })
const keys = rule.path === '/admin/settings' ? { keys: protectedKeys(req.body) } : {}
return refuse(res, keys)
}
return next()
}
}
// Any multipart content type, not only form-data: nothing on the demo takes one,
// and a narrower test is one more spelling to get wrong. The header alone
// decides, so a multipart request with no body is refused too.
function refuseMultipart(req, res, next) {
const type = String(req.headers['content-type'] || '').trim().toLowerCase()
if (!type.startsWith('multipart/')) return next()
log.info('refused upload', { method: req.method, path: req.originalUrl })
return refuse(res)
}
// DM25: an image pasted into rich text travels inside the JSON body as a data:
// URI, which is an upload by another name. Any string anywhere in a write's
// body is checked, so a field added later, a setting or a module's route is
// covered without being listed, as with multipart.
//
// A data: URI is `data:` (not the tail of a word like "metadata:") followed by
// a comma, a `;`, or a type/subtype and then a comma. Prose such as
// "Data: none, yet" does not match. Before testing, HTML character references
// are decoded and tabs and newlines removed, because a browser does both when
// it reads an attribute: `&#100;ata:` and `da<newline>ta:` are still data: URIs.
const DATA_URI = /(?<![a-z0-9+.-])data:\s*(?:[,;]|[a-z]+\/[a-z0-9.+-]+[^,<>"']{0,256},)/i
function browserReading(s) {
return s
.replace(/&#x([0-9a-f]+);?/gi, (m, hex) => safeChar(parseInt(hex, 16), m))
.replace(/&#(\d+);?/g, (m, dec) => safeChar(parseInt(dec, 10), m))
.replace(/&colon;/gi, ':')
.replace(/&(?:tab|newline);/gi, '')
.replace(/[\t\n\r]/g, '')
}
function safeChar(code, original) {
return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : original
}
function hasDataUri(body) {
const stack = [body]
while (stack.length) {
const value = stack.pop()
if (typeof value === 'string') {
if (value.length > 5 && DATA_URI.test(browserReading(value))) return true
} else if (value && typeof value === 'object') {
for (const v of Object.values(value)) stack.push(v)
}
}
return false
}
function refuseDataUri(req, res, next) {
if (!WRITES.includes(req.method) || !hasDataUri(req.body)) return next()
log.info('refused data: URI', { method: req.method, path: req.originalUrl })
return refuse(res)
}
function noindex(req, res, next) {
res.set('X-Robots-Tag', 'noindex, nofollow')
next()
}
function robots(req, res) {
res.type('text/plain').send('User-agent: *\nDisallow: /\n')
}
module.exports = {
CODE,
MESSAGE,
WRITES,
CORE_LOCKS,
PROTECTED_SETTINGS,
createLock,
refuseMultipart,
refuseDataUri,
hasDataUri,
noindex,
robots,
refuse,
protectedKeys,
}

26
server/src/demo/mode.js Normal file
View File

@@ -0,0 +1,26 @@
// ── Demo mode: the flag and the four shared accounts ───────────────────────
//
// `DEMO_MODE=true` on a deployment turns it into the public demo
// (docs/website/DEMO_MODE.md). Read per call rather than once at require time,
// so a test can flip it without re-requiring every file that asks — and so
// there is exactly one spelling of "is this the demo" in the codebase.
//
// Only the literal string `true` turns it on. A stray `DEMO_MODE=1` or `yes` on
// a real shard must not lock its admin panel, and the opposite mistake — a demo
// that forgot the flag — is caught by `seed:demo`, which refuses to run without it.
function isDemo() {
return process.env.DEMO_MODE === 'true'
}
// The static logins (DEMO_MODE.md §4). Fixed here, in core, rather than read
// from the seed fixture: the lock has to recognise them on every request, and a
// fixture that dropped one would quietly unlock the account everybody shares.
// The seed creates exactly these; their passwords come from the fixture.
const DEMO_ACCOUNTS = Object.freeze(['demo-admin', 'demo-moderator', 'demo-player', 'play-review'])
function isDemoUsername(username) {
return typeof username === 'string' && DEMO_ACCOUNTS.includes(username.toLowerCase())
}
module.exports = { isDemo, DEMO_ACCOUNTS, isDemoUsername }

70
server/src/demo/paths.js Normal file
View File

@@ -0,0 +1,70 @@
// ── Lock patterns: `:param` segments and a trailing `*` ────────────────────
//
// The demo's lock list names routes the way the routers declare them —
// `/admin/users/:id` — so a reviewer can hold the list against the route
// manifest line by line. A trailing `/*` means "this path and everything under
// it", for the areas DEMO_MODE.md §3.3 locks as a whole ("every write under
// /admin/modules").
//
// The matcher reproduces the two express 4 routing defaults that decide whether
// a request reaches a handler at all: matching is case-insensitive, and a
// trailing slash is optional. A lock that was stricter than the router about
// either would let `PUT /Admin/Site-Mode/` through to a handler that answers it.
const SEGMENT = /^(?::[A-Za-z][A-Za-z0-9]*|[A-Za-z0-9._-]+)$/
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
/**
* Compile a lock pattern.
*
* @param {string} pattern e.g. `/admin/users/:id` or `/admin/modules/*`
* @returns {{pattern: string, names: string[], re: RegExp}}
* @throws on a pattern that is not absolute, has an empty segment, or puts `*`
* anywhere but last — a lock that silently compiled to something else would
* be worse than no lock, because it would read as one.
*/
function compile(pattern) {
if (typeof pattern !== 'string' || !pattern.startsWith('/')) {
throw new Error(`lock path "${pattern}" must start with /`)
}
const segs = pattern.slice(1).split('/')
const names = []
let source = ''
segs.forEach((seg, i) => {
if (seg === '*') {
if (i !== segs.length - 1) throw new Error(`lock path "${pattern}": * must be the last segment`)
source += '(?:/.*)?'
return
}
if (!SEGMENT.test(seg)) throw new Error(`lock path "${pattern}": bad segment "${seg}"`)
if (seg.startsWith(':')) {
names.push(seg.slice(1))
source += '/([^/]+)'
} else {
source += `/${escape(seg)}`
}
})
return { pattern, names, re: new RegExp(`^${source}/?$`, 'i') }
}
/**
* Match a request path against a compiled pattern.
*
* @returns {Record<string,string>|null} the decoded params, or null
*/
function match(compiled, path) {
const m = compiled.re.exec(path)
if (!m) return null
const params = {}
compiled.names.forEach((name, i) => {
try {
params[name] = decodeURIComponent(m[i + 1])
} catch {
params[name] = m[i + 1]
}
})
return params
}
module.exports = { compile, match }

View File

@@ -157,6 +157,36 @@ const SEEDS = [
),
],
},
{
// ACCOUNT_DELETION.md §4.1 step 3: the one notice a person gets if somebody
// else used their unlocked phone. It goes to the address the account HAD,
// read before the row went, and there is no link in it because there is no
// account left for a link to reach.
key: 'auth.account-deleted',
name: 'Account deleted',
channel: 'email',
protected: true,
seedVersion: 1,
subject: 'Your {{siteName}} account was deleted',
variables: [
{ name: 'username', type: 'string', required: true, example: 'Darrow' },
{ name: 'deletedOn', type: 'string', required: true, example: '29 October 2026' },
{ name: 'byWhom', type: 'string', required: false, example: ' at your request' },
],
blocks: [
text('p1', 'The {{siteName}} account “{{username}}” was deleted on {{deletedOn}}{{byWhom}}.'),
text(
'p2',
'Its sign-in, email address, two-factor settings, linked sign-ins and game links are gone. ' +
'What it wrote where others read it stays, and other members see it as written by “[deleted player]”.',
),
text(
'p3',
'If you did not ask for this, contact the {{siteName}} staff at {{siteUrl}}. ' +
'A deleted account cannot be restored, but they can find out what happened.',
),
],
},
{
key: 'admin.contact-message',
name: 'Contact form message',

View File

@@ -29,6 +29,8 @@ const spec = require('../../events/spec')
const authorize = require('../../events/authorize')
const verifier = require('../../events/verify')
const registries = require('../../modules/registries')
const { isDemo } = require('../../demo/mode')
const { CODE: DEMO_LOCKED } = require('../../demo/locks')
const { slugify, uniqueSlug } = require('../teams/teamSlug')
const { cleanBody } = require('../../utils/sanitizeHtml')
const log = require('../../utils/logger')('events')
@@ -181,6 +183,8 @@ async function create(input, userId, { role = null } = {}) {
if (!result.ok) return result
const floor = checkRoleFloor(result.definition.spec, role)
if (floor) return floor
const refused = await checkDemoRefusals(result.definition.spec)
if (refused) return refused
const id = await db.insert({ ...result.definition, created_by: userId })
return { ok: true, id, definition: await db.getById(id) }
}
@@ -203,6 +207,8 @@ async function save(id, input, userId, { role = null } = {}) {
if (!result.ok) return result
const floor = checkRoleFloor(result.definition.spec, role)
if (floor) return floor
const refused = await checkDemoRefusals(result.definition.spec)
if (refused) return refused
await db.update(id, { ...result.definition, updated_by: userId })
return { ok: true, id, definition: await db.getById(id) }
}
@@ -228,6 +234,11 @@ async function publish(id, userId) {
})
if (!checked.ok) return { ok: false, status: 400, errors: checked.errors }
// Asked again here, as the registries are: a draft saved before the module's
// list grew must not become a version that runs (DM18).
const refused = await checkDemoRefusals(checked.spec)
if (refused) return refused
const publishable = spec.publishable(checked.spec)
if (!publishable.ok) {
return {
@@ -365,6 +376,37 @@ function checkRoleFloor(specValue, role) {
}
}
/**
* The public demo's save-time refusal (MODULE_API 1.19.0, DEMO_MODE.md §3.4,
* DM18): each step whose action declares `demoRefuses(params)` is asked, and
* the first sentence back refuses the whole save `403 DEMO_LOCKED`. Null off
* the demo, where nothing is asked.
*
* A hook that throws refuses too, with core's sentence: this is a lock, and a
* lock that cannot decide fails closed, as the route lock does (§3.3).
*/
async function checkDemoRefusals(specValue) {
if (!isDemo()) return null
for (const phase of specValue?.phases || []) {
for (const step of phase.steps || []) {
const action = registries.eventAction(step.actionId)
if (!action || !action.demoRefuses) continue
let said
try {
said = await action.demoRefuses(step.params || {})
} catch (err) {
log.error('demoRefuses failed', { action: action.id, message: err.message })
said = `"${action.label}" could not be checked, so it is turned off on the demo site.`
}
if (typeof said === 'string' && said.trim()) {
const message = said.trim()
return { ok: false, status: 403, code: DEMO_LOCKED, message, errors: [message] }
}
}
}
return null
}
/**
* Dry-run a definition, and record the pass when there is a version to record it
* on (Phase 6).
@@ -545,6 +587,7 @@ module.exports = {
archive,
verify,
checkRoleFloor,
checkDemoRefusals,
isTimezone,
MIN_GRACE_SECONDS,
MAX_GRACE_SECONDS,

View File

@@ -15,7 +15,7 @@ const { query } = require('../../utils/db')
const COLUMNS = `
id, target_type, target_id, team_id, reporter_user_id, reporter_username,
reason, detail, status, handled_by, handled_username, handled_note, handled_at,
created_at`
created_at, snapshot, actioned_with`
const OPEN_STATUSES = ['open', 'reviewing']
@@ -28,13 +28,16 @@ const OPEN_STATUSES = ['open', 'reviewing']
* comes back as a clean `null` so the caller can answer 409 without knowing what
* a MySQL error code looks like.
*/
async function insert({ targetType, targetId, teamId, reporterUserId, reporterUsername, reason, detail }) {
async function insert({ targetType, targetId, teamId, reporterUserId, reporterUsername, reason, detail, snapshot }) {
try {
const res = await query(
`INSERT INTO content_reports
(target_type, target_id, team_id, reporter_user_id, reporter_username, reason, detail)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[targetType, targetId, teamId ?? null, reporterUserId, reporterUsername, reason, detail ?? null],
(target_type, target_id, team_id, reporter_user_id, reporter_username, reason, detail, snapshot)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
targetType, targetId, teamId ?? null, reporterUserId, reporterUsername, reason, detail ?? null,
snapshot ? JSON.stringify(snapshot) : null,
],
)
return res.insertId
} catch (err) {
@@ -93,13 +96,19 @@ async function openCount() {
* `handled_*` is stamped for every status including `reviewing`, so "who has this"
* is answerable while it is in progress and not only after it is closed — that is
* what stops two staffers working the same report.
*
* `actionedWith` is written only when given (the queue's "Hide for everyone"):
* a later status change by hand records a new decision, and does not un-say
* that the item was hidden.
*/
async function handle(id, { status, handledBy, handledUsername, note }) {
async function handle(id, { status, handledBy, handledUsername, note, actionedWith }) {
const withAction = actionedWith !== undefined
const res = await query(
`UPDATE content_reports
SET status = ?, handled_by = ?, handled_username = ?, handled_note = ?, handled_at = NOW()
${withAction ? ', actioned_with = ?' : ''}
WHERE id = ?`,
[status, handledBy, handledUsername, note ?? null, id],
[status, handledBy, handledUsername, note ?? null, ...(withAction ? [actionedWith] : []), id],
)
return res.affectedRows > 0
}
@@ -114,7 +123,7 @@ async function handle(id, { status, handledBy, handledUsername, note }) {
async function threadsByIds(ids) {
if (!ids.length) return []
return query(
`SELECT id, team_id, title, type, status, created_username FROM team_forum_threads
`SELECT id, team_id, title, type, status, created_by, created_username FROM team_forum_threads
WHERE id IN (${ids.map(() => '?').join(',')})`,
ids,
)

View File

@@ -24,10 +24,24 @@
// participant in one Team could file reports carrying another Team's id, and the
// queue's per-Team filter would quietly be lying.
//
// **Since 1.21.0 (USER_SAFETY.md §4.1) a target can be a module's** — a Rust chat
// line is `rust.chat` — and every report stores a `snapshot` of what the item
// said when it was reported. Who may see the item is now asked of one resolver
// (`model/safety/targets.js`) for every type, forum or module, so reaching
// `file()` through `/player/reports` without a Team is as safe as reaching it
// through a Team's forum route.
const reportsDb = require('./contentReports.db')
const forumDb = require('../teams/teamForum.db')
const forum = require('../teams/teamForum.model')
const targets = require('../safety/targets')
const registries = require('../../modules/registries')
const { displayAuthor, hasActor } = require('../../utils/displayAuthor')
const TARGET_TYPES = ['team_forum_thread', 'team_forum_post', 'team_forum_upload']
// Core's own types — what the forum's report route accepts. Module types are
// registered (registries.reportTarget) and checked by `targets.isReportable`.
const TARGET_TYPES = targets.FORUM_TYPES
const REASONS = ['spam', 'abuse', 'sexual', 'illegal', 'impersonation', 'other']
const STATUSES = ['open', 'reviewing', 'actioned', 'dismissed']
@@ -67,6 +81,23 @@ async function targetTeamId(targetType, targetId) {
return null
}
/**
* What `content_reports.snapshot` keeps: the item as the reporter saw it, with
* its author. The author's account id is kept beside the name so the queue can
* put the name through displayAuthor's rule like every other snapshot.
*/
function snapshotOf(item) {
return {
...item.snapshot,
module: item.module || null,
author: {
name: item.author.name,
userId: item.author.userId,
identity: item.author.identity,
},
}
}
/**
* File a report.
*
@@ -75,27 +106,30 @@ async function targetTeamId(targetType, targetId) {
* reports twice because nothing seemed to happen deserves to be told the first
* one is already in the queue.
*/
async function file({ team, actor, targetType, targetId, reason, detail }) {
if (!TARGET_TYPES.includes(targetType)) {
async function file({ team = null, actor, targetType, targetId, reason, detail }) {
if (!targets.isReportable(targetType)) {
return { ok: false, status: 400, error: 'Unknown report target' }
}
if (!REASONS.includes(reason)) {
return { ok: false, status: 400, error: 'Unknown report reason' }
}
const owner = await targetTeamId(targetType, targetId)
if (owner == null || owner !== team.id) {
return { ok: false, status: 404, error: 'Not found' }
}
// The reporter must be able to see what they report — the same resolver a
// block uses, so "not there" and "not yours to see" are one answer (404).
const item = await targets.resolve({ targetType, targetId, viewer: actor })
if (!item) return { ok: false, status: 404, error: 'Not found' }
// Through a Team's forum route, the target must be in THAT Team.
if (team && item.teamId !== team.id) return { ok: false, status: 404, error: 'Not found' }
const id = await reportsDb.insert({
targetType,
targetId,
teamId: team.id,
teamId: item.teamId,
reporterUserId: actor.id,
reporterUsername: actor.username,
reason,
detail,
snapshot: snapshotOf(item),
})
if (id == null) {
return { ok: false, status: 409, error: 'You have already reported this. Staff are looking at it.' }
@@ -116,7 +150,7 @@ async function file({ team, actor, targetType, targetId, reason, detail }) {
* hide the pattern of a member deleting their own content the moment it is
* reported.
*/
async function queue({ status, teamId, limit, offset } = {}) {
async function queue({ status, teamId, limit, offset, viewer = null } = {}) {
const rows = await reportsDb.list({ status, teamId, limit, offset })
if (!rows.length) return []
@@ -132,9 +166,18 @@ async function queue({ status, teamId, limit, offset } = {}) {
const postMap = byId(posts)
const uploadMap = byId(uploads)
return rows.map((r) => ({ ...publicReport(r), target: describeTarget(r, { threadMap, postMap, uploadMap }) }))
return rows.map((r) => ({
...publicReport(r, viewer),
target: describeTarget(r, { threadMap, postMap, uploadMap }, viewer),
}))
}
// A snapshot pair through displayAuthor's rule (ACCOUNT_DELETION.md AD5, AD6).
// The queue is staff-only by its route, so a staff viewer sees the stored name
// and the `*Deleted` flag marks it; the rule is still asked rather than assumed,
// so this file cannot become the place a member's view leaks a deleted name.
const shown = (userId, username, viewer) => displayAuthor({ userId, username }, viewer)
/**
* The reported content, resolved.
*
@@ -144,29 +187,33 @@ async function queue({ status, teamId, limit, offset } = {}) {
* than as an explicit null, and the queue's own contract says nullable. A client
* distinguishing "gone" from "not resolved yet" would get it wrong.
*/
function describeTarget(report, { threadMap, postMap, uploadMap }) {
function describeTarget(report, { threadMap, postMap, uploadMap }, viewer = null) {
const id = Number(report.target_id)
if (report.target_type === 'team_forum_thread') {
const t = threadMap.get(id)
if (!t) return null
const author = shown(t.created_by, t.created_username, viewer)
return {
kind: 'thread',
threadId: t.id,
title: t.title,
type: t.type,
status: t.status,
author: t.created_username,
author: author.name,
authorDeleted: author.deleted,
}
}
if (report.target_type === 'team_forum_post') {
const p = postMap.get(id)
if (!p) return null
const author = shown(p.author_user_id, p.author_username, viewer)
return {
kind: 'post',
postId: p.id,
threadId: p.thread_id,
threadTitle: p.thread_title,
author: p.author_username,
author: author.name,
authorDeleted: author.deleted,
status: p.status,
excerpt: excerpt(p.body_html),
createdAt: p.created_at,
@@ -178,11 +225,13 @@ function describeTarget(report, { threadMap, postMap, uploadMap }) {
// §5.6's fourth rule: uploader, size and the SNIFFED type, without hunting.
// This is the payoff for §5.5.4's attribution table being load-bearing rather
// than bookkeeping.
const uploader = shown(u.uploader_user_id, u.uploader_username, viewer)
return {
kind: 'upload',
uploadId: u.id,
postId: u.post_id,
uploader: u.uploader_username,
uploader: uploader.name,
uploaderDeleted: uploader.deleted,
filename: u.filename,
url: `/uploads/${u.filename}`,
mimetype: u.mimetype,
@@ -191,23 +240,86 @@ function describeTarget(report, { threadMap, postMap, uploadMap }) {
deleted: u.deleted_at != null,
}
}
// A module's target (1.21.0). Core cannot read the module's rows, and does
// not need to: the snapshot taken when the report was filed is the target as
// the reporter saw it, and it outlives the module pruning its event log.
const target = registries.reportTarget(report.target_type)
const snap = parseSnapshot(report.snapshot)
if (target && snap) {
return {
kind: 'module',
module: target.owner,
label: target.label,
text: snap.text || null,
title: snap.title || null,
at: snap.at || null,
where: snap.where || {},
}
}
return null
}
function publicReport(row) {
function parseSnapshot(raw) {
if (!raw) return null
if (typeof raw === 'object') return raw
try {
return JSON.parse(raw)
} catch {
return null
}
}
/**
* The stored snapshot, for one viewer. The author's name goes through
* displayAuthor's rule like every other snapshot in the row; a game identity is
* staff's to see, and the queue is staff-only by its route.
*/
function publicSnapshot(raw, viewer) {
const snap = parseSnapshot(raw)
if (!snap) return null
const a = snap.author || {}
// An author with no account at all (an unlinked chat speaker) is not a deleted
// account: their name is the in-game one and shows as it is.
const author = a.userId == null && a.identity
? { name: a.name || null, deleted: false }
: displayAuthor({ userId: a.userId ?? null, username: a.name }, viewer)
return {
label: snap.label || null,
title: snap.title || null,
text: snap.text || null,
at: snap.at || null,
where: snap.where || {},
module: snap.module || null,
author: author.name,
authorDeleted: author.deleted,
authorIdentity: a.identity || null,
}
}
function publicReport(row, viewer = null) {
const reporter = shown(row.reporter_user_id, row.reporter_username, viewer)
// Nobody has handled an open report, which is not the same as a deleted
// handler: only a pair that names someone goes through the rule.
const handler = hasActor(row.handled_by, row.handled_username)
? shown(row.handled_by, row.handled_username, viewer)
: null
return {
id: row.id,
targetType: row.target_type,
targetId: Number(row.target_id),
teamId: row.team_id,
reporter: row.reporter_username || '[deleted account]',
reporterDeleted: row.reporter_user_id == null,
reporter: reporter.name,
reporterDeleted: reporter.deleted,
reason: row.reason,
detail: row.detail,
status: row.status,
handledBy: row.handled_username,
handledBy: handler ? handler.name : null,
handledByDeleted: handler ? handler.deleted : false,
handledNote: row.handled_note,
handledAt: row.handled_at,
actionedWith: row.actioned_with || null,
// Reports filed before 1.21.0 have none.
snapshot: publicSnapshot(row.snapshot, viewer),
createdAt: row.created_at,
}
}
@@ -226,7 +338,85 @@ async function handle({ id, actor, status, note }) {
handledUsername: actor.username,
note,
})
return { ok: true, report: publicReport(await reportsDb.byId(id)) }
return { ok: true, report: publicReport(await reportsDb.byId(id), actor) }
}
/**
* "Hide for everyone" from the queue (USER_SAFETY.md §4.1, S3, S10). Staff-only
* by its route.
*
* The one queue action that DOES something to the content — which is why it is
* its own verb and not a status. It goes through the content's own hide, so
* nothing new can hide anything: a forum thread or post through the forum's
* moderation, recorded in its ledger as a staff action like a hide from the
* forum's own menu; a module's item through the module's `hide`. Then the
* report is actioned with `actioned_with = 'hide'`, so the queue says what was
* done and not only that something was decided.
*
* Undoing it is not here: the hidden item is still shown to staff, marked, where
* it lives (S10), and its own menu unhides it.
*
* Uploads cannot be hidden: an upload is shown inside a post, and hiding that
* post is the act.
*/
async function hideForEveryone({ id, actor, note }) {
const report = await reportsDb.byId(id)
if (!report) return { ok: false, status: 404, error: 'Report not found' }
const type = report.target_type
const targetId = Number(report.target_id)
if (type === 'team_forum_thread' || type === 'team_forum_post') {
const team = await forumTeamOf(type, targetId)
if (!team) return { ok: false, status: 404, error: 'That item no longer exists' }
const moderate = type === 'team_forum_thread' ? forum.moderateThread : forum.moderatePost
const result = await moderate({
team,
...(type === 'team_forum_thread' ? { threadId: targetId } : { postId: targetId }),
action: 'hide',
actor,
actorRole: 'staff',
reason: `Report #${id}`,
})
if (!result.ok) return result
} else {
const target = registries.reportTarget(type)
if (!target) {
return { ok: false, status: 400, error: type === 'team_forum_upload'
? 'An upload is hidden by hiding the post it is in'
: 'That kind of item cannot be hidden from here' }
}
try {
await targets.withTimeout(
() => target.hide({ targetId, actor: Object.freeze({ id: actor.id, username: actor.username, role: actor.role }) }),
targets.MODULE_TIMEOUT_MS,
)
} catch (err) {
return { ok: false, status: 503, error: 'The game module did not answer. Try again in a moment.' }
}
}
await reportsDb.handle(id, {
status: 'actioned',
handledBy: actor.id,
handledUsername: actor.username,
note,
actionedWith: 'hide',
})
return { ok: true, report: publicReport(await reportsDb.byId(id), actor), teamId: report.team_id }
}
/** The Team a forum thread or post is in, by id — for the queue, which has no slug. */
async function forumTeamOf(type, targetId) {
let threadId = targetId
if (type === 'team_forum_post') {
const post = await forumDb.postById(targetId)
if (!post) return null
threadId = post.thread_id
}
const thread = await forumDb.threadById(threadId)
if (!thread) return null
// The forum model wants the Team row only for its id; the ledger names it.
return { id: thread.team_id }
}
module.exports = {
@@ -237,6 +427,7 @@ module.exports = {
file,
queue,
handle,
hideForEveryone,
openCount: reportsDb.openCount,
publicReport,
targetTeamId,

View File

@@ -0,0 +1,217 @@
// ── What a reported, hidden or blocked item IS, for one reader ─────────────
//
// USER_SAFETY.md §4.1. Every report, every "Block author" and the Reports
// queue's "Hide for everyone" starts by asking one question about an item: what
// is it, who wrote it, and may this reader see it at all? This file answers it
// once, for core's forum types and for every module's registered types.
//
// **A reader who cannot see the item gets `null`, the same answer as an item
// that is not there.** Telling them apart would let anyone ask whether thread
// 812 exists in a private Team's forum, or who said chat line 4410 on a server
// whose presence is hidden from them (§5.5.1 of TEAMS.md, and the reason the
// forum answers 404 rather than 403).
//
// Core's forum types are answered here directly; a module's are answered by its
// own `snapshot`, under a time limit, because the module owns the content.
const forumDb = require('../teams/teamForum.db')
const teamsDb = require('../teams/teams.db')
const access = require('../teams/teamAccess.model')
const forumSettings = require('../teams/teamForumSettings.model')
const registries = require('../../modules/registries')
const log = require('../../utils/logger')('safety')
// Report types core owns. Uploads can be reported (the forum's own route takes
// them) but not hidden or blocked from: an upload is shown inside a post, so
// hiding the post, or blocking its author, is what hides it.
const FORUM_TYPES = ['team_forum_thread', 'team_forum_post', 'team_forum_upload']
const FORUM_HIDEABLE = ['team_forum_thread', 'team_forum_post']
// The forum's own "staff": who may moderate there (teamForum.controller.js).
const FORUM_STAFF_ROLES = ['admin', 'moderator']
const MODULE_TIMEOUT_MS = 5_000
const TEXT_CHARS = 500
const excerpt = (html) => String(html || '')
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, TEXT_CHARS)
const clip = (value, n) => (value == null ? null : String(value).slice(0, n))
function withTimeout(fn, ms) {
let timer
const deadline = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`no answer within ${ms} ms`)), ms)
})
return Promise.race([Promise.resolve().then(fn), deadline]).finally(() => clearTimeout(timer))
}
/** Is this a type anybody can report — core's or a started module's? */
function isReportable(type) {
return FORUM_TYPES.includes(type) || registries.reportTarget(type) != null
}
/** Is this a type a member can hide for themself, or block the author of? */
function isHideable(type) {
return FORUM_HIDEABLE.includes(type) || registries.reportTarget(type) != null
}
/**
* The forum that holds a thread, and whether this reader may see it and
* moderate in it. Null when the forum is off, the Team is gone or the reader
* has no access — the forum controller's `resolveForum`, for a target found by
* id rather than reached through a slug.
*/
async function forumFor(teamId, viewer) {
if (!(await forumSettings.forumsEnabled())) return null
const team = await teamsDb.findById(teamId)
if (!team) return null
const resolved = await access.forumAccess(team.id, viewer.id)
const staff = FORUM_STAFF_ROLES.includes(viewer.role)
if (!resolved.allowed && !staff) return null
return { team, canModerate: resolved.isLeader || staff }
}
/** A forum thread, post or upload, for one reader. */
async function resolveForum(type, id, viewer) {
if (type === 'team_forum_thread') {
const t = await forumDb.threadById(id)
if (!t) return null
const forum = await forumFor(t.team_id, viewer)
if (!forum) return null
if (t.status !== 'visible' && !forum.canModerate) return null
const first = await forumDb.firstPostOf(t.id)
return {
type,
targetId: id,
teamId: t.team_id,
team: forum.team,
author: { userId: t.created_by ?? null, name: t.created_username || null, identity: null },
snapshot: {
label: 'Forum thread',
title: clip(t.title, 200),
text: first ? excerpt(first.body_html) : null,
at: t.created_at,
where: { teamId: t.team_id, team: forum.team.name, threadId: t.id },
},
}
}
if (type === 'team_forum_post') {
const p = await forumDb.postById(id)
if (!p) return null
const t = await forumDb.threadById(p.thread_id)
if (!t) return null
const forum = await forumFor(t.team_id, viewer)
if (!forum) return null
if ((p.status !== 'visible' || t.status !== 'visible') && !forum.canModerate) return null
return {
type,
targetId: id,
teamId: t.team_id,
team: forum.team,
author: { userId: p.author_user_id ?? null, name: p.author_username || null, identity: null },
snapshot: {
label: 'Forum post',
title: clip(t.title, 200),
text: excerpt(p.body_html),
at: p.created_at,
where: { teamId: t.team_id, team: forum.team.name, threadId: t.id, postId: p.id },
},
}
}
if (type === 'team_forum_upload') {
const u = await forumDb.uploadById(id)
if (!u) return null
const forum = await forumFor(u.team_id, viewer)
if (!forum) return null
return {
type,
targetId: id,
teamId: u.team_id,
team: forum.team,
author: { userId: u.uploader_user_id ?? null, name: u.uploader_username || null, identity: null },
snapshot: {
label: 'Forum upload',
title: null,
text: clip(u.filename, 200),
at: u.created_at,
where: { teamId: u.team_id, team: forum.team.name, postId: u.post_id },
},
}
}
return null
}
/**
* A module's answer, checked. A module is trusted to describe its own content
* and nothing more: what reaches the queue is clipped to known fields, so a
* snapshot cannot carry markup or a megabyte of frame into `content_reports`.
*/
function shapeModuleSnapshot(target, raw) {
if (!raw || typeof raw !== 'object') return null
const a = raw.author && typeof raw.author === 'object' ? raw.author : {}
const ident = a.identity && typeof a.identity === 'object'
&& typeof a.identity.kind === 'string' && a.identity.id != null
? { kind: a.identity.kind, id: String(a.identity.id).slice(0, 64) }
: null
const userId = Number.isInteger(Number(a.userId)) && a.userId != null ? Number(a.userId) : null
const where = raw.where && typeof raw.where === 'object'
? Object.fromEntries(Object.entries(raw.where)
.filter(([, v]) => v == null || ['string', 'number', 'boolean'].includes(typeof v))
.slice(0, 8)
.map(([k, v]) => [String(k).slice(0, 32), typeof v === 'string' ? v.slice(0, 120) : v]))
: {}
return {
author: { userId, name: clip(a.name, 64), identity: ident },
snapshot: {
label: target.label,
title: clip(raw.title, 200),
text: clip(raw.text, TEXT_CHARS),
at: raw.at || null,
where,
},
}
}
/**
* The item, for this reader, or null.
*
* @param {{targetType: string, targetId: number, viewer: {id: number, role: string}}} q
* @returns {Promise<null | {type, targetId, teamId, team?, module?, author, snapshot}>}
*/
async function resolve({ targetType, targetId, viewer }) {
if (FORUM_TYPES.includes(targetType)) return resolveForum(targetType, targetId, viewer)
const target = registries.reportTarget(targetType)
if (!target) return null
let raw
try {
raw = await withTimeout(
() => target.snapshot({ targetId, viewer: Object.freeze({ userId: viewer.id, role: viewer.role }) }),
MODULE_TIMEOUT_MS,
)
} catch (err) {
log.error('report target snapshot failed', { module: target.owner, type: targetType, message: err.message })
const e = new Error('The game module did not answer')
e.status = 503
throw e
}
const shaped = shapeModuleSnapshot(target, raw)
if (!shaped) return null
return { type: targetType, targetId, teamId: null, module: target.owner, ...shaped }
}
module.exports = {
FORUM_TYPES,
FORUM_HIDEABLE,
FORUM_STAFF_ROLES,
MODULE_TIMEOUT_MS,
isReportable,
isHideable,
resolve,
withTimeout,
_shapeModuleSnapshot: shapeModuleSnapshot,
}

View File

@@ -0,0 +1,161 @@
// SQL for a member's blocks and hides (USER_SAFETY.md §4.1).
//
// `user_blocks` holds two kinds of row — an account block and a game block —
// and `user_hidden_items` one row per item a member hid for themself. Nothing
// here decides who may block whom; that is the model's job (S8, S12, S14).
const { query } = require('../../utils/db')
const BLOCK_COLUMNS = `
b.id, b.blocker_user_id, b.blocked_user_id, b.module_id, b.identity_kind,
b.external_id, b.display_name, b.created_at`
const isDuplicate = (err) => Boolean(err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062))
/**
* A member's blocks, newest first, each account block with the blocked
* account's CURRENT username and role beside the snapshot.
*
* The role is read here rather than stored, because S8 is about who the target
* is now: a block on a player who later became a moderator stops applying.
*/
async function blocksFor(blockerUserId) {
return query(
`SELECT ${BLOCK_COLUMNS}, u.username AS blocked_username, u.role AS blocked_role
FROM user_blocks b
LEFT JOIN users u ON u.id = b.blocked_user_id
WHERE b.blocker_user_id = ?
ORDER BY b.created_at DESC, b.id DESC`,
[blockerUserId],
)
}
/**
* Block an account. Answers the new row's id, or null when the member had
* already blocked them — caught by the unique key, so two taps cannot race into
* two rows.
*/
async function insertAccountBlock({ blockerUserId, blockedUserId, displayName }) {
try {
const res = await query(
`INSERT INTO user_blocks (blocker_user_id, blocked_user_id, display_name) VALUES (?, ?, ?)`,
[blockerUserId, blockedUserId, displayName ?? null],
)
return res.insertId
} catch (err) {
if (isDuplicate(err)) return null
throw err
}
}
/** Block an unlinked game identity. Null on a duplicate, as above. */
async function insertGameBlock({ blockerUserId, moduleId, kind, externalId, displayName }) {
try {
const res = await query(
`INSERT INTO user_blocks (blocker_user_id, module_id, identity_kind, external_id, display_name)
VALUES (?, ?, ?, ?, ?)`,
[blockerUserId, moduleId, kind, externalId, displayName ?? null],
)
return res.insertId
} catch (err) {
if (isDuplicate(err)) return null
throw err
}
}
/**
* Rewrite a game block as an account block (S14): its identity is linked now.
*
* When the member had ALSO blocked that account directly, the unique key refuses
* the rewrite, and the game row is then simply redundant — the account block
* already says everything it said — so it is deleted instead.
*/
async function convertToAccountBlock(id, blockedUserId) {
try {
await query(
`UPDATE user_blocks
SET blocked_user_id = ?, module_id = NULL, identity_kind = NULL, external_id = NULL
WHERE id = ?`,
[blockedUserId, id],
)
return 'converted'
} catch (err) {
if (!isDuplicate(err)) throw err
await query('DELETE FROM user_blocks WHERE id = ?', [id])
return 'merged'
}
}
/** Undo one of this member's blocks. Scoped to the blocker, so an id is not a capability. */
async function deleteBlock(blockerUserId, id) {
const res = await query('DELETE FROM user_blocks WHERE id = ? AND blocker_user_id = ?', [id, blockerUserId])
return res.affectedRows > 0
}
/** Which of these accounts are staff (admin, editor, moderator). */
async function staffAmong(userIds, staffRoles) {
if (!userIds.length) return []
const rows = await query(
`SELECT id FROM users
WHERE id IN (${userIds.map(() => '?').join(',')})
AND role IN (${staffRoles.map(() => '?').join(',')})`,
[...userIds, ...staffRoles],
)
return rows.map((r) => Number(r.id))
}
/** One account's id, username and role — the target of a block. */
async function userById(id) {
const rows = await query('SELECT id, username, role FROM users WHERE id = ? LIMIT 1', [id])
return rows[0] || null
}
// ── hide for me ────────────────────────────────────────────────────────────
/** Every item this member hid, newest first; optionally of the given types only. */
async function hiddenFor(userId, types = null) {
if (types && !types.length) return []
const where = types ? ` AND target_type IN (${types.map(() => '?').join(',')})` : ''
return query(
`SELECT target_type, target_id, created_at FROM user_hidden_items
WHERE user_id = ?${where}
ORDER BY created_at DESC`,
[userId, ...(types || [])],
)
}
/** Hide one item. Idempotent: hiding twice is still hidden. */
async function insertHidden(userId, targetType, targetId) {
await query(
`INSERT IGNORE INTO user_hidden_items (user_id, target_type, target_id) VALUES (?, ?, ?)`,
[userId, targetType, targetId],
)
}
async function deleteHidden(userId, targetType, targetId) {
const res = await query(
'DELETE FROM user_hidden_items WHERE user_id = ? AND target_type = ? AND target_id = ?',
[userId, targetType, targetId],
)
return res.affectedRows > 0
}
/** Show every hidden item again. Answers how many there were. */
async function clearHidden(userId) {
const res = await query('DELETE FROM user_hidden_items WHERE user_id = ?', [userId])
return res.affectedRows
}
module.exports = {
clearHidden,
blocksFor,
insertAccountBlock,
insertGameBlock,
convertToAccountBlock,
deleteBlock,
staffAmong,
userById,
hiddenFor,
insertHidden,
deleteHidden,
}

View File

@@ -0,0 +1,306 @@
// ── Block and "Hide for me" (USER_SAFETY.md §1, §4.1) ──────────────────────
//
// A block lives on the blocker's ACCOUNT (S2), so it applies on the website and
// in the app, on every device. The rules that shape this file, each decided by
// the org lead:
//
// S12 A block names the author's WEBSITE ACCOUNT whenever there is one. A
// forum author always has one; a chat speaker has one when their game
// identity is linked. Only an unlinked game identity — most people in a
// Rust chat feed — is blocked as itself, a Steam id.
// S14 When that identity is later linked, the game block becomes an account
// block, the next time the member's blocks are read. From then on it hides
// the account's forum posts too.
// S8 Staff (admin, editor, moderator) cannot be blocked. Blocking one is
// refused. A block whose target LATER became staff — promoted, or a game
// block whose identity linked to a staff account — is kept but stops
// applying, and the Blocked page says why.
// S9 A block does nothing to the blocked person. They are not told.
//
// "Hide for me" is one item gone from one member's view; it files no report and
// changes nothing about the item (§1).
//
// Filtering happens on the server for every reader-specific read: core's forum
// here, and a module's per-viewer feeds through `ctx.safety.forViewer` (S11).
const db = require('./userSafety.db')
const targets = require('./targets')
const registries = require('../../modules/registries')
const { STAFF_ROLES } = require('../../utils/displayAuthor')
const log = require('../../utils/logger')('safety')
const isStaffRole = (role) => STAFF_ROLES.includes(role)
const refuse = (status, code, error) => ({ ok: false, status, code, error })
/**
* This member's blocks, as they stand NOW.
*
* S14 runs here: every game block is checked against its module's links, and
* one whose identity is linked is rewritten as an account block before the list
* is answered. Reading is where it happens because a link is made in a module,
* which has no reason to tell core, and a block that waited for a notification
* would be a block that silently missed one.
*
* A module that does not answer leaves its game blocks as they are: they still
* apply to the Steam id, which is the block the member made.
*/
async function currentBlocks(userId) {
let rows = await db.blocksFor(userId)
const game = rows.filter((r) => r.blocked_user_id == null && r.module_id)
if (game.length) {
const groups = new Map()
for (const r of game) {
const key = `${r.module_id}:${r.identity_kind}`
if (!groups.has(key)) groups.set(key, [])
groups.get(key).push(r)
}
let changed = false
for (const group of groups.values()) {
const ident = registries.gameIdentity(group[0].module_id, group[0].identity_kind)
if (!ident) continue
let owners = {}
try {
owners = (await targets.withTimeout(
() => ident.ownersOf(group.map((r) => r.external_id)),
targets.MODULE_TIMEOUT_MS,
)) || {}
} catch (err) {
log.warn('game identity lookup failed; game blocks left as they are', {
module: ident.owner, kind: ident.kind, message: err.message,
})
continue
}
for (const r of group) {
const owner = Number(owners[r.external_id])
if (!Number.isInteger(owner) || owner <= 0) continue
// The member blocked a Steam id that turned out to be their own. A block
// on yourself hides nothing worth hiding, so it is simply removed.
if (owner === Number(userId)) await db.deleteBlock(userId, r.id)
else await db.convertToAccountBlock(r.id, owner)
changed = true
}
}
if (changed) rows = await db.blocksFor(userId)
}
return rows.map((r) => ({
...r,
applies: r.blocked_user_id == null ? true : !isStaffRole(r.blocked_role),
}))
}
/** One block as the member sees it on Account → Blocked. */
function publicBlock(row) {
const account = row.blocked_user_id != null
const ident = account ? null : registries.gameIdentity(row.module_id, row.identity_kind)
return {
id: row.id,
kind: account ? 'account' : 'game',
name: account ? (row.blocked_username || row.display_name) : row.display_name,
identity: account
? null
: {
module: row.module_id,
kind: row.identity_kind,
label: ident ? ident.label : row.identity_kind,
id: row.external_id,
},
applies: row.applies,
reason: row.applies ? null : 'staff',
createdAt: row.created_at,
}
}
async function listBlocks(actor) {
return (await currentBlocks(actor.id)).map(publicBlock)
}
/** Block an account, after S8's check. */
async function blockAccount(actor, userId) {
if (Number(userId) === Number(actor.id)) return refuse(400, 'self', 'You can’t block yourself.')
const target = await db.userById(userId)
if (!target) return refuse(404, 'not_found', 'Not found')
if (isStaffRole(target.role)) return refuse(403, 'staff', 'You can’t block a staff member.')
const id = await db.insertAccountBlock({
blockerUserId: actor.id,
blockedUserId: target.id,
displayName: target.username,
})
if (id == null) return refuse(409, 'already_blocked', `You have already blocked ${target.username}.`)
return { ok: true, block: { id, kind: 'account', name: target.username, identity: null, applies: true, reason: null } }
}
/**
* Block the author of one item (S12).
*
* The client names the item, never the person: the server reads who wrote it,
* through the same resolver a report uses, so a member can only block someone
* whose words they could see, and the block lands on the account when there is
* one.
*/
async function blockAuthorOf({ actor, targetType, targetId }) {
if (!targets.isHideable(targetType)) return refuse(400, 'unknown_target', 'Unknown target')
const item = await targets.resolve({ targetType, targetId, viewer: actor })
if (!item) return refuse(404, 'not_found', 'Not found')
const { author } = item
if (author.userId != null) return blockAccount(actor, author.userId)
// No account on the item. A forum item with no account is a deleted one, and
// there is nobody left to block.
if (!item.module || !author.identity) {
return refuse(400, 'no_author', 'That account no longer exists.')
}
// A game identity. S12: its account first, when it has one.
const ident = registries.gameIdentity(item.module, author.identity.kind)
if (!ident) return refuse(400, 'no_author', 'This player can’t be blocked.')
let owners
try {
owners = (await targets.withTimeout(() => ident.ownersOf([author.identity.id]), targets.MODULE_TIMEOUT_MS)) || {}
} catch (err) {
log.error('game identity lookup failed', { module: ident.owner, kind: ident.kind, message: err.message })
return refuse(503, 'module_unavailable', 'The game module did not answer. Try again in a moment.')
}
const owner = Number(owners[author.identity.id])
if (Number.isInteger(owner) && owner > 0) return blockAccount(actor, owner)
const id = await db.insertGameBlock({
blockerUserId: actor.id,
moduleId: item.module,
kind: author.identity.kind,
externalId: author.identity.id,
displayName: author.name,
})
if (id == null) return refuse(409, 'already_blocked', `You have already blocked ${author.name || 'this player'}.`)
return {
ok: true,
block: {
id,
kind: 'game',
name: author.name,
identity: { module: item.module, kind: ident.kind, label: ident.label, id: author.identity.id },
applies: true,
reason: null,
},
}
}
async function unblock(actor, id) {
return (await db.deleteBlock(actor.id, id)) ? { ok: true } : refuse(404, 'not_found', 'Not found')
}
// ── hide for me ────────────────────────────────────────────────────────────
/**
* Hide one item for this member. The type must be one a member can hide; the
* item is not looked up — see `user_hidden_items` in schema.sql for why a row for
* an id that does not exist is harmless and an existence check is not.
*/
async function hide(actor, targetType, targetId) {
if (!targets.isHideable(targetType)) return refuse(400, 'unknown_target', 'Unknown target')
await db.insertHidden(actor.id, targetType, targetId)
return { ok: true }
}
async function unhide(actor, targetType, targetId) {
if (!targets.isHideable(targetType)) return refuse(400, 'unknown_target', 'Unknown target')
await db.deleteHidden(actor.id, targetType, targetId)
return { ok: true }
}
/** Account → Blocked's "Show them all again". */
async function clearHidden(actor) {
return db.clearHidden(actor.id)
}
async function listHidden(actor) {
return (await db.hiddenFor(actor.id)).map((r) => ({
targetType: r.target_type,
targetId: Number(r.target_id),
createdAt: r.created_at,
}))
}
// ── what one reader's reads leave out ──────────────────────────────────────
const EMPTY_FILTER = Object.freeze({
blockedUserIds: new Set(),
hidden: Object.freeze({ team_forum_thread: new Set(), team_forum_post: new Set() }),
})
/**
* What core's forum leaves out for this reader: the accounts they block (that
* still apply) and the threads and posts they hid.
*/
async function forumFilter(userId) {
if (!userId) return EMPTY_FILTER
const [blocks, hidden] = await Promise.all([
currentBlocks(userId),
db.hiddenFor(userId, targets.FORUM_HIDEABLE),
])
const filter = {
blockedUserIds: new Set(blocks.filter((b) => b.applies && b.blocked_user_id != null).map((b) => Number(b.blocked_user_id))),
hidden: { team_forum_thread: new Set(), team_forum_post: new Set() },
}
for (const h of hidden) filter.hidden[h.target_type].add(Number(h.target_id))
return filter
}
/**
* `ctx.safety.forViewer` (API 1.21.0, S11): what one reader has blocked and
* hidden, for the calling module.
*
* `blockedUserIds` — accounts, every module's concern: a speaker linked to one
* is hidden whichever module's feed they speak in. `blockedIdentities` — this
* module's own unlinked game blocks, by kind. `hidden` — this reader's hides on
* this module's report targets, by type. Ids are answered as arrays of the type
* the module gave them (numbers for target ids, strings for identities).
*/
async function forModuleViewer(moduleId, userId) {
const empty = { blockedUserIds: [], blockedIdentities: {}, hidden: {} }
if (!userId) return empty
const types = registries.allReportTargets().filter((t) => t.owner === moduleId).map((t) => t.id)
const [blocks, hidden] = await Promise.all([currentBlocks(userId), db.hiddenFor(userId, types)])
const out = { blockedUserIds: [], blockedIdentities: {}, hidden: {} }
for (const b of blocks) {
if (!b.applies) continue
if (b.blocked_user_id != null) out.blockedUserIds.push(Number(b.blocked_user_id))
else if (b.module_id === moduleId) {
if (!out.blockedIdentities[b.identity_kind]) out.blockedIdentities[b.identity_kind] = []
out.blockedIdentities[b.identity_kind].push(String(b.external_id))
}
}
for (const t of types) out.hidden[t] = []
for (const h of hidden) out.hidden[h.target_type].push(Number(h.target_id))
return out
}
/** `ctx.safety.staffAmong` (S13): which of these accounts are staff. */
async function staffAmong(userIds) {
const ids = [...new Set((Array.isArray(userIds) ? userIds : [])
.map(Number)
.filter((n) => Number.isInteger(n) && n > 0))]
.slice(0, 500)
return db.staffAmong(ids, STAFF_ROLES)
}
module.exports = {
currentBlocks,
listBlocks,
blockAuthorOf,
blockAccount,
unblock,
hide,
unhide,
listHidden,
clearHidden,
forumFilter,
forModuleViewer,
staffAmong,
publicBlock,
}

View File

@@ -3,6 +3,7 @@ const brand = require('../../config/brand')
const { parseJsonSetting } = require('../../utils/settingsJson')
const { resolveThemeTokens } = require('../../utils/themeResolve')
const { resolveBrandAssets } = require('../../utils/brandAssets')
const { isDemo } = require('../../demo/mode')
// Keys safe to expose on the public site.
const PUBLIC_KEYS = [
@@ -215,9 +216,30 @@ async function getPublic() {
// before doing so (the server would otherwise reject an unallowlisted one). The
// custom-scheme callback works regardless of this flag.
out.mobileAppLinks = String(all[MOBILE_APP_LINKS_KEY]) === 'true'
// The public demo's banner and sign-in panel (DEMO_MODE.md §4, §6). Present
// only with DEMO_MODE on, so a real shard's payload is unchanged. Written by
// `seed:demo` at every wipe; the demo lock keeps `demo_*` out of PUT
// /admin/settings, so a visitor cannot rewrite the published passwords.
if (isDemo()) out.demo = demoBlock(all[DEMO_STATE_KEY])
return out
}
// `demo_state` is one JSON object: `{ resetsAt, logins: [{ username, password,
// role }] }`. The passwords are public by design (§4) — the logins are printed
// on the sign-in page and on runicgateway.com/beta. Read forgivingly: a missing
// or broken row is a banner with no time and no logins, never a 500.
const DEMO_STATE_KEY = 'demo_state'
function demoBlock(raw) {
const state = parseJsonSetting(raw) || {}
const resetsAt = typeof state.resetsAt === 'string' && !Number.isNaN(Date.parse(state.resetsAt))
? state.resetsAt
: null
const logins = (Array.isArray(state.logins) ? state.logins : [])
.filter((l) => l && typeof l.username === 'string' && typeof l.password === 'string')
.map((l) => ({ username: l.username, password: l.password, role: typeof l.role === 'string' ? l.role : null }))
return { resetsAt, logins }
}
/**
* What the HTML shell needs, resolved exactly as getPublic() resolves it: the
* effective favicon and logo, plus the theme token map for the boot <style>
@@ -285,4 +307,5 @@ module.exports = {
registrationFlags,
MOBILE_APP_LINKS_KEY,
isMobileAppLinksEnabled,
DEMO_STATE_KEY,
}

View File

@@ -112,7 +112,15 @@ async function rosterWithOverrides(teamId, { includeDeparted = false } = {}) {
is_leader_synced: Boolean(m.is_leader),
is_leader: override ? override.effect === 'grant' : Boolean(m.is_leader),
leader_override: override
? { effect: override.effect, reason: override.reason, by: override.actor_username, at: override.created_at }
? {
effect: override.effect,
reason: override.reason,
by: override.actor_username,
// Staff-only (adminMember); a deleted actor keeps the stored name here,
// marked (ACCOUNT_DELETION.md AD6). The public roster never carries it.
byDeleted: override.actor_user_id == null && Boolean(override.actor_username),
at: override.created_at,
}
: null,
}
})

View File

@@ -79,6 +79,15 @@ async function postById(id) {
return rows[0] || null
}
/** A thread's opening post, whatever its status — what a report on the thread quotes. */
async function firstPostOf(threadId) {
const rows = await query(
`SELECT ${POST_COLUMNS} FROM team_forum_posts WHERE thread_id = ? ORDER BY created_at, id LIMIT 1`,
[threadId],
)
return rows[0] || null
}
/**
* Append a post and move the thread's counters in the same breath.
*
@@ -275,6 +284,7 @@ module.exports = {
setThreadFlags,
postsByThread,
postById,
firstPostOf,
insertPost,
setPostStatus,
updatePostBody,

View File

@@ -21,6 +21,8 @@
const forumDb = require('./teamForum.db')
const forumSettings = require('./teamForumSettings.model')
const { cleanForumBody, renderForumBody } = require('../../utils/forumHtml')
const { displayAuthor, STAFF_ROLES } = require('../../utils/displayAuthor')
const safetyDb = require('../safety/userSafety.db')
// Announcements are leader-authored and take no replies; discussion threads are
// member-authored and do. Both have been in the enum since 5a — what phase 5
@@ -42,8 +44,6 @@ const CREATABLE_TYPES_5A = ['announcement']
// look like it would open replies on an announcement.
const REPLYABLE_TYPES = ['discussion']
const DELETED_AUTHOR = '[deleted account]'
/**
* Moderation actions, and what each one does to the row.
*
@@ -74,13 +74,21 @@ const POST_ACTIONS = {
restore: { status: 'visible' },
}
function publicThread(row) {
/**
* One thread's list shape, for one viewer.
*
* `author` follows displayAuthor's rule: a deleted account's snapshot reads
* "[deleted player]" to a member and its stored name to staff, and
* `authorDeleted` says which case it is so the staff view can mark it.
*/
function publicThread(row, viewer = null) {
const author = displayAuthor({ userId: row.created_by, username: row.created_username }, viewer)
return {
id: row.id,
type: row.type,
title: row.title,
author: row.created_username || DELETED_AUTHOR,
authorDeleted: row.created_by == null,
author: author.name,
authorDeleted: author.deleted,
createdAt: row.created_at,
lastPostAt: row.last_post_at,
postCount: row.post_count,
@@ -129,10 +137,11 @@ function editability(row, { userId = null, isStaff = false, windowMinutes = 0, n
* said who is reading.
*/
function renderPost(row, mode, viewer) {
const author = displayAuthor({ userId: row.author_user_id, username: row.author_username }, viewer)
return {
id: row.id,
author: row.author_username || DELETED_AUTHOR,
authorDeleted: row.author_user_id == null,
author: author.name,
authorDeleted: author.deleted,
body: renderForumBody(row.body_html, mode),
createdAt: row.created_at,
editedAt: row.edited_at,
@@ -150,9 +159,42 @@ function renderPost(row, mode, viewer) {
* same call answers both audiences without a second endpoint that could disagree
* with this one.
*/
async function listThreads(teamId, { canModerate = false, limit = 50, offset = 0 } = {}) {
async function listThreads(teamId, { canModerate = false, viewer = null, filter = null, limit = 50, offset = 0 } = {}) {
const rows = await forumDb.threadsByTeam(teamId, { includeHidden: canModerate, limit, offset })
return rows.map(publicThread)
return rows.filter((row) => !leftOutThread(row, filter)).map((row) => publicThread(row, viewer))
}
// ── one reader's blocks and hides (USER_SAFETY.md §4.1) ───────────────────
//
// `filter` is `userSafety.forumFilter(userId)`: the accounts this reader blocks
// and the threads and posts they hid. Applied on the server, to this reader's
// read only — nothing on disk changes, and nobody else's view does (S9).
/** Does this reader's filter leave this thread out? */
function leftOutThread(row, filter) {
if (!filter) return false
if (filter.hidden.team_forum_thread.has(Number(row.id))) return true
return row.created_by != null && filter.blockedUserIds.has(Number(row.created_by))
}
/**
* May this reader block these authors? Decided here and handed to the client,
* like `canEdit`: not yourself, not a deleted account, and not staff (S8) —
* the menu does not offer what the server would refuse.
*/
async function blockable(viewer, authorIds) {
const me = viewer && viewer.userId
if (!me) return () => false
const ids = [...new Set(authorIds.filter((id) => id != null && id !== me).map(Number))]
const staff = new Set(ids.length ? await safetyDb.staffAmong(ids, STAFF_ROLES) : [])
return (id) => id != null && Number(id) !== Number(me) && !staff.has(Number(id))
}
/** Does this reader's filter leave this post out? */
function leftOutPost(row, filter) {
if (!filter) return false
if (filter.hidden.team_forum_post.has(Number(row.id))) return true
return row.author_user_id != null && filter.blockedUserIds.has(Number(row.author_user_id))
}
/**
@@ -164,7 +206,7 @@ async function listThreads(teamId, { canModerate = false, limit = 50, offset = 0
* the window to the client and letting it compare timestamps — is the thing
* `editability` exists not to do.
*/
async function getThread(teamId, threadId, { canModerate = false, viewer } = {}) {
async function getThread(teamId, threadId, { canModerate = false, viewer, filter = null } = {}) {
const thread = await forumDb.threadById(threadId)
// The team check is here rather than in the SQL so a thread id from another
// Team reads as "not found" and not as "found, but not yours" — a forum is a
@@ -172,17 +214,23 @@ async function getThread(teamId, threadId, { canModerate = false, viewer } = {})
if (!thread || thread.team_id !== teamId) return null
if (thread.status === 'deleted' && !canModerate) return null
if (thread.status === 'hidden' && !canModerate) return null
// A thread this reader hid, or opened by someone they block, is gone from
// their view — a link to it included.
if (leftOutThread(thread, filter)) return null
const mode = await forumSettings.imageMode()
const posts = await forumDb.postsByThread(threadId, { includeHidden: canModerate })
const posts = (await forumDb.postsByThread(threadId, { includeHidden: canModerate }))
.filter((p) => !leftOutPost(p, filter))
const canBlock = await blockable(viewer, [thread.created_by, ...posts.map((p) => p.author_user_id)])
return {
...publicThread(thread),
...publicThread(thread, viewer),
canBlock: canBlock(thread.created_by),
// A reply control is offered when the TYPE takes replies and the thread is
// open. Both halves are reported separately (`type`, `locked`) so the UI can
// say which one is why, but the decision itself is made here — a client that
// recomputed it would be a second place for the rule to live.
canReply: REPLYABLE_TYPES.includes(thread.type) && !thread.locked && thread.status === 'visible',
posts: posts.map((p) => renderPost(p, mode, viewer)),
posts: posts.map((p) => ({ ...renderPost(p, mode, viewer), canBlock: canBlock(p.author_user_id) })),
}
}
@@ -434,4 +482,6 @@ module.exports = {
publicThread,
renderPost,
editability,
leftOutThread,
leftOutPost,
}

View File

@@ -26,6 +26,7 @@ const teamsDb = require('./teams.db')
const access = require('./teamAccess.model')
const usersDb = require('../users/users.db')
const settingsDb = require('../settings/settings.db')
const { displayAuthor } = require('../../utils/displayAuthor')
// The per-Team ceiling on ACTIVE leader-issued grants. A leader admitting
// unlimited arbitrary accounts to a private space on the operator's host is a
@@ -136,19 +137,25 @@ async function revoke({ team, actor, userId, reason }) {
* The subtraction is the §3.2 "Forum guests" list: someone who is both a member
* and a grantee is a member, listed on the roster, and appears here not at all.
* Both facts stay true in the ledger; only the presentation picks one.
*
* **A grant whose account was deleted is not listed** (ACCOUNT_DELETION.md AD13).
* Deleting an account clears `user_id` and leaves the row, so the grant would
* otherwise sit here as a guest nobody can be: a grant is checked by user id, and
* there is no id left to match it. The staff grant ledger still shows it, with the
* name. Who GRANTED it follows displayAuthor's rule for `viewer`.
*/
async function forumGuests(teamId) {
async function forumGuests(teamId, viewer = null) {
const [grants, members] = await Promise.all([
accessDb.activeGrants(teamId),
teamsDb.membersByTeam(teamId, { includeDeparted: false }),
])
const memberUserIds = new Set(members.map((m) => m.user_id).filter((id) => id != null))
return grants
.filter((g) => g.user_id == null || !memberUserIds.has(g.user_id))
.filter((g) => g.user_id != null && !memberUserIds.has(g.user_id))
.map((g) => ({
userId: g.user_id,
username: g.username,
grantedBy: g.granted_username,
grantedBy: displayAuthor({ userId: g.granted_by, username: g.granted_username }, viewer).name,
grantedAt: g.granted_at,
reason: g.reason,
}))

View File

@@ -197,7 +197,7 @@ async function teamsWithForumActivitySince(since) {
*/
async function digestPostsSince(teamId, since, limit = 20) {
return query(
`SELECT po.id, po.thread_id, po.body_html, po.created_at, po.author_username,
`SELECT po.id, po.thread_id, po.body_html, po.created_at, po.author_user_id, po.author_username,
th.title, th.type
FROM team_forum_posts po
JOIN team_forum_threads th ON th.id = po.thread_id

View File

@@ -135,7 +135,7 @@ async function syncStatus() {
configured: true,
lastSyncAt,
// Never synced at all is stale: a page must not present an empty projection
// as a confirmed empty shard.
// as a confirmed empty one.
stale: ageS > intervalS * STALE_INTERVALS,
consecutiveFailures: state ? state.consecutive_failures : 0,
}

View File

@@ -1,4 +1,4 @@
const { query } = require('../../utils/db')
const { query, transaction } = require('../../utils/db')
const PUBLIC_COLS =
'id, username, role, status, email, email_verified, email_pending, totp_enabled, created_at, last_login_at'
@@ -92,6 +92,31 @@ async function deleteUser(id) {
return query('DELETE FROM users WHERE id = ?', [id])
}
// Delete the account and, when `reserveName` is given, reserve it, as ONE
// transaction (ACCOUNT_DELETION.md §4.1 step 2): a staff name is never free for
// a moment between the two. `INSERT IGNORE` because a name already reserved
// (a second staff account renamed onto it cannot exist, but a hand-inserted row
// can) is the state wanted, not an error. Returns the rows deleted, 0 or 1.
async function deleteUserReserving(id, reserveName = null) {
return transaction(async (conn) => {
if (reserveName) {
await conn.query(
"INSERT IGNORE INTO reserved_usernames (username_norm, reason) VALUES (LOWER(?), 'deleted_staff')",
[reserveName],
)
}
const result = await conn.query('DELETE FROM users WHERE id = ?', [id])
return Number(result.affectedRows || 0)
})
}
// True when the name can never be taken again (AD9). The column's collation is
// users.username's, so this folds case exactly as the UNIQUE index does.
async function isUsernameReserved(username) {
const rows = await query('SELECT 1 FROM reserved_usernames WHERE username_norm = LOWER(?) LIMIT 1', [username])
return rows.length > 0
}
async function countUsers() {
const rows = await query('SELECT COUNT(*) AS c FROM users')
return Number(rows[0].c)
@@ -148,6 +173,8 @@ module.exports = {
listUsers,
updateUser,
deleteUser,
deleteUserReserving,
isUsernameReserved,
countUsers,
countAdmins,
touchLastLogin,

View File

@@ -13,6 +13,7 @@ function sanitize(user) {
// password may be omitted/null — an SSO-provisioned player has no password until
// they set one (a null hash makes password login impossible, see validatePassword).
async function createUser({ username, password, role = 'admin', email = null, status = 'active', emailVerified = false }) {
await refuseReserved(username)
const passwordHash = password ? await bcrypt.hash(password, SALT_ROUNDS) : null
const id = await usersDb.insertUser({ username, passwordHash, role, email, status, emailVerified })
return sanitize(await usersDb.findById(id))
@@ -63,9 +64,36 @@ function isDuplicateEmail(err) {
// and nothing newly falls through to a 500. Only the case we can positively
// identify — email — is carved out.
function isDuplicateUsername(err) {
if (err && err.code === USERNAME_RESERVED) return true
return isDuplicateKeyError(err) && !isDuplicateEmail(err)
}
// ── Reserved usernames (ACCOUNT_DELETION.md AD9) ───────────────────────────
//
// A deleted staff account's name can never be taken again. The refusal is
// raised HERE, in the two writes every username passes through, rather than in
// each of the six routes that create or rename an account: registration, an
// invite's acceptance, SSO provisioning, an admin creating or renaming a user,
// and a self-service change. Each of them already answers a duplicate username,
// and `isDuplicateUsername` above reports a reserved one as exactly that, so a
// reserved name reads as a taken one everywhere — the same 409, the same words,
// and SSO provisioning moves on to its next candidate.
//
// A check-then-write, so not atomic against a deletion committing in between.
// That window is one request wide and needs a staff account deleted at the same
// instant someone registers its name; the UNIQUE index still holds until the
// deletion commits.
const USERNAME_RESERVED = 'USERNAME_RESERVED'
async function refuseReserved(username) {
if (typeof username !== 'string' || !username) return
if (await usersDb.isUsernameReserved(username.trim())) {
const err = new Error('username is reserved')
err.code = USERNAME_RESERVED
throw err
}
}
// Returns the raw row (incl. hash) — used by login only.
async function getRawByUsername(username) {
return usersDb.findByUsername(username)
@@ -112,7 +140,10 @@ async function list() {
async function update(id, { username, password, role, email, status, emailVerified }) {
const fields = {}
if (username !== undefined) fields.username = username
if (username !== undefined) {
await refuseReserved(username)
fields.username = username
}
if (role !== undefined) fields.role = role
if (email !== undefined) fields.email = email
if (status !== undefined) fields.status = status
@@ -155,6 +186,15 @@ async function remove(id) {
return usersDb.deleteUser(id)
}
// The deletion's own write: the row goes, and a staff name is reserved in the
// same transaction. Only auth/accountDeletion.js calls this; `remove` above is
// for undoing an account this request itself just created (a lost invite race).
async function removeReserving(id, reserveName = null) {
return usersDb.deleteUserReserving(id, reserveName)
}
const isUsernameReserved = (username) => usersDb.isUsernameReserved(username)
async function count() {
return usersDb.countUsers()
}
@@ -186,6 +226,9 @@ module.exports = {
invalidateSessions,
setSessionCutoff,
remove,
removeReserving,
isUsernameReserved,
USERNAME_RESERVED,
count,
countAdmins,
recordLogin,

View File

@@ -41,6 +41,8 @@ const { MODULE_API_VERSION } = require('./version')
const semver = require('./semver')
const registries = require('./registries')
const { splitStatements } = require('../utils/sqlStatements')
const demoPaths = require('../demo/paths')
const { isDemo } = require('../demo/mode')
const log = require('../utils/logger')('modules')
@@ -62,9 +64,15 @@ const TIERS = ['public', 'admin', 'player']
const MANIFEST_KEYS = new Set([
'id', 'name', 'version', 'coreApi', 'server', 'client',
'schema', 'purge', 'mounts', 'extensions', 'capabilities',
'schema', 'purge', 'mounts', 'extensions', 'capabilities', 'demoLocks',
])
// What a `demoLocks` entry may lock (API 1.18.0, DEMO_MODE.md §3.2): writes
// only. Reads are never locked by declaration — the one read the demo refuses
// (a plugin config that holds credentials) depends on WHICH file is asked for,
// so the module decides it behind `ctx.demoMode` rather than by route.
const DEMO_LOCK_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE']
// Extension slots are declared by core, at require time, in the router that owns
// the resource (registries.declareSlot). The loader asks the registry which exist
// rather than keeping a list, for the same reason the prefix check probes the
@@ -338,6 +346,31 @@ function buildCtx(id, moduleRoot) {
// configuration, so core answers it. A getter, not a captured string: the
// value is read per call, so it cannot go stale against the env.
site: { get baseUrl() { return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '') } },
// API 1.21.0 (USER_SAFETY.md §4.1, S11). READ only. What one reader has
// blocked and hidden, so a module filters its own per-reader feeds on the
// server — a chat feed is already read per viewer, so the website and the app
// need no list of their own.
//
// `forViewer` is bound to the CALLING module: it answers the reader's account
// blocks (with any game block whose identity is now linked already folded in,
// S14, and staff dropped, S8), this module's own unlinked game blocks, and
// the reader's hides on this module's report targets. Another module's game
// blocks and hides are not its business. A signed-out reader has none.
//
// `staffAmong(userIds)` answers which of those accounts are staff, for a
// module that marks lines `canBlock` (S13).
safety: {
// eslint-disable-next-line global-require
forViewer: (userId) => require('../model/safety/userSafety.model').forModuleViewer(id, userId),
// eslint-disable-next-line global-require
staffAmong: (userIds) => require('../model/safety/userSafety.model').staffAmong(userIds),
},
// API 1.18.0 (DEMO_MODE.md §3.4, §3.5). True on the public demo. For the
// locks a route list cannot express — a console command by its first word,
// a config file by which plugin owns it — which the module enforces in its
// own handlers. A boolean, read once: the flag is the deployment's, and a
// deployment does not change what it is while it runs.
demoMode: isDemo(),
}
// A guard against accident, not against a hostile module — the boundary is
// organisational, not a security boundary (MODULE_SYSTEM.md §2.2).
@@ -486,6 +519,18 @@ function buildApi(record) {
once('registerEventTemplates')
record.staged.registerEventTemplates(templates)
},
// API 1.21.0 (USER_SAFETY.md §4.1). What of the module's content a member
// can report, hide for themself and block the author of, and the game
// identity those authors are known by. Both STAGE, and `once` for the reason
// every batch above takes it.
registerReportTargets(targets) {
once('registerReportTargets')
record.staged.registerReportTargets(targets)
},
registerGameIdentities(identities) {
once('registerGameIdentities')
record.staged.registerGameIdentities(identities)
},
// The two lifecycle hooks (§2.5). Registered here, dispatched from
// lifecycle.js — this file runs with no database and the hooks run with one.
// Both are optional: a module with no warm-up and nothing to close simply
@@ -503,6 +548,21 @@ function buildApi(record) {
// nothing to undo when a sign-in identity goes.
onIdentityLinking: hook('onIdentityLinking'),
onIdentityLinked: hook('onIdentityLinked'),
// API 1.18.0 (DEMO_MODE.md §7.3, DM15). Core's `seed:demo` handing the
// module its part of the seed fixture, so the module writes its own rows —
// a game server link, a player's game link — through its own model. Core
// never writes a module's tables. Called from the seed script only, never
// by the running server, and only with DEMO_MODE on.
onDemoSeed: hook('onDemoSeed'),
// API 1.20.0 (ACCOUNT_DELETION.md §4.1, AD10, AD11). Core telling the module
// an account is about to be deleted, dispatched from auth/accountDeletion.js
// BEFORE the row goes, so the module can still read the person's links and
// undo them through its own unlink — a cascade is not an unlink, and for UO
// the link lives on the shard. It cannot veto: a throw or a hook past core's
// time limit is logged and the deletion goes ahead, because a member's
// deletion request must be honoured. Whatever the module could not finish is
// its own pending work.
onUserDeleting: hook('onUserDeleting'),
}
}
@@ -741,6 +801,8 @@ function readManifest(dir, id, tierRouters) {
if (!registries.hasSlot(slot)) fail('extensions', `unknown extension slot "${slot}"`)
}
checkDemoLocks(manifest)
if (manifest.client !== undefined) {
if (typeof manifest.client !== 'object' || manifest.client === null || Array.isArray(manifest.client)) {
fail('manifest', 'client must be an object')
@@ -761,6 +823,40 @@ function readManifest(dir, id, tierRouters) {
return manifest
}
/**
* Validate `demoLocks` (API 1.18.0, DEMO_MODE.md §3.2), on every boot and not
* only on the demo: a module whose declaration is wrong should find out on the
* developer's machine, not on the one deployment where it is read.
*
* Each entry is `{ method, path }`, the path relative to /api/v1 and under one
* of the module's own declared mounts — a module may lock its own routes and
* nobody else's, the same boundary `mounts` draws for serving them.
*/
function checkDemoLocks(manifest) {
if (manifest.demoLocks === undefined) return
if (!Array.isArray(manifest.demoLocks)) fail('manifest', 'demoLocks must be an array')
for (const entry of manifest.demoLocks) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
fail('manifest', 'each demoLocks entry must be an object')
}
for (const key of Object.keys(entry)) {
if (key !== 'method' && key !== 'path') fail('manifest', `unknown key "demoLocks.${key}" in module.json`)
}
if (!DEMO_LOCK_METHODS.includes(entry.method)) {
fail('manifest', `demoLocks method "${entry.method}" must be one of ${DEMO_LOCK_METHODS.join(', ')}`)
}
try {
demoPaths.compile(entry.path)
} catch (err) {
fail('manifest', `demoLocks: ${err.message}`)
}
const [, tier, prefix] = entry.path.split('/')
if (!(manifest.mounts?.[tier] || []).includes(`/${prefix}`)) {
fail('mounts', `demoLocks path ${entry.path} is outside the module's mounts`)
}
}
}
// What the module registered must equal what it declared — in both directions.
function checkDeclared(record) {
const declared = record.manifest.mounts || {}
@@ -841,7 +937,14 @@ function load(tierRouters) {
staged: registries.stage(id),
tables: new Set(),
called: new Set(),
hooks: { onBoot: null, onShutdown: null, onIdentityLinking: null, onIdentityLinked: null },
hooks: {
onBoot: null,
onShutdown: null,
onIdentityLinking: null,
onIdentityLinked: null,
onDemoSeed: null,
onUserDeleting: null,
},
client: null,
ctx: null,
state: 'installed',
@@ -949,7 +1052,7 @@ function refuseSecond(id, dir, holder) {
staged: null,
tables: new Set(),
called: new Set(),
hooks: { onBoot: null, onShutdown: null, onIdentityLinking: null, onIdentityLinked: null },
hooks: { onBoot: null, onShutdown: null, onIdentityLinking: null, onIdentityLinked: null, onUserDeleting: null },
client: null,
ctx: null,
state: 'startup_failed',
@@ -1128,6 +1231,72 @@ function identityHooks(name) {
.map((r) => ({ id: r.id, hook: r.hooks[name] }))
}
/**
* The `onUserDeleting` hooks, in registration order (API 1.20.0,
* ACCOUNT_DELETION.md AD10).
*
* `started` only, like the identity hooks above: a disabled module, or one whose
* `onBoot` threw, is not serving and holds no link it could undo now. Empty in a
* process that never loaded modules, so a deletion there is core's alone.
*
* @returns {{id: string, hook: Function}[]}
*/
function userDeletingHooks() {
if (!loaded) return []
return [...modules.values()]
.filter((r) => r.state === 'started' && r.hooks.onUserDeleting)
.map((r) => ({ id: r.id, hook: r.hooks.onUserDeleting }))
}
/**
* Every installed module's `demoLocks`, compiled, for the demo lock in
* demo/locks.js (API 1.18.0).
*
* Every module whose manifest validated, whatever its state. A disabled or
* failed module's routes already answer 404 or 503, so its locks change
* nothing — and a lock that disappeared whenever its module was between states
* is a lock with a gap in it. Empty before load() has run, rather than throwing:
* the middleware is mounted before the loader and asks per request.
*
* @returns {{methods: string[], path: string, compiled: object, source: string}[]}
*/
let demoLockCache = null
function demoLocks() {
if (!loaded) return []
// Manifests are read once, in load(), which is synchronous — so the answer
// cannot change after the first request asks, and is compiled once.
if (demoLockCache) return demoLockCache
const out = []
for (const record of modules.values()) {
for (const entry of record.manifest?.demoLocks || []) {
out.push({
methods: [entry.method],
path: entry.path,
compiled: demoPaths.compile(entry.path),
source: record.id,
})
}
}
demoLockCache = out
return out
}
/**
* The `onDemoSeed` hooks, for `npm run seed:demo` (API 1.18.0, DM15).
*
* `registered` or `started`: the seed is its own process, where nothing calls
* onBoot, so its modules never get past `registered`. A module that failed to
* load is not handed a fixture for tables it may not have.
*
* @returns {{id: string, hook: Function, ctx: object}[]}
*/
function demoSeedHooks() {
assertLoaded('demoSeedHooks')
return [...modules.values()]
.filter((r) => (r.state === 'registered' || r.state === 'started') && r.hooks.onDemoSeed)
.map((r) => ({ id: r.id, hook: r.hooks.onDemoSeed, ctx: r.ctx }))
}
/**
* One module's `onShutdown`, for stopping it on its own rather than at exit.
*
@@ -1254,6 +1423,9 @@ module.exports = {
bootable,
shutdownHooks,
identityHooks,
userDeletingHooks,
demoLocks,
demoSeedHooks,
stopHook,
clientChunks,
clientEntryUrls,

View File

@@ -97,6 +97,26 @@ const postHooks = new Map()
// second registration is a collision rather than an addition.
let teamProvider = null
// report target type → { owner, id, label, snapshot, hide, unhide } (API 1.21.0,
// USER_SAFETY.md §4.1). A module's content that a member can report, hide for
// themself, and block the author of — and that staff can hide for everyone. Core
// files the report and keeps the snapshot; the module owns the content and its
// hidden flag, so `hide`/`unhide` are the module's.
//
// Core's own forum types are NOT in this map: they predate it and their reads
// are core's, so `model/safety/targets.js` answers them directly. Registering
// them here would put core's forum behind a call core makes to itself.
const reportTargets = new Map()
// `${owner}:${kind}` → { owner, kind, label, ownersOf } (API 1.21.0, S12, S14).
// A game identity a module's report targets name as their author — for Rust, a
// Steam id. `ownersOf(externalIds)` answers which website account each is linked
// to, which is how a block on a chat speaker lands on their ACCOUNT when they
// have one, and how a block on an unlinked id becomes an account block once it
// is linked. Keyed by owner as well as kind: two modules may both speak of
// Steam ids, and each answers only for its own links.
const gameIdentities = new Map()
// command name → { owner, name, description, options, access, handler }. Slash
// commands a registrant has published for the chat platform (API 1.6.0,
// TEAMS.md §7.1).
@@ -361,6 +381,17 @@ const registeredTeamProvider = () => teamProvider
/** Is there a Team provider at all? Read by the reconciler and the read API. */
const hasTeamProvider = () => teamProvider !== null
// ── Report targets and game identities (USER_SAFETY.md §4.1) ──────────────
/** One module report target, or null. Core's forum types are never here. */
const reportTarget = (type) => reportTargets.get(type) || null
/** Every module report target, in registration order. */
const allReportTargets = () => [...reportTargets.values()]
/** One module's game identity of one kind, or null. */
const gameIdentity = (owner, kind) => gameIdentities.get(`${owner}:${kind}`) || null
// ── Slash commands (TEAMS.md §7.1) ─────────────────────────────────────────
/**
@@ -445,14 +476,14 @@ async function resolveAudience(id, params = {}) {
/**
* Every declaration WITHOUT its callables — what the admin catalog serves.
*
* `perform`, `revert`, `reconcile`, `cost` and `progress` are stripped for the same reason `resolve` is
* `perform`, `revert`, `reconcile`, `cost`, `progress` and `demoRefuses` are stripped for the same reason `resolve` is
* stripped from an audience and `handler` from a slash command: this is the
* object that leaves the process, and the browser's whole relationship with an
* action is naming one by id. §F's "a module registers actions server-side and
* adds no routes for them" is only true if the functions never ride out.
*/
const allEventActions = () =>
[...eventActions.values()].map(({ perform, revert, reconcile, cost, progress, ...rest }) => rest)
[...eventActions.values()].map(({ perform, revert, reconcile, cost, progress, demoRefuses, ...rest }) => rest)
/** One declaration, callables included. The runner's lookup (Phase 2). */
const eventAction = (id) => eventActions.get(id) || null
@@ -682,6 +713,55 @@ function checkTeamProviderShape(entry) {
return out
}
// A report target (API 1.21.0, USER_SAFETY.md §4.1). The copy is explicit, like
// the team provider's: what core calls is what is named here.
//
// snapshot({ targetId, viewer }) → { author, text, at, where } | null
// What the item is, for THIS viewer. `null` when it is gone or the viewer
// may not see it — core answers 404 either way, so a report, a block or a
// snapshot can never confirm an item the viewer could not have seen.
// `author` is { name, userId|null, identity: { kind, id }|null }.
// hide({ targetId, actor }) / unhide({ targetId, actor })
// Staff's hide for everyone (S3, S10), called from the Reports queue.
const REPORT_TARGET_ID = /^[a-z][a-z0-9-]*\.[a-z0-9][a-z0-9_.-]{0,22}$/
function checkReportTargetShape(entry) {
const t = entry || {}
if (typeof t.id !== 'string' || !REPORT_TARGET_ID.test(t.id) || t.id.length > 32) {
throw new Error(`registerReportTargets: bad id "${t.id}"`)
}
if (typeof t.label !== 'string' || !t.label.trim() || t.label.length > 40) {
throw new Error(`registerReportTargets: ${t.id} needs a label of at most 40 characters`)
}
for (const name of ['snapshot', 'hide', 'unhide']) {
if (typeof t[name] !== 'function') {
throw new Error(`registerReportTargets: ${t.id}.${name}() is missing or not a function`)
}
}
return { id: t.id, label: t.label.trim(), snapshot: t.snapshot, hide: t.hide, unhide: t.unhide }
}
// A game identity (API 1.21.0, S12, S14).
//
// ownersOf(externalIds) → { [externalId]: userId }
// The website account each id is linked to, through THIS module's links.
// An id with no account is left out.
const IDENTITY_KIND = /^[a-z][a-z0-9-]{1,31}$/
function checkGameIdentityShape(entry) {
const g = entry || {}
if (typeof g.kind !== 'string' || !IDENTITY_KIND.test(g.kind)) {
throw new Error(`registerGameIdentities: bad kind "${g.kind}"`)
}
if (typeof g.label !== 'string' || !g.label.trim() || g.label.length > 40) {
throw new Error(`registerGameIdentities: ${g.kind} needs a label of at most 40 characters`)
}
if (typeof g.ownersOf !== 'function') {
throw new Error(`registerGameIdentities: ${g.kind}.ownersOf() is missing or not a function`)
}
return { kind: g.kind, label: g.label.trim(), ownersOf: g.ownersOf }
}
// `pageUrlTemplate` is the fifth member and OPTIONAL (TEAMS.md §6.4, phase 6).
//
// **Why a module has to supply this at all.** Teams are a contract primitive with
@@ -1128,7 +1208,7 @@ function checkActionParam(actionId, entry, seen, call = 'registerEventActions')
}
/**
* `registerEventActions([{ id, label, risk, reversible, version, budgetMs, cost, params, perform, revert, reconcile, progress }])`.
* `registerEventActions([{ id, label, risk, reversible, version, budgetMs, cost, params, perform, revert, reconcile, progress, demoRefuses }])`.
*
* A typed verb core may ask a registrant to carry out. Everything decidable from
* the argument alone is decided here, at the call; the collision — is this id
@@ -1211,6 +1291,13 @@ function checkEventActionShape(entry) {
if (a.progress !== undefined && typeof a.progress !== 'function') {
throw new Error(`registerEventActions: ${a.id} progress must be a function`)
}
// 1.19.0, optional: the public demo's save-time refusal (DEMO_MODE.md §3.4,
// DM18). Asked only with DEMO_MODE on, by the definition save path; a
// sentence back refuses the save `403 DEMO_LOCKED`. The module still checks
// at run, because a definition can predate the list it is checked against.
if (a.demoRefuses !== undefined && typeof a.demoRefuses !== 'function') {
throw new Error(`registerEventActions: ${a.id} demoRefuses must be a function of its params`)
}
const version = a.version === undefined ? 1 : a.version
if (!Number.isInteger(version) || version < 1) {
@@ -1244,6 +1331,7 @@ function checkEventActionShape(entry) {
revert: a.revert || null,
reconcile: a.reconcile || null,
progress: a.progress || null,
demoRefuses: a.demoRefuses || null,
}
}
@@ -1780,6 +1868,8 @@ function stage(owner) {
eventLeases: [],
eventOptionSources: [],
eventTemplates: [],
reportTargets: [],
gameIdentities: [],
}
return {
staged,
@@ -1847,6 +1937,16 @@ function stage(owner) {
registerEngagementSeeds(entry) {
staged.engagementSeeds.push(checkEngagementSeeds(owner, entry))
},
// MODULE_API 1.21.0 (USER_SAFETY.md §4.1). Core registers none: its forum
// types are answered by core directly (see `reportTargets` above).
registerReportTargets(entries) {
if (!Array.isArray(entries)) throw new Error('registerReportTargets: expected an array')
for (const e of entries) staged.reportTargets.push(checkReportTargetShape(e))
},
registerGameIdentities(entries) {
if (!Array.isArray(entries)) throw new Error('registerGameIdentities: expected an array')
for (const e of entries) staged.gameIdentities.push(checkGameIdentityShape(e))
},
}
}
@@ -1875,6 +1975,8 @@ function apply({
eventOptionSources: newEventOptionSources = [],
eventTemplates: newEventTemplates = [],
engagementSeeds: newSeeds = [],
reportTargets: newReportTargets = [],
gameIdentities: newGameIdentities = [],
}) {
// ── validate ──
const seenStreams = new Set()
@@ -2000,6 +2102,30 @@ function apply({
seenSources.add(s.id)
}
// Report targets: their own id space, namespaced, no legacy allowlist. Core's
// forum types are unprefixed and are not in this map, so a module can never
// claim one — `team_forum_post` fails the prefix rule before it gets here.
const seenReportTargets = new Set()
for (const t of newReportTargets) {
const held = reportTargets.get(t.id)
if (held) throw new Error(`report target "${t.id}" is already registered by "${held.owner}"`)
if (seenReportTargets.has(t.id)) throw new Error(`report target "${t.id}" registered twice`)
if (!namespaced(owner, t.id, {})) {
throw new Error(`report target "${t.id}" is not namespaced "${owner}."`)
}
seenReportTargets.add(t.id)
}
// Game identities are per owner, so the only collision is a module naming the
// same kind twice.
const seenKinds = new Set()
for (const g of newGameIdentities) {
if (seenKinds.has(g.kind) || gameIdentities.has(`${owner}:${g.kind}`)) {
throw new Error(`game identity "${g.kind}" registered twice by "${owner}"`)
}
seenKinds.add(g.kind)
}
const seenLegs = new Set()
for (const l of newLegs) {
const held = legs.get(l.leg)
@@ -2070,6 +2196,8 @@ function apply({
for (const s of newEventOptionSources) eventOptionSources.set(s.id, { owner, ...s })
for (const t of newEventTemplates) eventTemplates.set(t.id, { owner, ...t })
for (const seeds of newSeeds) engagementSeeds.set(owner, seeds)
for (const t of newReportTargets) reportTargets.set(t.id, { owner, ...t })
for (const g of newGameIdentities) gameIdentities.set(`${owner}:${g.kind}`, { owner, ...g })
}
// ── Core's own registrations ───────────────────────────────────────────────
@@ -2166,6 +2294,8 @@ function _reset() {
eventOptionSources.clear()
eventTemplates.clear()
engagementSeeds.clear()
reportTargets.clear()
gameIdentities.clear()
coreRegistered = false
}
@@ -2185,6 +2315,9 @@ module.exports = {
dispatchPostHook,
registeredTeamProvider,
hasTeamProvider,
reportTarget,
allReportTargets,
gameIdentity,
slashCommandDefinitions,
slashCommand,
allTriggers,

View File

@@ -9,6 +9,61 @@
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
// has nothing to say about a website module) and from any module's own version.
// 1.21.0 — report, block and hide reach a module's content
// (docs/website/USER_SAFETY.md §4.1, S11-S14). Four additions, each optional:
// • `api.registerReportTargets([{ id, label, snapshot, hide, unhide }])` — a
// type of the module's content a member can report, hide for themself and
// block the author of, and that staff can hide for everyone from the
// Reports queue. `snapshot({ targetId, viewer })` answers what the item is
// FOR THAT VIEWER, or null when it is gone or they may not see it;
// • `api.registerGameIdentities([{ kind, label, ownersOf }])` — the game
// identity those authors are known by (Rust: a Steam id), and which website
// account each is linked to. A block lands on the account when there is one
// (S12), and a block on an unlinked id becomes an account block once it is
// linked (S14);
// • `ctx.safety.forViewer(userId)` — one reader's blocks and hides, so the
// module filters its own per-viewer feeds on the server (S11);
// • `ctx.safety.staffAmong(userIds)` — which accounts are staff, for marking
// the lines a reader cannot block (S8, S13).
// Additions only, so minor. Module-uo declares `^1.10.0` and registers none.
// Module-Rust's `rust.chat` is the first user.
//
// 1.20.0 — a module hears that an account is about to be deleted
// (docs/website/ACCOUNT_DELETION.md §4.1, AD10, AD11). One addition, optional:
// • `api.onUserDeleting(async ({ userId, deletedBy }) => {})`, where
// `deletedBy` is 'self' | 'staff'. Called BEFORE the users row goes, while
// the module can still read the person's links, so it unlinks each game link
// through its own unlink and cleans up anything it keeps without a foreign
// key. It cannot veto: a throw, or running past core's ten seconds, is logged
// and the deletion goes ahead. A module that could not finish keeps its own
// record of what is left and retries.
// Additions only, so minor. Module-uo declares `^1.10.0` and registers none
// until its own PR: with no hook, a deletion is core's cascade alone, exactly as
// the admin route's delete was. USER_SAFETY.md's `reportTargets` moves to 1.21.0.
//
// 1.19.0 — the public demo refuses a step at SAVE (docs/website/DEMO_MODE.md
// §3.4, DM18). An event action may declare `demoRefuses(params)`, returning a
// sentence or null. With DEMO_MODE on, core asks it for every step when a
// definition is created, saved or published, and a sentence refuses the whole
// write `403 DEMO_LOCKED` with that sentence as `message`; a hook that throws
// refuses too. Off the demo it is validated and never called. One optional
// action member, so minor. Module-uo declares `^1.10.0` and declares none.
// Module-Rust's console-command deny list is the first user.
//
// 1.18.0 — the public demo (docs/website/DEMO_MODE.md §3.2, §3.4, §7.3; website#222).
// Three additions, each optional:
// • `demoLocks` in module.json — `[{ method, path }]`, writes only, each path
// under one of the module's own mounts. With DEMO_MODE on, core answers a
// matching request `403 DEMO_LOCKED` before the module's handler runs; with
// it off the list is validated and otherwise unread;
// • `ctx.demoMode` — true on the demo, for the locks a route cannot express,
// which the module enforces in its own handlers;
// • `api.onDemoSeed(fn)` — `npm run seed:demo` hands the module its part of
// the seed fixture (DM15), so it writes its own rows.
// Additions only, so minor. Module-uo declares `^1.10.0` and uses none of them.
// A module that DECLARES `demoLocks` needs `^1.18.0`, because older cores refuse
// a manifest key they do not know.
//
// 1.17.0 — a module may take part in linking an external sign-in identity
// (docs/website/STEAM_SIGNIN.md §4; Rust PLAN_REDESIGNS §15, D392 and D396).
// Three additions, each optional:
@@ -231,6 +286,6 @@
// an admin action a module performs belongs in core's one audit log, the
// extension slot needs the user its prefix names, and §2.7 forbids a module
// reading core's `APP_BASE_URL` for itself. Additions only, so minor.
const MODULE_API_VERSION = '1.17.0'
const MODULE_API_VERSION = '1.21.0'
module.exports = { MODULE_API_VERSION }

View File

@@ -7,6 +7,8 @@ const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model')
const trustedDevices = require('../../../model/trustedDevices/trustedDevices.model')
const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model')
const accountDeletion = require('../../../auth/accountDeletion')
const { refuse: demoRefuse } = require('../../../demo/locks')
const registries = require('../../../modules/registries')
const announceJobs = require('../../../model/announceJobs/announceJobs.model')
const emailConfig = require('../../../model/emailConfig/emailConfig.model')
@@ -1028,19 +1030,21 @@ async function updateUser(req, res) {
}
}
// The same procedure a member's own deletion runs (auth/accountDeletion.js):
// modules unlink their game links first, a staff name is reserved, and the
// account's owner is mailed. The refusals keep this route's 400s.
async function deleteUser(req, res) {
const id = Number(req.params.id)
try {
if (id === req.user.id) {
return res.status(400).json({ message: 'You cannot delete your own account' })
}
const target = await users.getById(id)
if (!target) return res.status(404).json({ message: 'Not found' })
if (target.role === 'admin' && (await users.countAdmins()) <= 1) {
return res.status(400).json({ message: 'Cannot delete the last admin' })
const result = await accountDeletion.deleteAccount(id, { by: 'staff', req })
if (!result.ok) {
if (result.reason === 'not_found') return res.status(404).json({ message: 'Not found' })
if (result.reason === 'last_admin') return res.status(400).json({ message: 'Cannot delete the last admin' })
if (result.reason === 'demo') return demoRefuse(res)
}
await users.remove(id)
await activity.log({ req, action: 'user.delete', detail: { id } })
return res.json({ id })
} catch (err) {
log.error('deleteUser', err)

View File

@@ -42,6 +42,16 @@ const asId = (raw) => {
return Number.isInteger(n) && n > 0 ? n : null
}
/**
* A refused save's body: the errors, plus `code` and `message` when the model
* set them. The demo's step refusal does (DM18), so the client's one
* `DEMO_LOCKED` toast fires as it does for a route lock.
*/
const refusal = (result) => ({
errors: result.errors,
...(result.code ? { code: result.code, message: result.message } : {}),
})
/**
* The shape a definition takes on the wire.
*
@@ -413,7 +423,7 @@ exports.listVersions = async (req, res) => {
/** POST /api/v1/admin/events */
exports.create = async (req, res) => {
const result = await definitions.create(req.body, req.user.id, { role: req.user.role })
if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
if (!result.ok) return res.status(result.status || 400).json(refusal(result))
await activity.log({
req,
action: 'event.definition.created',
@@ -427,7 +437,7 @@ exports.update = async (req, res) => {
const id = asId(req.params.id)
if (!id) return res.status(400).json({ error: 'bad event id' })
const result = await definitions.save(id, req.body, req.user.id, { role: req.user.role })
if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
if (!result.ok) return res.status(result.status || 400).json(refusal(result))
await activity.log({
req,
action: 'event.definition.updated',
@@ -441,7 +451,7 @@ exports.publish = async (req, res) => {
const id = asId(req.params.id)
if (!id) return res.status(400).json({ error: 'bad event id' })
const result = await definitions.publish(id, req.user.id)
if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
if (!result.ok) return res.status(result.status || 400).json(refusal(result))
await activity.log({
req,
action: 'event.definition.published',

View File

@@ -318,7 +318,7 @@ async function getContentReports(req, res) {
}
const teamId = Number(req.query.teamId) || undefined
return res.json({
reports: await contentReports.queue({ status, teamId, limit, offset }),
reports: await contentReports.queue({ status, teamId, limit, offset, viewer: req.user }),
openCount: await contentReports.openCount(),
})
} catch (err) {
@@ -358,6 +358,34 @@ async function handleContentReport(req, res) {
}
}
/**
* Hide the reported item for everyone, and action the report (USER_SAFETY.md
* §4.1). The item goes through its own hide — the forum's moderation, or the
* module's — so this is a staff moderation of the content and is audited as one.
*/
async function hideReportedItem(req, res) {
try {
const result = await contentReports.hideForEveryone({
id: Number(req.params.id),
actor: req.user,
note: req.body.note,
})
if (!result.ok) return res.status(result.status || 400).json({ message: result.error })
const r = result.report
await activity.log({
req,
action: 'moderation.report.hide',
detail: `${req.user.username} (#${req.user.id}) hid ${r.targetType} #${r.targetId} for everyone `
+ `from report #${req.params.id}${req.body.note ? `: "${req.body.note}"` : ''}`,
})
return res.json(r)
} catch (err) {
log.error('hideReportedItem failed', { error: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = {
getSummary,
getRecent,
@@ -376,4 +404,5 @@ module.exports = {
getUserAppeals,
getContentReports,
handleContentReport,
hideReportedItem,
}

View File

@@ -202,5 +202,22 @@ moderationRouter.post(
validate,
moderation.handleContentReport,
)
moderationRouter.post(
'/reports/:id/hide',
// #swagger.tags = ['Admin · Moderation']
// #swagger.summary = 'Hide the reported item for everyone, and action the report'
// #swagger.description = 'USER_SAFETY.md §4.1 (MODULE_API 1.21.0). The one queue action that changes the content. A forum thread or post is hidden through the forum’s own moderation (a staff row in its ledger); a game module’s item (e.g. a `rust.chat` line) through the module’s own hide. Staff still see the item where it lives, marked hidden by staff, and undo it there. The report becomes `actioned` with `actionedWith: "hide"`, and activity_log records it. An upload cannot be hidden from here: hide the post it is in.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Report id.' }
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: 'object', properties: { note: { type: 'string', maxLength: 500 } } } } } } */
/* #swagger.responses[200] = { description: 'The actioned report', content: { "application/json": { schema: { $ref: "#/components/schemas/ContentReport" } } } } */
/* #swagger.responses[400] = { description: 'The item cannot be hidden from here (an upload)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'No such report, or its item is gone', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[503] = { description: 'The game module that owns the item did not answer', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }),
body('note').optional({ values: 'falsy' }).isString().trim().isLength({ max: 500 }),
validate,
moderation.hideReportedItem,
)
module.exports = moderationRouter

View File

@@ -18,7 +18,9 @@ const trustedDevices = require('../../../model/trustedDevices/trustedDevices.mod
const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model')
const sessionService = require('../../../auth/session.service')
const { establishTrust } = require('./trustDevice.helper')
const { setAuthCookie, setTrustCookie, clearTrustCookie } = require('../../../auth/token')
const { setAuthCookie, setTrustCookie, clearTrustCookie, clearAuthCookie } = require('../../../auth/token')
const accountDeletion = require('../../../auth/accountDeletion')
const { refuse: demoRefuse } = require('../../../demo/locks')
const usernamePolicy = require('../../../auth/usernamePolicy')
const loginProtection = require('../../../middleware/loginProtection')
const botScore = require('../../../middleware/botScore')
@@ -538,7 +540,88 @@ async function generateRecoveryCodes(req, res) {
}
}
// ── Deleting your own account (ACCOUNT_DELETION.md §4.1) ───────────────────
//
// DELETE /account. The proof is the password, always (AD7): signing in with
// Google, Discord or Steam is a convenience on an account that already has its
// own password. A two-factor code or a recovery code is asked for too when 2FA is
// on, and the typed username guards against a mis-tap on a phone.
//
// **A wrong password or code is 400, not 401** (AD12). Every self-service
// password check here answers 400, and the app's TokenAuthenticator reads a 401
// on a signed-in call as an expired session: it would spend the refresh token and
// replay the DELETE, making two password attempts out of one typo.
//
// Each refusal carries a `code` the screens switch on; the words are for a
// client that does not know the code.
async function deleteAccount(req, res) {
try {
const raw = await users.getRawById(req.user.id)
if (!raw) return res.status(401).json({ message: 'Unauthorized' })
const typed = String(req.body.username || '').trim()
if (typed.toLowerCase() !== String(raw.username).toLowerCase()) {
return res.status(400).json({ code: 'username_mismatch', message: 'Type your username exactly as it is shown.' })
}
if (!raw.password_hash) {
return res.status(409).json({
code: 'password_required',
message: 'This account has no password yet. Set one above, then delete the account.',
})
}
const passwordOk = await users.validatePassword(raw, req.body.password || '')
if (!passwordOk) {
// Credential-guessing on an open session, like a wrong current password.
loginProtection.recordFailure(req.ip)
botScore.recordLoginFailure(req.ip)
log.warn('deleteAccount wrong password', { id: raw.id, ip: req.ip })
return res.status(400).json({ code: 'wrong_password', message: 'Your password is incorrect.' })
}
if (raw.totp_enabled) {
const code = String(req.body.code || '').trim()
const recovery = String(req.body.recoveryCode || '').trim()
if (!code && !recovery) {
return res.status(400).json({ code: 'code_required', message: 'Enter a code from your authenticator app, or a recovery code.' })
}
// A recovery code is spent here even though the account is about to go:
// the deletion can still be refused below, and a code that worked once must
// not work twice.
const passed = code ? totp.verifyCode(raw.totp_secret, code) : await recoveryCodes.consumeForUser(raw.id, recovery)
if (!passed) {
loginProtection.recordFailure(req.ip)
botScore.recordLoginFailure(req.ip)
log.warn('deleteAccount wrong second factor', { id: raw.id, ip: req.ip, recovery: Boolean(!code) })
return res.status(400).json({ code: 'wrong_code', message: 'That code is not valid. Try again.' })
}
}
const result = await accountDeletion.deleteAccount(raw.id, { by: 'self', req })
if (!result.ok) {
if (result.reason === 'last_admin') {
return res.status(409).json({
code: 'last_admin',
message: 'You are the only admin. Make someone else an admin before you delete your account.',
})
}
if (result.reason === 'demo') return demoRefuse(res)
return res.status(401).json({ message: 'Unauthorized' })
}
// The server has already dropped every session, trusted device and push
// registration with the row. These two cookies are this browser's copies.
clearAuthCookie(req, res)
clearTrustCookie(req, res)
return res.json({ deleted: true })
} catch (err) {
log.error('deleteAccount', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = {
deleteAccount,
changeEmail,
resendEmailVerification,
cancelEmailChange,

View File

@@ -21,10 +21,11 @@ const express = require('express')
const { body, param, query } = require('express-validator')
const account = require('./account.controller')
const safetyCtrl = require('./safety.controller')
const { requireAuth } = require('../../../auth/session.middleware')
const noindex = require('../../../middleware/noindex')
const validate = require('../../../middleware/validate')
const { accountChangeLimiter } = require('../../../middleware/rateLimit')
const { accountChangeLimiter, makeLimiter } = require('../../../middleware/rateLimit')
const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection')
const meRouter = express.Router()
@@ -79,6 +80,151 @@ meRouter.patch(
account.changePassword,
)
// Deleting your own account (ACCOUNT_DELETION.md §4.1). Behind the login backoff
// and bot guards as well as the change limiter, because a wrong password here is
// credential-guessing exactly as it is on the recovery-codes route.
meRouter.delete(
'/account',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Delete the current account (self, any role)'
// #swagger.description = 'Deletes the account at once (no grace period). Requires the typed username (case-insensitive), the account password, and — when two-factor sign-in is on — an authenticator code or a recovery code. Before the row goes, every installed game module unlinks the account’s game links through its own unlink (MODULE_API 1.20.0 onUserDeleting); a module that cannot finish never stops the deletion. Every session, trusted device, push registration, linked sign-in and game link goes with the account. What the member wrote stays and other members see it as "[deleted player]"; staff keep the name and the activity log for moderation. A deleted staff account’s username can never be registered again. A wrong password or code is a 400 (not 401), so a native client’s refresh-on-401 never replays it. Refused with 403 DEMO_LOCKED for the demo’s shared logins.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/DeleteAccountRequest" } } } } */
/* #swagger.responses[200] = { description: 'Deleted; this browser’s session and trust cookies are cleared', content: { "application/json": { schema: { type: "object", properties: { deleted: { type: "boolean", example: true } } } } } } */
/* #swagger.responses[400] = { description: 'username_mismatch, wrong_password, code_required or wrong_code', content: { "application/json": { schema: { $ref: "#/components/schemas/DeleteAccountRefusal" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Demo login (DEMO_LOCKED)', content: { "application/json": { schema: { $ref: "#/components/schemas/DemoLocked" } } } } */
/* #swagger.responses[409] = { description: 'password_required (the account has no password yet) or last_admin', content: { "application/json": { schema: { $ref: "#/components/schemas/DeleteAccountRefusal" } } } } */
/* #swagger.responses[429] = { description: 'Too many attempts (rate limited)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
backoffGuard,
slowLogin,
accountChangeLimiter,
body('username').isString().trim().isLength({ min: 1, max: 64 }),
body('password').optional({ values: 'falsy' }).isString().isLength({ max: 128 }),
body('code').optional({ values: 'falsy' }).isString().trim().isLength({ max: 16 }),
body('recoveryCode').optional({ values: 'falsy' }).isString().trim().isLength({ max: 64 }),
validate,
account.deleteAccount,
)
// ── Block and "Hide for me" (USER_SAFETY.md §4.1) ─────────────────────────
//
// Saved on the account, so they apply on the website and in the app on every
// device (S2). A block names an ITEM, never a person: the server reads who wrote
// it, so a member can only block someone whose words they could see, and the
// block lands on the author's account whenever they have one (S12).
const safetyLimiter = makeLimiter({
windowMs: 10 * 60 * 1000,
max: 60,
label: 'user-safety',
message: 'Too many changes. Please slow down.',
})
meRouter.get(
'/blocks',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'List the accounts and players this account has blocked'
// #swagger.description = 'Each block is an account block or, for a game identity no account has linked (a Steam id in a chat feed), a game block. A game block whose identity has since been linked is answered — and stored — as an account block (S14). A block on an account that is now staff does not apply, and says so (S8).'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The blocks, newest first', content: { "application/json": { schema: { type: 'object', properties: { blocks: { type: 'array', items: { $ref: "#/components/schemas/UserBlock" } } } } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
safetyCtrl.listBlocks,
)
meRouter.post(
'/blocks',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Block the author of an item'
// #swagger.description = 'Names the item (a forum thread or post, or a game module’s item such as a `rust.chat` line), never the person. The block lands on the author’s website account; a chat speaker with no linked account is blocked by their game identity. Everything the blocked author writes then disappears from this account’s view, on the website and in the app. The blocked person is not told (S9). Staff cannot be blocked (S8). An item this account cannot see answers 404.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/SafetyTarget" } } } } */
/* #swagger.responses[201] = { description: 'Blocked', content: { "application/json": { schema: { type: 'object', properties: { block: { $ref: "#/components/schemas/UserBlock" } } } } } } */
/* #swagger.responses[400] = { description: 'unknown_target, self or no_author', content: { "application/json": { schema: { $ref: "#/components/schemas/SafetyRefusal" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'staff — a staff member cannot be blocked', content: { "application/json": { schema: { $ref: "#/components/schemas/SafetyRefusal" } } } } */
/* #swagger.responses[404] = { description: 'No such item, or not one this account can see', content: { "application/json": { schema: { $ref: "#/components/schemas/SafetyRefusal" } } } } */
/* #swagger.responses[409] = { description: 'already_blocked', content: { "application/json": { schema: { $ref: "#/components/schemas/SafetyRefusal" } } } } */
/* #swagger.responses[429] = { description: 'Too many changes (rate limited)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[503] = { description: 'module_unavailable — the game module did not answer', content: { "application/json": { schema: { $ref: "#/components/schemas/SafetyRefusal" } } } } */
safetyLimiter,
body('targetType').isString().trim().isLength({ min: 1, max: 32 }),
body('targetId').isInt({ min: 1 }).toInt(),
validate,
safetyCtrl.createBlock,
)
meRouter.delete(
'/blocks/:id',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Undo a block'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' } }
/* #swagger.responses[200] = { description: 'Unblocked', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'Not one of this account’s blocks', content: { "application/json": { schema: { $ref: "#/components/schemas/SafetyRefusal" } } } } */
safetyLimiter,
param('id').isInt({ min: 1 }),
validate,
safetyCtrl.deleteBlock,
)
meRouter.get(
'/hidden',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'List the items this account hid for itself'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The hidden items, newest first', content: { "application/json": { schema: { type: 'object', properties: { hidden: { type: 'array', items: { type: 'object', properties: { targetType: { type: 'string' }, targetId: { type: 'integer' }, createdAt: { type: 'string', format: 'date-time' } } } } } } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
safetyCtrl.listHidden,
)
meRouter.post(
'/hidden',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Hide one item for this account'
// #swagger.description = 'The item disappears from this account’s own view, on the website and in the app. No report is filed and nothing about the item changes. Hiding twice is still hidden.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/SafetyTarget" } } } } */
/* #swagger.responses[200] = { description: 'Hidden', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */
/* #swagger.responses[400] = { description: 'unknown_target', content: { "application/json": { schema: { $ref: "#/components/schemas/SafetyRefusal" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Too many changes (rate limited)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
safetyLimiter,
body('targetType').isString().trim().isLength({ min: 1, max: 32 }),
body('targetId').isInt({ min: 1 }).toInt(),
validate,
safetyCtrl.createHidden,
)
meRouter.delete(
'/hidden',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Show every hidden item again'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Cleared', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, cleared: { type: 'integer' } } } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
safetyLimiter,
safetyCtrl.clearHidden,
)
meRouter.delete(
'/hidden/:targetType/:targetId',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Show one hidden item again'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['targetType'] = { in: 'path', required: true, schema: { type: 'string' }, example: 'rust.chat' }
// #swagger.parameters['targetId'] = { in: 'path', required: true, schema: { type: 'integer' } }
/* #swagger.responses[200] = { description: 'Shown again', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */
/* #swagger.responses[400] = { description: 'unknown_target', content: { "application/json": { schema: { $ref: "#/components/schemas/SafetyRefusal" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
safetyLimiter,
param('targetType').isString().isLength({ min: 1, max: 32 }),
param('targetId').isInt({ min: 1 }),
validate,
safetyCtrl.deleteHidden,
)
// Email address (engagement Phase 1b). The change is STAGED and only a tokened
// link installs it, so these three routes never alter the address that is
// currently receiving mail. The confirm half is public and lives at

View File

@@ -0,0 +1,85 @@
// /auth/me/blocks and /auth/me/hidden — a member's own blocks and hides
// (USER_SAFETY.md §4.1). Self-scoped on req.user.id; requireAuth is the group's.
const safety = require('../../../model/safety/userSafety.model')
const log = require('../../../utils/logger')('safety')
const fail = (res, err, what) => {
if (err && err.status === 503) {
return res.status(503).json({ code: 'module_unavailable', message: 'The game module did not answer. Try again in a moment.' })
}
log.error(`${what} failed`, { message: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
const refused = (res, result) => res.status(result.status || 400).json({ code: result.code, message: result.error })
async function listBlocks(req, res) {
try {
return res.json({ blocks: await safety.listBlocks(req.user) })
} catch (err) {
return fail(res, err, 'list blocks')
}
}
async function createBlock(req, res) {
try {
const result = await safety.blockAuthorOf({
actor: req.user,
targetType: req.body.targetType,
targetId: req.body.targetId,
})
if (!result.ok) return refused(res, result)
return res.status(201).json({ block: result.block })
} catch (err) {
return fail(res, err, 'create block')
}
}
async function deleteBlock(req, res) {
try {
const result = await safety.unblock(req.user, Number(req.params.id))
if (!result.ok) return refused(res, result)
return res.json({ ok: true })
} catch (err) {
return fail(res, err, 'delete block')
}
}
async function listHidden(req, res) {
try {
return res.json({ hidden: await safety.listHidden(req.user) })
} catch (err) {
return fail(res, err, 'list hidden')
}
}
async function createHidden(req, res) {
try {
const result = await safety.hide(req.user, req.body.targetType, req.body.targetId)
if (!result.ok) return refused(res, result)
return res.json({ ok: true })
} catch (err) {
return fail(res, err, 'hide item')
}
}
async function deleteHidden(req, res) {
try {
const result = await safety.unhide(req.user, req.params.targetType, Number(req.params.targetId))
if (!result.ok) return refused(res, result)
return res.json({ ok: true })
} catch (err) {
return fail(res, err, 'unhide item')
}
}
async function clearHidden(req, res) {
try {
return res.json({ ok: true, cleared: await safety.clearHidden(req.user) })
} catch (err) {
return fail(res, err, 'clear hidden')
}
}
module.exports = { listBlocks, createBlock, deleteBlock, listHidden, createHidden, deleteHidden, clearHidden }

View File

@@ -28,6 +28,7 @@ const appealsRouter = require('./appeals.router')
const teamsRouter = require('./teams.router')
const teamForumRouter = require('./teamForum.router')
const eventsRouter = require('./events.router')
const reportsRouter = require('./reports.router')
const playerRouter = express.Router()
@@ -43,6 +44,9 @@ playerRouter.use('/appeals', appealsRouter)
// This account's own event participation (Phase 14a). Self-scoped on
// req.user.id, like everything else in this group.
playerRouter.use('/events', eventsRouter)
// Report anything reportable (USER_SAFETY.md §4.1): forum items and a game
// module's items through one route.
playerRouter.use('/reports', reportsRouter)
playerRouter.use('/teams', teamsRouter)
// Same prefix, second router. The forum and the leader-exercised grant flow are a
// different capability from "the caller's own Teams", and splitting them keeps

View File

@@ -0,0 +1,71 @@
// /player/reports — file a report about any reportable item (USER_SAFETY.md §4.1).
//
// One route for every target: core's forum types and every module's registered
// types (`rust.chat`, …). The forum's own `/teams/:slug/forum/report` keeps
// working and goes through the same model.
//
// A signed-in account is required (S6), which the player group's gate gives:
// a queue anybody with `curl` could fill is a queue staff stop reading.
const express = require('express')
const { body } = require('express-validator')
const reports = require('../../../model/reports/contentReports.model')
const validate = require('../../../middleware/validate')
const { makeLimiter } = require('../../../middleware/rateLimit')
const log = require('../../../utils/logger')('reports')
const reportsRouter = express.Router()
// The forum report route's cap, for the forum report route's reason (TEAMS.md
// §5.6's third rule): a report costs the reporter nothing and costs a staffer
// attention.
const reportLimiter = makeLimiter({
windowMs: 60 * 60 * 1000,
max: 10,
label: 'content-report',
message: 'Too many reports. Please give staff a chance to look at the ones you have raised.',
})
async function createReport(req, res) {
try {
const result = await reports.file({
actor: req.user,
targetType: req.body.targetType,
targetId: req.body.targetId,
reason: req.body.reason,
detail: req.body.detail,
})
if (!result.ok) return res.status(result.status || 400).json({ message: result.error })
return res.json({ ok: true, reportId: result.reportId })
} catch (err) {
if (err.status === 503) return res.status(503).json({ message: 'The game module did not answer. Try again in a moment.' })
log.error('file report failed', { message: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
}
reportsRouter.post(
'/',
// #swagger.tags = ['Player · Reports']
// #swagger.summary = 'Report an item to site staff'
// #swagger.description = 'Files a report about a forum thread, post or upload, or a game module’s item (e.g. `rust.chat`, a Rust chat line; MODULE_API 1.21.0). The reporter must be able to see the item: one they cannot see answers 404, the same as one that does not exist. Filing changes nothing about the item; it opens a row in the staff Reports queue with a snapshot of what the item said, which outlives the item. Reports reach site staff and nobody else.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ReportRequest" } } } } */
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Raised', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, reportId: { type: 'integer' } } } } } } */
/* #swagger.responses[400] = { description: 'Unknown target type or reason', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'No such item, or not one this account can see', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'You already have an open report on this', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Too many reports (rate limited)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[503] = { description: 'The game module that owns the item did not answer', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
reportLimiter,
body('targetType').isString().trim().isLength({ min: 1, max: 32 }),
body('targetId').isInt({ min: 1 }).toInt(),
body('reason').isIn(reports.REASONS),
body('detail').optional().isString().trim().isLength({ max: 500 }),
validate,
createReport,
)
module.exports = reportsRouter

View File

@@ -25,6 +25,7 @@ const forum = require('../../../model/teams/teamForum.model')
const forumSettings = require('../../../model/teams/teamForumSettings.model')
const uploads = require('../../../model/teams/teamForumUploads.model')
const reports = require('../../../model/reports/contentReports.model')
const safety = require('../../../model/safety/userSafety.model')
const activity = require('../../../model/activity/activity.model')
const teamNotify = require('../../../utils/teamNotify')
@@ -82,6 +83,9 @@ async function resolveForum(req) {
async function viewerFor(ctx, user) {
return {
userId: user.id,
// The account role, for whose name a deleted author shows as (displayAuthor):
// an editor is not forum staff and still sees it (AD6).
role: user.role,
isStaff: ctx.staff,
windowMinutes: await forumSettings.editWindowMinutes(),
}
@@ -122,7 +126,11 @@ async function listThreads(req, res) {
const ctx = await resolveForum(req)
if (!ctx) return res.status(404).json({ message: 'Not found' })
return res.json({
threads: await forum.listThreads(ctx.team.id, { canModerate: ctx.canModerate }),
threads: await forum.listThreads(ctx.team.id, {
canModerate: ctx.canModerate,
viewer: { role: req.user.role },
filter: await safety.forumFilter(req.user.id),
}),
// Two capabilities, not one. Phase 4 had a single `canPost` because there
// was a single kind of thread to post; phase 5 opened discussion to every
// participant while announcements stayed with the leaders, so a client that
@@ -147,6 +155,7 @@ async function getThread(req, res) {
const thread = await forum.getThread(ctx.team.id, Number(req.params.id), {
canModerate: ctx.canModerate,
viewer: await viewerFor(ctx, req.user),
filter: await safety.forumFilter(req.user.id),
})
if (!thread) return res.status(404).json({ message: 'Not found' })
return res.json({ ...thread, canModerate: ctx.canModerate })
@@ -334,7 +343,7 @@ async function listGrants(req, res) {
const authority = await grants.authorityFor(team.id, req.user)
if (!authority.may) return res.status(403).json({ message: 'Not a leader of this Team' })
return res.json({
guests: await grants.forumGuests(team.id),
guests: await grants.forumGuests(team.id, req.user),
cap: await grants.grantCap(),
as: authority.as,
})

View File

@@ -65,7 +65,7 @@ forumRouter.get(
'/:slug/forum/threads',
// #swagger.tags = ['Player · Teams']
// #swagger.summary = 'List a Team forum’s threads'
// #swagger.description = 'Reachable by a member (path 1) OR a granted account (path 3) — a forum guest with no linked game identity reads exactly as a member does. Answers 404 while `teams_forums_enabled` is off, and 404 (never 403) to a caller with no access: in a private room, the contents and the existence are the same secret. Hidden threads are included for a leader or staff and for nobody else.'
// #swagger.description = 'Reachable by a member (path 1) OR a granted account (path 3) — a forum guest with no linked game identity reads exactly as a member does. Answers 404 while `teams_forums_enabled` is off, and 404 (never 403) to a caller with no access: in a private room, the contents and the existence are the same secret. Hidden threads are included for a leader or staff and for nobody else. Threads this caller hid for themself, or opened by an account they block, are left out (USER_SAFETY.md §4.1).'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The thread list, with what this caller may do', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumThreadList" } } } } */
@@ -96,7 +96,7 @@ forumRouter.get(
'/:slug/forum/threads/:id',
// #swagger.tags = ['Player · Teams']
// #swagger.summary = 'Read one thread and its posts'
// #swagger.description = 'Post bodies are rendered under the CURRENT image policy: `disabled` serves the stored HTML unchanged, `remote` and `uploads` add a core-generated <img> beneath each link that names an image. The stored HTML is identical in all three — flipping the policy back to disabled un-renders every image on every existing post with no data migration.'
// #swagger.description = 'Post bodies are rendered under the CURRENT image policy: `disabled` serves the stored HTML unchanged, `remote` and `uploads` add a core-generated <img> beneath each link that names an image. The stored HTML is identical in all three — flipping the policy back to disabled un-renders every image on every existing post with no data migration. Posts this caller hid, or written by an account they block, are left out, and a thread they hid or whose author they block answers 404 (USER_SAFETY.md §4.1). The thread and every post carry `canBlock`: false for the caller’s own, a deleted account’s and a staff member’s (S8).'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The thread id.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]

View File

@@ -40,6 +40,37 @@ async function query(sql, params) {
}
}
/**
* Run `fn(conn)` inside one transaction on one connection: committed when it
* resolves, rolled back when it throws (and the error rethrown).
*
* Every statement that must stand or fall together goes through the `conn` it is
* handed, never through `query()` above, which takes a different connection from
* the pool and so sits outside the transaction.
*
* @template T
* @param {(conn: import('mariadb').PoolConnection) => Promise<T>} fn
* @returns {Promise<T>}
*/
async function transaction(fn) {
const conn = await pool.getConnection()
try {
await conn.beginTransaction()
const out = await fn(conn)
await conn.commit()
return out
} catch (err) {
try {
await conn.rollback()
} catch (rollbackErr) {
log.error('rollback failed', { message: rollbackErr.message })
}
throw err
} finally {
conn.release()
}
}
const SCHEMA_PATH = path.join(__dirname, '..', '..', 'db', 'schema.sql')
/**
@@ -106,4 +137,4 @@ async function close() {
await pool.end()
}
module.exports = { pool, query, ensureSchema, close }
module.exports = { pool, query, transaction, ensureSchema, close }

View File

@@ -0,0 +1,52 @@
// ── Who a username snapshot shows as, to whom (ACCOUNT_DELETION.md AD5, AD6) ──
//
// Several tables keep a username next to a user id, on purpose, so a record still
// names someone after the account is gone (§3 gap 2): forum threads, posts and
// uploads, content reports, the forum's moderation ledger, forum grants, leader
// overrides and Team moderation requests. Deleting an account clears the id
// (ON DELETE SET NULL) and leaves the snapshot alone, and nothing else clears
// those ids. So **"id NULL, snapshot present" means a deleted account.**
//
// What a viewer sees then is a rule about the VIEWER, not a change to the row:
//
// • members, Team leaders included, and signed-out visitors see
// "[deleted player]" for every snapshot of a deleted account (AD5);
// • staff — admin, editor, moderator — see the stored name, marked as a
// deleted account, for moderation (AD6).
//
// One function, so the rule cannot drift between the eight places that render a
// snapshot. A caller with a pair that is legitimately empty (a report nobody has
// handled yet) asks `hasActor` first: an absent actor is not a deleted one.
const DELETED_PLAYER = '[deleted player]'
// Every staff role, which is wider than the forum's own "staff" (admin and
// moderator, who may moderate there). An editor cannot moderate a forum and is
// still staff for the question this file answers: may they see who it was.
const STAFF_ROLES = Object.freeze(['admin', 'editor', 'moderator'])
/** True when this viewer sees the stored name of a deleted account. */
function seesDeletedNames(viewer) {
return Boolean(viewer && STAFF_ROLES.includes(viewer.role))
}
/** True when the pair names anybody at all, deleted or not. */
function hasActor(userId, username) {
return userId != null || (username != null && username !== '')
}
/**
* One snapshot, shown to one viewer.
*
* @param {{userId: number|null|undefined, username: string|null|undefined}} who
* @param {{role?: string}|null|undefined} viewer the reader; null for a visitor
* @returns {{name: string, deleted: boolean}}
*/
function displayAuthor({ userId, username }, viewer) {
const deleted = userId == null
if (!deleted) return { name: username || DELETED_PLAYER, deleted: false }
if (username && seesDeletedNames(viewer)) return { name: username, deleted: true }
return { name: DELETED_PLAYER, deleted: true }
}
module.exports = { DELETED_PLAYER, STAFF_ROLES, seesDeletedNames, hasActor, displayAuthor }

View File

@@ -15,7 +15,7 @@
// it says is an `engagement_templates` row rendered by `engagement/templates.js`
// (§4.6.1), which an operator can edit and which falls back to the shipped seed.
//
// **The failure contracts are the point of this file.** Six call sites, five
// **The failure contracts are the point of this file.** Seven call sites, six
// senders, and each one degrades a specific way when mail is unconfigured. Those
// contracts are unchanged by the transport rewrite and are asserted in
// test/mailer.test.js: sendContactMessage returns a mailto fallback, sendInvite
@@ -344,6 +344,41 @@ async function sendEmailVerification({ to, verifyUrl, username }) {
}
}
/**
* Tell an address its account was deleted (ACCOUNT_DELETION.md §4.1 step 3).
*
* Sent after the row is gone, to the address read before it went, so nothing
* here can reach the account. `by` decides one phrase: a member deleting their
* own account reads "at your request", a staff deletion "by the site's staff".
*
* Returns { sent: false, reason: 'NOT_CONFIGURED' } when mail is unconfigured
* and throws on a send failure, like the senders above; the deletion's caller
* has already finished and only logs either.
*/
async function sendAccountDeleted({ to, username, deletedOn, by }) {
const built = await buildTransport()
if (!built) return { sent: false, reason: 'NOT_CONFIGURED' }
const { transport, config } = built
const byWhom = by === 'staff' ? ' by the site’s staff' : ' at your request'
const { subject, html, text } = await body('auth.account-deleted', { username, deletedOn, byWhom })
try {
await transport.sendMail({
from: fromHeader(config),
to,
replyTo: replyToFor(config),
subject,
text,
html,
})
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Account deletion notice send OK', lastVerifiedAt: new Date() })
return { sent: true }
} catch (err) {
log.error('account deletion notice send failed', err)
await emailConfig.recordStatus({ status: 'error', statusDetail: describeSendError(err, config) })
throw err
}
}
/**
* Send an ALREADY-RENDERED body to one address — the template editor's test send
* (§4.6.2, Phase 5b).
@@ -481,5 +516,6 @@ module.exports = {
sendInvite,
sendPasswordReset,
sendEmailVerification,
sendAccountDeleted,
PERMANENT_CODES,
}

View File

@@ -42,6 +42,7 @@ const templates = require('../engagement/templates')
const emailChannel = require('../engagement/emailChannel')
const mailer = require('./mailer')
const notify = require('./teamNotify')
const { DELETED_PLAYER } = require('./displayAuthor')
const log = require('./logger')('team-digest')
const INTERVAL_MS = Number(process.env.TEAM_DIGEST_INTERVAL_MS) || 24 * 60 * 60 * 1000
@@ -127,7 +128,10 @@ async function sendOne(team, recipient, rule, now) {
periodLabel: `${label}: ${posts.length} new post${posts.length === 1 ? '' : 's'}`,
intro: `Since your last digest, ${posts.length} new post${posts.length === 1 ? '' : 's'} in ${label}:`,
items: posts.map((p) => ({
heading: `${p.title} — ${p.author_username || 'someone'}`,
// A digest goes to members, who see a deleted author as "[deleted player]"
// (ACCOUNT_DELETION.md AD5). The post outlives its author on purpose; the
// name in it does not reach a member's inbox.
heading: `${p.title} — ${p.author_user_id === null ? DELETED_PLAYER : p.author_username || 'someone'}`,
excerpt: notify.excerpt(p.body_html),
url: notify.threadPath(team, p.thread_id),
})),

File diff suppressed because it is too large Load Diff

View File

@@ -44,7 +44,13 @@ const doc = {
'- **Native / mobile clients** use bearer access tokens from ' +
'`POST /api/v1/auth/mobile/login`, refreshed via `/auth/mobile/refresh`.\n\n' +
'Endpoints under `/api/v1/admin/**` require a valid session; some are further ' +
'restricted to the `admin` role (editors are limited to content).',
'restricted to the `admin` role (editors are limited to content).\n\n' +
'### The public demo\n' +
'A deployment run with `DEMO_MODE=true` (the public demo, docs/website/DEMO_MODE.md) ' +
'refuses a fixed set of write routes, and any `multipart/*` request, before the ' +
'route runs. **Any write route may then answer** `403` with the `DemoLocked` body ' +
'(`code: "DEMO_LOCKED"`); clients recognise the refusal by `code`, not by status. ' +
'Reads are never refused this way. With the flag unset no route answers it.',
},
servers: [
{ url: '/', description: 'Same-origin (current host)' },
@@ -63,6 +69,7 @@ const doc = {
{ name: 'Auth · SSO', description: 'OAuth2 / OIDC provider discovery and redirect flow' },
{ name: 'Public', description: 'Unauthenticated site content (settings, posts, wiki, contact)' },
{ name: 'Player · Appeals', description: 'Player-submitted moderation appeals' },
{ name: 'Player · Reports', description: 'Reporting a forum or game item to site staff' },
{ name: 'Settings', description: 'Site-wide settings any authenticated account may read (nav overrides)' },
{ name: 'Admin · Dashboard', description: 'Dashboard summary and site mode' },
{ name: 'Admin · Posts', description: 'News / five-on-friday / newsletter / screenshots + uploads' },
@@ -100,6 +107,18 @@ const doc = {
type: 'object',
properties: { message: { type: 'string', example: 'Not found' } },
},
DemoLocked: {
type: 'object',
description:
'The public demo refused a write (DEMO_MODE.md §3). Only on a deployment with `DEMO_MODE=true`. `keys` is present on `PUT /admin/settings` and names the settings the demo keeps. `errors` is present when an event save or publish is refused because a step\'s action refused its params (MODULE_API 1.19.0 `demoRefuses`, DM18); `message` is then the module\'s sentence.',
properties: {
message: { type: 'string', example: 'This is turned off on the demo site.' },
code: { type: 'string', enum: ['DEMO_LOCKED'] },
keys: { type: 'array', items: { type: 'string' }, example: ['site_mode'] },
errors: { type: 'array', items: { type: 'string' }, example: ['"quit" is turned off on the demo site.'] },
},
required: ['message', 'code'],
},
ValidationError: {
type: 'object',
properties: {
@@ -475,6 +494,76 @@ const doc = {
username: { type: 'string', minLength: 3, maxLength: 32, example: 'newname' },
},
},
DeleteAccountRequest: {
type: 'object',
required: ['username', 'password'],
description: 'The confirm step of deleting your own account (ACCOUNT_DELETION.md §4.1). The password is always required (AD7); `code` or `recoveryCode` only when two-factor sign-in is on.',
properties: {
username: { type: 'string', example: 'wanderer', description: 'The account\'s username, typed to confirm; compared ignoring case.' },
password: { type: 'string', format: 'password' },
code: { type: 'string', example: '123456', description: 'A current authenticator code.' },
recoveryCode: { type: 'string', example: 'ABCD-EFGH', description: 'A single-use recovery code, instead of `code`.' },
},
},
DeleteAccountRefusal: {
type: 'object',
properties: {
code: {
type: 'string',
enum: ['username_mismatch', 'wrong_password', 'code_required', 'wrong_code', 'password_required', 'last_admin'],
},
message: { type: 'string', example: 'Your password is incorrect.' },
},
},
// Report, block and hide (USER_SAFETY.md §4.1, MODULE_API 1.21.0).
SafetyTarget: {
type: 'object',
required: ['targetType', 'targetId'],
description: 'One item: a forum thread or post, or a game module’s item. `team_forum_upload` can be reported but not hidden or blocked from — hiding the post it is in, or blocking its author, is what hides it.',
properties: {
targetType: { type: 'string', example: 'rust.chat', description: '`team_forum_thread`, `team_forum_post`, or a module’s registered type such as `rust.chat`.' },
targetId: { type: 'integer', example: 4410 },
},
},
ReportRequest: {
type: 'object',
required: ['targetType', 'targetId', 'reason'],
properties: {
targetType: { type: 'string', example: 'rust.chat', description: '`team_forum_thread`, `team_forum_post`, `team_forum_upload`, or a module’s registered type such as `rust.chat`.' },
targetId: { type: 'integer', example: 4410 },
reason: { type: 'string', enum: ['spam', 'abuse', 'sexual', 'illegal', 'impersonation', 'other'] },
detail: { type: 'string', maxLength: 500 },
},
},
UserBlock: {
type: 'object',
description: 'One of the signed-in member’s blocks. `account` blocks name a website account; `game` blocks name an unlinked game identity (a Steam id) and become account blocks once it is linked (S14). A block on a staff account does not apply (S8) and says so with `reason: "staff"`.',
properties: {
id: { type: 'integer' },
kind: { type: 'string', enum: ['account', 'game'] },
name: { type: 'string', example: 'rusty' },
identity: {
type: 'object',
nullable: true,
properties: {
module: { type: 'string', example: 'rust' },
kind: { type: 'string', example: 'steam' },
label: { type: 'string', example: 'Steam account' },
id: { type: 'string', example: '76561198000000123' },
},
},
applies: { type: 'boolean' },
reason: { type: 'string', nullable: true, enum: ['staff', null] },
createdAt: { type: 'string', format: 'date-time' },
},
},
SafetyRefusal: {
type: 'object',
properties: {
code: { type: 'string', enum: ['unknown_target', 'not_found', 'self', 'staff', 'no_author', 'already_blocked', 'module_unavailable'] },
message: { type: 'string', example: 'You can’t block a staff member.' },
},
},
ChangePasswordRequest: {
type: 'object',
required: ['newPassword'],
@@ -823,17 +912,35 @@ const doc = {
+ 'because a Team\'s leaders are exactly the people who will not report their own Team.',
properties: {
id: { type: 'integer', example: 41 },
targetType: { type: 'string', example: 'team_forum_post', description: 'team_forum_thread | team_forum_post | team_forum_upload' },
targetType: { type: 'string', example: 'team_forum_post', description: 'team_forum_thread | team_forum_post | team_forum_upload, or a game module\'s registered type such as `rust.chat` (MODULE_API 1.21.0).' },
targetId: { type: 'integer', example: 812 },
teamId: { type: 'integer', nullable: true, example: 7, description: 'Denormalised so the queue can filter by Team.' },
reporter: { type: 'string', example: 'wanderer', description: 'Username snapshot; "[deleted account]" once the account is gone.' },
reporter: { type: 'string', example: 'wanderer', description: 'Username snapshot. Staff see it even after the account is deleted, with `reporterDeleted` true; anyone else would see "[deleted player]" (ACCOUNT_DELETION.md AD5, AD6).' },
reporterDeleted: { type: 'boolean', example: false },
reason: { type: 'string', enum: ['spam', 'abuse', 'sexual', 'illegal', 'impersonation', 'other'], example: 'abuse' },
detail: { type: 'string', nullable: true, maxLength: 500, example: 'Personal attacks in the third paragraph.' },
status: { type: 'string', enum: ['open', 'reviewing', 'actioned', 'dismissed'], example: 'open' },
handledBy: { type: 'string', nullable: true, example: 'moderator1' },
handledByDeleted: { type: 'boolean', example: false },
handledNote: { type: 'string', nullable: true, example: 'Post hidden, author warned.' },
handledAt: { type: 'string', format: 'date-time', nullable: true },
actionedWith: { type: 'string', nullable: true, enum: ['hide', null], description: 'What the queue DID when it actioned the report: `hide` is "Hide for everyone" (USER_SAFETY.md §4.1). NULL when "actioned" only records a decision.' },
snapshot: {
type: 'object',
nullable: true,
description: 'What the item said when it was reported, kept on the report so it outlives the item and an author\'s later edit (USER_SAFETY.md §4.1). NULL on reports filed before MODULE_API 1.21.0. The author\'s name follows the same rule as `reporter`; `authorIdentity` is the game identity of an author with no account (an unlinked Rust chat speaker\'s Steam id).',
properties: {
label: { type: 'string', example: 'Chat line' },
title: { type: 'string', nullable: true },
text: { type: 'string', nullable: true, example: 'gg ez' },
at: { type: 'string', nullable: true },
where: { type: 'object', additionalProperties: true },
module: { type: 'string', nullable: true, example: 'rust' },
author: { type: 'string', nullable: true, example: 'Rusty' },
authorDeleted: { type: 'boolean' },
authorIdentity: { type: 'object', nullable: true, properties: { kind: { type: 'string', example: 'steam' }, id: { type: 'string', example: '76561198000000123' } } },
},
},
createdAt: { type: 'string', format: 'date-time' },
target: {
type: 'object',
@@ -843,7 +950,12 @@ const doc = {
+ '"somebody reported this and by the time we looked it was gone" is a fact a moderator needs. '
+ 'An upload target carries uploader, byte size and the SNIFFED mimetype (§5.6 rule 4).',
properties: {
kind: { type: 'string', enum: ['thread', 'post', 'upload'], example: 'post' },
kind: { type: 'string', enum: ['thread', 'post', 'upload', 'module'], example: 'post', description: '`module` is a game module\'s item, read from the stored snapshot: core cannot read the module\'s rows.' },
module: { type: 'string', nullable: true, example: 'rust' },
label: { type: 'string', nullable: true, example: 'Chat line' },
text: { type: 'string', nullable: true },
at: { type: 'string', nullable: true },
where: { type: 'object', nullable: true, additionalProperties: true },
threadId: { type: 'integer', nullable: true, example: 19 },
threadTitle: { type: 'string', nullable: true, example: 'Raid night' },
postId: { type: 'integer', nullable: true, example: 812 },
@@ -851,7 +963,9 @@ const doc = {
title: { type: 'string', nullable: true },
type: { type: 'string', nullable: true, enum: ['announcement', 'discussion'] },
author: { type: 'string', nullable: true, example: 'someone' },
authorDeleted: { type: 'boolean', nullable: true, description: 'The author\'s account was deleted; `author` is the stored name, shown to staff (ACCOUNT_DELETION.md AD6).' },
uploader: { type: 'string', nullable: true },
uploaderDeleted: { type: 'boolean', nullable: true },
excerpt: { type: 'string', nullable: true, description: 'Plain-text excerpt of the post body, capped at 300 characters.' },
status: { type: 'string', nullable: true, enum: ['visible', 'hidden', 'deleted'] },
filename: { type: 'string', nullable: true },
@@ -1080,6 +1194,25 @@ const doc = {
ntfyUrl: { type: 'string', nullable: true, example: 'https://ntfy.example.com' },
},
},
demo: {
type: 'object',
description:
'Present only on the public demo (`DEMO_MODE=true`, DEMO_MODE.md §4, §6). `resetsAt` is when the next wipe is due, for the banner; `logins` are the published static sign-ins — public by design, not secrets. Both are written by `npm run seed:demo` at every wipe.',
properties: {
resetsAt: { type: 'string', format: 'date-time', nullable: true, example: '2026-10-24T14:00:00.000Z' },
logins: {
type: 'array',
items: {
type: 'object',
properties: {
username: { type: 'string', example: 'demo-admin' },
password: { type: 'string', example: 'demo-admin' },
role: { type: 'string', nullable: true, example: 'admin' },
},
},
},
},
},
},
additionalProperties: true,
},

View File

@@ -0,0 +1,389 @@
// ── Deleting an account (docs/website/ACCOUNT_DELETION.md §4.1) ────────────
//
// The service's procedure, the member's route and its refusals, the admin
// route going through the same procedure, and reserved staff usernames. Every
// model call is stubbed; the DB pool points at a dead port (test/_setup.js).
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, beforeEach, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')
const users = require('../src/model/users/users.model')
const usersDb = require('../src/model/users/users.db')
const activity = require('../src/model/activity/activity.model')
const teamSync = require('../src/model/teams/teamSync.model')
const recoveryCodes = require('../src/model/recoveryCodes/recoveryCodes.model')
const loader = require('../src/modules/loader')
const mailer = require('../src/utils/mailer')
const totp = require('../src/utils/totp')
const loginProtection = require('../src/middleware/loginProtection')
const botScore = require('../src/middleware/botScore')
const accountDeletion = require('../src/auth/accountDeletion')
const account = require('../src/router/v1/auth/account.controller')
const admin = require('../src/router/v1/admin/admin.controller')
const db = require('../src/utils/db')
after(() => db.close())
// ── stubbing ───────────────────────────────────────────────────────────────
const saved = []
function patch(obj, key, value) {
saved.push([obj, key, obj[key]])
obj[key] = value
}
afterEach(() => {
while (saved.length) {
const [obj, key, value] = saved.pop()
obj[key] = value
}
delete process.env.DEMO_MODE
})
// What the procedure did, in order.
let events
let logged
let mailed
const ACCOUNTS = {
7: { id: 7, username: 'Thorne', role: 'player', email: 'thorne@example.com' },
8: { id: 8, username: 'Ravenna', role: 'moderator', email: null },
9: { id: 9, username: 'boss', role: 'admin', email: 'boss@example.com' },
3: { id: 3, username: 'demo-player', role: 'player', email: null },
}
beforeEach(() => {
events = []
logged = []
mailed = []
patch(users, 'getById', async (id) => (ACCOUNTS[id] ? { ...ACCOUNTS[id] } : null))
patch(users, 'countAdmins', async () => 2)
patch(users, 'removeReserving', async (id, name) => {
events.push(['delete', id, name])
return ACCOUNTS[id] ? 1 : 0
})
patch(loader, 'userDeletingHooks', () => [])
patch(teamSync, 'request', (opts) => events.push(['teams', opts.reason]))
patch(activity, 'log', async (entry) => { logged.push(entry) })
patch(mailer, 'sendAccountDeleted', async (args) => {
mailed.push(args)
return { sent: true }
})
})
function mockRes() {
return {
statusCode: 200,
body: null,
cleared: [],
status(c) { this.statusCode = c; return this },
json(b) { this.body = b; return this },
clearCookie(name) { this.cleared.push(name); return this },
set() { return this },
}
}
// ── the service ────────────────────────────────────────────────────────────
test('modules are asked BEFORE the row goes, with who deleted it (AD10)', async () => {
patch(loader, 'userDeletingHooks', () => [
{ id: 'rust', hook: async (payload) => { events.push(['hook', payload]) } },
])
const result = await accountDeletion.deleteAccount(7, { by: 'self', req: { ip: '10.0.0.1' } })
await result.settled
assert.equal(result.ok, true)
assert.deepEqual(events.slice(0, 2), [
['hook', { userId: 7, deletedBy: 'self' }],
['delete', 7, null],
])
})
test('the hook payload is frozen, so a module cannot rewrite what the next one hears', async () => {
let seen
patch(loader, 'userDeletingHooks', () => [{ id: 'rust', hook: async (p) => { seen = p } }])
await (await accountDeletion.deleteAccount(7, { by: 'staff' })).settled
assert.equal(Object.isFrozen(seen), true)
assert.deepEqual(seen, { userId: 7, deletedBy: 'staff' })
})
test('a module hook that throws never stops the deletion (AD11)', async () => {
patch(loader, 'userDeletingHooks', () => [
{ id: 'uo', hook: async () => { throw new Error('sidecar down') } },
])
const result = await accountDeletion.deleteAccount(7, { by: 'self' })
await result.settled
assert.equal(result.ok, true)
assert.deepEqual(result.modules, [{ module: 'uo', ok: false, error: 'sidecar down' }])
assert.deepEqual(events.find((e) => e[0] === 'delete'), ['delete', 7, null])
})
test('a module hook that hangs is cut off at the time limit and the deletion goes ahead', async () => {
patch(loader, 'userDeletingHooks', () => [{ id: 'uo', hook: () => new Promise(() => {}) }])
const result = await accountDeletion.deleteAccount(7, { by: 'self', hookTimeoutMs: 20 })
await result.settled
assert.equal(result.ok, true)
assert.equal(result.modules[0].ok, false)
assert.match(result.modules[0].error, /no answer within 20 ms/)
assert.equal(accountDeletion.HOOK_TIMEOUT_MS, 10_000)
})
test('a staff account’s username is reserved in the deletion’s transaction; a player’s is not (AD9)', async () => {
const staff = await accountDeletion.deleteAccount(8, { by: 'staff', req: { user: { id: 9 } } })
await staff.settled
assert.equal(staff.reserved, true)
const player = await accountDeletion.deleteAccount(7, { by: 'self' })
await player.settled
assert.equal(player.reserved, false)
assert.deepEqual(events.filter((e) => e[0] === 'delete'), [['delete', 8, 'Ravenna'], ['delete', 7, null]])
})
test('the last admin is refused before any module is asked', async () => {
let asked = false
patch(users, 'countAdmins', async () => 1)
patch(loader, 'userDeletingHooks', () => [{ id: 'rust', hook: async () => { asked = true } }])
const result = await accountDeletion.deleteAccount(9, { by: 'self' })
assert.deepEqual(result, { ok: false, reason: 'last_admin' })
assert.equal(asked, false)
assert.equal(events.length, 0)
})
test('the demo’s shared logins are refused by the service too, behind the demo lock', async () => {
process.env.DEMO_MODE = 'true'
assert.deepEqual(await accountDeletion.deleteAccount(3, { by: 'staff' }), { ok: false, reason: 'demo' })
assert.equal(events.length, 0)
// Off the demo the same username is just a name.
delete process.env.DEMO_MODE
assert.equal((await accountDeletion.deleteAccount(3, { by: 'staff' })).ok, true)
})
test('an account that is already gone is not_found and nothing runs after it', async () => {
assert.deepEqual(await accountDeletion.deleteAccount(404, { by: 'self' }), { ok: false, reason: 'not_found' })
// Gone between the read and the delete: somebody else's deletion won.
patch(users, 'removeReserving', async () => 0)
assert.deepEqual(await accountDeletion.deleteAccount(7, { by: 'self' }), { ok: false, reason: 'not_found' })
assert.equal(logged.length, 0)
assert.equal(mailed.length, 0)
})
test('after commit: a Teams reconcile, an activity entry with no username, and the notice', async () => {
const result = await accountDeletion.deleteAccount(7, { by: 'self', req: { ip: '10.0.0.1', user: { id: 7 } } })
await result.settled
assert.deepEqual(events.find((e) => e[0] === 'teams'), ['teams', 'account deleted'])
// The member is gone, so the entry carries no user id — only the IP (AD6).
assert.equal(logged.length, 1)
assert.equal(logged[0].action, 'user.self_delete')
assert.deepEqual(logged[0].detail, { id: 7 })
assert.equal(logged[0].req.user, undefined)
assert.equal(logged[0].req.ip, '10.0.0.1')
assert.ok(!JSON.stringify(logged[0]).includes('Thorne'), 'the username is never logged')
assert.equal(mailed.length, 1)
assert.equal(mailed[0].to, 'thorne@example.com')
assert.equal(mailed[0].username, 'Thorne')
assert.equal(mailed[0].by, 'self')
assert.match(mailed[0].deletedOn, /^\d{1,2} [A-Z][a-z]+ \d{4}$/)
})
test('a staff deletion is logged as user.delete against the acting admin', async () => {
const req = { ip: '10.0.0.2', user: { id: 9 } }
await (await accountDeletion.deleteAccount(8, { by: 'staff', req })).settled
assert.equal(logged[0].action, 'user.delete')
assert.equal(logged[0].req, req)
assert.deepEqual(logged[0].detail, { id: 8, usernameReserved: true })
// No address on file, so no notice.
assert.equal(mailed.length, 0)
})
test('a failing mail or audit step after commit never rejects', async () => {
patch(mailer, 'sendAccountDeleted', async () => { throw new Error('smtp down') })
patch(activity, 'log', async () => { throw new Error('db gone') })
patch(teamSync, 'request', () => { throw new Error('sync broken') })
const result = await accountDeletion.deleteAccount(7, { by: 'self' })
await result.settled // resolves
assert.equal(result.ok, true)
})
test('by must say who deleted it', async () => {
await assert.rejects(() => accountDeletion.deleteAccount(7, {}), /by must be one of/)
})
// ── DELETE /auth/me/account ────────────────────────────────────────────────
const RAW = {
id: 7, username: 'Thorne', role: 'player', password_hash: '$2a$hash', totp_enabled: 0, totp_secret: null,
}
let failures
let deleted
function stubSelf(raw = RAW, { passwordOk = true } = {}) {
failures = 0
deleted = null
patch(users, 'getRawById', async () => (raw ? { ...raw } : null))
patch(users, 'validatePassword', async () => passwordOk)
patch(loginProtection, 'recordFailure', () => { failures += 1 })
patch(botScore, 'recordLoginFailure', () => {})
patch(accountDeletion, 'deleteAccount', async (id, opts) => {
deleted = { id, by: opts.by }
return { ok: true, id, settled: Promise.resolve() }
})
}
const selfReq = (body) => ({ body, user: { id: 7, username: 'Thorne' }, ip: '10.0.0.1', headers: {}, cookies: {} })
test('the typed username must match, ignoring case, before the password is even checked', async () => {
let checked = false
stubSelf()
patch(users, 'validatePassword', async () => { checked = true; return true })
const res = mockRes()
await account.deleteAccount(selfReq({ username: 'Thorn', password: 'pw' }), res)
assert.equal(res.statusCode, 400)
assert.equal(res.body.code, 'username_mismatch')
assert.equal(checked, false)
assert.equal(deleted, null)
const ok = mockRes()
await account.deleteAccount(selfReq({ username: ' thorne ', password: 'pw' }), ok)
assert.equal(ok.statusCode, 200)
assert.deepEqual(ok.body, { deleted: true })
assert.deepEqual(deleted, { id: 7, by: 'self' })
})
test('an account with no password is told to set one first (AD7, 409 password_required)', async () => {
stubSelf({ ...RAW, password_hash: null })
const res = mockRes()
await account.deleteAccount(selfReq({ username: 'Thorne' }), res)
assert.equal(res.statusCode, 409)
assert.equal(res.body.code, 'password_required')
assert.equal(deleted, null)
})
test('a wrong password is a 400, never a 401, and counts as a failed login (AD12)', async () => {
stubSelf(RAW, { passwordOk: false })
const res = mockRes()
await account.deleteAccount(selfReq({ username: 'Thorne', password: 'nope' }), res)
assert.equal(res.statusCode, 400)
assert.equal(res.body.code, 'wrong_password')
assert.equal(failures, 1)
assert.equal(deleted, null)
})
test('with two-factor on, a code is required, and a wrong one is a 400', async () => {
stubSelf({ ...RAW, totp_enabled: 1, totp_secret: 'SECRET' })
patch(totp, 'verifyCode', (secret, code) => secret === 'SECRET' && code === '123456')
const none = mockRes()
await account.deleteAccount(selfReq({ username: 'Thorne', password: 'pw' }), none)
assert.equal(none.statusCode, 400)
assert.equal(none.body.code, 'code_required')
const wrong = mockRes()
await account.deleteAccount(selfReq({ username: 'Thorne', password: 'pw', code: '000000' }), wrong)
assert.equal(wrong.statusCode, 400)
assert.equal(wrong.body.code, 'wrong_code')
assert.equal(failures, 1)
assert.equal(deleted, null)
const right = mockRes()
await account.deleteAccount(selfReq({ username: 'Thorne', password: 'pw', code: '123456' }), right)
assert.equal(right.statusCode, 200)
assert.deepEqual(deleted, { id: 7, by: 'self' })
})
test('a recovery code stands in for the authenticator, and is spent', async () => {
stubSelf({ ...RAW, totp_enabled: 1, totp_secret: 'SECRET' })
const spent = []
patch(recoveryCodes, 'consumeForUser', async (id, code) => { spent.push([id, code]); return code === 'ABCD-EFGH' })
const res = mockRes()
await account.deleteAccount(selfReq({ username: 'Thorne', password: 'pw', recoveryCode: 'ABCD-EFGH' }), res)
assert.equal(res.statusCode, 200)
assert.deepEqual(spent, [[7, 'ABCD-EFGH']])
})
test('success clears this browser’s session and trust cookies', async () => {
stubSelf()
const res = mockRes()
await account.deleteAccount(selfReq({ username: 'Thorne', password: 'pw' }), res)
const { COOKIE_NAME, TRUST_COOKIE_NAME } = require('../src/auth/token')
assert.deepEqual(res.cleared.sort(), [COOKIE_NAME, TRUST_COOKIE_NAME].sort())
})
test('the last admin deleting themself is a 409 with a code the screen can explain', async () => {
stubSelf({ ...RAW, role: 'admin' })
patch(accountDeletion, 'deleteAccount', async () => ({ ok: false, reason: 'last_admin' }))
const res = mockRes()
await account.deleteAccount(selfReq({ username: 'Thorne', password: 'pw' }), res)
assert.equal(res.statusCode, 409)
assert.equal(res.body.code, 'last_admin')
})
test('a demo login reaching the handler gets the demo’s own refusal', async () => {
stubSelf()
patch(accountDeletion, 'deleteAccount', async () => ({ ok: false, reason: 'demo' }))
const res = mockRes()
await account.deleteAccount(selfReq({ username: 'Thorne', password: 'pw' }), res)
assert.equal(res.statusCode, 403)
assert.equal(res.body.code, 'DEMO_LOCKED')
})
// ── DELETE /admin/users/:id ────────────────────────────────────────────────
test('the admin route runs the same procedure, by staff, and keeps its 400s', async () => {
let calledWith = null
patch(accountDeletion, 'deleteAccount', async (id, opts) => {
calledWith = { id, by: opts.by }
return { ok: true, id, settled: Promise.resolve() }
})
const res = mockRes()
await admin.deleteUser({ params: { id: '8' }, user: { id: 9 }, ip: '10.0.0.2' }, res)
assert.equal(res.statusCode, 200)
assert.deepEqual(res.body, { id: 8 })
assert.deepEqual(calledWith, { id: 8, by: 'staff' })
const self = mockRes()
await admin.deleteUser({ params: { id: '9' }, user: { id: 9 } }, self)
assert.equal(self.statusCode, 400)
patch(accountDeletion, 'deleteAccount', async () => ({ ok: false, reason: 'last_admin' }))
const last = mockRes()
await admin.deleteUser({ params: { id: '8' }, user: { id: 9 } }, last)
assert.equal(last.statusCode, 400)
assert.match(last.body.message, /last admin/)
patch(accountDeletion, 'deleteAccount', async () => ({ ok: false, reason: 'not_found' }))
const missing = mockRes()
await admin.deleteUser({ params: { id: '8' }, user: { id: 9 } }, missing)
assert.equal(missing.statusCode, 404)
})
// ── reserved usernames (AD9) ───────────────────────────────────────────────
test('a reserved name reads as a taken one wherever a username is written', async () => {
patch(usersDb, 'isUsernameReserved', async (name) => name.toLowerCase() === 'ravenna')
patch(usersDb, 'insertUser', async () => { throw new Error('must not insert') })
patch(usersDb, 'updateUser', async () => { throw new Error('must not update') })
const created = await users.createUser({ username: 'RAVENNA', password: 'pw12345678', role: 'player' }).catch((e) => e)
assert.equal(created.code, users.USERNAME_RESERVED)
assert.equal(users.isDuplicateUsername(created), true)
assert.equal(users.isDuplicateEmail(created), false)
const renamed = await users.update(7, { username: 'Ravenna' }).catch((e) => e)
assert.equal(users.isDuplicateUsername(renamed), true)
})
test('a self-service rename onto a reserved name answers the taken-name 409', async () => {
patch(usersDb, 'isUsernameReserved', async () => true)
const res = mockRes()
await account.changeUsername({ body: { username: 'Ravenna' }, user: { id: 7, username: 'Thorne' }, headers: {} }, res)
assert.equal(res.statusCode, 409)
assert.equal(res.body.message, 'That username is already taken.')
})
test('a name that is not reserved goes through untouched', async () => {
patch(usersDb, 'isUsernameReserved', async () => false)
let inserted = null
patch(usersDb, 'insertUser', async (row) => { inserted = row; return 70 })
patch(usersDb, 'findById', async () => ({ id: 70, username: 'Newcomer', role: 'player' }))
const user = await users.createUser({ username: 'Newcomer', role: 'player' })
assert.equal(user.id, 70)
assert.equal(inserted.username, 'Newcomer')
})

View File

@@ -15,6 +15,11 @@ const assert = require('node:assert/strict')
const reports = require('../src/model/reports/contentReports.model')
const reportsDb = require('../src/model/reports/contentReports.db')
const forumDb = require('../src/model/teams/teamForum.db')
const forum = require('../src/model/teams/teamForum.model')
const teamsDb = require('../src/model/teams/teams.db')
const access = require('../src/model/teams/teamAccess.model')
const forumSettings = require('../src/model/teams/teamForumSettings.model')
const registries = require('../src/modules/registries')
const saved = []
function patch(mod, name, fn) {
@@ -29,14 +34,24 @@ afterEach(() => {
})
const team = { id: 1, name: 'Ossuary' }
const reporter = { id: 11, username: 'wanderer' }
const reporter = { id: 11, username: 'wanderer', role: 'player' }
// The world a report is filed into: one thread, one post in it, one upload, all
// in team 1.
function stubTargets({ teamId = 1 } = {}) {
patch(forumDb, 'threadById', async (id) => (id === 5 ? { id: 5, team_id: teamId } : null))
patch(forumDb, 'postById', async (id) => (id === 80 ? { id: 80, thread_id: 5 } : null))
patch(forumDb, 'uploadById', async (id) => (id === 3 ? { id: 3, team_id: teamId } : null))
// in team 1, in a forum that is on and that the reporter can read.
function stubTargets({ teamId = 1, allowed = true } = {}) {
patch(forumDb, 'threadById', async (id) => (id === 5
? { id: 5, team_id: teamId, title: 'Raid night', status: 'visible', created_by: 12, created_username: 'someone', created_at: new Date() }
: null))
patch(forumDb, 'postById', async (id) => (id === 80
? { id: 80, thread_id: 5, status: 'visible', author_user_id: 12, author_username: 'someone', body_html: '<p>Rude words</p>', created_at: new Date() }
: null))
patch(forumDb, 'firstPostOf', async () => ({ id: 79, body_html: '<p>Opening words</p>' }))
patch(forumDb, 'uploadById', async (id) => (id === 3
? { id: 3, team_id: teamId, post_id: 80, uploader_user_id: 12, uploader_username: 'someone', filename: 'a1b2.png' }
: null))
patch(forumSettings, 'forumsEnabled', async () => true)
patch(teamsDb, 'findById', async (id) => ({ id, name: id === 1 ? 'Ossuary' : 'Elsewhere' }))
patch(access, 'forumAccess', async () => ({ allowed, viaMembership: allowed, viaGrant: false, isLeader: false }))
}
let written = []
@@ -119,6 +134,85 @@ test('an unknown target type or reason is refused before any lookup', async () =
})).status, 400)
})
// ── 1.21.0: one route for every target, and a snapshot (USER_SAFETY.md §4.1) ──
test('a report stores what the item said, so a later edit cannot change what staff read', async () => {
stubTargets()
stubInsert()
const result = await reports.file({ actor: reporter, targetType: 'team_forum_post', targetId: 80, reason: 'abuse' })
assert.equal(result.ok, true)
const [w] = written
assert.equal(w.teamId, 1, 'the Team comes from the item when no Team route was used')
assert.equal(w.snapshot.text, 'Rude words')
assert.equal(w.snapshot.label, 'Forum post')
assert.deepEqual(w.snapshot.author, { name: 'someone', userId: 12, identity: null })
})
test('a reporter who cannot see the item gets the same 404 as an item that is not there', async () => {
// Otherwise /player/reports would answer "does thread 5 exist" for a private
// Team's forum to anybody signed in.
stubTargets({ allowed: false })
stubInsert()
const result = await reports.file({ actor: reporter, targetType: 'team_forum_thread', targetId: 5, reason: 'abuse' })
assert.equal(result.status, 404)
assert.equal(written.length, 0)
})
test('a module target is reported through the module’s snapshot, for this viewer', async () => {
stubInsert()
const asked = []
patch(registries, 'reportTarget', (type) => (type === 'demo.chat'
? {
owner: 'demo',
id: 'demo.chat',
label: 'Chat line',
snapshot: async (q) => {
asked.push(q)
return q.targetId === 4410
? { author: { name: 'Rusty', userId: null, identity: { kind: 'steam', id: '765611980000001' } }, text: 'gg ez', at: '2026-10-30T10:00:00Z', where: { server: 'main' } }
: null
},
hide: async () => {},
unhide: async () => {},
}
: null))
const ok = await reports.file({ actor: reporter, targetType: 'demo.chat', targetId: 4410, reason: 'abuse' })
assert.equal(ok.ok, true)
assert.deepEqual(asked[0], { targetId: 4410, viewer: { userId: 11, role: 'player' } })
assert.equal(written[0].teamId, null)
assert.equal(written[0].snapshot.module, 'demo')
assert.equal(written[0].snapshot.text, 'gg ez')
assert.deepEqual(written[0].snapshot.author.identity, { kind: 'steam', id: '765611980000001' })
// The module said null: gone, or this viewer may not see it. One answer.
assert.equal((await reports.file({ actor: reporter, targetType: 'demo.chat', targetId: 1, reason: 'abuse' })).status, 404)
})
test('a module snapshot is clipped to known fields before it reaches the queue', async () => {
stubInsert()
patch(registries, 'reportTarget', () => ({
owner: 'demo',
id: 'demo.chat',
label: 'Chat line',
snapshot: async () => ({
author: { name: 'x'.repeat(200), userId: 'nope' },
text: 'y'.repeat(5000),
frame: { huge: 'z'.repeat(10000) },
where: { server: 'main', nested: { no: true } },
}),
hide: async () => {},
unhide: async () => {},
}))
await reports.file({ actor: reporter, targetType: 'demo.chat', targetId: 2, reason: 'spam' })
const snap = written[0].snapshot
assert.equal(snap.author.name.length, 64)
assert.equal(snap.author.userId, null)
assert.equal(snap.text.length, 500)
assert.equal(snap.frame, undefined)
assert.deepEqual(snap.where, { server: 'main' })
})
test('a second open report on the same target answers 409 rather than pretending', async () => {
stubTargets()
stubInsert({ duplicate: true })
@@ -192,7 +286,7 @@ test('an upload report carries uploader, size and the SNIFFED type', async () =>
patch(reportsDb, 'threadsByIds', async () => [])
patch(reportsDb, 'postsByIds', async () => [])
patch(reportsDb, 'uploadsByIds', async () => [{
id: 3, team_id: 1, post_id: 80, uploader_username: 'someone', filename: 'a1b2.png',
id: 3, team_id: 1, post_id: 80, uploader_user_id: 12, uploader_username: 'someone', filename: 'a1b2.png',
mimetype: 'image/png', byte_size: 184320, created_at: new Date(), deleted_at: null,
}])
@@ -201,6 +295,7 @@ test('an upload report carries uploader, size and the SNIFFED type', async () =>
// load-bearing rather than bookkeeping.
assert.equal(item.target.kind, 'upload')
assert.equal(item.target.uploader, 'someone')
assert.equal(item.target.uploaderDeleted, false)
assert.equal(item.target.byteSize, 184320)
assert.equal(item.target.mimetype, 'image/png')
assert.equal(item.target.url, '/uploads/a1b2.png')
@@ -234,17 +329,64 @@ test('a report whose target is already gone still lists, with a null target', as
assert.equal(item.target, null)
})
test('a deleted reporter still shows as somebody, and is marked deleted', async () => {
test('a deleted reporter with no snapshot still shows as somebody, and is marked deleted', async () => {
patch(reportsDb, 'list', async () => [row({ reporter_user_id: null, reporter_username: null })])
patch(reportsDb, 'threadsByIds', async () => [])
patch(reportsDb, 'postsByIds', async () => [])
patch(reportsDb, 'uploadsByIds', async () => [])
const [item] = await reports.queue({})
assert.equal(item.reporter, '[deleted account]')
const [item] = await reports.queue({ viewer: { role: 'moderator' } })
assert.equal(item.reporter, '[deleted player]')
assert.equal(item.reporterDeleted, true)
})
// ACCOUNT_DELETION.md AD6: staff keep the stored name of a deleted account, for
// moderation, and the flag says which it is. Every snapshot in a report row goes
// through the one rule — reporter, handler, and the target's author.
test('staff see a deleted account’s stored names in the queue, each marked deleted', async () => {
patch(reportsDb, 'list', async () => [row({
reporter_user_id: null, reporter_username: 'Ravenna',
status: 'actioned', handled_by: null, handled_username: 'OldMod', handled_at: new Date(),
})])
patch(reportsDb, 'threadsByIds', async () => [])
patch(reportsDb, 'uploadsByIds', async () => [])
patch(reportsDb, 'postsByIds', async () => [{
id: 80, thread_id: 5, author_user_id: null, author_username: 'Thorne', status: 'hidden',
body_html: '<p>x</p>', created_at: new Date(), team_id: 1, thread_title: 'Raid night',
}])
for (const role of ['admin', 'editor', 'moderator']) {
// eslint-disable-next-line no-await-in-loop
const [item] = await reports.queue({ viewer: { role } })
assert.equal(item.reporter, 'Ravenna', role)
assert.equal(item.reporterDeleted, true)
assert.equal(item.handledBy, 'OldMod')
assert.equal(item.handledByDeleted, true)
assert.equal(item.target.author, 'Thorne')
assert.equal(item.target.authorDeleted, true)
}
// Nothing but a staff viewer sees those names, so a caller that forgot to say
// who is reading cannot leak them.
const [blind] = await reports.queue({})
assert.equal(blind.reporter, '[deleted player]')
assert.equal(blind.handledBy, '[deleted player]')
assert.equal(blind.target.author, '[deleted player]')
})
test('an open report has no handler, which is not a deleted handler', async () => {
patch(reportsDb, 'list', async () => [row()])
patch(reportsDb, 'threadsByIds', async () => [])
patch(reportsDb, 'postsByIds', async () => [])
patch(reportsDb, 'uploadsByIds', async () => [])
const [item] = await reports.queue({ viewer: { role: 'admin' } })
assert.equal(item.handledBy, null)
assert.equal(item.handledByDeleted, false)
assert.equal(item.reporter, 'wanderer')
assert.equal(item.reporterDeleted, false)
})
// ── handling ───────────────────────────────────────────────────────────────
test('handling records who decided, when, and why', async () => {
@@ -260,6 +402,74 @@ test('handling records who decided, when, and why', async () => {
}]])
})
// ── Hide for everyone (USER_SAFETY.md §4.1) ────────────────────────────────
test('hide for everyone goes through the forum’s own moderation, as staff, and actions the report', async () => {
const moderated = []
const updates = []
patch(reportsDb, 'byId', async () => row({ target_type: 'team_forum_post', target_id: 80 }))
patch(reportsDb, 'handle', async (id, p) => { updates.push(p); return true })
patch(forumDb, 'postById', async () => ({ id: 80, thread_id: 5 }))
patch(forumDb, 'threadById', async () => ({ id: 5, team_id: 1 }))
patch(forum, 'moderatePost', async (args) => { moderated.push(args); return { ok: true } })
const staff = { id: 2, username: 'root', role: 'admin' }
const result = await reports.hideForEveryone({ id: 41, actor: staff, note: 'Slur.' })
assert.equal(result.ok, true)
assert.equal(moderated[0].action, 'hide')
assert.equal(moderated[0].actorRole, 'staff', 'a staff hide is a staff action in the forum’s ledger')
assert.equal(moderated[0].postId, 80)
assert.deepEqual(updates[0], { status: 'actioned', handledBy: 2, handledUsername: 'root', note: 'Slur.', actionedWith: 'hide' })
})
test('hide for everyone on a module item calls the module’s hide', async () => {
const hidden = []
patch(reportsDb, 'byId', async () => row({ target_type: 'demo.chat', target_id: 4410, team_id: null }))
patch(reportsDb, 'handle', async () => true)
patch(registries, 'reportTarget', () => ({
owner: 'demo', id: 'demo.chat', label: 'Chat line',
snapshot: async () => null,
hide: async (q) => { hidden.push(q) },
unhide: async () => {},
}))
const result = await reports.hideForEveryone({ id: 41, actor: { id: 2, username: 'root', role: 'admin' } })
assert.equal(result.ok, true)
assert.deepEqual(hidden, [{ targetId: 4410, actor: { id: 2, username: 'root', role: 'admin' } }])
})
test('an upload cannot be hidden from the queue, and nothing is recorded', async () => {
patch(reportsDb, 'byId', async () => row({ target_type: 'team_forum_upload', target_id: 3 }))
patch(reportsDb, 'handle', async () => { throw new Error('must not action') })
const result = await reports.hideForEveryone({ id: 41, actor: { id: 2, username: 'root' } })
assert.equal(result.status, 400)
assert.match(result.error, /post it is in/)
})
test('the queue shows a module target from its stored snapshot', async () => {
patch(registries, 'reportTarget', (type) => (type === 'demo.chat'
? { owner: 'demo', id: 'demo.chat', label: 'Chat line' }
: null))
patch(reportsDb, 'list', async () => [row({
target_type: 'demo.chat', target_id: 4410, team_id: null, actioned_with: 'hide',
snapshot: JSON.stringify({
label: 'Chat line', text: 'gg ez', at: 't', where: { server: 'main' }, module: 'demo',
author: { name: 'Rusty', userId: null, identity: { kind: 'steam', id: '765611980000001' } },
}),
})])
patch(reportsDb, 'threadsByIds', async () => [])
patch(reportsDb, 'postsByIds', async () => [])
patch(reportsDb, 'uploadsByIds', async () => [])
const [item] = await reports.queue({ viewer: { role: 'moderator' } })
assert.equal(item.target.kind, 'module')
assert.equal(item.target.text, 'gg ez')
assert.equal(item.actionedWith, 'hide')
// An unlinked chat speaker has no account, which is not a deleted account.
assert.equal(item.snapshot.author, 'Rusty')
assert.equal(item.snapshot.authorDeleted, false)
assert.deepEqual(item.snapshot.authorIdentity, { kind: 'steam', id: '765611980000001' })
})
test('an unknown status is refused, and an absent report is 404', async () => {
patch(reportsDb, 'byId', async () => null)
patch(reportsDb, 'handle', async () => { throw new Error('must not write') })
@@ -286,11 +496,14 @@ test('acceptance: nothing in the report model is reachable by a Team leader', ()
// If a leader-facing queue is ever wanted, it is a design decision for the org
// lead and not a refactor — which is what this test is here to make somebody
// notice.
// `hideForEveryone` (USER_SAFETY.md §4.1) is a staff verb like `handle`, and is
// mounted only under /admin/moderation beside it.
const surface = Object.keys(reports).filter((k) => typeof reports[k] === 'function')
assert.deepEqual(surface.sort(), ['file', 'handle', 'openCount', 'publicReport', 'queue', 'targetTeamId'])
assert.deepEqual(surface.sort(), ['file', 'handle', 'hideForEveryone', 'openCount', 'publicReport', 'queue', 'targetTeamId'])
// `handle` takes the actor and never a Team: there is no seat at this table for
// "the leader of the Team the report is about".
assert.ok(!/isLeader|leaderOf|forumAccess/.test(reports.handle.toString()))
assert.ok(!/isLeader|leaderOf|forumAccess/.test(reports.queue.toString()))
assert.ok(!/isLeader|leaderOf|forumAccess/.test(reports.hideForEveryone.toString()))
})

View File

@@ -0,0 +1,86 @@
// ── The demo lock, wired into the real app (DEMO_MODE.md §3.1) ─────────────
//
// demoLocks.test.js proves each rule against a stand-in app. This file proves
// the wiring: with DEMO_MODE=true, app.js mounts the lock ahead of every
// router, so a locked route is refused before its handler — before even its
// auth check — and an open one still reaches it. One process per flag value,
// since app.js decides at require time: this file is the demo, and every other
// test file that requires app.js is the normal deployment.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
process.env.DEMO_MODE = 'true'
process.env.MODULES_DIR = require('path').join(require('os').tmpdir(), 'rg-demo-app-no-modules')
const { test, after } = require('node:test')
const assert = require('node:assert/strict')
const app = require('../src/app')
const db = require('../src/utils/db')
after(() => db.close())
async function withServer(fn) {
const server = await new Promise((resolve) => {
const s = app.listen(0, '127.0.0.1', () => resolve(s))
})
try {
return await fn(`http://127.0.0.1:${server.address().port}`)
} finally {
server.closeAllConnections()
await new Promise((resolve) => server.close(resolve))
}
}
test('a locked route answers DEMO_LOCKED before its own auth check', async () => {
await withServer(async (base) => {
const res = await fetch(`${base}/api/v1/admin/site-mode`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'maintenance' }),
})
assert.equal(res.status, 403)
assert.equal((await res.json()).code, 'DEMO_LOCKED')
})
})
test('an open route still reaches its handler, which answers as usual', async () => {
await withServer(async (base) => {
const res = await fetch(`${base}/api/v1/admin/posts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'x' }),
})
assert.equal(res.status, 401) // requireAuth, not the lock
})
})
test('an upload is refused before multer or auth sees it', async () => {
await withServer(async (base) => {
const form = new FormData()
form.append('image', new Blob(['x'], { type: 'image/png' }), 'x.png')
const res = await fetch(`${base}/api/v1/admin/uploads`, { method: 'POST', body: form })
assert.equal(res.status, 403)
assert.equal((await res.json()).code, 'DEMO_LOCKED')
})
})
test('a pasted data: image is refused before auth sees it, on an open route', async () => {
await withServer(async (base) => {
const res = await fetch(`${base}/api/v1/admin/posts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'x', body: '<p><img src="data:image/png;base64,iVBORw0KGgo="></p>' }),
})
assert.equal(res.status, 403)
assert.equal((await res.json()).code, 'DEMO_LOCKED')
})
})
test('every response carries noindex, and robots.txt disallows everything', async () => {
await withServer(async (base) => {
const health = await fetch(`${base}/api/health`)
assert.equal(health.headers.get('x-robots-tag'), 'noindex, nofollow')
const robots = await fetch(`${base}/robots.txt`)
assert.equal(await robots.text(), 'User-agent: *\nDisallow: /\n')
})
})

View File

@@ -0,0 +1,37 @@
// ── Without DEMO_MODE, none of the demo is mounted (DEMO_MODE.md §2) ───────
//
// The other half of demoApp.test.js, in its own process because app.js reads
// the flag at require time. A normal shard must answer exactly as it did before
// demo mode existed: its own auth check, no noindex header, its own robots.txt.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
delete process.env.DEMO_MODE
process.env.MODULES_DIR = require('path').join(require('os').tmpdir(), 'rg-demo-app-no-modules')
const { test, after } = require('node:test')
const assert = require('node:assert/strict')
const app = require('../src/app')
const db = require('../src/utils/db')
after(() => db.close())
test('a route the demo locks answers its own 401 on a normal deployment', async () => {
const server = await new Promise((resolve) => {
const s = app.listen(0, '127.0.0.1', () => resolve(s))
})
try {
const base = `http://127.0.0.1:${server.address().port}`
const res = await fetch(`${base}/api/v1/admin/site-mode`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'maintenance' }),
})
assert.equal(res.status, 401)
const health = await fetch(`${base}/api/health`)
assert.equal(health.headers.get('x-robots-tag'), null)
} finally {
server.closeAllConnections()
await new Promise((resolve) => server.close(resolve))
}
})

View File

@@ -0,0 +1,379 @@
// ── The demo lock (docs/website/DEMO_MODE.md §3) ───────────────────────────
//
// Every row of §3.3 that core owns, the matcher's two express defaults, the
// conditions on the shared-login rows, the settings keys that are another door
// into a Tier A lock, the multipart rule, and the module locks. The whole-app
// wiring — mounted with DEMO_MODE on, absent with it off — is demoApp.test.js.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')
const db = require('../src/utils/db')
const users = require('../src/model/users/users.model')
const sessionService = require('../src/auth/session.service')
const { compile, match } = require('../src/demo/paths')
const locks = require('../src/demo/locks')
const { isDemo, isDemoUsername, DEMO_ACCOUNTS } = require('../src/demo/mode')
const manifest = require('../routes.manifest.json')
const { startApp } = require('./_helper')
after(() => db.close())
const originalGetById = users.getById
const originalValidate = sessionService.validateSession
afterEach(() => {
users.getById = originalGetById
sessionService.validateSession = originalValidate
delete process.env.DEMO_MODE
})
// id 1-4 are the shared logins, 9 is somebody an admin created on the demo.
const ACCOUNTS = { 1: 'demo-admin', 2: 'demo-moderator', 3: 'demo-player', 4: 'play-review', 9: 'alice' }
function stubUsers() {
users.getById = async (id) => (ACCOUNTS[id] ? { id, username: ACCOUNTS[id], role: 'admin' } : null)
}
function signedInAs(userId) {
sessionService.validateSession = () => (userId ? { userId, username: ACCOUNTS[userId], role: 'admin' } : null)
}
async function withLock(moduleLocks, fn) {
const app = await startApp((a) => {
a.use(locks.refuseMultipart)
a.use('/api/v1', locks.createLock(moduleLocks))
a.all('*', (req, res) => res.json({ reached: true }))
})
try {
return await fn((method, path, body, headers = {}) =>
fetch(`${app.url}/api/v1${path}`, {
method,
headers: body === undefined ? headers : { 'Content-Type': 'application/json', ...headers },
body: body === undefined ? undefined : JSON.stringify(body),
}))
} finally {
await app.close()
}
}
async function expectLocked(res) {
assert.equal(res.status, 403)
const body = await res.json()
assert.equal(body.code, 'DEMO_LOCKED')
assert.equal(body.message, 'This is turned off on the demo site.')
return body
}
async function expectReached(res) {
assert.equal(res.status, 200)
assert.deepEqual(await res.json(), { reached: true })
}
// ── The flag and the accounts ──────────────────────────────────────────────
test('only DEMO_MODE=true is the demo', () => {
for (const v of [undefined, '', '1', 'yes', 'TRUE', 'false']) {
if (v === undefined) delete process.env.DEMO_MODE
else process.env.DEMO_MODE = v
assert.equal(isDemo(), false, String(v))
}
process.env.DEMO_MODE = 'true'
assert.equal(isDemo(), true)
})
test('the four shared logins are recognised whatever their case', () => {
assert.deepEqual([...DEMO_ACCOUNTS], ['demo-admin', 'demo-moderator', 'demo-player', 'play-review'])
assert.equal(isDemoUsername('Demo-Admin'), true)
assert.equal(isDemoUsername('alice'), false)
assert.equal(isDemoUsername(null), false)
})
// ── The matcher ────────────────────────────────────────────────────────────
test('patterns match the way express routes: any case, optional trailing slash', () => {
const p = compile('/admin/users/:id')
assert.deepEqual(match(p, '/admin/users/7'), { id: '7' })
assert.deepEqual(match(p, '/Admin/USERS/7/'), { id: '7' })
assert.equal(match(p, '/admin/users'), null)
assert.equal(match(p, '/admin/users/7/mfa/reset'), null)
assert.deepEqual(match(compile('/a/:x'), '/a/b%20c'), { x: 'b c' })
})
test('a trailing * is the path and everything under it', () => {
const p = compile('/admin/modules/*')
for (const path of ['/admin/modules', '/admin/modules/', '/admin/modules/rust/enable', '/admin/modules/sources']) {
assert.ok(match(p, path), path)
}
assert.equal(match(p, '/admin/modulesx'), null)
})
test('a pattern that would compile to something else is refused', () => {
for (const bad of ['admin/x', '/admin//x', '/admin/*/x', '/admin/x y', '/admin/:']) {
assert.throws(() => compile(bad), /lock path/, bad)
}
})
// ── Core's list against the real routes ────────────────────────────────────
test("every row of core's list names a route the server actually serves", () => {
// A row that matched nothing would read as a lock and lock nothing. Held
// against routes.manifest.json, which `npm run routes:manifest` keeps exact.
const served = manifest.public
.filter((r) => r.path.startsWith('/api/v1/'))
.map((r) => ({ method: r.method, path: r.path.slice('/api/v1'.length) }))
for (const rule of locks.CORE_LOCKS) {
const hits = served.filter((r) => rule.methods.includes(r.method) && match(rule.compiled, r.path))
assert.ok(hits.length > 0, `${rule.methods.join('/')} ${rule.path} matches no served route`)
}
})
test('every upload route in the manifest is locked by name as well as by the multipart rule', () => {
const uploads = manifest.public.filter((r) => r.method === 'POST' && /upload|brand-asset/.test(r.path))
assert.ok(uploads.length >= 4)
for (const r of uploads) {
const path = r.path.slice('/api/v1'.length)
assert.ok(locks.CORE_LOCKS.some((l) => l.methods.includes('POST') && match(l.compiled, path)), path)
}
})
// ── Tier A, unconditional ──────────────────────────────────────────────────
test('the Tier A rows are refused, and reads of the same paths are not', async () => {
await withLock(undefined, async (req) => {
await expectLocked(await req('PUT', '/admin/site-mode', { mode: 'maintenance' }))
await expectLocked(await req('PUT', '/Admin/Site-Mode/', { mode: 'maintenance' }))
await expectLocked(await req('POST', '/admin/modules/rust/disable'))
await expectLocked(await req('DELETE', '/admin/modules/rust'))
await expectLocked(await req('POST', '/admin/modules/restart'))
await expectLocked(await req('POST', '/admin/auth/providers', { name: 'x' }))
await expectLocked(await req('PUT', '/admin/email/config', {}))
await expectLocked(await req('POST', '/admin/email/test'))
await expectLocked(await req('POST', '/admin/email/disconnect'))
await expectLocked(await req('PUT', '/admin/discord-bot/config', {}))
await expectLocked(await req('POST', '/admin/teams/voice/sync'))
await expectLocked(await req('POST', '/auth/password/forgot', { email: 'a@b.c' }))
await expectLocked(await req('POST', '/auth/password/reset/abc', { password: 'x' }))
await expectLocked(await req('POST', '/auth/register', { username: 'x' }))
await expectLocked(await req('POST', '/public/contact', { message: 'hi' }))
await expectLocked(await req('POST', '/admin/uploads'))
await expectReached(await req('GET', '/admin/site-mode'))
await expectReached(await req('GET', '/admin/modules'))
await expectReached(await req('GET', '/admin/email/config'))
await expectReached(await req('GET', '/auth/password/reset/abc'))
})
})
test('content, the rig and the rest of Tier C stay open', async () => {
await withLock(undefined, async (req) => {
await expectReached(await req('POST', '/admin/posts', { title: 'x' }))
await expectReached(await req('PUT', '/admin/wiki/guides', { title: 'x' }))
await expectReached(await req('POST', '/admin/events', {}))
await expectReached(await req('POST', '/admin/invites', {}))
await expectReached(await req('POST', '/admin/users', { username: 'bob' }))
await expectReached(await req('POST', '/auth/login', { username: 'demo-admin' }))
await expectReached(await req('POST', '/auth/mobile/logout', {}))
})
})
// ── Settings: open, except the keys that reach a Tier A lock ───────────────
test('PUT /admin/settings is open for theme and nav', async () => {
await withLock(undefined, async (req) => {
await expectReached(await req('PUT', '/admin/settings', { theme_visual: { preset: 'fantasy' }, nav_public: {} }))
await expectReached(await req('PUT', '/admin/settings', { player_registration: 'password' }))
})
})
// DM21: the hero editor's autosave and Preview write only the draft; Publish
// writes both, and the live key refuses the whole body.
test('the hero draft is open, publishing the hero is refused', async () => {
await withLock(undefined, async (req) => {
await expectReached(await req('PUT', '/admin/settings', { hero_layout_draft: '{"v":1}' }))
const body = await expectLocked(
await req('PUT', '/admin/settings', { hero_layout: '{"v":1}', hero_layout_draft: '{"v":1}' }),
)
assert.deepEqual(body.keys, ['hero_layout'])
})
})
test('PUT /admin/settings naming a protected key is refused whole, with the keys named', async () => {
await withLock(undefined, async (req) => {
for (const key of ['site_mode', 'site_mode_changed_by', 'module_source_hosts', 'teams_voice_enabled', 'demo_state']) {
const body = await expectLocked(await req('PUT', '/admin/settings', { theme_visual: {}, [key]: 'x' }))
assert.deepEqual(body.keys, [key])
}
})
})
// ── The shared logins ──────────────────────────────────────────────────────
test('an admin cannot change, delete or reset a shared login, but can anybody else', async () => {
stubUsers()
await withLock(undefined, async (req) => {
for (const id of [1, 2, 3, 4]) {
await expectLocked(await req('PUT', `/admin/users/${id}`, { role: 'player' }))
await expectLocked(await req('DELETE', `/admin/users/${id}`))
await expectLocked(await req('DELETE', `/admin/users/${id}/trusted-devices`))
await expectLocked(await req('DELETE', `/admin/users/${id}/trusted-devices/5`))
await expectLocked(await req('POST', `/admin/users/${id}/mfa/reset`))
}
await expectReached(await req('PUT', '/admin/users/9', { role: 'player' }))
await expectReached(await req('DELETE', '/admin/users/9'))
await expectReached(await req('PUT', '/admin/users/404', { role: 'player' }))
await expectReached(await req('PUT', '/admin/users/not-a-number', {}))
})
})
const SELF_ROWS = [
['PATCH', '/auth/me/account/username'],
['PATCH', '/auth/me/account/password'],
['PATCH', '/auth/me/account/email'],
['POST', '/auth/me/account/totp/setup'],
['POST', '/auth/me/account/totp/enable'],
['POST', '/auth/me/account/totp/disable'],
['POST', '/auth/me/account/recovery-codes/generate'],
['DELETE', '/auth/me/account/identities/steam'],
['DELETE', '/auth/me/sessions/12'], // DM17
['DELETE', '/auth/me/account'], // ACCOUNT_DELETION.md §4.3
]
test('signed in as a shared login, the account cannot be changed or other sessions revoked', async () => {
stubUsers()
signedInAs(4)
await withLock(undefined, async (req) => {
for (const [method, path] of SELF_ROWS) await expectLocked(await req(method, path, {}))
// Self-service that cannot lock anyone else out stays open.
await expectReached(await req('PUT', '/auth/me/notifications/channels', {}))
})
})
test('signed in as anyone else, or not at all, the same routes go to their handlers', async () => {
stubUsers()
signedInAs(9)
await withLock(undefined, async (req) => {
for (const [method, path] of SELF_ROWS) await expectReached(await req(method, path, {}))
})
signedInAs(null) // the route's own requireAuth answers 401
await withLock(undefined, async (req) => {
for (const [method, path] of SELF_ROWS) await expectReached(await req(method, path, {}))
})
})
test('a lock that cannot decide refuses', async () => {
users.getById = async () => {
throw new Error('pool down')
}
await withLock(undefined, async (req) => {
await expectLocked(await req('PUT', '/admin/users/9', {}))
})
})
// ── Uploads ────────────────────────────────────────────────────────────────
test('any multipart request is refused before a router sees it, wherever it is sent', async () => {
await withLock(undefined, async (req) => {
const multipart = { 'Content-Type': 'multipart/form-data; boundary=x' }
await expectLocked(await req('POST', '/admin/posts', undefined, multipart))
await expectLocked(await req('POST', '/rust/anything', undefined, { 'Content-Type': 'Multipart/Mixed; boundary=y' }))
await expectLocked(await req('GET', '/public/settings', undefined, multipart))
await expectReached(await req('POST', '/admin/posts', { title: 'json is fine' }))
})
})
// DM25: a data: URI in any string of a write's body is an upload too.
async function withDataUriLock(fn) {
const app = await startApp((a) => {
a.use(locks.refuseDataUri)
a.all('*', (req, res) => res.json({ reached: true }))
})
try {
return await fn((method, path, body) =>
fetch(`${app.url}/api/v1${path}`, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}))
} finally {
await app.close()
}
}
test('a data: URI anywhere in a write body is refused, on any route and any write method', async () => {
await withDataUriLock(async (req) => {
const img = '<p>hi</p><img src="data:image/png;base64,iVBORw0KGgo=">'
await expectLocked(await req('POST', '/admin/posts', { title: 't', body: img }))
await expectLocked(await req('PUT', '/admin/wiki/1', { content: img }))
await expectLocked(await req('PATCH', '/rust/anything', { deep: [{ x: { y: [img] } }] }))
await expectLocked(await req('PUT', '/admin/settings', { hero_layout_draft: JSON.stringify([{ src: 'data:image/webp;base64,UklG' }]) }))
await expectLocked(await req('DELETE', '/player/teams/x/forum/1', { reason: img }))
})
})
test('every data: URI shape a browser would load is refused', async () => {
const shapes = [
'DATA:image/png;base64,AAAA',
'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg"/>',
'data:text/html;base64,PHNjcmlwdD4=',
'data:,hello',
'data:;base64,AAAA',
'data: image/png;base64,AAAA',
'<div style="background:url(data:image/gif;base64,R0lG)">',
'<img src="&#100;ata:image/png;base64,AAAA">',
'<img src="&#x64;&#x61;&#x74;&#x61;&#x3a;image/png;base64,AAAA">',
'<img src="data&colon;image/png;base64,AAAA">',
'<img src="da\nta:image/png;base64,AAAA">',
'<img src="data:im\tage/png;base64,AAAA">',
]
for (const s of shapes) assert.equal(locks.hasDataUri({ body: s }), true, s)
})
test('prose that mentions data, reads, and bodies without strings pass', async () => {
for (const s of ['metadata: none', 'Data: none, yet', 'the data: it is fine', 'see https://example.com/data:x', 'mydata:image/png,x']) {
assert.equal(locks.hasDataUri({ body: s }), false, s)
}
assert.equal(locks.hasDataUri(undefined), false)
assert.equal(locks.hasDataUri({ n: 1, ok: true, list: [null, 2] }), false)
await withDataUriLock(async (req) => {
await expectReached(await req('POST', '/admin/posts', { title: 'json is fine', body: '<p>metadata: none</p>' }))
})
// A read is never refused, whatever it carries.
const app = await startApp((a) => {
a.use(locks.refuseDataUri)
a.all('*', (req, res) => res.json({ reached: true }))
})
try {
await expectReached(await fetch(`${app.url}/api/v1/public/settings?x=data:image/png;base64,AAAA`))
} finally {
await app.close()
}
})
// ── Module locks ───────────────────────────────────────────────────────────
test("a module's declared locks are refused alongside core's", async () => {
const declared = [{ methods: ['PUT'], path: '/admin/rust/servers/:id', compiled: compile('/admin/rust/servers/:id'), source: 'rust' }]
await withLock(() => declared, async (req) => {
await expectLocked(await req('PUT', '/admin/rust/servers/demo', {}))
await expectReached(await req('GET', '/admin/rust/servers/demo'))
await expectReached(await req('DELETE', '/admin/rust/servers/demo'))
await expectReached(await req('POST', '/admin/rust/servers/demo/test'))
})
})
// ── Search engines ─────────────────────────────────────────────────────────
test('noindex and robots keep the demo out of search engines', async () => {
const app = await startApp((a) => {
a.use(locks.noindex)
a.get('/robots.txt', locks.robots)
a.get('/', (req, res) => res.send('ok'))
})
try {
const page = await fetch(`${app.url}/`)
assert.equal(page.headers.get('x-robots-tag'), 'noindex, nofollow')
const robots = await fetch(`${app.url}/robots.txt`)
assert.equal(await robots.text(), 'User-agent: *\nDisallow: /\n')
} finally {
await app.close()
}
})

View File

@@ -0,0 +1,229 @@
// ── The demo's module contract (MODULE_API 1.18.0) and the seed's helpers ──
//
// `demoLocks` in module.json, `ctx.demoMode`, `api.onDemoSeed` (DM15), the
// `demo` block on GET /public/settings, and the pure parts of `seed:demo`.
// Each loader test builds a throwaway modules directory and re-requires the
// loader, the way moduleLoader.test.js does and for the same reason.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const fs = require('fs')
const os = require('os')
const path = require('path')
const { test, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')
const express = require('express')
const db = require('../src/utils/db')
const registries = require('../src/modules/registries')
const settingsDb = require('../src/model/settings/settings.db')
const settings = require('../src/model/settings/settings.model')
const { resolveEnv, nextResetAt, checkFixture, eventActionIds } = require('../db/seedDemo')
after(() => db.close())
afterEach(() => {
delete process.env.DEMO_MODE
})
const emptyTiers = () => ({ public: express.Router(), admin: express.Router(), player: express.Router() })
function loadOne(manifest, server = 'module.exports = (ctx, api) => { const r = ctx.express.Router(); api.registerRoutes({ admin: { "/rust": r } }) }') {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-demo-'))
const dir = path.join(root, 'rust')
fs.mkdirSync(dir)
fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify({
id: 'rust',
name: 'Rust',
version: '1.0.0',
coreApi: '^1.18.0',
server: 'index.js',
mounts: { admin: ['/rust'] },
...manifest,
}))
fs.writeFileSync(path.join(dir, 'index.js'), server)
process.env.MODULES_DIR = root
registries._reset()
delete require.cache[require.resolve('../src/modules/loader')]
// eslint-disable-next-line global-require
const loader = require('../src/modules/loader')
loader.load(emptyTiers())
return loader
}
const stateOf = (loader) => loader.list().find((m) => m.id === 'rust')
// ── demoLocks ──────────────────────────────────────────────────────────────
test('declared demoLocks load and come back compiled', () => {
const loader = loadOne({
demoLocks: [
{ method: 'PUT', path: '/admin/rust/servers/:id' },
{ method: 'DELETE', path: '/admin/rust/servers/:id' },
],
})
assert.equal(stateOf(loader).state, 'registered')
const got = loader.demoLocks()
assert.deepEqual(got.map((l) => [l.methods, l.path, l.source]), [
[['PUT'], '/admin/rust/servers/:id', 'rust'],
[['DELETE'], '/admin/rust/servers/:id', 'rust'],
])
assert.ok(got[0].compiled.re.test('/admin/rust/servers/demo'))
})
test('a module without demoLocks declares none', () => {
assert.deepEqual(loadOne({}).demoLocks(), [])
})
test('a bad demoLocks entry fails the module at load, demo or not', () => {
const cases = [
[{ demoLocks: {} }, /demoLocks must be an array/],
[{ demoLocks: ['PUT /x'] }, /must be an object/],
[{ demoLocks: [{ method: 'GET', path: '/admin/rust/x' }] }, /must be one of POST, PUT, PATCH, DELETE/],
[{ demoLocks: [{ method: 'PUT', path: '/admin/rust/x', why: 'y' }] }, /unknown key "demoLocks.why"/],
[{ demoLocks: [{ method: 'PUT', path: '/admin/rust/*/x' }] }, /\* must be the last segment/],
// Outside the module's own mounts: a module locks its routes and nobody else's.
[{ demoLocks: [{ method: 'PUT', path: '/admin/site-mode' }] }, /outside the module's mounts/],
[{ demoLocks: [{ method: 'PUT', path: '/public/rust/x' }] }, /outside the module's mounts/],
[{ demoLocks: [{ method: 'PUT', path: '/admin/*' }] }, /outside the module's mounts/],
]
for (const [manifest, reason] of cases) {
const loader = loadOne(manifest)
const m = stateOf(loader)
assert.equal(m.state, 'startup_failed', JSON.stringify(manifest))
assert.match(m.reason, reason)
}
})
test('a module declaring ^1.18.0 and an empty demoLocks loads on this core', () => {
const loader = loadOne({ coreApi: '^1.18.0', demoLocks: [] })
assert.equal(stateOf(loader).state, 'registered')
})
// ── ctx.demoMode ───────────────────────────────────────────────────────────
const PROBE = `module.exports = (ctx, api) => {
global.__demoProbe = ctx.demoMode
api.registerRoutes({ admin: { '/rust': ctx.express.Router() } })
}`
test('ctx.demoMode is false on a normal deployment and true on the demo', () => {
loadOne({}, PROBE)
assert.equal(global.__demoProbe, false)
process.env.DEMO_MODE = 'true'
loadOne({}, PROBE)
assert.equal(global.__demoProbe, true)
delete global.__demoProbe
})
// ── onDemoSeed ─────────────────────────────────────────────────────────────
test('onDemoSeed is collected for the seed and handed the fixture and account ids', async () => {
const loader = loadOne({}, `module.exports = (ctx, api) => {
api.registerRoutes({ admin: { '/rust': ctx.express.Router() } })
api.onDemoSeed(async (data, { accounts }) => { global.__demoSeed = { data, accounts } })
}`)
const hooks = loader.demoSeedHooks()
assert.deepEqual(hooks.map((h) => h.id), ['rust'])
await hooks[0].hook({ servers: [] }, { accounts: { 'demo-player': 3 } })
assert.deepEqual(global.__demoSeed, { data: { servers: [] }, accounts: { 'demo-player': 3 } })
delete global.__demoSeed
})
test('onDemoSeed twice is a registration error, like every other hook', () => {
const loader = loadOne({}, `module.exports = (ctx, api) => {
api.registerRoutes({ admin: { '/rust': ctx.express.Router() } })
api.onDemoSeed(() => {})
api.onDemoSeed(() => {})
}`)
assert.equal(stateOf(loader).state, 'startup_failed')
assert.match(stateOf(loader).reason, /onDemoSeed\(\) called twice/)
})
// ── GET /public/settings ───────────────────────────────────────────────────
const originalGetAll = settingsDb.getAll
afterEach(() => {
settingsDb.getAll = originalGetAll
})
test('the demo block is present only on the demo, and read forgivingly', async () => {
const state = {
resetsAt: '2026-10-24T14:00:00.000Z',
logins: [{ username: 'demo-admin', password: 'demo-admin-pass', role: 'admin' }, { username: 7 }],
}
settingsDb.getAll = async () => [{ key: 'demo_state', value: JSON.stringify(state) }]
const normal = await settings.getPublic()
assert.equal('demo' in normal, false)
process.env.DEMO_MODE = 'true'
const demo = await settings.getPublic()
assert.deepEqual(demo.demo, {
resetsAt: '2026-10-24T14:00:00.000Z',
logins: [{ username: 'demo-admin', password: 'demo-admin-pass', role: 'admin' }],
})
settingsDb.getAll = async () => [{ key: 'demo_state', value: '{broken' }]
assert.deepEqual((await settings.getPublic()).demo, { resetsAt: null, logins: [] })
})
// ── seed:demo's pure helpers ───────────────────────────────────────────────
test('resolveEnv replaces $env: strings at any depth and refuses an unset one', () => {
const env = { DEMO_RUST_SIDECAR_TOKEN: 'tok', DEMO_RUST_SIDECAR_URL: 'http://rig:7790' }
assert.deepEqual(
resolveEnv({ a: '$env:DEMO_RUST_SIDECAR_URL', b: [{ t: '$env:DEMO_RUST_SIDECAR_TOKEN' }], c: 'plain $env:X', d: 3 }, env),
{ a: 'http://rig:7790', b: [{ t: 'tok' }], c: 'plain $env:X', d: 3 },
)
assert.throws(() => resolveEnv({ m: { t: '$env:NOPE' } }, env), /fixture\.m\.t: environment variable NOPE is not set/)
assert.throws(() => resolveEnv('$env:EMPTY', { EMPTY: '' }), /EMPTY is not set/)
})
test('nextResetAt is the next two-hour slot on the hour, UTC', () => {
const at = (iso) => nextResetAt(new Date(iso), 2).toISOString()
assert.equal(at('2026-10-24T14:02:10Z'), '2026-10-24T16:00:00.000Z')
assert.equal(at('2026-10-24T13:59:59Z'), '2026-10-24T14:00:00.000Z')
assert.equal(at('2026-10-24T14:00:00Z'), '2026-10-24T16:00:00.000Z')
assert.equal(at('2026-10-24T23:30:00Z'), '2026-10-25T00:00:00.000Z')
assert.equal(nextResetAt(new Date('2026-10-24T14:02:00Z'), 3).toISOString(), '2026-10-24T15:00:00.000Z')
})
const GOOD = {
accounts: {
'demo-admin': { password: 'demo-admin-pass', role: 'admin' },
'demo-moderator': { password: 'demo-moderator-pass', role: 'moderator' },
'demo-player': { password: 'demo-player-pass', role: 'player' },
'play-review': { password: 'play-review-pass', role: 'admin' },
},
}
test('checkFixture accepts the four accounts and refuses anything that would leave the lock wrong', () => {
assert.deepEqual(checkFixture(GOOD), [])
const { 'play-review': _gone, ...three } = GOOD.accounts
assert.match(checkFixture({ accounts: three }).join('\n'), /accounts\.play-review is missing/)
assert.match(checkFixture({ accounts: { ...GOOD.accounts, alice: { password: 'alicealice', role: 'admin' } } }).join('\n'),
/accounts\.alice is not a demo account/)
assert.match(checkFixture({ accounts: { ...GOOD.accounts, 'demo-admin': { password: 'short', role: 'admin' } } }).join('\n'),
/at least 8 characters/)
assert.match(checkFixture({ accounts: { ...GOOD.accounts, 'demo-admin': { password: 'longenough', role: 'root' } } }).join('\n'),
/role must be one of/)
assert.match(checkFixture({ ...GOOD, settings: { demo_state: '{}' } }).join('\n'), /written by the seed itself/)
assert.match(checkFixture({ ...GOOD, content: { posts: {} } }).join('\n'), /content\.posts must be an array/)
assert.match(checkFixture({ ...GOOD, modules: [] }).join('\n'), /modules must be an object/)
})
test('eventActionIds lists each action the seeded events use, once, and the shipped fixture names all three', () => {
const step = (actionId) => ({ actionId, params: {} })
const ev = (...phases) => ({ spec: { phases: phases.map((steps) => ({ steps: steps.map(step) })) } })
assert.deepEqual(eventActionIds([ev(['a.run', 'core.wait'], ['a.run']), ev(['b.pay']), {}, { spec: {} }]),
['a.run', 'core.wait', 'b.pay'])
assert.deepEqual(eventActionIds(), [])
const fixture = require('../../deploy/demo/seed.json') // eslint-disable-line global-require
assert.deepEqual(eventActionIds(fixture.content.events), ['rust.command.run', 'rust.dt.start', 'rust.coins.pay'])
})
test('seed:demo refuses to run off the demo', async () => {
const { run } = require('../db/seedDemo') // eslint-disable-line global-require
await assert.rejects(() => run('anything.json'), /only with DEMO_MODE=true/)
})

View File

@@ -0,0 +1,154 @@
// ── The demo refuses a step at save (MODULE_API 1.19.0, DEMO_MODE.md §3.4) ──
//
// DM18: an event action may declare `demoRefuses(params)`. With DEMO_MODE on,
// the definition save path asks it for every step at create, save and publish,
// and a sentence back refuses the write `403 DEMO_LOCKED`. Off the demo it is
// never called. The definitions table is stubbed on the module object the model
// holds; the pool points at a closed port.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, beforeEach, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')
const db = require('../src/utils/db')
const registries = require('../src/modules/registries')
const definitions = require('../src/model/events/eventDefinitions.model')
const definitionsDb = require('../src/model/events/eventDefinitions.db')
const controller = require('../src/router/v1/admin/events.controller')
after(() => db.close())
let asked
const register = (extra = {}) => {
const api = registries.stage('demo')
api.registerEventActions([{
id: 'demo.console.run',
label: 'Run console commands',
risk: 'irreversible',
reversible: 'none',
params: [{ name: 'command', type: 'string', required: true, example: 'say hi' }],
perform: async () => ({ ok: true }),
demoRefuses: (params) => {
asked.push(params)
return params.command === 'quit' ? '"quit" is turned off on the demo site.' : null
},
...extra,
}])
registries.apply(api.staged)
}
const input = (command) => ({
title: 'Console night',
spec: {
schedule: { kind: 'manual' },
phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'demo.console.run', params: { command } }] }],
},
})
const stubTable = (t, existing = null) => {
const wrote = []
t.mock.method(definitionsDb, 'list', async () => [])
t.mock.method(definitionsDb, 'insert', async (row) => { wrote.push(row); return 7 })
t.mock.method(definitionsDb, 'update', async (_id, row) => { wrote.push(row) })
t.mock.method(definitionsDb, 'getById', async () => existing || { id: 7, state: 'draft', title: 'Console night', spec: input('say hi').spec })
return wrote
}
beforeEach(() => {
asked = []
registries._reset()
registries.registerCore()
})
afterEach(() => {
registries._reset()
delete process.env.DEMO_MODE
})
test('demoRefuses must be a function, and never rides out to the catalog', () => {
assert.throws(() => register({ demoRefuses: 'quit' }), /demoRefuses must be a function/)
registries._reset()
register()
assert.equal(typeof registries.eventAction('demo.console.run').demoRefuses, 'function')
const served = registries.allEventActions().find((a) => a.id === 'demo.console.run')
assert.equal('demoRefuses' in served, false)
})
test('off the demo the hook is never asked, and a denied command saves', async (t) => {
register()
const wrote = stubTable(t)
const made = await definitions.create(input('quit'), 1, { role: 'admin' })
assert.equal(made.ok, true)
assert.equal(wrote.length, 1)
assert.deepEqual(asked, [])
})
test('on the demo a create naming a denied command is refused 403 DEMO_LOCKED and writes nothing', async (t) => {
process.env.DEMO_MODE = 'true'
register()
const wrote = stubTable(t)
const made = await definitions.create(input('quit'), 1, { role: 'admin' })
assert.deepEqual(made, {
ok: false,
status: 403,
code: 'DEMO_LOCKED',
message: '"quit" is turned off on the demo site.',
errors: ['"quit" is turned off on the demo site.'],
})
assert.equal(wrote.length, 0)
// The hook is handed the step's checked params.
assert.deepEqual(asked, [{ command: 'quit' }])
})
test('on the demo a command the hook allows saves', async (t) => {
process.env.DEMO_MODE = 'true'
register()
const wrote = stubTable(t)
assert.equal((await definitions.create(input('say hi'), 1, { role: 'admin' })).ok, true)
assert.equal((await definitions.save(7, input('say hi'), 1, { role: 'admin' })).ok, true)
assert.equal(wrote.length, 2)
})
test('on the demo a save and a publish are refused too', async (t) => {
process.env.DEMO_MODE = 'true'
register()
const wrote = stubTable(t, { id: 7, state: 'draft', title: 'Console night', spec: input('quit').spec })
const saved = await definitions.save(7, input('quit'), 1, { role: 'admin' })
assert.equal(saved.status, 403)
assert.equal(saved.code, 'DEMO_LOCKED')
// A draft written before the list grew: publish asks again.
const published = await definitions.publish(7, 1)
assert.equal(published.status, 403)
assert.equal(published.code, 'DEMO_LOCKED')
assert.equal(wrote.length, 0)
})
test('a hook that throws refuses with core\'s sentence (fails closed)', async (t) => {
process.env.DEMO_MODE = 'true'
register({ demoRefuses: () => { throw new Error('boom') } })
stubTable(t)
const made = await definitions.create(input('say hi'), 1, { role: 'admin' })
assert.equal(made.status, 403)
assert.equal(made.code, 'DEMO_LOCKED')
assert.match(made.message, /"Run console commands" could not be checked/)
})
test('the controller answers the refusal with code and message, so the toast fires', async (t) => {
process.env.DEMO_MODE = 'true'
register()
stubTable(t)
let status
let body
const res = {
status(s) { status = s; return this },
json(b) { body = b; return this },
}
await controller.create({ body: input('quit'), user: { id: 1, role: 'admin' } }, res)
assert.equal(status, 403)
assert.deepEqual(body, {
errors: ['"quit" is turned off on the demo site.'],
code: 'DEMO_LOCKED',
message: '"quit" is turned off on the demo site.',
})
})

View File

@@ -0,0 +1,159 @@
// ── Who a deleted account's snapshots show as (ACCOUNT_DELETION.md AD5, AD6) ──
//
// The rule itself, then each place a member can read a snapshot: forum threads
// and posts, the leader's guest list, and the Team digest mail. The Reports
// queue (staff-only) is covered in contentReports.test.js.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')
const { displayAuthor, DELETED_PLAYER, seesDeletedNames, hasActor } = require('../src/utils/displayAuthor')
const forum = require('../src/model/teams/teamForum.model')
const grants = require('../src/model/teams/teamGrants.model')
const accessDb = require('../src/model/teams/teamAccess.db')
const teamsDb = require('../src/model/teams/teams.db')
const db = require('../src/utils/db')
after(() => db.close())
const saved = []
function patch(obj, key, value) {
saved.push([obj, key, obj[key]])
obj[key] = value
}
afterEach(() => {
while (saved.length) {
const [obj, key, value] = saved.pop()
obj[key] = value
}
})
const MEMBER = { role: 'player' }
const STAFF = ['admin', 'editor', 'moderator'].map((role) => ({ role }))
// ── the rule ───────────────────────────────────────────────────────────────
test('a live account shows its name to everyone', () => {
for (const viewer of [null, MEMBER, ...STAFF]) {
assert.deepEqual(displayAuthor({ userId: 4, username: 'Thorne' }, viewer), { name: 'Thorne', deleted: false })
}
})
test('a deleted account reads "[deleted player]" to members and visitors (AD5)', () => {
assert.equal(DELETED_PLAYER, '[deleted player]')
for (const viewer of [null, undefined, MEMBER, { role: 'leader' }, {}]) {
assert.deepEqual(displayAuthor({ userId: null, username: 'Ravenna' }, viewer), { name: DELETED_PLAYER, deleted: true })
}
})
test('staff — admin, editor and moderator — see the stored name, marked deleted (AD6)', () => {
for (const viewer of STAFF) {
assert.equal(seesDeletedNames(viewer), true)
assert.deepEqual(displayAuthor({ userId: null, username: 'Ravenna' }, viewer), { name: 'Ravenna', deleted: true })
}
// With no snapshot at all there is no name to show anybody.
assert.deepEqual(displayAuthor({ userId: null, username: null }, STAFF[0]), { name: DELETED_PLAYER, deleted: true })
})
test('an absent actor is not a deleted one', () => {
assert.equal(hasActor(null, null), false)
assert.equal(hasActor(null, ''), false)
assert.equal(hasActor(null, 'OldMod'), true)
assert.equal(hasActor(5, null), true)
})
// ── forum threads and posts ────────────────────────────────────────────────
const threadRow = (over = {}) => ({
id: 5, type: 'discussion', title: 'Raid night', created_by: null, created_username: 'Ravenna',
created_at: new Date(), last_post_at: new Date(), post_count: 2, pinned: 0, locked: 0, status: 'visible', ...over,
})
const postRow = (over = {}) => ({
id: 80, thread_id: 5, author_user_id: null, author_username: 'Ravenna', body_html: '<p>Guide</p>',
created_at: new Date(), edited_at: null, status: 'visible', ...over,
})
test('a forum thread by a deleted account: "[deleted player]" to a member, the name to staff', () => {
assert.equal(forum.publicThread(threadRow(), MEMBER).author, DELETED_PLAYER)
assert.equal(forum.publicThread(threadRow()).author, DELETED_PLAYER) // no viewer said
assert.equal(forum.publicThread(threadRow(), MEMBER).authorDeleted, true)
for (const viewer of STAFF) {
const t = forum.publicThread(threadRow(), viewer)
assert.equal(t.author, 'Ravenna')
assert.equal(t.authorDeleted, true)
}
assert.equal(forum.publicThread(threadRow({ created_by: 4 }), MEMBER).author, 'Ravenna')
assert.equal(forum.publicThread(threadRow({ created_by: 4 }), MEMBER).authorDeleted, false)
})
test('a forum post by a deleted account follows the same rule, and is nobody’s to edit', () => {
const asMember = forum.renderPost(postRow(), 'disabled', { userId: 9, role: 'player', isStaff: false })
assert.equal(asMember.author, DELETED_PLAYER)
assert.equal(asMember.authorDeleted, true)
assert.equal(asMember.mine, false)
assert.equal(asMember.canEdit, false)
const asEditor = forum.renderPost(postRow(), 'disabled', { userId: 2, role: 'editor', isStaff: false })
assert.equal(asEditor.author, 'Ravenna')
assert.equal(asEditor.authorDeleted, true)
})
test('the thread list and a thread pass the viewer down to every row', async () => {
const forumDb = require('../src/model/teams/teamForum.db')
const forumSettings = require('../src/model/teams/teamForumSettings.model')
patch(forumDb, 'threadsByTeam', async () => [threadRow()])
patch(forumDb, 'threadById', async () => ({ ...threadRow(), team_id: 1 }))
patch(forumDb, 'postsByThread', async () => [postRow()])
patch(forumSettings, 'imageMode', async () => 'disabled')
const [listedForMember] = await forum.listThreads(1, { viewer: MEMBER })
assert.equal(listedForMember.author, DELETED_PLAYER)
const [listedForStaff] = await forum.listThreads(1, { viewer: { role: 'moderator' } })
assert.equal(listedForStaff.author, 'Ravenna')
const thread = await forum.getThread(1, 5, { viewer: { userId: 9, role: 'player' } })
assert.equal(thread.author, DELETED_PLAYER)
assert.equal(thread.posts[0].author, DELETED_PLAYER)
const staffThread = await forum.getThread(1, 5, { canModerate: true, viewer: { userId: 2, role: 'admin', isStaff: true } })
assert.equal(staffThread.author, 'Ravenna')
assert.equal(staffThread.posts[0].author, 'Ravenna')
})
// ── the leader's guest list ────────────────────────────────────────────────
test('a deleted account is not a forum guest, and a deleted granter reads "[deleted player]" (AD13)', async () => {
patch(accessDb, 'activeGrants', async () => [
{ user_id: 21, username: 'Guest', granted_by: null, granted_username: 'OldLeader', granted_at: new Date(), reason: null },
{ user_id: null, username: 'Ravenna', granted_by: 4, granted_username: 'Leader', granted_at: new Date(), reason: null },
{ user_id: 22, username: 'Member', granted_by: 4, granted_username: 'Leader', granted_at: new Date(), reason: null },
])
patch(teamsDb, 'membersByTeam', async () => [{ user_id: 22 }])
const guests = await grants.forumGuests(1, MEMBER)
assert.deepEqual(guests.map((g) => g.username), ['Guest'])
assert.equal(guests[0].grantedBy, DELETED_PLAYER)
const forStaff = await grants.forumGuests(1, { role: 'moderator' })
assert.equal(forStaff[0].grantedBy, 'OldLeader')
})
// ── the Team digest ────────────────────────────────────────────────────────
test('the digest mail names a deleted author "[deleted player]"', async () => {
const worker = require('../src/utils/teamDigestWorker')
const teamNotify = require('../src/model/teams/teamNotify.model')
const templates = require('../src/engagement/templates')
let items = null
patch(teamNotify, 'digestPostsSince', async () => [
{ id: 1, thread_id: 5, body_html: '<p>a</p>', created_at: new Date(), author_user_id: null, author_username: 'Ravenna', title: 'Raid night', type: 'discussion' },
{ id: 2, thread_id: 5, body_html: '<p>b</p>', created_at: new Date(), author_user_id: 4, author_username: 'Thorne', title: 'Raid night', type: 'discussion' },
])
patch(templates, 'renderByKey', async (key, values) => {
items = values.items
return null // stop before anything is sent
})
await worker.sendOne({ id: 1, slug: 'wolves', name: 'Iron Wolves' }, { user_id: 9, last_digest_at: null }, null, new Date())
assert.deepEqual(items.map((i) => i.heading), ['Raid night — [deleted player]', 'Raid night — Thorne'])
})

View File

@@ -299,12 +299,13 @@ test('every shipped template validates against the email registry', () => {
assert.equal(typeof seed.seedVersion, 'number')
assert.equal(['email', 'inapp'].includes(seed.channel), true)
}
// The five transactional bodies the system itself depends on are protected.
// The six transactional bodies the system itself depends on are protected.
const guarded = SEEDS.filter((s) => s.protected).map((s) => s.key)
assert.deepEqual(guarded, [
'auth.password-reset',
'auth.invite',
'auth.email-verify',
'auth.account-deleted',
'admin.contact-message',
'admin.test',
])

View File

@@ -112,7 +112,7 @@ test('the version a module declares against satisfies ^1.10.0', () => {
// 1.10.0 loading — Module-uo declares exactly `^1.10.0` — and this is the
// assertion that says it does. The number itself is pinned in version.js.
assert.ok(semver.satisfies(MODULE_API_VERSION, '^1.10.0'), MODULE_API_VERSION)
assert.equal(MODULE_API_VERSION, '1.17.0')
assert.equal(MODULE_API_VERSION, '1.21.0')
})
test('a module registers actions, budgets, leases and option sources', () => {
@@ -813,6 +813,92 @@ test('a dry run of core.lease checks everything and takes nothing', async () =>
assert.equal(bad.outcome, 'terminal')
})
// ── A lease's refusal keeps its `retry: false` (website#206) ────────────────
//
// `core.lease` rebuilt both failure returns as `{ ok: false, error }`, so a module
// that said "a retry cannot help" (a shard that refuses the value outright, a
// target that does not exist) was dispatched as `retry: true` and the run paused
// and re-asked on a loop. The module's `retry: false` now passes through, and
// anything else is left undefined so the dispatcher's own default still applies.
//
// The reservation lands in MariaDB, which these tests do not have, so the two
// calls `core.lease` makes on it are swapped for recorders and put back after.
const REFUSING_LEASES = `module.exports = (ctx, api) => {
const lease = (id, read, apply) => ({
id, label: 'Gain rate', type: 'float', min: 0.5, max: 5, maxDurationMs: 3600000,
read, apply, async restore() { return { ok: true } },
})
const fine = async () => ({ ok: true, value: 1 })
const accepts = async () => ({ ok: true })
api.registerEventLeases([
lease('demo.read.final', async () => ({ ok: false, retry: false, error: 'no such spawner' }), accepts),
lease('demo.read.soft', async () => ({ ok: false, error: 'shard is restarting' }), accepts),
lease('demo.apply.final', fine, async () => ({ ok: false, retry: false, error: 'value refused' })),
lease('demo.apply.soft', fine, async () => ({ ok: false, error: 'shard is restarting' })),
])
}`
/** Run `fn` with the lease reservation calls recorded rather than sent to a database. */
async function withStubbedReservations(fn) {
// eslint-disable-next-line global-require
const resourcesDb = require('../src/model/events/eventRunResources.db')
const original = { reserve: resourcesDb.reserve, markReverted: resourcesDb.markReverted }
const calls = { reserved: 0, reverted: [] }
resourcesDb.reserve = async () => { calls.reserved += 1; return { ok: true, id: 77 } }
resourcesDb.markReverted = async (id) => { calls.reverted.push(id) }
try {
return await fn(calls)
} finally {
Object.assign(resourcesDb, original)
}
}
const leaseStep = (lease) => step('core.lease', { lease, value: '3', minutes: 10 })
test('a lease read refusal that says retry: false is terminal, not retried', async () => {
assertRegistered(loadModule('demo', REFUSING_LEASES))
registries.registerCore()
await withStubbedReservations(async (calls) => {
const result = await dispatch.dispatchStep(leaseStep('demo.read.final'), { run: RUN })
assert.equal(result.outcome, 'terminal')
assert.match(result.error, /no such spawner/)
// Nothing was reserved, so there is nothing to give back.
assert.equal(calls.reserved, 0)
})
})
test('a lease apply refusal that says retry: false is terminal, and the reservation is still released', async () => {
assertRegistered(loadModule('demo', REFUSING_LEASES))
registries.registerCore()
await withStubbedReservations(async (calls) => {
const result = await dispatch.dispatchStep(leaseStep('demo.apply.final'), { run: RUN })
assert.equal(result.outcome, 'terminal')
assert.match(result.error, /value refused/)
assert.equal(calls.reserved, 1)
assert.deepEqual(calls.reverted, [77])
})
})
test('a lease refusal that does not say retry: false is still retried', async () => {
assertRegistered(loadModule('demo', REFUSING_LEASES))
registries.registerCore()
await withStubbedReservations(async (calls) => {
const read = await dispatch.dispatchStep(leaseStep('demo.read.soft'), { run: RUN })
assert.equal(read.outcome, 'retry')
assert.match(read.error, /shard is restarting/)
const apply = await dispatch.dispatchStep(leaseStep('demo.apply.soft'), { run: RUN })
assert.equal(apply.outcome, 'retry')
assert.match(apply.error, /shard is restarting/)
// The apply refusal released its reservation exactly as before.
assert.deepEqual(calls.reverted, [77])
})
})
// ── Targeted leases and searchable sources (Phase 12b) ──────────────────────
test('a targeted lease is a family of values, and the target rides in the ref', async () => {

Some files were not shown because too many files have changed in this diff Show More