67 Commits

Author SHA1 Message Date
10efafbae5 Merge branch 'main' into fix/lease-retry-206
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 52s
PR Checks / client-build (pull_request) Successful in 1m10s
PR Checks / server-tests (pull_request) Successful in 6m14s
2026-11-02 11:15:17 +00:00
36ee31896b Merge pull request 'fix(safety): team notifications skip members who block the author' (#234) from fix/safety-team-notifications into main
Some checks failed
sync-project-tree / sync (push) Successful in 17s
Build container images / build (push) Failing after 3m45s
Build container images / deploy (push) Has been skipped
SonarQube / analysis (push) Has been cancelled
Reviewed-on: #234
2026-11-02 11:14:54 +00:00
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
673701c91b fix(safety): team notifications skip members who block the author
All checks were successful
PR Checks / client-build (pull_request) Successful in 1m14s
PR Checks / server-tests (pull_request) Successful in 6m4s
PR Checks / bot-tests (pull_request) Successful in 56s
#233 left this open: the forum hid a blocked author's posts from a
member, but the member was still pushed, put in the inbox and emailed
about them.

- teamNotify.forumPost excludes every member who blocks the post's
  author, or the member who started the thread (the forum 404s that
  thread for them). One recipient list, so push, the inbox and email
  skip the same people. userSafety.blockersOf answers it, and a staff
  author is blocked by nobody (S8). A failed lookup notifies without it.
- The daily digest applies each recipient's forumFilter at send time:
  blocked authors, blocked thread starters, hidden threads and posts.
  digestPostsSince now also returns the thread's created_by.
- A reply's notify payload carries threadAuthorUserId.

The Team activity feed needed nothing: forum posts are never written to
it (membership, renames and module game events only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-31 00:23:18 -05: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
56c6a279dc Merge pull request 'fix(core): the last fixes' core half (W1 W2 W5 W19)' (#219) from fix/last-fixes-core into edge
All checks were successful
PR Checks / client-build (pull_request) Successful in 1m10s
PR Checks / server-tests (pull_request) Successful in 6m15s
PR Checks / bot-tests (pull_request) Successful in 7m21s
Reviewed-on: #219
2026-10-22 06:18:20 +00:00
f40dd765c3 fix(core): the last fixes' core half (W1 W2 W5 W19)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 49s
PR Checks / client-build (pull_request) Successful in 1m4s
PR Checks / server-tests (pull_request) Successful in 6m6s
Stage 4 of docs/modules/rust/PLAN_LAST_FIXES.md, with the org lead's
2026-10-22 decisions.

W19: one module per site at boot, as at install. The first module
directory by name holds the site, whether or not it then loads; every
other is recorded startup_failed at the manifest stage, before a line of
its code is required, with a reason naming the module that holds the
site. Module-uo and Module-Rust side by side used to fail the second
with a slot collision. Loader, lifecycle, schema and event-contract
tests that loaded several fixture modules now load each case alone.

W1: a reconcile files a resource the game no longer has, and that is
past its `until`, as expired rather than orphaned, with the same log
line expired() writes. No MODULE_API bump: `until` and reconcile's
answer are unchanged.

W5: a dry run that passes on the draft is kept with the fingerprint of
the validated spec (three nullable columns on event_definitions).
Publish stamps the new version verified, with that dry run's reviewer
and time, when the spec it freezes has the same fingerprint, and writes
version.verified on the occurrences it re-pins. Verify answers
draftRecorded and publish answers verified; OpenAPI regenerated.

W2: the run console says "Phase p1: steps done, waiting to advance" and
"Why this phase has not advanced", not "has not started".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-22 00:38:39 -05:00
9a1654fc54 Merge pull request 'feat(auth): sign in with Steam (MODULE_API 1.17.0)' (#218) from feat/steam-signin into edge
Reviewed-on: #218
2026-10-20 15:25:59 +00:00
02897030e5 test(auth): a linked Steam account signs in through the mobile bridge
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 39s
PR Checks / client-build (pull_request) Successful in 54s
PR Checks / server-tests (pull_request) Successful in 12m40s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-19 20:33:11 -05:00
5e398d8acd feat(auth): sign in with Steam (MODULE_API 1.17.0)
Steam is a built-in provider beside Google and Discord (STEAM_SIGNIN.md,
D392-D396). OpenID 2.0: the assertion is verified server to server with
check_authentication, return_to must match byte for byte, and the claimed id
must be a 17-digit SteamID64. A Steam Web API key is required to enable it
(D394); persona name and avatar are stored on the identity and refreshed at
each sign-in, and a failed lookup never blocks one. Steam never signs anybody
up (D395), on the web or the mobile bridge. Several Steam accounts may be
linked; DELETE /auth/me/account/identities/:provider takes ?id= to unlink one.

MODULE_API 1.17.0: ctx.identities.list, api.onIdentityLinking (refuse-only,
fails closed, module text never shown) and api.onIdentityLinked. No unlink
hook (D393). Schema additive; OpenAPI, routes and engagement manifests
regenerated with no modules installed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-19 14:02:49 -05:00
6ba2ed90ed Merge pull request 'feat(events): a dry run carries a module's advice (MODULE_API 1.16.0)' (#217) from feat/dry-run-warnings into main
All checks were successful
sync-project-tree / sync (push) Successful in 13s
Build container images / build (push) Successful in -14s
Build container images / deploy (push) Successful in 56s
SonarQube / analysis (push) Successful in 10m52s
Reviewed-on: #217
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-17 06:32:10 +00:00
8c30cbbddd feat(events): a dry run carries a module's advice (MODULE_API 1.16.0)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 43s
PR Checks / server-tests (pull_request) Successful in 6m8s
PR Checks / client-build (pull_request) Successful in 7m58s
An action answering verify: true with { ok: true, detail: { warnings } } has
each line shown as a warning finding (code `advice`) that never fails the
dry run. Strings only, at most ten per step, each cut at 500 characters;
anything else under warnings is ignored, and a refused step carries none.

Rust PLAN_REDESIGNS §11.4, D391: a raid base's spot is checked against the
world as it stands now (a player on it, a rock beside it), which may change
before the run, so the editor shows it as advice. One optional reading of an
existing member, so minor; Module-uo's ^1.10.0 still resolves and says no
warnings. STEAM_SIGNIN.md's claim moves to 1.17.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-16 15:18:53 -05:00
0f29f0acb2 Merge pull request 'feat(events): a trigger can start a run (Rust §14.3 stage 9, D370)' (#216) from feat/event-trigger-starts into main
All checks were successful
sync-project-tree / sync (push) Successful in 21s
Build container images / build (push) Successful in 1m31s
SonarQube / analysis (push) Successful in 9m14s
Build container images / deploy (push) Successful in 56s
Reviewed-on: #216
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-16 07:07:59 +00:00
c9e992588b feat(events): a trigger can start a run
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 35s
PR Checks / client-build (pull_request) Successful in 44s
PR Checks / server-tests (pull_request) Successful in 12m45s
Rust PLAN_REDESIGNS §14.3.12 (D370), stage 9 of §14.3. A definition's
published version may carry `starts`: a trigger, a condition and a
cooldown. A matching firing creates a run of it, now.

- spec: `starts` validated by the phase gate's own function (same
  grammar, same dormant rule; a dormant start refuses publish), a
  cooldown from 0m to 7d (30m default), at most ten, event.* refused.
- events/starts.js: the emit path's third subscriber, beside dispatch
  and the gate observer. An unverified version starts nothing; one run
  at a time per definition and the cooldown are one conditional UPDATE
  on the new event_trigger_starts table; the occurrence key makes one
  firing reported twice one run. Ignored firings are counted by why.
- event_runs.origin: the firing (trigger, label, time, subject, matched
  condition, declared variables), also on the run.created audit line.
- Editor: a "Started by the game" panel using the gate's condition
  builder, with the ignored-firings line. Run console: "Started because
  ... fired".
- No module contract change and no MODULE_API bump.

Tests: eventStarts (server 21, client 8); eventRunnerSql +7 against a
real MariaDB (75/75); Module-uo proof on uo.champ.boss_up against the
dev database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-16 01:51:55 -05:00
eb773693d3 Merge pull request 'feat(events): a failed step may say when to retry (MODULE_API 1.15.0)' (#215) from feat/event-step-retry-after into main
Some checks failed
sync-project-tree / sync (push) Successful in 12s
Build container images / build (push) Successful in 1m38s
SonarQube / analysis (push) Successful in 9m29s
Build container images / deploy (push) Has been cancelled
Reviewed-on: #215
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-15 07:47:46 +00:00
769794d183 feat(events): a failed step may say when to retry (MODULE_API 1.15.0)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 48s
PR Checks / client-build (pull_request) Successful in 59s
PR Checks / server-tests (pull_request) Successful in 6m6s
A module's perform() may answer { ok: false, retry: true, retryAfterMs }
and the runner waits that long instead of the flat EVENT_STEP_RETRY_MS
(Rust PLAN_REDESIGNS §14.3.13, D371). Dangerous Treasures' warm-up is the
first user.

- dispatch.classify reads it on the retry shape only, clamped to 60 000 to
  1 800 000 ms; a value that is not a number is dropped and logged, and the
  step retries on the flat delay.
- It is one attempt of EVENT_STEP_MAX_ATTEMPTS and degrades the run like any
  retry. A run paused or cancelled in the wait is not picked up.
- Every step.retry log line now carries retryAt (and retryAfterMs when
  hinted); the run console reads "retrying at 20:10".
- MODULE_API 1.15.0, additions only; the engagement manifest is regenerated.
  Module-uo (^1.10.0) answers none of it and is unaffected.
- Test harness: resourcesDb.finishedRunsWithNothingLeft was never stubbed in
  eventRunner.test.js, so a tick that reached the cleanup sweep with a
  terminal run waited on the dead pool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-15 02:39:43 -05:00
a981b2b854 Merge pull request 'feat(events): a param that holds several values (MODULE_API 1.14.0)' (#213) from feat/event-param-multiple into main
Some checks failed
sync-project-tree / sync (push) Successful in 29s
Build container images / build (push) Successful in -18s
SonarQube / analysis (push) Successful in 17m58s
Build container images / deploy (push) Has been cancelled
Reviewed-on: #213
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-13 01:03:19 +00:00
743f1bc8b2 feat(events): a param that holds several values (MODULE_API 1.14.0)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 47s
PR Checks / client-build (pull_request) Successful in 48s
PR Checks / server-tests (pull_request) Successful in 13m55s
Rust PLAN_REDESIGNS §12.3 (D344): an event action param may declare
`multiple: true`, with an optional `max` (1-100, 20 when left out), on a
`string` or `int` param. The permission reward steps take several permissions
in one step through it.

- registries: the flag and `max` are checked at registration and served in the
  catalog; refused on other types, on a duration, on a template's inputs, and
  with a list as the example. A param that does not say it reads back exactly
  as declared, without the keys.
- spec: the value is a JSON array, each item the type's literal, duplicates
  dropped in order, at most `max`; a bare value is a list of one; `required`
  means at least one; an empty item is refused.
- authorize: a multiple param's example is priced as a list of one, since
  cost() is handed the array.
- editor: a multi-select. Chips above a grouped, filterable checklist for a
  source that answered; the typeahead for a searchable source; a tag box
  otherwise. Ticking past max is refused in words. A new step holds the
  example as a list of one.
- run console: params read as `name: value`, a list joined by commas.
- MODULE_API 1.13.0 -> 1.14.0 on both halves; engagement-triggers.json and the
  catalog's OpenAPI note regenerated.

Additive only: Module-uo (`^1.10.0`) declares no multiple param.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-12 15:40:30 -05:00
ca7ec53b14 Merge pull request 'feat(events): the step editor reworked, and event templates (MODULE_API 1.13.0)' (#212) from feat/event-step-editor into main
Some checks failed
sync-project-tree / sync (push) Successful in 21s
Build container images / build (push) Successful in 1m50s
SonarQube / analysis (push) Successful in 17m50s
Build container images / deploy (push) Has been cancelled
Reviewed-on: #212
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-12 09:53:42 +00:00
e58857c133 feat(events): a searchable source's chosen note is found when the page lacks it
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 1m47s
PR Checks / client-build (pull_request) Successful in 1m58s
PR Checks / server-tests (pull_request) Successful in 14m58s
A searchable source answers the editor a bounded first page, so the value an
author picked (or a template filled in) may not be on it, and its note with it.
useChosenNote asks the source for that value as a search term, once per value,
and shows the exact match's note. Found building the Rust kit warning
(PLAN_REDESIGNS §2.6), whose source - rust.options.grouppermissions - is
searchable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-12 02:23:18 -05:00
3e243ce772 feat(events): the step editor reworked, and event templates (MODULE_API 1.13.0)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 2m26s
PR Checks / client-build (pull_request) Successful in 2m44s
PR Checks / server-tests (pull_request) Successful in 20m38s
Rust PLAN_REDESIGNS §2 (U-3-U-6, F11; D192, D334), core half. Game-agnostic:
Module-uo's steps get the new controls and nothing else.

Contract (MODULE_API 1.12.0 -> 1.13.0, four optional additions):
- api.registerEventTemplates([{ id, label, description, inputs, build }]):
  inputs are params (<= 8); core checks them, calls build() (10 s), validates
  the result as a spec and answers an unsaved draft.
  POST /admin/events/catalog/templates/:templateId (admin, editor).
- a param's duration: 'minutes' (int/float only); core.lease's minutes has it.
- a lease's example for its value.
- an option row's note (<= 300 chars), shown under the chosen field.

Editor:
- no type tags or bare asterisks; a required field says so in words once a
  save has been tried with it empty (and the save stops before the round trip).
- boolean: toggle when required, [Not set] [Yes] [No] when optional (D334).
- a choice of two or three is a row of buttons (lease values, small sources).
- core.lease drawn as the chosen lease: picked by label; value by the lease's
  type, range, values and example; target picker over target.source (F11);
  minutes with the lease's longest hold.
- a timed step keeps its run open: a phase with no advance rule gets
  after: <longest hold> when a timed step is added; a sentence under any phase
  whose run can end sooner (U-6).
- Start asks "Publish and start" or "Start version N" when the working copy is
  ahead of the published version (draftAhead on GET/PUT/publish; U-5).
- New event can start from a module's template.
- core's own examples name no game (§0.9).

Swagger, routes.manifest/guards and engagement-triggers.json regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-12 02:08:04 -05:00
43f706c003 Merge pull request 'fix(events): the public phase label finds the phase by key (runicnpc D314)' (#211) from fix/event-public-phase-label into main
Some checks failed
sync-project-tree / sync (push) Successful in 18s
Build container images / build (push) Successful in 1m39s
SonarQube / analysis (push) Successful in 17m24s
Build container images / deploy (push) Has been cancelled
Reviewed-on: #211
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-07 02:50:14 +00:00
ef93c73d2c fix(events): the public phase label finds the phase by key (runicnpc D314)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 36s
PR Checks / client-build (pull_request) Successful in 7m50s
PR Checks / server-tests (pull_request) Successful in 13m50s
eventPublic.phaseLabel matched a run's current_phase against p.id, but spec.js
validates and the engine writes the phase's `key`, so every live run's public
page read "Under way" since Events Phase 14a, UO's events included. It now
matches `key` first and still accepts `id` second. The test fixture moves to
`key`, as real specs are, and a unit test covers a UO-shaped spec, the id
fallback, key-before-id and an unknown phase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-06 16:57:46 -05:00
0a37a44164 Merge pull request 'feat(events): an event action's progress() on a live run's public page (MODULE_API 1.12.0)' (#210) from feat/events-action-progress into main
Some checks failed
sync-project-tree / sync (push) Successful in 26s
Build container images / build (push) Successful in 1m46s
SonarQube / analysis (push) Failing after 16m44s
Build container images / deploy (push) Has been cancelled
Reviewed-on: #210
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-06 12:58:52 +00:00
9ddb59a326 feat(events): an event action's progress() on a live run's public page (MODULE_API 1.12.0)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 38s
PR Checks / client-build (pull_request) Successful in 49s
PR Checks / server-tests (pull_request) Successful in 20m42s
An event action may declare an optional `progress(envelope)`: how its
step stands while the run is live. Core accepts two shapes and
interprets neither:

- a count: { label, left, of }, rendered "Bandits: 3 of 8 left";
- a percentage: { label, percent }, rendered "The Juggernaut: 62%".

How core asks:

- `events/progress.js` asks each step of a live run whose action has
  one. It passes the step's own held ledger rows and the reader's
  account id, so the module applies its own visibility.
- Each call has a 2 s deadline. A throw, a late answer or any other
  shape is a line not shown, never an error on the page.
- One answer per run and reader is cached for 5 s.
- The public event's live occurrence carries the lines as `progress`.
  The route takes an optional session for the reader.
- The page renders them under the phase label and re-reads them every
  15 s while the event is live.

Core stays game-agnostic: no game's words are in it. Additive, so a
minor bump. Module-uo declares ^1.10.0, registers no action with
progress(), and its actions and pages are unchanged.

From RunicNPC stage 8 (D304, D305).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-06 02:47:00 -05:00
f0e7d2aa2a Merge pull request 'feat(events): an expired ledger status and ctx.events.expired (MODULE_API 1.11.0)' (#209) from feat/events-expired-status into main
All checks were successful
sync-project-tree / sync (push) Successful in 17s
Build container images / build (push) Successful in -1s
Build container images / deploy (push) Successful in 39s
SonarQube / analysis (push) Successful in 17m21s
Reviewed-on: #209
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-27 06:10:43 +00:00
224e2b4dbc fix(events): settle a finished run whose last resource expired while it ran
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 20m48s
Found walking Rust PLAN_FIXES step 2 (run 46 on the walk core): a zone expired
while its run was still going, so expireResource left the run's cleanup status
to its terminal path. When the run was cancelled the sweep never selected it,
because runsNeedingCleanup joins on an unresolved row and it had none, so
cleanup_status sat at `pending` for ever over an empty ledger.

The sweep now settles finished runs that are `pending` with no unresolved row
as `complete` (never over `incomplete`, which is a human's to clear). Checked
against the walk database: the query returns run 46 and nothing else.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 22:19:17 -05:00
cc1f49af29 feat(events): an expired ledger status and ctx.events.expired (MODULE_API 1.11.0)
All checks were successful
PR Checks / client-build (pull_request) Successful in 35s
PR Checks / server-tests (pull_request) Successful in 5m48s
PR Checks / bot-tests (pull_request) Successful in 7m51s
A game that ends a resource at its own deadline — a Rust zone erased when its
time is up — could reach core only through ctx.events.reconcile(), which files
it `orphaned`: amber, "it vanished", and still claimable for a revert. The new
call files it as the plan working (Rust PLAN_FIXES F14, D170, D183):

- event_run_resources.status gains `expired`, terminal like `reverted`: the
  sweep never takes it back, live_marker releases the target, and it joins
  neither HELD nor UNRESOLVED. The ENUM ALTER re-runs as a no-op on every boot
  (checked on MariaDB 11.8 with the stored generated column depending on it).
- ctx.events.expired({ kind, ref }) marks the calling module's own pending,
  confirmed or orphaned rows for that target expired and logs
  `resource.expired`; a revert in flight is left to finish. The owner is bound
  by the loader, like reconcile. A finished run whose last unresolved row this
  was goes to cleanup `complete`, even from `incomplete`.
- The run console shows it green, "ended by the game on time".

Additions only, so minor. Module-uo (coreApi ^1.10.0) calls none of it and
reads no ledger status; the contract test now asserts the range still holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 21:13:07 -05:00
1a76b98d76 Merge pull request 'fix(modules): a site runs one module; the installer refuses a second' (#204) from fix/one-module-per-site into main
All checks were successful
sync-project-tree / sync (push) Successful in 10s
Build container images / build (push) Successful in -2s
Build container images / deploy (push) Successful in 1m46s
SonarQube / analysis (push) Successful in 9m11s
Reviewed-on: #204
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-26 22:28:57 +00:00
a169a463a5 Merge branch 'main' into fix/one-module-per-site
All checks were successful
PR Checks / client-build (pull_request) Successful in 33s
PR Checks / bot-tests (pull_request) Successful in 32s
PR Checks / server-tests (pull_request) Successful in 5m47s
2026-09-26 22:22:56 +00:00
90ba8cca16 Merge pull request 'feat(events): publish runId on a public calendar run entry (Rust D125)' (#208) from feat/public-calendar-run-id into main
Some checks failed
sync-project-tree / sync (push) Successful in 12s
Build container images / build (push) Successful in 1m50s
Build container images / deploy (push) Successful in 42s
SonarQube / analysis (push) Failing after 6m11s
Reviewed-on: #208
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-25 17:43:50 +00:00
b5616f359c feat(events): publish runId on a public calendar run entry (Rust D125)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 39s
PR Checks / client-build (pull_request) Successful in 42s
PR Checks / server-tests (pull_request) Successful in 5m54s
A run entry on GET /public/events now names its run, the same id the
event page already publishes on each occurrence and `?run=` takes. A
Rust map marker carries core's run id and nothing else about its event,
so without this the app could only find the event by fetching every
event page.

A projected entry has no runId: nothing is committed to it. Rehearsals
and unlisted events stay absent from the calendar, so their markers
stay unlinked. The web calendar ignores the field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 07:38:59 -05:00
e4f088e90b fix(modules): a site runs one module; the installer refuses a second
All checks were successful
PR Checks / client-build (pull_request) Successful in 33s
PR Checks / bot-tests (pull_request) Successful in 34s
PR Checks / server-tests (pull_request) Successful in 13m28s
A site is one game, and the module contract already has singletons that
assume it. registerTeamProvider holds one value per deployment, and a
second module registering one fails that module's whole load. The loader
scans alphabetically, so installing module-rust (which gains a Team
provider in its phase 9) beside module-uo would have taken uo down, not
rust.

install() now refuses, with 409 and before the artifact is downloaded,
any install whose id differs from a module already on the volume. An
upgrade of the installed module is still accepted; to change game,
remove the module first. Both install surfaces share this path, so a
MODULES declaration naming two modules installs the first and reports
the second as refused without failing the boot.

"Installed" means what the loader would scan: a directory named with a
module id that holds a module.json. An install's scratch directory and a
swap's aside copy do not count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-23 01:26:52 -05:00
702ab89ae2 Merge pull request 'fix(admin): stop a long action name printing over the activity row beside it' (#203) from fix/activity-action-overflow into main
All checks were successful
sync-project-tree / sync (push) Successful in 13s
Build container images / build (push) Successful in 1m29s
Build container images / deploy (push) Successful in 46s
SonarQube / analysis (push) Successful in 9m11s
Reviewed-on: #203
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-21 22:24:29 +00:00
9e1f591b25 fix(admin): stop a long action name printing over the activity row beside it
All checks were successful
PR Checks / client-build (pull_request) Successful in 57s
PR Checks / server-tests (pull_request) Successful in 6m9s
PR Checks / bot-tests (pull_request) Successful in 8m3s
The dashboard renders an activity row action in a fixed `width: 110` span with
`flex: none` and no overflow handling, so a name wider than that overflows its
box and prints on top of the detail text next to it.

Core own actions all fit. A module one need not: `module-rust` writes
`rust.account.unlink.staff` when staff sever a player Steam link, and it
overlapped `steamId: …` on a live dashboard. `module-uo` `uoLink.account.link`
is already close to the edge.

`minWidth` instead of `width` keeps the column aligned for every short name and
lets a longer one push the detail right rather than sit under it. One property,
verified in a browser with both rows on screen.

Found while walking module-rust phase 6; raised here rather than worked around
there, because a module may legitimately name an action and shortening one
module names only moves the ceiling to the next one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-21 09:10:43 -05:00
efa9db7330 Merge pull request 'chore(tools): delete the cliloc converter the Asset Bridge replaced (Asset Bridge cutover, 2 of 5)' (#202) from edge into main
All checks were successful
sync-project-tree / sync (push) Successful in 34s
Build container images / build (push) Successful in 22s
Build container images / deploy (push) Successful in 38s
SonarQube / analysis (push) Successful in 9m11s
Reviewed-on: #202
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-15 10:20:24 +00:00
720103e3d4 Merge branch 'main' into edge
All checks were successful
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 13m24s
2026-09-15 10:04:54 +00:00
f373f2e897 Merge pull request 'chore(tools): delete the cliloc converter the Asset Bridge replaced (Phase 2)' (#201) from feat/asset-bridge-p2 into edge
All checks were successful
PR Checks / client-build (pull_request) Successful in 40s
PR Checks / bot-tests (pull_request) Successful in 41s
PR Checks / server-tests (pull_request) Successful in 13m28s
Reviewed-on: #201
2026-09-10 16:20:24 +00:00
655fbf3f69 Merge pull request 'feat(events): the Event System — core (Phase 16b cutover, 2 of 6)' (#199) from edge into main
Some checks failed
sync-project-tree / sync (push) Successful in 1m32s
Build container images / build (push) Successful in 2m12s
Build container images / deploy (push) Successful in 49s
SonarQube / analysis (push) Failing after 33m0s
Reviewed-on: #199
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-10 00:43:29 +00:00
170 changed files with 16623 additions and 605 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) =>
@@ -133,14 +142,32 @@ export const api = {
totpDisable: (code) => req('/auth/me/account/totp/disable', { method: 'POST', body: { code } }),
// Linked SSO identities (self-service). Linking starts at /auth/sso/:id/link.
myIdentities: () => req('/auth/me/account/identities'),
unlinkIdentity: (provider) =>
req(`/auth/me/account/identities/${encodeURIComponent(provider)}`, { method: 'DELETE' }),
// With an identity id, only that one goes (a person may hold several Steam accounts).
unlinkIdentity: (provider, id) =>
req(
`/auth/me/account/identities/${encodeURIComponent(provider)}${id != null ? `?id=${encodeURIComponent(id)}` : ''}`,
{ method: 'DELETE' },
),
// Recovery (backup) codes. status → remaining count; generate → a fresh set,
// returned ONCE (password step-up for accounts that have a password).
recoveryCodesStatus: () => req('/auth/me/account/recovery-codes/status'),
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
@@ -518,6 +545,10 @@ export const api = {
const qs = q ? `?${new URLSearchParams({ q }).toString()}` : ''
return req(`/admin/events/catalog/options/${encodeURIComponent(sourceId)}${qs}`)
},
// MODULE_API 1.13.0. Fill an event template in; the answer is a draft the
// editor opens, and nothing is saved until the author saves it.
buildEventTemplate: (templateId, inputs) =>
req(`/admin/events/catalog/templates/${encodeURIComponent(templateId)}`, { method: 'POST', body: { inputs } }),
// Phase 6. The dry run is admin+editor: it dispatches nothing, and the author
// who wrote the definition is who should be able to price it against the caps
// before asking an admin to publish it. A report with findings comes back 200
@@ -639,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

@@ -1,6 +1,6 @@
// Inline SVG brand icons for SSO providers. No binary assets — these scale
// crisply at any size and keep their own brand colors. `icon` matches the
// provider `kind` from the discovery endpoint ('google' | 'discord' | oidc/oauth2).
// provider `kind` from the discovery endpoint ('google' | 'discord' | 'steam' | oidc/oauth2).
// Anything unknown falls back to a neutral key glyph in the current text color.
function GoogleMark({ size }) {
@@ -22,6 +22,16 @@ function DiscordMark({ size }) {
)
}
// Steam's mark in the current text color: its brand ink is near-black, which
// would vanish on the dark theme.
function SteamMark({ size }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
<path d="M11.979 0C5.678 0 .511 4.86.022 11.037l6.432 2.658c.545-.371 1.203-.59 1.912-.59.063 0 .125.004.188.006l2.861-4.142V8.91c0-2.495 2.028-4.524 4.524-4.524 2.494 0 4.524 2.031 4.524 4.527s-2.03 4.525-4.524 4.525h-.105l-4.076 2.911c0 .052.004.105.004.159 0 1.875-1.515 3.396-3.39 3.396-1.635 0-3.016-1.173-3.331-2.727L.436 15.27C1.862 20.307 6.486 24 11.979 24c6.627 0 11.999-5.373 11.999-12S18.605 0 11.979 0zM7.54 18.21l-1.473-.61c.262.543.714.999 1.314 1.25 1.297.539 2.793-.076 3.332-1.375.263-.63.264-1.319.005-1.949s-.75-1.121-1.377-1.383c-.624-.26-1.29-.249-1.878-.03l1.523.63c.956.4 1.409 1.5 1.009 2.455-.397.957-1.497 1.41-2.454 1.012H7.54zm11.415-9.303c0-1.662-1.353-3.015-3.015-3.015-1.665 0-3.015 1.353-3.015 3.015 0 1.665 1.35 3.015 3.015 3.015 1.663 0 3.015-1.35 3.015-3.015zm-5.273-.005c0-1.252 1.013-2.266 2.265-2.266 1.249 0 2.266 1.014 2.266 2.266 0 1.251-1.017 2.265-2.266 2.265-1.253 0-2.265-1.014-2.265-2.265z" />
</svg>
)
}
function GenericMark({ size }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false">
@@ -33,5 +43,6 @@ function GenericMark({ size }) {
export default function ProviderIcon({ icon, size = 18 }) {
if (icon === 'google') return <GoogleMark size={size} />
if (icon === 'discord') return <DiscordMark size={size} />
if (icon === 'steam') return <SteamMark size={size} />
return <GenericMark size={size} />
}

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,66 @@
import ProviderIcon from '../ProviderIcon.jsx'
// What the player's and the staff account pages share about a linked sign-in
// identity. Steam is the one provider that differs (STEAM_SIGNIN.md): a person may
// link several Steam accounts, each shows its persona name, avatar and SteamID64,
// and unlinking one removes only the sign-in — a game link made from it stays
// (D393).
// Providers a person may link more than one account of.
const MULTI_LINK = new Set(['steam'])
export function canLinkAgain(providerId) {
return MULTI_LINK.has(providerId)
}
export function identityIcon(provider) {
return provider === 'google' || provider === 'discord' || provider === 'steam' ? provider : 'oidc'
}
// The outcome of a link round trip, from the query string it returns with.
export function linkBanner(search) {
const q = new URLSearchParams(search)
if (q.get('linked')) return { ok: true, text: 'Account linked.' }
if (q.get('link_error') === 'in_use') return { ok: false, text: 'That external account is already linked to another user on this site.' }
if (q.get('link_error') === 'refused') return { ok: false, text: 'That account cannot be linked right now. Please try again later.' }
if (q.get('link_error')) return { ok: false, text: 'Could not link that account. Please try again.' }
return null
}
// Providers to offer a Link button for: every enabled one not yet linked, and a
// multi-link one (Steam) always.
export function linkableProviders(available, linked) {
const linkedIds = new Set(linked.map((i) => i.provider))
return available.filter((p) => !linkedIds.has(p.id) || canLinkAgain(p.id))
}
export function unlinkPrompt(identity, providerName) {
if (identity.provider === 'steam') {
const who = identity.display_name ? `${identity.display_name} (${identity.subject})` : identity.subject
return `Unlink the Steam account ${who}? You will no longer be able to sign in with it. A game link made with it stays until you remove it on that game's page.`
}
return `Unlink ${providerName} from your account?`
}
// The icon (or Steam avatar), the provider name, and what identifies the account.
export function IdentitySummary({ identity, providerName }) {
const isSteam = identity.provider === 'steam'
return (
<>
<span style={{ display: 'inline-flex', width: 20, height: 20, color: 'var(--head)', flex: '0 0 auto' }}>
{isSteam && identity.avatar_url ? (
<img src={identity.avatar_url} alt="" width={20} height={20} style={{ borderRadius: 4 }} referrerPolicy="no-referrer" />
) : (
<ProviderIcon icon={identityIcon(identity.provider)} size={20} />
)}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem', overflowWrap: 'anywhere' }}>
{isSteam && identity.display_name ? `${providerName}: ${identity.display_name}` : providerName}
</div>
{isSteam && identity.subject && <div className="sans dim" style={{ fontSize: '0.78rem' }}>{identity.subject}</div>}
{!isSteam && identity.email && <div className="sans dim" style={{ fontSize: '0.78rem' }}>{identity.email}</div>}
</div>
</>
)
}

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`
}

View File

@@ -133,7 +133,9 @@ const nextPhaseKey = (phases) => {
export function blankStep(action) {
const params = {}
for (const p of action?.params || []) {
if (p.required || p.example !== undefined) params[p.name] = p.example
// A list's example is one value (MODULE_API 1.14.0), so the step starts
// holding that one.
if (p.required || p.example !== undefined) params[p.name] = p.multiple ? [p.example] : p.example
}
return {
actionId: action?.id || '',
@@ -219,6 +221,89 @@ export function whereFormFrom(where) {
}
}
// ── Starting from a trigger (Rust PLAN_REDESIGNS §14.3.12, D370) ───────────
//
// A start is a phase gate's trigger and condition plus a cooldown, so the form
// holds it in the gate's own fields — and the same `WhereBuilder` draws it. The
// server (`events/spec.js`) decides; this only carries the fields.
export const DEFAULT_COOLDOWN = '30m'
// Core's own lifecycle triggers cannot start a run; the editor does not offer
// them, and the server refuses them if posted anyway.
export const isStartableTrigger = (trigger) => !String(trigger?.id || '').startsWith('event.')
export function blankStart() {
return { on: '', cooldown: DEFAULT_COOLDOWN, dormant: false, ...blankWhere() }
}
/** The stored `starts`, as the form's rows. */
export function startsFormFrom(starts) {
return (starts || []).map((s) => ({
...blankStart(),
on: s.on || '',
cooldown: s.cooldown ?? DEFAULT_COOLDOWN,
dormant: Boolean(s.dormant),
...whereFormFrom(s.where),
}))
}
/**
* The form's starts as the spec's list. A row with no trigger chosen is dropped
* rather than posted — it is a half-added row, not a start the author wrote.
*/
export function startsPayload(rows, errors, triggersById = new Map()) {
const out = []
;(rows || []).forEach((row, i) => {
if (!row.on) return
const gate = advancePayload(
{ ...row, kind: 'on' },
`Start ${i + 1}`,
errors,
triggersById.get(row.on)?.variables || [],
)
const entry = { on: row.on, cooldown: String(row.cooldown ?? '').trim() || DEFAULT_COOLDOWN }
if (gate?.where) entry.where = gate.where
out.push(entry)
})
return out
}
/**
* Why a run exists, when a game firing started it — the console's sentence.
* Null for a run started by hand or by the schedule.
*/
export function describeOrigin(origin) {
if (!origin || origin.kind !== 'trigger') return null
const vars = Object.entries(origin.variables || {})
.filter(([k]) => k !== '_truncated')
.map(([k, v]) => `${k} ${typeof v === 'string' ? v : JSON.stringify(v)}`)
const label = origin.label || origin.triggerId
return [
`Started because “${label}” fired`,
origin.matched ? ` (matching ${origin.matched})` : '',
vars.length ? `: ${vars.join(' · ')}` : '',
].join('')
}
/**
* The editor's line about what its trigger starts have done — or null when a
* trigger has never tried. "2 firings ignored while running, 1 in its cooldown".
*/
export function describeTriggerStarts(state) {
if (!state) return null
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`
const ignored = [
state.ignoredRunning ? `${plural(state.ignoredRunning, 'firing')} ignored while a run was going` : null,
state.ignoredCooldown ? `${plural(state.ignoredCooldown, 'firing')} ignored in the cooldown` : null,
state.ignoredUnverified
? `${plural(state.ignoredUnverified, 'firing')} ignored because the published version is not verified`
: null,
].filter(Boolean)
const last = state.lastRunId ? `Last started run #${state.lastRunId}` : 'No run started by a trigger yet'
return ignored.length ? `${last}; since then ${ignored.join(', ')}.` : `${last}.`
}
/** The editor's working state, from what `GET /admin/events/:id` returned. */
export function formFromDefinition(event) {
const spec = event?.spec || {}
@@ -241,6 +326,7 @@ export function formFromDefinition(event) {
// `false`, and `||` would quietly re-list it on the next save.
listed: event?.listed ?? true,
...scheduleFormFrom(spec.schedule),
starts: startsFormFrom(spec.starts),
phases: (spec.phases || []).map((p) => ({
key: p.key || '',
label: p.label || '',
@@ -345,6 +431,8 @@ export function payloadFromForm(form, { triggersById = new Map() } = {}) {
}
})
const starts = startsPayload(form.starts, errors, triggersById)
if (errors.length) return { ok: false, errors }
return {
@@ -361,7 +449,10 @@ export function payloadFromForm(form, { triggersById = new Map() } = {}) {
timezone: form.timezone,
listed: Boolean(form.listed),
listed: Boolean(form.listed),
spec: { schedule: scheduleFromForm(form), phases },
// `starts` only when there is one, as the server stores it (D370).
spec: starts.length
? { schedule: scheduleFromForm(form), starts, phases }
: { schedule: scheduleFromForm(form), phases },
},
}
}
@@ -545,7 +636,9 @@ export const PARAM_JSON = 'json'
* is what the author needs to see — and a form that dropped it would post a
* step that saves cleanly having deleted something they typed.
* • **a value no single control can hold** — an object or an array against a
* scalar declaration.
* scalar declaration, or anything but a flat list against a `multiple` one
* (MODULE_API 1.14.0). A bare value there is a list of one, as the server
* reads it, and the multi-select shows it as one chip.
*/
export function paramsRenderable(action, params) {
if (!action) return { ok: false, reason: 'the module that registered this action is not installed' }
@@ -554,6 +647,13 @@ export function paramsRenderable(action, params) {
if (!declared.has(name)) {
return { ok: false, reason: `this step carries "${name}", which ${action.id} does not declare` }
}
if (declared.get(name).multiple) {
const items = Array.isArray(value) ? value : [value]
if (items.some((v) => v !== null && typeof v === 'object')) {
return { ok: false, reason: `"${name}" holds a structure, which a list of values cannot hold` }
}
continue
}
if (value !== null && typeof value === 'object') {
return { ok: false, reason: `"${name}" holds a ${Array.isArray(value) ? 'list' : 'structure'}, which no single field can hold` }
}
@@ -625,6 +725,227 @@ export function setParam(step, name, raw, type) {
*/
export const datetimeInputValue = (value) => (typeof value === 'string' ? value.slice(0, 16) : '')
// ── A param that holds several values (MODULE_API 1.14.0, Rust PLAN_REDESIGNS §12.3) ──
/**
* A `multiple` param's value as the list the multi-select draws: an array as
* stored, a bare value as a list of one (the server's own reading), and nothing
* as the empty list.
*/
export function listValue(value) {
if (value === undefined || value === null || value === '') return []
return Array.isArray(value) ? value : [value]
}
/**
* Write a `multiple` param's whole list, and give back the whole box.
*
* Each item is coerced as `setParam` coerces one value, duplicates dropped
* keeping the first, and **an empty list removes the key** — `setParam`'s rule,
* for its reason: the server reads absent and `[]` alike, and "is required" is
* the error a required list left empty should get.
*/
export function setParamList(step, name, items, type) {
const parsed = parseParams(step?.paramsText)
if (parsed.error) return step?.paramsText || '{}'
const next = { ...parsed.params }
const out = []
for (const raw of items || []) {
if (raw === '' || raw === undefined || raw === null) continue
const value = coerceLiteral(type, raw)
if (!out.some((v) => v === value)) out.push(value)
}
if (out.length) next[name] = out
else delete next[name]
return JSON.stringify(next, null, 2)
}
/**
* Add one value to a list, or say why not. `{ list }` or `{ refused }`, so the
* control can put the sentence where the click was rather than silently doing
* nothing past `max`.
*/
export function addToList(list, value, max) {
const text = String(value ?? '').trim()
if (!text) return { list }
if (list.some((v) => String(v) === text)) return { list }
if (max && list.length >= max) return { refused: `This takes at most ${max} — remove one first.` }
return { list: [...list, text] }
}
/**
* The options a filter box leaves, matched on value, label and group alike,
* case-insensitively. The order is the source's own — a source sorts its rows
* (§12.2) and the editor keeps it.
*/
export function filterOptions(options, term) {
const t = String(term || '').trim().toLowerCase()
if (!t) return options || []
return (options || []).filter((o) =>
[o.value, o.label, o.group].some((s) => String(s || '').toLowerCase().includes(t)))
}
/**
* One step's params as a line the run console can show: `name: value`, a list
* joined by commas, anything else as its JSON. Shown rather than the raw object
* so a list of ten permissions reads as ten names, not as brackets and quotes.
*/
export function describeParams(params) {
if (!params || typeof params !== 'object') return ''
return Object.entries(params)
.map(([k, v]) => {
if (Array.isArray(v) && v.every((x) => x === null || typeof x !== 'object')) return `${k}: ${v.join(', ')}`
return `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`
})
.join(' · ')
}
// ── The step editor's own rules (MODULE_API 1.13.0; Rust PLAN_REDESIGNS §2) ──
//
// What the controls need to decide and nothing the server re-decides: which
// control a field gets, which required fields are empty, and whether a phase
// gives a timed step back before its time is up. The server still refuses a
// missing param by name — these exist so the form can say it first, in place.
/**
* A boolean as the control holds it. A lease's value is a string param that the
* lease reads as a boolean, so "true" and "false" are booleans here too.
*/
export function boolValue(value) {
if (value === true || value === 'true') return true
if (value === false || value === 'false') return false
return null
}
/**
* Should this source be drawn as a row of buttons? (§2.4)
*
* Two or three choices that fit on one line, from a source that answered and is
* not searched. A dropdown of two hides the other choice behind a click.
*/
export function isSegmentable(entry) {
return Boolean(
entry && entry.state === 'ok' && !entry.searchable &&
entry.options.length >= 2 && entry.options.length <= 3,
)
}
/** The note on the chosen option, if it carries one (§2.6). */
export function noteFor(entry, value) {
if (!entry || entry.state !== 'ok' || value === undefined || value === null || value === '') return null
const chosen = entry.options.find((o) => o.value === String(value))
return chosen?.note || null
}
/**
* The required params a step leaves empty, by name. `core.lease`'s `target` is
* optional on the action and required by a targeted LEASE, so the lease is
* asked too when there is one.
*/
export function missingParams(step, action, leasesById = new Map()) {
if (!action) return []
const parsed = parseParams(step?.paramsText)
if (parsed.error) return []
const empty = (v) => v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0)
const missing = (action.params || []).filter((p) => p.required && empty(parsed.params[p.name])).map((p) => p.name)
if (action.id === 'core.lease') {
const lease = leasesById.get(parsed.params.lease)
if (lease?.target && empty(parsed.params.target)) missing.push('target')
}
return missing
}
/** `30m`, `2h`, `90s`, `1d` → minutes. Null for anything the server would refuse. */
export function afterMinutes(after) {
const m = /^(\d{1,6})(s|m|h|d)$/.exec(String(after ?? '').trim())
if (!m) return null
const n = Number(m[1])
return { s: n / 60, m: n, h: n * 60, d: n * 1440 }[m[2]]
}
/** The longest declared duration in a step, in minutes; 0 for an untimed one. */
export function stepMinutes(step, action) {
const parsed = parseParams(step?.paramsText)
if (parsed.error || !action) return 0
let longest = 0
for (const p of action.params || []) {
if (p.duration !== 'minutes') continue
const n = Number(parsed.params[p.name])
if (Number.isFinite(n) && n > longest) longest = n
}
return longest
}
/** The longest timed step in a phase, in minutes. */
export const phaseMinutes = (phase, actionById) =>
Math.max(0, ...(phase?.steps || []).map((s) => stepMinutes(s, actionById.get(s.actionId))))
/**
* How long, at the least, the run goes on from the start of phase `pi`, in
* minutes — or Infinity when a phase from there on waits for something in the
* game, which may take any time at all.
*
* **Why the RUN and not the phase.** Teardown gives everything back when the run
* ends, so a 10-minute hold in phase 1 of three is safe if phases 2 and 3 take
* ten minutes between them. A phase with no advance rule ends when its steps do,
* which is counted as no time: a floor, never a promise.
*/
export function runFloorMinutes(phases, pi) {
let total = 0
for (const phase of (phases || []).slice(pi)) {
const kind = phase.advance?.kind || ''
if (kind === 'on') return Infinity
if (kind === 'after') total += afterMinutes(phase.advance.after) || 0
}
return total
}
/**
* Does phase `pi` give a timed step back before its time? (§2.2, U-6)
*
* `null`, or `{ longest, floor }` in minutes. Only a run that can END sooner than
* the longest hold counts: teardown is what gives a lease back, not the phase.
*/
export function timedPhaseShortfall(phases, pi, actionById) {
const longest = phaseMinutes(phases?.[pi], actionById)
if (!longest) return null
const floor = runFloorMinutes(phases, pi)
return floor < longest ? { longest, floor } : null
}
/**
* The phase's advance after a timed step was added or changed (§2.2).
*
* A phase with NO advance rule that would give the step back early gets
* `after: '<longest>m'`, visibly, and the author may change it. A phase the
* author already gave a rule keeps it: the warning under the phase says what it
* costs, and overriding an author's choice is not this function's to do.
*/
export function guardedAdvance(phases, pi, actionById) {
const phase = phases?.[pi]
if (!phase || (phase.advance?.kind || '') !== '') return null
const short = timedPhaseShortfall(phases, pi, actionById)
if (!short) return null
return { ...(phase.advance || blankAdvance()), kind: 'after', after: `${Math.ceil(short.longest)}m` }
}
/** "1 minute", "90 minutes", "2 hours". */
export function minutesWord(n) {
if (n >= 120 && n % 60 === 0) return `${n / 60} hours`
return `${n} minute${n === 1 ? '' : 's'}`
}
/** The sentence under a phase that ends a timed step early, or null. */
export function shortfallSentence(phases, pi, actionById) {
const short = timedPhaseShortfall(phases, pi, actionById)
if (!short) return null
const last = pi === (phases || []).length - 1
const ends = short.floor === 0
? (last ? 'This phase ends as soon as its steps are done, and the run with it' : 'The rest of this run can be over as soon as its steps are done')
: (last ? `This phase ends after ${minutesWord(short.floor)}, and the run with it` : `The rest of this run can be over ${minutesWord(short.floor)} after this phase starts`)
return `${ends}; what is held for ${minutesWord(short.longest)} is given back then, not after ${minutesWord(short.longest)}.`
}
/**
* Everything the meter needs out of the form, and nothing else.
*
@@ -734,6 +1055,15 @@ function detailValue(value) {
export const logKindWord = (kind) => KIND_WORDS[kind] || kind
// "20:10", in the viewer's own clock. Time only: the log line it sits in carries
// its own timestamp, and a retry is at most thirty minutes after it.
function clockTime(value) {
if (!value) return ''
const t = new Date(value)
if (Number.isNaN(t.getTime())) return ''
return `${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}`
}
/**
* One log line as a sentence.
*
@@ -760,14 +1090,20 @@ export function describeLogLine(line) {
return `${line.phase} finished`
case 'step.parked':
return `${d.action} is waiting on a human`
case 'step.retry':
return `${d.action} failed, attempt ${d.attempt} of ${d.of}${d.error ? `: ${d.error}` : ''}`
case 'step.retry': {
// `retryAt` is on every retry line since MODULE_API 1.15.0; a line written
// before it says what it always said.
const at = clockTime(d.retryAt)
return `${d.action} failed, attempt ${d.attempt} of ${d.of}${at ? `, retrying at ${at}` : ''}${d.error ? `: ${d.error}` : ''}`
}
case 'step.status':
return d.control
? `${d.action} → ${d.to}${by} — ${d.control}${d.note || d.reason ? `: ${d.note || d.reason}` : ''}`
: `${d.action} → ${d.to}${d.error ? `: ${d.error}` : ''}`
case 'run.created':
return `Occurrence created from version ${d.version}${d.rehearsal ? ' (rehearsal)' : ''}`
return `Occurrence created from version ${d.version}${d.rehearsal ? ' (rehearsal)' : ''}${
d.source === 'trigger' && d.origin ? ` — ${describeOrigin(d.origin)}` : ''
}`
case 'phase.gate':
return d.kind === 'after'
? `${line.phase} advances ${d.after} after it started`

View File

@@ -97,3 +97,13 @@ export function statusWord(status, scheduledFor, now = Date.now()) {
const at = new Date(scheduledFor).getTime()
return Number.isNaN(at) || at <= now ? 'Did not happen' : 'Cancelled'
}
// A live run's progress line (MODULE_API 1.12.0), as text. Core accepts two
// shapes from a module and interprets neither: a count, "Bandits: 3 of 8 left",
// and a percentage, "The Juggernaut: 62%". Anything else renders nothing.
export function progressText(line) {
if (!line || typeof line.label !== 'string' || !line.label) return ''
if (Number.isInteger(line.left) && Number.isInteger(line.of)) return `${line.label}: ${line.left} of ${line.of} left`
if (typeof line.percent === 'number') return `${line.label}: ${line.percent}%`
return ''
}

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,46 @@
// 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
// file bumps for the reason at the top.
// 1.16.0 — a dry run's `detail.warnings` (Rust PLAN_REDESIGNS §11.4, D391): a
// module's advice, shown by core's own event editor as warning findings.
// Server-side; this file bumps for the reason at the top.
// 1.15.0 — a failed step's `retryAfterMs` (Rust PLAN_REDESIGNS §14.3.13): when
// to try again. Server-side; the run console's retry line now says when. This
// file bumps for the reason at the top.
// 1.14.0 —an action param's `multiple` (Rust PLAN_REDESIGNS §12.3): a param
// that holds several values, drawn by core's step editor as a multi-select.
// Server-side declaration; this file bumps for the reason at the top.
// 1.13.0 — the event step editor (Rust PLAN_REDESIGNS §2): event templates, a
// param's `duration`, a lease's `example` and an option's `note`. All four are
// server-side declarations the editor, core's own page, renders. This file bumps
// for the reason at the top.
// 1.12.0 — an event action's `progress()`: a live run's public page shows how
// its steps stand. Server-side registration; the client half is core's own event
// page rendering the lines. This file bumps for the reason at the top.
// 1.11.0 — `ctx.events.expired({ kind, ref })` and the ledger's `expired` status
// (Rust PLAN_FIXES D183): a module may say the game ended a ledgered resource at
// its own deadline. Server-side only; the run console, which is core's own page,
// shows the new status. This file bumps for the reason at the top.
// 1.10.0 — the event contract opens to modules (EVENTS.md §F, EVENTS_PLAN.md
// Phase 7): a module may register event actions, budget dimensions, leases and
// param option sources. All four are server-side registrations and nothing on
@@ -74,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.10.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,9 @@ 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'
// Link/unlink external SSO identities to this account. Linking redirects through
@@ -15,13 +18,7 @@ function LinkedAccounts() {
const [available, setAvailable] = useState([])
const [error, setError] = useState('')
const banner = (() => {
const q = new URLSearchParams(window.location.search)
if (q.get('linked')) return { ok: true, text: 'Account linked.' }
if (q.get('link_error') === 'in_use') return { ok: false, text: 'That external account is already linked to another user.' }
if (q.get('link_error')) return { ok: false, text: 'Could not link that account. Please try again.' }
return null
})()
const banner = linkBanner(window.location.search)
const load = useCallback(async () => {
try {
@@ -40,12 +37,11 @@ function LinkedAccounts() {
}, [load])
const nameFor = (id) => available.find((p) => p.id === id)?.name || id.charAt(0).toUpperCase() + id.slice(1)
const iconFor = (id) => (id === 'google' || id === 'discord' ? id : 'oidc')
async function unlink(provider) {
if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return
async function unlink(identity) {
if (!window.confirm(unlinkPrompt(identity, nameFor(identity.provider)))) return
try {
await api.unlinkIdentity(provider)
await api.unlinkIdentity(identity.provider, identity.id)
await load()
} catch (err) {
setError(err.message || 'Could not unlink.')
@@ -55,8 +51,7 @@ function LinkedAccounts() {
if (error) return <ErrorState message={error} />
if (!linked) return null
const linkedIds = new Set(linked.map((i) => i.provider))
const linkable = available.filter((p) => !linkedIds.has(p.id))
const linkable = linkableProviders(available, linked)
return (
<div style={{ marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 28 }}>
@@ -64,7 +59,7 @@ function LinkedAccounts() {
Linked accounts
</h2>
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
Link a Google, Discord, or other SSO account so you can sign in with it. SSO can only sign in
Link a Google, Discord, Steam, or other SSO account so you can sign in with it. SSO can only sign in
to an account it is linked to — linking here is what grants that access.
</p>
@@ -77,15 +72,9 @@ function LinkedAccounts() {
{linked.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
{linked.map((i) => (
<div key={i.provider} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
<span style={{ display: 'inline-flex', width: 20, height: 20 }}>
<ProviderIcon icon={iconFor(i.provider)} size={20} />
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>{nameFor(i.provider)}</div>
{i.email && <div className="sans dim" style={{ fontSize: '0.78rem' }}>{i.email}</div>}
</div>
<button onClick={() => unlink(i.provider)} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
<div key={i.id ?? i.provider} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
<IdentitySummary identity={i} providerName={nameFor(i.provider)} />
<button onClick={() => unlink(i)} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
Unlink
</button>
</div>
@@ -328,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

@@ -5,12 +5,14 @@ import { api } from '../../../api/client.js'
// Admin config for authentication providers. Local password + TOTP is always on
// (informational tab). Google/Discord are built-ins with a fixed config surface
// (Enabled + Client ID + Client Secret). Custom providers use the full OIDC editor.
// (Enabled + Client ID + Client Secret); Steam is a built-in with Enabled + a
// Steam Web API key. Custom providers use the full OIDC editor.
const TABS = [
{ id: 'local', label: 'Local Accounts' },
{ id: 'google', label: 'Google' },
{ id: 'discord', label: 'Discord' },
{ id: 'steam', label: 'Steam' },
{ id: 'custom', label: 'Custom Providers' },
]
@@ -141,6 +143,87 @@ function BuiltinForm({ provider, onSaved }) {
)
}
// ── Steam (OpenID 2.0) ─────────────────────────────────────────────────────
// No client id and no redirect URL to register: Steam's sign-in takes neither.
// What it needs is a Steam Web API key, without which it cannot be enabled
// (STEAM_SIGNIN.md D394) — the key is how a linked account shows its persona
// name and avatar. Steam never creates an account (D395): a player links Steam
// to the account they already have.
function SteamForm({ provider, onSaved }) {
const [enabled, setEnabled] = useState(provider.enabled)
const [apiKey, setApiKey] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [error, setError] = useState('')
async function save() {
setBusy(true)
setMsg('')
setError('')
try {
const body = { enabled }
if (apiKey) body.secret = apiKey // only send a new key when entered
await api.admin.updateAuthProvider(provider.id, body)
setApiKey('')
setMsg('Saved.')
await onSaved()
} catch (err) {
setError(err.message || 'Could not save.')
} finally {
setBusy(false)
}
}
return (
<div style={{ maxWidth: 560, display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ display: 'inline-flex', width: 26, height: 26, color: 'var(--head)' }}>
<ProviderIcon icon="steam" size={26} />
</span>
<h2 className="display" style={{ margin: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
{provider.name}
</h2>
</div>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
Players link their Steam account from their account page and can then sign in with it. Steam never
creates an account: an unlinked Steam account signs nobody in, whatever the registration setting.
</p>
<Toggle checked={enabled} onChange={setEnabled} label="Enable this sign-in method" />
<HealthWarning provider={provider} />
<label style={{ display: 'block' }}>
<span className="field-label">Steam Web API key (required)</span>
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="input"
autoComplete="new-password"
placeholder={provider.hasSecret ? '•••••••• configured — leave blank to keep' : 'Steam Web API key'}
/>
</label>
<p className="sans dim" style={{ margin: '-8px 0 0', fontSize: '0.82rem', lineHeight: 1.5 }}>
Get one at{' '}
<a href="https://steamcommunity.com/dev/apikey" target="_blank" rel="noreferrer" className="link-accent">
steamcommunity.com/dev/apikey
</a>{' '}
with this site&apos;s domain. It is used only to show a linked account&apos;s Steam name and avatar, and is
never sent to a browser. No redirect URL needs registering with Steam.
</p>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 4 }}>
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : 'Save changes'}
</button>
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
</div>
</div>
)
}
// ── Local accounts (informational) ─────────────────────────────────────────
function LocalInfo() {
return (
@@ -376,6 +459,7 @@ export default function AuthProvidersAdmin() {
{tab === 'local' && <LocalInfo />}
{tab === 'google' && <BuiltinForm provider={byId('google')} onSaved={load} />}
{tab === 'discord' && <BuiltinForm provider={byId('discord')} onSaved={load} />}
{tab === 'steam' && byId('steam') && <SteamForm key="steam" provider={byId('steam')} onSaved={load} />}
{tab === 'custom' && <CustomProviders items={customs} onChanged={load} />}
</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)
}
@@ -166,7 +166,12 @@ export default function Dashboard() {
className="sans"
style={{ display: 'flex', gap: 14, alignItems: 'center', padding: '13px 18px', borderBottom: '1px solid var(--line-soft)', fontSize: '0.86rem' }}
>
<span style={{ flex: 'none', color: 'var(--accent)', fontSize: '0.66rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', width: 110, fontFamily: 'ui-monospace,Menlo,monospace' }}>
{/* `minWidth` rather than `width`: the column still lines up for core's
own short action names, and a longer one — a module's namespaced
action, say `rust.account.unlink.staff` — grows the box instead of
overflowing it and printing on top of the detail beside it. Found
on a live dashboard with a module installed. */}
<span style={{ flex: 'none', color: 'var(--accent)', fontSize: '0.66rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', minWidth: 110, fontFamily: 'ui-monospace,Menlo,monospace' }}>
{a.action}
</span>
<span style={{ flex: 1, color: 'var(--text)' }}>{formatDetail(a)}</span>

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,8 @@ import {
runControlsFor,
stepControlsFor,
describeLogLine,
describeParams,
describeOrigin,
} from '../../../lib/eventAuthoring.js'
// Admin → Events → the run console (EVENTS.md §I, Phase 3).
@@ -59,7 +61,7 @@ const STATUS_COLOR = {
completed: '#8fc79a',
}
// The six ledger statuses, in the two groups that matter to a reader: green is
// The seven ledger statuses, in the two groups that matter to a reader: green is
// resolved, amber wants a person. `orphaned` and `drifted` are amber rather than
// red because neither is a fault — one thing vanished, the other was taken by
// somebody with every right to take it — and red is reserved for "this did not
@@ -71,6 +73,9 @@ const RESOURCE_COLOR = {
reverting: '#d9c184',
drifted: '#d9c184',
orphaned: '#d9c184',
// Green, like `reverted`: the game ended it at the deadline it was given, which
// is the plan working (MODULE_API 1.11.0). `orphaned` is the amber one.
expired: '#8fc79a',
}
const RESOURCE_WORD = {
@@ -80,6 +85,7 @@ const RESOURCE_WORD = {
reverted: 'given back',
orphaned: 'gone',
drifted: 'someone else moved it',
expired: 'ended by the game on time',
}
const STEP_COLOR = {
@@ -126,7 +132,9 @@ function GateRow({ gate, current }) {
<div style={{ padding: '8px 0', borderTop: '1px solid var(--rule)' }}>
<div className="sans" style={{ fontSize: '0.86rem', color: colour }}>
Phase <strong>{gate.phase}</strong>
{current && !gate.satisfied ? ' has not started' : ''}
{/* W2: the phase is running and its steps are done; it is the advance
that waits, so "has not started" was the wrong sentence. */}
{current && !gate.satisfied ? ': steps done, waiting to advance' : ''}
{gate.satisfied && ` — released ${gate.satisfiedBy === 'forced' ? 'by hand' : `on its ${gate.satisfiedBy === 'elapsed' ? 'deadline' : 'condition'}`}`}
{gate.stalled && ' — STALLED'}
</div>
@@ -289,6 +297,13 @@ export default function EventRun() {
{run.rehearsal ? ' · rehearsal' : ''}
{run.concurrencyKey ? ` · key ${run.concurrencyKey}` : ''}
</p>
{/* Why it exists, when a game firing started it (D370). */}
{describeOrigin(run.origin) && (
<p className="sans" style={{ margin: '4px 0 0', fontSize: '0.8rem' }}>
{describeOrigin(run.origin)}
<span className="dim"> · {when(run.origin.occurredAt)}</span>
</p>
)}
</div>
<div style={{ textAlign: 'right' }}>
<div className="sans" style={{ fontSize: '1rem', color: STATUS_COLOR[run.status] || undefined }}>
@@ -376,7 +391,7 @@ export default function EventRun() {
</p>
)}
{/* ── Why this phase has not started (Phase 5) ──
{/* ── Why this phase has not advanced (Phase 5) ──
Above the step list for the same reason the parked cue is: a phase
waiting on a condition is `running` and looks completely healthy, and
the one screen an operator opens to find out why nothing is happening
@@ -387,7 +402,7 @@ export default function EventRun() {
style={{ padding: 14, marginBottom: 14, borderLeft: `3px solid ${waiting ? (waiting.stalled ? '#d98b84' : '#d9c184') : 'var(--rule)'}` }}
>
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>
{waiting ? 'Why this phase has not started' : 'Phase advance conditions'}
{waiting ? 'Why this phase has not advanced' : 'Phase advance conditions'}
</h3>
<p className="sans dim" style={{ margin: '0 0 4px', fontSize: '0.8rem' }}>
{waiting ? (
@@ -651,7 +666,7 @@ export default function EventRun() {
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
<code style={{ fontSize: '0.78rem' }}>{step.actionId}</code>
<div className="dim" style={{ fontSize: '0.74rem', maxWidth: 320, overflowWrap: 'anywhere' }}>
{JSON.stringify(step.params)}
{describeParams(step.params)}
</div>
</td>
<td className="adm-td" style={{ fontSize: '0.82rem', color: STEP_COLOR[step.status] || undefined }}>

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,9 @@ 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'
@@ -224,13 +227,7 @@ function LinkedAccounts() {
const [available, setAvailable] = useState([])
const [error, setError] = useState('')
const banner = (() => {
const q = new URLSearchParams(window.location.search)
if (q.get('linked')) return { ok: true, text: 'Account linked.' }
if (q.get('link_error') === 'in_use') return { ok: false, text: 'That external account is already linked to another user.' }
if (q.get('link_error')) return { ok: false, text: 'Could not link that account. Please try again.' }
return null
})()
const banner = linkBanner(window.location.search)
const load = useCallback(async () => {
try {
@@ -247,12 +244,11 @@ function LinkedAccounts() {
useEffect(() => { load() }, [load])
const nameFor = (id) => available.find((p) => p.id === id)?.name || id.charAt(0).toUpperCase() + id.slice(1)
const iconFor = (id) => (id === 'google' || id === 'discord' ? id : 'oidc')
async function unlink(provider) {
if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return
async function unlink(identity) {
if (!window.confirm(unlinkPrompt(identity, nameFor(identity.provider)))) return
try {
await api.unlinkIdentity(provider)
await api.unlinkIdentity(identity.provider, identity.id)
await load()
} catch (err) {
setError(err.message || 'Could not unlink.')
@@ -262,13 +258,12 @@ function LinkedAccounts() {
if (error) return <ErrorState message={error} />
if (!linked) return null
const linkedIds = new Set(linked.map((i) => i.provider))
const linkable = available.filter((p) => !linkedIds.has(p.id))
const linkable = linkableProviders(available, linked)
return (
<Section title="Linked accounts">
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
Link a Google, Discord, or other provider so you can sign in with it.
Link a Google, Discord, Steam, or other provider so you can sign in with it.
</p>
{banner && (
<p className="sans" style={{ color: banner.ok ? '#7fd0a4' : '#d98b84', fontSize: '0.86rem' }}>{banner.text}</p>
@@ -276,15 +271,9 @@ function LinkedAccounts() {
{linked.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
{linked.map((i) => (
<div key={i.provider} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
<span style={{ display: 'inline-flex', width: 20, height: 20 }}>
<ProviderIcon icon={iconFor(i.provider)} size={20} />
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>{nameFor(i.provider)}</div>
{i.email && <div className="sans dim" style={{ fontSize: '0.78rem' }}>{i.email}</div>}
</div>
<button onClick={() => unlink(i.provider)} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>Unlink</button>
<div key={i.id ?? i.provider} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
<IdentitySummary identity={i} providerName={nameFor(i.provider)} />
<button onClick={() => unlink(i)} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>Unlink</button>
</div>
))}
</div>
@@ -435,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

@@ -14,13 +14,19 @@
// branch left a failed load spinning for ever with nothing on screen naming the
// problem. Order matters, and the order is error first.
import { useEffect, useState } from 'react'
import { useParams, useSearchParams, Link } from 'react-router-dom'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js'
import { eventDateTime, statusWord } from '../../lib/eventCalendar.js'
import { eventDateTime, statusWord, progressText } from '../../lib/eventCalendar.js'
// How often a live event's progress lines are read again while the page is open.
// The server caches each run's answer for five seconds, so this is the reader's
// cadence, not load on the game.
const PROGRESS_REFRESH_MS = 15_000
export default function EventPage() {
const { slug } = useParams()
@@ -28,6 +34,27 @@ export default function EventPage() {
const run = params.get('run')
const { loading, error, data } = useAsync(() => api.publicEvent(slug, run), [slug, run])
// The live occurrence's progress (MODULE_API 1.12.0), re-read on a timer while
// it is live. Only the lines are replaced: re-running the page's own fetch
// would blank the page to a spinner every fifteen seconds.
const [progress, setProgress] = useState(null)
const live = Boolean(data && data.event && data.event.live)
useEffect(() => {
setProgress(null)
if (!live) return undefined
let active = true
const timer = setInterval(() => {
api
.publicEvent(slug, run)
.then((fresh) => active && setProgress(fresh?.event?.current?.progress || []))
.catch(() => {})
}, PROGRESS_REFRESH_MS)
return () => {
active = false
clearInterval(timer)
}
}, [slug, run, live])
if (error) {
return (
<PublicLayout section="website">
@@ -91,6 +118,16 @@ export default function EventPage() {
the event is never published. */}
{event.current.phase || 'Under way'}
</div>
{/* How it stands, in the words the steps' modules chose. Core
renders the two shapes it accepts and interprets neither. */}
{(progress || event.current.progress || []).map((line, i) => {
const text = progressText(line)
return text ? (
<div key={i} className="sans" style={{ marginTop: 4 }}>
{text}
</div>
) : null
})}
</>
) : event.next ? (
<>

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

@@ -347,6 +347,21 @@ test('the log lines a run produces all render as something', () => {
}
})
test('a retry line says when it will run (MODULE_API 1.15.0), and an older line still reads', () => {
// Built from local parts so the assertion holds in any timezone the suite runs in.
const at = new Date(2026, 9, 15, 20, 10, 0)
const text = describeLogLine({
kind: 'step.retry',
detail: { action: 'rust.dt.start', attempt: 1, of: 3, error: 'not ready yet', retryAt: at.toISOString() },
})
assert.equal(text, 'rust.dt.start failed, attempt 1 of 3, retrying at 20:10: not ready yet')
const before = describeLogLine({ kind: 'step.retry', detail: { action: 'core.announce', attempt: 2, of: 3 } })
assert.equal(before, 'core.announce failed, attempt 2 of 3')
const junk = describeLogLine({ kind: 'step.retry', detail: { action: 'a', attempt: 1, of: 3, retryAt: 'soon' } })
assert.equal(junk, 'a failed, attempt 1 of 3', 'an unreadable time is left out, not shown as NaN')
})
// ── The one log line core did not compose (Phase 15) ──────────────────────
test('a module detail line renders the module keys, not the kind id', () => {

View File

@@ -9,7 +9,7 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { eventTime, eventDateTime, readerDayLabel, statusWord } from '../src/lib/eventCalendar.js'
import { eventTime, eventDateTime, readerDayLabel, statusWord, progressText } from '../src/lib/eventCalendar.js'
// 2026-09-12T00:00Z is 2026-09-11 20:00 in New York — deliberately an instant
// whose DATE differs between the two zones, which is what makes the split
@@ -87,3 +87,11 @@ test('the other three words do not depend on the clock at all', () => {
test('an unreadable instant falls to the past-tense word rather than throwing', () => {
assert.equal(statusWord('cancelled', 'not a date', NOW), 'Did not happen')
})
test('a progress line is a count or a percent, and nothing else renders (MODULE_API 1.12.0)', () => {
assert.equal(progressText({ label: 'Bandits', left: 3, of: 8 }), 'Bandits: 3 of 8 left')
assert.equal(progressText({ label: 'The Juggernaut', percent: 62 }), 'The Juggernaut: 62%')
assert.equal(progressText({ label: 'Odd' }), '')
assert.equal(progressText({ left: 1, of: 2 }), '')
assert.equal(progressText(null), '')
})

View File

@@ -0,0 +1,94 @@
// A param that holds several values (MODULE_API 1.14.0; Rust PLAN_REDESIGNS
// §12.3): what the multi-select stores, when it gives way to JSON, and how the
// run console reads a list back.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
blankStep,
paramsMode,
PARAM_FORM,
PARAM_JSON,
listValue,
setParamList,
addToList,
filterOptions,
missingParams,
describeParams,
} from '../src/lib/eventAuthoring.js'
const LEND = {
id: 'test.perm.lend',
params: [
{ name: 'server', type: 'string', required: true, example: 'main' },
{ name: 'perms', type: 'string', required: true, example: 'nightvision.allowed', multiple: true, max: 3 },
{ name: 'ids', type: 'int', required: false, example: 7, multiple: true, max: 20 },
],
}
const step = (params) => ({ actionId: LEND.id, paramsText: JSON.stringify(params) })
test('a new step holds the example as a list of one', () => {
const fresh = JSON.parse(blankStep(LEND).paramsText)
assert.deepEqual(fresh.perms, ['nightvision.allowed'])
assert.deepEqual(fresh.ids, [7])
assert.equal(fresh.server, 'main')
})
test('a list is a value the form can hold; a structure in it is not', () => {
assert.equal(paramsMode(step({ perms: ['a', 'b'] }), LEND).mode, PARAM_FORM)
assert.equal(paramsMode(step({ perms: 'a' }), LEND).mode, PARAM_FORM)
const nested = paramsMode(step({ perms: [{ a: 1 }] }), LEND)
assert.equal(nested.mode, PARAM_JSON)
assert.match(nested.reason, /"perms" holds a structure/)
// A list against a single-value param still forces JSON, as before 1.14.0.
assert.equal(paramsMode(step({ server: ['main'] }), LEND).mode, PARAM_JSON)
})
test('listValue reads a bare value as a list of one, and nothing as none', () => {
assert.deepEqual(listValue(['a', 'b']), ['a', 'b'])
assert.deepEqual(listValue('a'), ['a'])
for (const empty of [undefined, null, '']) assert.deepEqual(listValue(empty), [])
})
test('setParamList coerces, drops duplicates in order, and removes an empty list', () => {
const s = step({ server: 'main' })
assert.deepEqual(JSON.parse(setParamList(s, 'ids', ['3', '1', '3'], 'int')).ids, [3, 1])
assert.deepEqual(JSON.parse(setParamList(s, 'perms', ['b', 'a', 'b', ''], 'string')).perms, ['b', 'a'])
const cleared = JSON.parse(setParamList(step({ server: 'main', perms: ['a'] }), 'perms', [], 'string'))
assert.equal('perms' in cleared, false)
// A box that does not parse is handed back untouched.
assert.equal(setParamList({ paramsText: '{nope' }, 'perms', ['a'], 'string'), '{nope')
})
test('addToList ignores blanks and repeats, and refuses past max in words', () => {
assert.deepEqual(addToList(['a'], ' b ', 3).list, ['a', 'b'])
assert.deepEqual(addToList(['a'], 'a', 3).list, ['a'])
assert.deepEqual(addToList(['a'], ' ', 3).list, ['a'])
assert.match(addToList(['a', 'b', 'c'], 'd', 3).refused, /at most 3/)
})
test('filterOptions matches value, label or group and keeps the source order', () => {
const options = [
{ value: 'nightvision.allowed', label: 'nightvision.allowed', group: 'NightVision' },
{ value: 'betterchat.color', label: 'betterchat.color', group: 'BetterChat' },
{ value: 'kits.vip', label: 'kits.vip', group: 'Kits' },
]
assert.equal(filterOptions(options, '').length, 3)
assert.deepEqual(filterOptions(options, 'NIGHT').map((o) => o.value), ['nightvision.allowed'])
assert.deepEqual(filterOptions(options, 'chat').map((o) => o.value), ['betterchat.color'])
assert.deepEqual(filterOptions(options, 'i').map((o) => o.value), ['nightvision.allowed', 'kits.vip'])
})
test('an empty required list is missing', () => {
assert.deepEqual(missingParams(step({ server: 'main', perms: [] }), LEND), ['perms'])
assert.deepEqual(missingParams(step({ server: 'main' }), LEND), ['perms'])
assert.deepEqual(missingParams(step({ server: 'main', perms: ['a'] }), LEND), [])
})
test('the run console shows a list as its values joined by commas', () => {
assert.equal(
describeParams({ server: 'main', perms: ['nightvision.allowed', 'betterchat.color'], confirm: true }),
'server: main · perms: nightvision.allowed, betterchat.color · confirm: true',
)
assert.equal(describeParams({ where: { a: 1 } }), 'where: {"a":1}')
assert.equal(describeParams(null), '')
})

View File

@@ -0,0 +1,110 @@
// A definition's trigger starts in the editor, and a run's origin on the
// console (Rust PLAN_REDESIGNS §14.3.12, D370). The server decides; these prove
// the form carries what it is given and the sentences read right.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
formFromDefinition,
payloadFromForm,
blankStart,
startsFormFrom,
startsPayload,
isStartableTrigger,
describeOrigin,
describeTriggerStarts,
describeLogLine,
} from '../src/lib/eventAuthoring.js'
const TRIGGER = {
id: 'rust.world.bradley_destroyed',
label: 'Bradley destroyed',
variables: [
{ name: 'server', type: 'string' },
{ name: 'kills', type: 'int' },
],
}
const triggersById = new Map([[TRIGGER.id, TRIGGER]])
const definition = (starts) => ({
title: 'Launch Site Lockdown',
spec: {
schedule: { kind: 'manual' },
...(starts ? { starts } : {}),
phases: [{ key: 'main', label: 'Main', steps: [] }],
},
})
test('a stored start round-trips through the form unchanged', () => {
const starts = [{ on: TRIGGER.id, where: { variable: 'server', cmp: 'eq', value: 'main' }, cooldown: '2h' }]
const form = formFromDefinition(definition(starts))
assert.equal(form.starts.length, 1)
assert.equal(form.starts[0].whereRows.length, 1)
const built = payloadFromForm(form, { triggersById })
assert.equal(built.ok, true)
assert.deepEqual(built.payload.spec.starts, starts)
})
test('no starts is no key, as the server stores it', () => {
const built = payloadFromForm(formFromDefinition(definition()), { triggersById })
assert.equal('starts' in built.payload.spec, false)
assert.deepEqual(formFromDefinition(definition()).starts, [])
})
test('a half-added row is dropped, a blank cooldown is the default, a literal takes the declared type', () => {
const rows = [
blankStart(),
{ ...blankStart(), on: TRIGGER.id, cooldown: ' ', whereRows: [{ variable: 'kills', cmp: 'gte', value: '2' }] },
]
const errors = []
const out = startsPayload(rows, errors, triggersById)
assert.deepEqual(errors, [])
assert.deepEqual(out, [{ on: TRIGGER.id, cooldown: '30m', where: { variable: 'kills', cmp: 'gte', value: 2 } }])
})
test('a nested condition the builder cannot draw is posted back verbatim', () => {
const where = { op: 'and', nodes: [{ variable: 'server', cmp: 'eq', value: 'main' }, { op: 'or', nodes: [{ variable: 'kills', cmp: 'gt', value: 1 }, { variable: 'kills', cmp: 'lt', value: 0 }] }] }
const [row] = startsFormFrom([{ on: TRIGGER.id, where, cooldown: '0m' }])
assert.equal(row.whereEditable, false)
assert.deepEqual(startsPayload([row], [], triggersById), [{ on: TRIGGER.id, cooldown: '0m', where }])
})
test('core\'s own lifecycle triggers are not offered', () => {
assert.equal(isStartableTrigger({ id: 'event.run.started' }), false)
assert.equal(isStartableTrigger(TRIGGER), true)
})
test('the origin sentence', () => {
assert.equal(describeOrigin(null), null)
assert.equal(
describeOrigin({
kind: 'trigger',
triggerId: TRIGGER.id,
label: 'Bradley destroyed',
matched: 'server is "main"',
variables: { server: 'main', grid: 'G8', _truncated: true },
}),
'Started because “Bradley destroyed” fired (matching server is "main"): server main · grid G8',
)
assert.equal(describeOrigin({ kind: 'trigger', triggerId: 'x.y', variables: {} }), 'Started because “x.y” fired')
})
test('the editor\'s ignored-firings line', () => {
assert.equal(describeTriggerStarts(null), null)
assert.equal(
describeTriggerStarts({ lastRunId: 12, ignoredRunning: 2, ignoredCooldown: 1, ignoredUnverified: 0 }),
'Last started run #12; since then 2 firings ignored while a run was going, 1 firing ignored in the cooldown.',
)
assert.equal(
describeTriggerStarts({ lastRunId: null, ignoredRunning: 0, ignoredCooldown: 0, ignoredUnverified: 1 }),
'No run started by a trigger yet; since then 1 firing ignored because the published version is not verified.',
)
assert.equal(describeTriggerStarts({ lastRunId: 3 }), 'Last started run #3.')
})
test('the audit line names a trigger start', () => {
const line = describeLogLine({
kind: 'run.created',
detail: { version: 2, source: 'trigger', origin: { kind: 'trigger', label: 'Bradley destroyed', variables: { grid: 'G8' } } },
})
assert.equal(line, 'Occurrence created from version 2 — Started because “Bradley destroyed” fired: grid G8')
})

View File

@@ -0,0 +1,144 @@
// The step editor's own rules (MODULE_API 1.13.0; Rust PLAN_REDESIGNS §2): which
// control a field gets, which required fields are empty, and whether a phase
// gives a timed step back before its time is up.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
boolValue,
isSegmentable,
noteFor,
missingParams,
afterMinutes,
stepMinutes,
runFloorMinutes,
timedPhaseShortfall,
guardedAdvance,
shortfallSentence,
minutesWord,
blankAdvance,
} from '../src/lib/eventAuthoring.js'
const LEASE_ACTION = {
id: 'core.lease',
params: [
{ name: 'lease', type: 'string', required: true },
{ name: 'value', type: 'string', required: true },
{ name: 'target', type: 'string', required: false },
{ name: 'minutes', type: 'int', required: true, duration: 'minutes' },
],
}
const SAY = { id: 'test.say', params: [{ name: 'message', type: 'string', required: true }] }
const actionById = new Map([[LEASE_ACTION.id, LEASE_ACTION], [SAY.id, SAY]])
const step = (actionId, params) => ({ actionId, paramsText: JSON.stringify(params) })
const lease = (minutes) => step('core.lease', { lease: 'x.flag', value: 'true', minutes })
const phase = (steps, advance = blankAdvance()) => ({ key: 'p', label: 'P', steps, advance })
const after = (a) => ({ ...blankAdvance(), kind: 'after', after: a })
const on = () => ({ ...blankAdvance(), kind: 'on', on: 'x.happened' })
test('a lease\'s "true" is a boolean to the control', () => {
assert.equal(boolValue('true'), true)
assert.equal(boolValue(false), false)
assert.equal(boolValue(''), null)
assert.equal(boolValue(undefined), null)
})
test('two or three answered choices are buttons; a search or a long list is not', () => {
const ok = (n, extra = {}) => ({ state: 'ok', options: Array.from({ length: n }, (_, i) => ({ value: String(i) })), ...extra })
assert.equal(isSegmentable(ok(2)), true)
assert.equal(isSegmentable(ok(3)), true)
assert.equal(isSegmentable(ok(1)), false)
assert.equal(isSegmentable(ok(4)), false)
assert.equal(isSegmentable(ok(2, { searchable: true })), false)
assert.equal(isSegmentable({ state: 'failed', options: [] }), false)
})
test('the chosen option\'s note, and nothing for one without', () => {
const entry = { state: 'ok', options: [{ value: 'vip', note: '9 of 41 used it up' }, { value: 'free' }] }
assert.equal(noteFor(entry, 'vip'), '9 of 41 used it up')
assert.equal(noteFor(entry, 'free'), null)
assert.equal(noteFor(entry, ''), null)
})
test('required and empty, including the target a targeted lease needs', () => {
assert.deepEqual(missingParams(step('test.say', {}), SAY), ['message'])
assert.deepEqual(missingParams(step('test.say', { message: 'hi' }), SAY), [])
const leases = new Map([['x.perm', { id: 'x.perm', target: { label: 'Group' } }], ['x.flag', { id: 'x.flag' }]])
assert.deepEqual(
missingParams(step('core.lease', { lease: 'x.perm', value: 'true', minutes: 5 }), LEASE_ACTION, leases),
['target'],
)
assert.deepEqual(missingParams(lease(5), LEASE_ACTION, leases), [])
// A step whose JSON does not parse is the JSON box's to report, not this.
assert.deepEqual(missingParams({ actionId: 'test.say', paramsText: '{' }, SAY), [])
})
test('the server\'s after grammar, in minutes', () => {
assert.equal(afterMinutes('30m'), 30)
assert.equal(afterMinutes('2h'), 120)
assert.equal(afterMinutes('90s'), 1.5)
assert.equal(afterMinutes('1d'), 1440)
assert.equal(afterMinutes('soon'), null)
})
test('a step is as long as its longest declared duration', () => {
assert.equal(stepMinutes(lease(10), LEASE_ACTION), 10)
assert.equal(stepMinutes(step('test.say', { message: 'x' }), SAY), 0)
assert.equal(stepMinutes(lease('ten'), LEASE_ACTION), 0)
})
test('the run\'s floor counts every later phase, and a game trigger is unbounded', () => {
const phases = [phase([]), phase([], after('5m')), phase([], after('1h'))]
assert.equal(runFloorMinutes(phases, 0), 65)
assert.equal(runFloorMinutes(phases, 2), 60)
assert.equal(runFloorMinutes([phase([]), phase([], on())], 0), Infinity)
})
test('U-6: a one-phase event with no rule gives a 10-minute lease back at once', () => {
const phases = [phase([lease(10)])]
assert.deepEqual(timedPhaseShortfall(phases, 0, actionById), { longest: 10, floor: 0 })
assert.equal(
shortfallSentence(phases, 0, actionById),
'This phase ends as soon as its steps are done, and the run with it; what is held for 10 minutes is given back then, not after 10 minutes.',
)
})
test('a phase with no rule is given after: <longest>, and a chosen rule is left alone', () => {
const advance = guardedAdvance([phase([lease(10)])], 0, actionById)
assert.equal(advance.kind, 'after')
assert.equal(advance.after, '10m')
// The author's 5 minutes stays; the sentence says what it costs instead.
const chosen = [phase([lease(10)], after('5m'))]
assert.equal(guardedAdvance(chosen, 0, actionById), null)
assert.equal(
shortfallSentence(chosen, 0, actionById),
'This phase ends after 5 minutes, and the run with it; what is held for 10 minutes is given back then, not after 10 minutes.',
)
})
test('later phases long enough to cover the hold are no shortfall at all', () => {
const phases = [phase([lease(10)]), phase([], after('15m'))]
assert.equal(timedPhaseShortfall(phases, 0, actionById), null)
assert.equal(guardedAdvance(phases, 0, actionById), null)
assert.equal(shortfallSentence(phases, 0, actionById), null)
// A later phase that waits on the game cannot be counted, and is not warned about.
assert.equal(timedPhaseShortfall([phase([lease(10)]), phase([], on())], 0, actionById), null)
const short = [phase([lease(10)]), phase([], after('3m'))]
assert.match(shortfallSentence(short, 0, actionById), /^The rest of this run can be over 3 minutes after this phase starts;/)
})
test('an untimed phase is never touched', () => {
const phases = [phase([step('test.say', { message: 'x' })])]
assert.equal(guardedAdvance(phases, 0, actionById), null)
assert.equal(shortfallSentence(phases, 0, actionById), null)
})
test('minutes read as words', () => {
assert.equal(minutesWord(1), '1 minute')
assert.equal(minutesWord(45), '45 minutes')
assert.equal(minutesWord(120), '2 hours')
assert.equal(minutesWord(90), '90 minutes')
})

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,
@@ -179,7 +195,7 @@ CREATE TABLE IF NOT EXISTS activity_log (
-- custom (oidc/oauth2) providers.
CREATE TABLE IF NOT EXISTS auth_providers (
id VARCHAR(64) PRIMARY KEY, -- 'google' | 'discord' | custom slug
kind ENUM('google','discord','oidc','oauth2') NOT NULL,
kind ENUM('google','discord','oidc','oauth2','steam') NOT NULL,
name VARCHAR(80) NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 0,
client_id VARCHAR(255) NULL,
@@ -203,6 +219,8 @@ CREATE TABLE IF NOT EXISTS user_identities (
provider VARCHAR(64) NOT NULL, -- matches auth_providers.id
subject VARCHAR(191) NOT NULL, -- external stable user id (sub / discord id)
email VARCHAR(255) NULL,
display_name VARCHAR(191) NULL, -- Steam persona name (D394); null for other providers
avatar_url VARCHAR(500) NULL, -- Steam avatar, a Steam CDN https URL; null for others
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_identity_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY uq_identity_provider_subject (provider, subject),
@@ -1151,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,
@@ -1283,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
@@ -2560,7 +2644,7 @@ CREATE TABLE IF NOT EXISTS event_run_resources (
-- defensible. This column is core's copy of that promise, for the console and
-- for the boot-time check.
lease_until DATETIME NULL,
status ENUM('pending','confirmed','reverting','reverted','orphaned','drifted')
status ENUM('pending','confirmed','reverting','reverted','orphaned','drifted','expired')
NOT NULL DEFAULT 'pending',
-- Bounded like a step's `attempts`, and for the same reason: a revert that can
-- never succeed must become visible rather than cycling for ever. Engagement
@@ -2587,6 +2671,14 @@ CREATE TABLE IF NOT EXISTS event_run_resources (
INDEX idx_evres_live (status, lease_until)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- MODULE_API 1.11.0 (Rust PLAN_FIXES D183): `expired`, the game ending a resource
-- at its own deadline, for databases created before it. Terminal, so `live_marker`
-- releases the target. MODIFY has no IF NOT EXISTS form, but re-declaring the same
-- ENUM is an idempotent no-op -- checked against MariaDB 11.8 with the stored
-- `live_marker` column depending on it -- so it is safe on every boot.
ALTER TABLE event_run_resources MODIFY COLUMN status
ENUM('pending','confirmed','reverting','reverted','orphaned','drifted','expired') NOT NULL DEFAULT 'pending';
-- §K's last bound: "a scheduled definition that has never been verified is the
-- case worth refusing to start". A version is immutable, so a dry run that passed
-- against it stays true — which is what makes the pass a property of the VERSION
@@ -2598,6 +2690,18 @@ CREATE TABLE IF NOT EXISTS event_run_resources (
ALTER TABLE event_versions ADD COLUMN IF NOT EXISTS verified_at DATETIME NULL;
ALTER TABLE event_versions ADD COLUMN IF NOT EXISTS verified_by INT NULL;
-- A dry run that passed on the DRAFT, and the fingerprint of the spec it passed
-- on (W5, Rust PLAN_LAST_FIXES). A draft changes under the author's hands, so a
-- pass on it is not a property of any version -- but publish compares this
-- fingerprint with the spec it is about to freeze, and when they are equal the
-- new version is stamped verified with this pass's reviewer and time. Without it
-- an author who dry-ran and then published found v1 "not dry run", and its
-- scheduled and trigger starts held, until they ran the same check again. An
-- edit after the dry run changes the fingerprint, so nothing stale carries.
ALTER TABLE event_definitions ADD COLUMN IF NOT EXISTS draft_verified_at DATETIME NULL;
ALTER TABLE event_definitions ADD COLUMN IF NOT EXISTS draft_verified_by INT NULL;
ALTER TABLE event_definitions ADD COLUMN IF NOT EXISTS draft_verified_hash CHAR(64) NULL;
-- Whether this definition appears on the PUBLIC calendar (Phase 14a).
--
-- **Not a second answer to the question `state` answers**, which is the trap the
@@ -2696,3 +2800,52 @@ ALTER TABLE event_runs ADD COLUMN IF NOT EXISTS results_published_at DATETIME NU
-- that no longer resolves answers that honestly.
ALTER TABLE announce_jobs ADD COLUMN IF NOT EXISTS run_id BIGINT NULL;
ALTER TABLE announce_jobs ADD INDEX IF NOT EXISTS idx_announce_run (run_id);
-- ── A trigger can start a run (Rust PLAN_REDESIGNS §14.3.12, D370) ─────────
-- Why a run exists, when a game firing started it: the trigger, when it fired,
-- its subject and its variables, so the console can say "started because a
-- champion boss is up at Abyss" and the audit can say what. NULL for a run an
-- admin or the schedule started, which `run.created`'s `source` already says.
--
-- A column rather than only a log line because the run log is pruned and the
-- run is not, and "why did this run happen" outlives "what did each step do".
ALTER TABLE event_runs ADD COLUMN IF NOT EXISTS origin JSON NULL;
-- One row per definition that a trigger has tried to start: the cooldown's
-- clock, the claim that makes "one run at a time" true, and the count of
-- firings it ignored, which the editor shows ("2 firings ignored while
-- running").
--
-- **The claim is a conditional UPDATE on this row**, the event_run_budget
-- argument once more: two firings arriving together each try it, and the
-- cooldown, the one-at-a-time check and a stale-claim guard are all in its
-- WHERE, so exactly one of them owns the start. `claimed_at` is held only
-- between the claim and the run row existing — a minute is a crashed process,
-- not a slow insert.
--
-- The counts are since the LAST trigger-started run, and a new start zeroes
-- them: what an operator asks is "is it ignoring things right now", and a
-- lifetime total cannot answer that.
CREATE TABLE IF NOT EXISTS event_trigger_starts (
definition_id INT NOT NULL PRIMARY KEY,
claimed_at DATETIME NULL,
last_started_at DATETIME NULL, -- the firing's time, which is the run's scheduled_for
last_run_id BIGINT NULL,
ignored_running INT NOT NULL DEFAULT 0,
ignored_cooldown INT NOT NULL DEFAULT 0,
ignored_unverified INT NOT NULL DEFAULT 0,
last_ignored_at DATETIME NULL,
last_ignored_reason VARCHAR(32) NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_evtrig_def FOREIGN KEY (definition_id) REFERENCES event_definitions(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Sign in with Steam (STEAM_SIGNIN.md §4). Steam is a built-in provider beside
-- Google and Discord, so its row needs the kind; and a linked Steam account
-- shows its persona name and avatar (D394), kept on the identity and refreshed
-- at each Steam sign-in. Both additive. MODIFY has no IF NOT EXISTS form, but
-- re-declaring the same ENUM is an idempotent no-op.
ALTER TABLE auth_providers MODIFY COLUMN kind ENUM('google','discord','oidc','oauth2','steam') NOT NULL;
ALTER TABLE user_identities ADD COLUMN IF NOT EXISTS display_name VARCHAR(191) NULL;
ALTER TABLE user_identities ADD COLUMN IF NOT EXISTS avatar_url VARCHAR(500) NULL;

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.10.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

@@ -545,6 +545,15 @@
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/events/catalog/templates/:templateId",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/events/price",
@@ -809,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",
@@ -1775,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",
@@ -1825,7 +1857,7 @@
{
"method": "DELETE",
"path": "/api/v1/auth/me/account/identities/:provider",
"handlers": 3,
"handlers": 4,
"gates": [
"noindex",
"requireAuth",
@@ -1907,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",
@@ -1938,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",
@@ -2276,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",
@@ -2464,9 +2578,10 @@
{
"method": "GET",
"path": "/api/v1/public/events/:slug",
"handlers": 2,
"handlers": 3,
"gates": [
"siteMode"
"siteMode",
"optionalAuth"
]
},
{

View File

@@ -241,6 +241,10 @@
"method": "GET",
"path": "/api/v1/admin/events/catalog/options/:sourceId"
},
{
"method": "POST",
"path": "/api/v1/admin/events/catalog/templates/:templateId"
},
{
"method": "POST",
"path": "/api/v1/admin/events/price"
@@ -353,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"
@@ -729,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"
@@ -781,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"
@@ -793,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"
@@ -937,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

@@ -0,0 +1,97 @@
// ── Modules and identity links (MODULE_API 1.17.0) ─────────────────────────
//
// Core owns linking an external sign-in identity to an account. Modules take part
// in exactly two places (STEAM_SIGNIN.md §4):
//
// checkLink() before the identity is written. Each module that registered
// `onIdentityLinking` may refuse. Rust uses it for D396: a Steam
// id that is already another website account's Rust link.
// notifyLinked() after it is written. Rust uses it for D392: the Steam account
// becomes a linked Rust player.
//
// A refusal can only stop a link. It runs inside the link flow of a user who is
// already signed in, so nothing here can mint a session or let through a link
// core would refuse.
//
// What a module says is never shown to the player: `reason: 'in_use'` reads as
// the same "already linked to another user" core says itself, and anything else
// as a plain refusal. A module's own words go to the log. Putting module text in
// a redirect URL would let anybody craft a link that prints a sentence of their
// choosing on our account page.
const loader = require('../modules/loader')
const log = require('../utils/logger')('sso')
const HOOK_TIMEOUT_MS = 5_000
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 whether this link may be made.
*
* **Fails closed.** A module that throws or does not answer in time refuses the
* link: the hook exists to stop a link that must not happen, and a module that
* could not look is a module that could not say it was safe. The player is told
* to try again; a link is never the urgent half of anything.
*
* @param {{userId: number, provider: string, subject: string}} link
* @returns {Promise<{ok: true} | {ok: false, reason: 'in_use' | 'refused', module: string}>}
*/
async function checkLink(link) {
const frozen = Object.freeze({ userId: link.userId, provider: link.provider, subject: link.subject })
for (const { id, hook } of loader.identityHooks('onIdentityLinking')) {
let answer
try {
answer = await withTimeout(() => hook(frozen), HOOK_TIMEOUT_MS)
} catch (err) {
log.error('identity link check failed; refusing the link', { module: id, provider: frozen.provider, message: err.message })
return { ok: false, reason: 'refused', module: id }
}
if (answer && answer.ok === false) {
const reason = answer.reason === 'in_use' ? 'in_use' : 'refused'
log.info('identity link refused by a module', {
module: id,
provider: frozen.provider,
userId: frozen.userId,
reason,
message: typeof answer.message === 'string' ? answer.message.slice(0, 200) : undefined,
})
return { ok: false, reason, module: id }
}
}
return { ok: true }
}
/**
* Tell every started module a link was written.
*
* Awaited (bounded) so the page the player lands on already shows what a module
* made of it — the Rust page listing the Steam account they just linked — but a
* failure never reaches the link: the identity is written, and that is core's
* answer. A module that missed it is logged.
*
* @param {{userId: number, provider: string, subject: string, displayName?: string|null}} link
*/
async function notifyLinked(link) {
const frozen = Object.freeze({
userId: link.userId,
provider: link.provider,
subject: link.subject,
displayName: link.displayName || null,
})
await Promise.all(
loader.identityHooks('onIdentityLinked').map(({ id, hook }) =>
withTimeout(() => hook(frozen), HOOK_TIMEOUT_MS).catch((err) => {
log.error('identity linked hook failed', { module: id, provider: frozen.provider, message: err.message })
}),
),
)
}
module.exports = { checkLink, notifyLinked, HOOK_TIMEOUT_MS }

View File

@@ -12,6 +12,7 @@
const GoogleProvider = require('./google.provider')
const DiscordProvider = require('./discord.provider')
const GenericOidcProvider = require('./genericOidc.provider')
const SteamProvider = require('./steam.provider')
const authProviders = require('../../model/authProviders/authProviders.model')
// kind → provider class.
@@ -20,6 +21,7 @@ const KINDS = {
discord: DiscordProvider,
oidc: GenericOidcProvider,
oauth2: GenericOidcProvider,
steam: SteamProvider,
}
// Built-in providers and their fixed display metadata. Endpoints are in the
@@ -27,6 +29,9 @@ const KINDS = {
const BUILTINS = [
{ id: 'google', kind: 'google', name: 'Google', priority: 1 },
{ id: 'discord', kind: 'discord', name: 'Discord', priority: 2 },
// OpenID 2.0, not OAuth2 (STEAM_SIGNIN.md §3): no client id, and the "secret"
// is the Steam Web API key it cannot be enabled without (D394).
{ id: 'steam', kind: 'steam', name: 'Steam', priority: 3 },
]
const BUILTIN_IDS = new Set(BUILTINS.map((b) => b.id))
@@ -35,6 +40,18 @@ function isBuiltin(id) {
return BUILTIN_IDS.has(id)
}
// Providers whose callback is OpenID 2.0 (`openid.*` fields) rather than an OAuth2
// `code`. Steam is the only one, and only as the built-in: a custom provider can
// only be oidc/oauth2, so the kind is known from the id before any row is read.
function isOpenId(id) {
return id === 'steam'
}
// Providers that never sign anybody up, whatever player_registration says (D395).
function neverProvisions(kind) {
return kind === 'steam'
}
// Instantiate a provider from a config row (secret already decrypted by the
// model as `client_secret`). Returns null for an unknown kind.
function instantiate(row) {
@@ -66,6 +83,11 @@ async function load(id) {
// custom (oidc/oauth2) also need the three endpoint URLs. Returns { valid, missing }.
function validateConfig(row) {
const missing = []
if (row.kind === 'steam') {
// No client id; the Web API key is the one thing it needs (D394).
if (!row.client_secret_enc && !row.client_secret) missing.push('api_key')
return { valid: missing.length === 0, missing }
}
if (!row.client_id) missing.push('client_id')
// A stored secret shows up as client_secret_enc on plain rows, or client_secret
// on decrypted rows — accept either as "has a secret".
@@ -110,7 +132,7 @@ async function listEnabledValid() {
.map((p) => ({
id: p.id,
name: p.name,
icon: p.kind, // 'google' | 'discord' | 'oidc' | 'oauth2'
icon: p.kind, // 'google' | 'discord' | 'steam' | 'oidc' | 'oauth2'
loginUrl: `/api/v1/auth/sso/${p.id}/start`,
priority: p.priority ?? 100,
}))
@@ -120,6 +142,8 @@ module.exports = {
KINDS,
BUILTINS,
isBuiltin,
isOpenId,
neverProvisions,
instantiate,
load,
validateConfig,

View File

@@ -0,0 +1,172 @@
// Built-in Steam provider (OpenID 2.0, STEAM_SIGNIN.md §3).
//
// Steam is not OAuth2: there is no client id, no token endpoint and no `state`
// parameter. The browser goes to steamcommunity.com with an OpenID 2.0
// `checkid_setup` request and comes back carrying `openid.*` fields, and the ONLY
// proof those fields are genuine is asking Steam itself — a server-to-server
// `check_authentication` POST of the same fields, which must answer
// `is_valid:true`. Our CSRF `state` rides inside `return_to`, where the existing
// single-use tx cookie verifies it exactly as it does for the OAuth providers.
//
// The admin-configured "secret" is a Steam Web API key (D394), used for one thing:
// the persona name and avatar shown beside a linked Steam account. That lookup
// decorates an identity and never decides a sign-in — if it fails, the sign-in
// goes ahead with the bare id.
const BaseProvider = require('./base.provider')
const log = require('../../utils/logger')('sso')
const OP_ENDPOINT = 'https://steamcommunity.com/openid/login'
const OPENID_NS = 'http://specs.openid.net/auth/2.0'
const IDENTIFIER_SELECT = 'http://specs.openid.net/auth/2.0/identifier_select'
const CLAIMED_ID_RE = /^https:\/\/steamcommunity\.com\/openid\/id\/(\d{17})$/
const SUMMARIES_URL = 'https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/'
// Steam serves avatars from its own CDNs. Anything else in that field is not
// something we render.
const AVATAR_RE = /^https:\/\/([a-z0-9-]+\.)*(steamstatic\.com|akamaihd\.net)\/[^\s"'<>]{1,400}$/i
const VERIFY_TIMEOUT_MS = 10_000
const SUMMARY_TIMEOUT_MS = 5_000
// A failure the controller turns into a sentence rather than a 500.
class SteamAssertionError extends Error {
constructor(reason) {
super(`steam assertion refused: ${reason}`)
this.reason = reason
}
}
// The 17-digit SteamID64 at the end of a claimed id, or null.
function parseClaimedId(claimedId) {
const m = typeof claimedId === 'string' ? CLAIMED_ID_RE.exec(claimedId) : null
return m ? m[1] : null
}
// The `return_to` we send, and must get back byte for byte: the callback URL with
// our tx nonce as `state`.
function returnToFor(redirectUri, state) {
const sep = redirectUri.includes('?') ? '&' : '?'
return `${redirectUri}${sep}state=${encodeURIComponent(state)}`
}
// The realm is the origin the return_to lives under — what Steam shows the user
// as "you are signing in to".
function realmFor(redirectUri) {
return new URL(redirectUri).origin
}
// Every `openid.*` field from a parsed query, as strings. Anything else is ours
// (`state`) and does not go back to Steam.
function openidFields(query = {}) {
const out = {}
for (const [k, v] of Object.entries(query)) {
if (k.startsWith('openid.') && typeof v === 'string') out[k] = v
}
return out
}
class SteamProvider extends BaseProvider {
constructor(config = {}) {
super({ kind: 'steam', name: 'Steam', ...config, id: config.id || 'steam' })
// Stored in client_secret_enc like any secret; it is a Web API key here.
this.apiKey = config.clientSecret ?? config.client_secret ?? null
}
getAuthorizationUrl(state, { redirectUri } = {}) {
const params = new URLSearchParams({
'openid.ns': OPENID_NS,
'openid.mode': 'checkid_setup',
'openid.return_to': returnToFor(redirectUri, state),
'openid.realm': realmFor(redirectUri),
'openid.identity': IDENTIFIER_SELECT,
'openid.claimed_id': IDENTIFIER_SELECT,
})
return `${OP_ENDPOINT}?${params.toString()}`
}
// `query` is the callback's parsed query string; `redirectUri` and `state` are
// what we sent, so `return_to` can be matched exactly. Resolves to the usual
// { subject, email, emailVerified, name } plus displayName/avatarUrl, or throws
// SteamAssertionError.
async handleCallback({ query, redirectUri, state } = {}) {
const fields = openidFields(query)
if (fields['openid.mode'] !== 'id_res') throw new SteamAssertionError('mode')
if (fields['openid.ns'] !== OPENID_NS) throw new SteamAssertionError('ns')
if (fields['openid.op_endpoint'] !== OP_ENDPOINT) throw new SteamAssertionError('op_endpoint')
if (fields['openid.return_to'] !== returnToFor(redirectUri, state)) throw new SteamAssertionError('return_to')
const subject = parseClaimedId(fields['openid.claimed_id'])
if (!subject) throw new SteamAssertionError('claimed_id')
if (fields['openid.identity'] !== fields['openid.claimed_id']) throw new SteamAssertionError('identity')
if (!(await this.verifyAssertion(fields))) throw new SteamAssertionError('not_valid')
const summary = await this.playerSummary(subject)
return {
subject,
email: null, // Steam never sends one
emailVerified: false,
name: summary.displayName,
displayName: summary.displayName,
avatarUrl: summary.avatarUrl,
}
}
// The one proof (§5): send the same fields back with mode check_authentication.
// Never skipped; any transport failure is a refusal, not a pass.
async verifyAssertion(fields) {
const body = new URLSearchParams({ ...fields, 'openid.mode': 'check_authentication' })
let text
try {
const res = await fetch(OP_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
signal: AbortSignal.timeout(VERIFY_TIMEOUT_MS),
})
if (!res.ok) {
log.warn('steam check_authentication failed', { status: res.status })
return false
}
text = await res.text()
} catch (err) {
log.warn('steam check_authentication unreachable', { message: err.message })
return false
}
return /^is_valid:true$/m.test(text)
}
// Persona name + avatar through the Web API key. Decoration only: every failure
// answers nulls and the sign-in carries on (D394). The key rides in the URL, so
// the URL is never logged.
async playerSummary(steamId) {
const none = { displayName: null, avatarUrl: null }
if (!this.apiKey) return none
try {
const url = `${SUMMARIES_URL}?key=${encodeURIComponent(this.apiKey)}&steamids=${steamId}`
const res = await fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(SUMMARY_TIMEOUT_MS) })
if (!res.ok) {
log.warn('steam player summary failed; signing in with the bare id', { status: res.status })
return none
}
const data = await res.json()
const player = data && data.response && Array.isArray(data.response.players) ? data.response.players[0] : null
if (!player || player.steamid !== steamId) return none
const name = typeof player.personaname === 'string' ? player.personaname.trim().slice(0, 191) : ''
const avatar = player.avatarfull || player.avatarmedium || player.avatar
return {
displayName: name || null,
avatarUrl: typeof avatar === 'string' && AVATAR_RE.test(avatar) ? avatar : null,
}
} catch (err) {
log.warn('steam player summary unreachable; signing in with the bare id', { message: err.message })
return none
}
}
}
SteamProvider.SteamAssertionError = SteamAssertionError
SteamProvider.parseClaimedId = parseClaimedId
SteamProvider.returnToFor = returnToFor
SteamProvider.OP_ENDPOINT = OP_ENDPOINT
module.exports = SteamProvider

View File

@@ -137,14 +137,14 @@ const ACTIONS = [
name: 'title',
type: 'string',
required: false,
example: 'The gates of Britain open at dusk',
example: 'The gates open at dusk',
description: 'Optional heading, for legs that render one.',
},
{
name: 'body',
type: 'string',
required: true,
example: 'A caravan has been sighted on the road east of Cove.',
example: 'A caravan has been sighted on the east road.',
description: 'The announcement itself. Plain text.',
},
],
@@ -250,7 +250,7 @@ const ACTIONS = [
name: 'instruction',
type: 'string',
required: true,
example: 'Open the north gate and read the herald script in Britain bank.',
example: 'Open the north gate and read the herald script in the town square.',
description: 'What the staff member is being asked to do.',
},
{
@@ -306,7 +306,10 @@ const ACTIONS = [
name: 'lease',
type: 'string',
required: true,
example: 'uo.rate.skillgain',
// Game-neutral (Rust PLAN_REDESIGNS §0.9): this was a UO rate, shown to
// every deployment's authors. The editor now draws the chosen lease's own
// form and examples (MODULE_API 1.13.0), so this is only ever a fallback.
example: 'mygame.rate.gather',
source: 'core.options.leases',
description: 'Which declared value to borrow.',
},
@@ -347,6 +350,10 @@ const ACTIONS = [
type: 'int',
required: true,
example: 120,
// A timed step (MODULE_API 1.13.0): the editor keeps the phase open at
// least this long, because teardown gives the value back when the run
// ends, whatever this says.
duration: 'minutes',
description: 'How long to hold it. Core refuses more than the lease allows.',
},
],
@@ -435,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}`,
@@ -487,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

@@ -144,7 +144,9 @@ function priceOf(action, params) {
function dimensionsOf(action) {
const params = {}
for (const p of action?.params || []) {
if (p.example !== undefined && p.example !== null) params[p.name] = p.example
// A `multiple` param's example is one value and `cost()` is handed a list
// (1.14.0), so it is priced as a list of one.
if (p.example !== undefined && p.example !== null) params[p.name] = p.multiple ? [p.example] : p.example
}
const priced = priceOf(action, params)
return priced ? Object.keys(priced).sort() : []

View File

@@ -320,6 +320,16 @@ async function sweep() {
// cleanup, whose counters were spent deliberately.
const candidates = await resourcesDb.runsNeedingCleanup(CLEANUP_RUN_BATCH, MAX_REVERT_ATTEMPTS)
let swept = 0
// **A finished run with nothing left to give back is complete** (MODULE_API
// 1.11.0). The scan above joins on an unresolved row, and until `expired` no
// run could end `pending` without one. Now a zone can expire while its run is
// still going — `expireResource` leaves a live run's status to its terminal
// path — and when that run ends, nothing in the scan would ever select it:
// `pending` for ever. Found by the step-2 walk, run 46.
for (const runId of await resourcesDb.finishedRunsWithNothingLeft(CLEANUP_RUN_BATCH)) {
if (await runsDb.setCleanupStatus(runId, 'complete', ['pending'])) swept += 1
}
for (const candidate of candidates) {
if (!runsDb.TERMINAL.includes(candidate.status)) continue
try {
@@ -347,13 +357,16 @@ async function sweep() {
* Reverting it would be core recording that it put something back when what
* actually happened is that the thing vanished while nobody was looking, and the
* two are different sentences to the operator reading the console afterwards.
* One gone after its `until` is neither: the game ended it on time, so it is
* `expired`, the line `expired()` would have written (W1).
*
* A module with no `reconcile()` on the action is not broken: core keeps
* believing its own ledger, which is precisely the behaviour before this phase.
*/
async function reconcileModule(owner) {
async function reconcileModule(owner, now = new Date()) {
const rows = await resourcesDb.liveForModule(owner)
const summary = { asked: 0, inForce: 0, orphaned: 0, unanswered: 0 }
const summary = { asked: 0, inForce: 0, orphaned: 0, expired: 0, unanswered: 0 }
const settled = new Set()
if (!rows.length) return summary
const byStep = new Map()
@@ -401,6 +414,19 @@ async function reconcileModule(owner) {
summary.inForce += 1
continue
}
// **Gone after its deadline is the deadline working** (W1, Rust
// PLAN_LAST_FIXES). A resource that carries `until` is one the game ends
// by itself, and on a reconnect the module's reconcile can run before the
// game's backlog says so: the run log read `resource.orphaned` five seconds
// before `resource.expired`. Past its `until`, gone is expired. Before it,
// gone is still nobody-was-looking, which is what orphaned means.
if (row.lease_until && new Date(row.lease_until).getTime() <= now.getTime()) {
if (await settleExpired(owner, row)) {
summary.expired += 1
settled.add(row.run_id)
}
continue
}
await resourcesDb.markOrphaned(row.id, 'the module reports this is no longer in force')
summary.orphaned += 1
await logDb.write({
@@ -410,9 +436,42 @@ async function reconcileModule(owner) {
})
}
}
await closeSettledRuns(settled)
return summary
}
/**
* One row the game ended at its deadline: `expired`, and its log line. The same
* line whichever way core learned it, from the module's `expired()` or from a
* reconcile that found it gone past its `until`, so the console reads one way.
*/
async function settleExpired(owner, row) {
if (!(await resourcesDb.markExpired(row.id, 'the game ended it at its deadline'))) return false
await logDb.write({
runId: row.run_id,
kind: 'resource.expired',
detail: { module: owner, resource: `${row.kind}:${row.ref}` },
})
return true
}
/**
* A finished run whose last unresolved row just expired has nothing left to
* clean up. The sweep would settle a `pending` one by itself, but not an
* `incomplete` one — that status takes a run out of the sweep's scan, and it
* would go on saying "not finished" about a ledger that is. A run still in
* flight is left alone: its own terminal path computes the status.
*/
async function closeSettledRuns(runIds) {
for (const runId of runIds) {
const run = await runsDb.getById(runId)
if (!run || !runsDb.TERMINAL.includes(run.status)) continue
if ((await resourcesDb.unresolvedCount(runId)) === 0) {
await runsDb.setCleanupStatus(runId, 'complete', ['pending', 'incomplete'])
}
}
}
/** Ask every module that owns a live row. Core's own boot-time sweep. */
async function reconcileAll() {
const owners = await resourcesDb.modulesWithLiveRows()
@@ -427,6 +486,47 @@ async function reconcileAll() {
return out
}
// The bounds on what a module may name. They are the columns' own: a longer
// value cannot be a row, so it is refused rather than truncated into a match
// against somebody else's.
const MAX_KIND = 64
const MAX_REF = 190
/**
* The game ended one of this module's resources by itself, as it was told to —
* `ctx.events.expired({ kind, ref })`, MODULE_API 1.11.0 (Rust PLAN_FIXES D183).
*
* **Expired is not orphaned, and the difference is the reason this exists.** A
* Rust zone is created with a deadline and the game erases it when the deadline
* passes, without being asked again — the fail-safe that makes an unattended
* world change defensible. Until a module could say so, core learned of it only
* through reconcile, which files it as `orphaned`: a thing that vanished while
* nobody was looking, amber on the console and still claimable for a revert.
* An expiry is the plan working, so it is terminal and green like `reverted`.
*
* `owner` is bound by the loader, never taken from the module's arguments, for
* `reconcile`'s reason: without the binding a module could close another
* module's rows. Answers `{ expired }`; nothing matching is `{ expired: 0 }`,
* because a module hears expiries of things no run ledgered too.
*/
async function expireResource(owner, { kind, ref } = {}) {
if (typeof kind !== 'string' || !kind || kind.length > MAX_KIND) return { expired: 0 }
if (typeof ref !== 'string' || !ref || ref.length > MAX_REF) return { expired: 0 }
const rows = await resourcesDb.expirableByTarget(owner, kind, ref)
const runs = new Set()
let expired = 0
for (const row of rows) {
if (!(await settleExpired(owner, row))) continue
expired += 1
runs.add(row.run_id)
}
await closeSettledRuns(runs)
return { expired }
}
module.exports = {
MAX_REVERT_ATTEMPTS,
classifyRevert,
@@ -434,4 +534,5 @@ module.exports = {
sweep,
reconcileModule,
reconcileAll,
expireResource,
}

View File

@@ -44,6 +44,38 @@ const MAX_HOLD_SECONDS = 7 * 24 * 60 * 60
// rendered half of one would be a second bug on top of the first.
const MAX_DETAIL_BYTES = 4096
// The bounds on a failure's `retryAfterMs`, in milliseconds (MODULE_API 1.15.0,
// Rust PLAN_REDESIGNS §14.3.13, D371). One minute to thirty. **Clamped, never
// refused**: it is the module's hint about its own verb — "the treasure is still
// warming up, try in ten minutes" — not an author's value, so a module that says
// five seconds or five hours gets the nearest bound rather than a failed step.
// The floor is the flat delay's own default, so no hint can make a retry come
// sooner than an unhinted one would on a stock deployment; the ceiling keeps
// `MAX_ATTEMPTS` tries of a step inside an hour and a half of the moment the
// event was about.
const MIN_RETRY_AFTER_MS = 60_000
const MAX_RETRY_AFTER_MS = 30 * 60_000
/**
* A failure's `retryAfterMs`, clamped, or null for "use the flat delay".
*
* A value that is not a number at all is the hint being absent rather than
* wrong: it is dropped and logged, the step retries on the flat delay, and
* nothing about the failure changes — the same posture as a bad `detail`.
*/
function safeRetryAfter(value, actionId) {
if (value === undefined || value === null) return null
const n = typeof value === 'number' ? value : Number.NaN
if (!Number.isFinite(n)) {
log.warn('event action answered a retryAfterMs that is not a number; using the flat delay', {
action: actionId,
retryAfterMs: String(value).slice(0, 40),
})
return null
}
return Math.min(MAX_RETRY_AFTER_MS, Math.max(MIN_RETRY_AFTER_MS, Math.round(n)))
}
/**
* A module's own account of what a successful step actually did.
*
@@ -127,7 +159,7 @@ function withDeadline(fn, ms, actionId) {
/**
* Turn a raw `perform()` answer into
* `{ outcome, error?, holdSeconds?, resources?, participants? }`.
* `{ outcome, error?, retryAfterMs?, holdSeconds?, resources?, participants? }`.
*
* Exported and pure, so the classification rules are testable without a registry,
* a database or a clock — which matters because they are the rules that decide
@@ -150,10 +182,13 @@ function classify(result, actionId) {
// says `retry: false`, and an envelope that forgot to say anything gets the
// benefit of the doubt on the transient question but not on the success one.
const retry = result.retry !== false
return {
outcome: retry ? 'retry' : 'terminal',
error: result.error ? String(result.error) : `${actionId} refused`,
}
const error = result.error ? String(result.error) : `${actionId} refused`
if (!retry) return { outcome: 'terminal', error }
// **When to try again** is the module's to suggest and core's to bound. Read
// on the retry shape only: a terminal failure is never attempted again, so a
// delay beside `retry: false` describes nothing.
const retryAfterMs = safeRetryAfter(result.retryAfterMs, actionId)
return retryAfterMs === null ? { outcome: 'retry', error } : { outcome: 'retry', error, retryAfterMs }
}
// ── The two success shapes that are not "finished" ──
@@ -250,4 +285,15 @@ async function dispatchStep(step, { run, actor = null, verify = false } = {}) {
return classification
}
module.exports = { dispatchStep, classify, withDeadline, safeDetail, OUTCOMES, MAX_HOLD_SECONDS, MAX_DETAIL_BYTES }
module.exports = {
dispatchStep,
classify,
withDeadline,
safeDetail,
safeRetryAfter,
OUTCOMES,
MAX_HOLD_SECONDS,
MAX_DETAIL_BYTES,
MIN_RETRY_AFTER_MS,
MAX_RETRY_AFTER_MS,
}

View File

@@ -0,0 +1,188 @@
// ── A live run's progress lines ────────────────────────────────────────────
//
// EVENTS.md §I and MODULE_API 1.12.0. While a run is live, its public page can
// say how it stands — "Bandits: 3 of 8 left", "The Juggernaut: 62%" — and only
// the module that carried out a step knows the answer. So an action may declare
// `progress(envelope)`, and this file asks each step of the run whose action has
// one.
//
// **Core never interprets a line.** It accepts exactly two shapes, a count and a
// percentage, each with a label, and renders them as text. Core cannot know what
// a "bandit" is, and does not need to: it knows "label: left of total" and
// "label: percent", which serve any game, and a UO module could fill them for its
// own actions without a word of this file changing (D305).
//
// **Nothing here may hurt the page that asked.** A public event page is read by
// anybody, so every failure — a module that throws, answers late, answers
// nonsense or is uninstalled — is a line that is not shown, and a log line. The
// page always renders. That is `registerTeamProvider`'s posture rather than
// `dispatch.js`'s: a missing progress line costs a reader one sentence, and a page
// that errored would cost them the event.
//
// **One answer per run and viewer level, for a few seconds.** A busy page is
// read by many people at once, and each read reaching the game through a module
// would turn the audience into load. The module's own caches (Rust's map answer)
// sit behind this one; this one bounds how often core asks at all.
const registries = require('../modules/registries')
const stepsDb = require('../model/events/eventRunSteps.db')
const resourcesDb = require('../model/events/eventRunResources.db')
const { withDeadline } = require('./dispatch')
const log = require('../utils/logger')('events')
// The deadline for one `progress()` call. Short, because a reader is waiting on
// it: the page renders without a late line rather than waiting for it.
const PROGRESS_BUDGET_MS = 2000
// How long one run's lines are reused for, per viewer.
const CACHE_MS = 5000
// The bound on a label, in characters. A label is a phrase beside a number.
const MAX_LABEL = 80
// The bound on lines per run. A run with more Place steps than this has a page
// that is a list of numbers, and the cap keeps a module bug from becoming a wall.
const MAX_LINES = 20
// The ledger statuses that still mean "this run's".
const HELD = new Set(resourcesDb.HELD)
/**
* One module answer as a line, or null. Pure and exported: the two shapes are the
* contract, and anything else is dropped rather than repaired.
*/
function toLine(answer, actionId) {
if (answer === null || answer === undefined) return null
if (typeof answer !== 'object' || Array.isArray(answer)) {
log.warn('event progress is not an object', { action: actionId })
return null
}
const label = typeof answer.label === 'string' ? answer.label.trim() : ''
if (!label || label.length > MAX_LABEL) {
log.warn('event progress has a bad label', { action: actionId })
return null
}
const hasCount = answer.left !== undefined || answer.of !== undefined
const hasPercent = answer.percent !== undefined
if (hasCount === hasPercent) {
log.warn('event progress must be a count or a percent', { action: actionId })
return null
}
if (hasCount) {
const { left, of } = answer
if (!Number.isInteger(left) || !Number.isInteger(of) || left < 0 || of < 1 || left > of) {
log.warn('event progress has a bad count', { action: actionId })
return null
}
return { label, left, of }
}
const percent = Number(answer.percent)
if (typeof answer.percent !== 'number' || !Number.isFinite(percent) || percent < 0 || percent > 100) {
log.warn('event progress has a bad percent', { action: actionId })
return null
}
return { label, percent: Math.round(percent) }
}
/** run id + viewer key → { at, lines } and { pending } while one is in flight. */
const cache = new Map()
// A key per reader, so the map is pruned of answers past their window once it
// grows; an ended run's entries go with them.
const PRUNE_AT = 1000
function prune(now) {
if (cache.size < PRUNE_AT) return
for (const [key, entry] of cache) {
if (!entry.pending && now() - entry.at >= CACHE_MS) cache.delete(key)
}
}
/**
* The lines one step's action answers, or null. Never throws.
*/
async function stepLine(step, run, resources, viewer) {
const action = registries.eventAction(step.action_id)
if (!action || typeof action.progress !== 'function') return null
const envelope = {
runId: run.id,
stepId: step.id,
idempotencyKey: step.idempotency_key,
scope: run.scope || '',
params: step.params || {},
// The ledger rows this step made that are still the run's, as the module
// wrote them. Core adds nothing a module did not already hold.
resources: resources.map((r) => ({ kind: r.kind, ref: r.ref, payload: r.payload })),
// Who is reading, as an account id or null. The module decides what its own
// visibility settings let that account see, from its own re-read of the row.
viewer: { userId: viewer.userId == null ? null : viewer.userId },
}
try {
const raw = await withDeadline(() => action.progress(envelope), PROGRESS_BUDGET_MS, action.id)
if (raw && raw.__timedOut) {
log.warn('event progress timed out', { action: action.id, run: run.id, step: step.id })
return null
}
return toLine(raw, action.id)
} catch (err) {
log.warn('event progress threw', { action: action.id, run: run.id, step: step.id, message: err.message })
return null
}
}
/**
* The progress lines for one live run, in step order. Resolves `[]` when there
* is nothing to say or anything went wrong; never rejects.
*
* `viewer.userId` is the reader's account id or null. The cache is keyed on it
* because what a module shows can depend on who is reading.
*/
async function forRun(run, { viewer = { userId: null }, now = Date.now } = {}) {
if (!run || run.id == null) return []
const key = `${run.id}|${viewer.userId == null ? '' : viewer.userId}`
const hit = cache.get(key)
if (hit && hit.lines && now() - hit.at < CACHE_MS) return hit.lines
if (hit && hit.pending) return hit.pending
prune(now)
const pending = (async () => {
let lines = []
try {
const steps = (await stepsDb.listForRun(run.id)).filter((s) => {
const action = registries.eventAction(s.action_id)
return action && typeof action.progress === 'function'
})
if (steps.length) {
const ledger = (await resourcesDb.forRun(run.id)).filter(
(r) => HELD.has(r.status) && r.kind !== resourcesDb.STEP_KIND,
)
const answers = await Promise.all(
steps.map((s) => stepLine(s, run, ledger.filter((r) => String(r.step_id) === String(s.id)), viewer)),
)
lines = answers.filter(Boolean).slice(0, MAX_LINES)
}
} catch (err) {
log.warn('event progress could not be read', { run: run.id, message: err.message })
lines = []
}
cache.set(key, { at: now(), lines })
return lines
})()
cache.set(key, { ...(hit || {}), pending })
try {
return await pending
} finally {
const entry = cache.get(key)
if (entry && entry.pending === pending) delete entry.pending
}
}
/** Test seam. */
function _reset() {
cache.clear()
}
module.exports = { forRun, toLine, PROGRESS_BUDGET_MS, CACHE_MS, MAX_LABEL, MAX_LINES, PRUNE_AT, _reset }

View File

@@ -12,13 +12,14 @@
// one that decides. A spec arriving by any other route (a restore, a fixture, a
// module shipping a definition as content) gets the same answer.
//
// **What this file knows, and what it deliberately refuses.** Two top-level keys
// exist today: `schedule` and `phases`. Unknown top-level keys are REFUSED rather
// **What this file knows, and what it deliberately refuses.** Three top-level
// keys exist today: `schedule`, `starts` and `phases`. Unknown top-level keys are REFUSED rather
// than preserved: a spec that silently carries `announcements` today is a spec
// whose author believes announcements work, and the later phase that gives the
// key meaning would inherit a corpus of unvalidated ones. The refusal list is the
// changelog — Phase 4 added the recurrence shapes, Phase 5 added a phase's
// `advance`, Phase 10 adds `announcements`.
// `advance`, Phase 10 adds `announcements`, and Rust PLAN_REDESIGNS §14.3.12
// added `starts`, the triggers that begin a run (D370).
//
// **Phase 4 widened `schedule` from one shape to four** — `manual`, `once`,
// `weekly`, `monthly` — and every check on them is a check on SHAPE. The
@@ -28,6 +29,8 @@
// DST gap shifts it, it is never rejected), so a schedule that validates here
// computes there.
const crypto = require('crypto')
const registries = require('../modules/registries')
// For `priceOf` and `undeclaredDimensions` only — the save-time half of §F's
// fail-closed budget rule (Phase 7). Nothing here reaches the database:
@@ -76,6 +79,24 @@ const MAX_AFTER_SECONDS = 30 * 86_400
// never advances written as one that eventually does.
const MAX_ADVANCE_COUNT = 1000
// A definition's `starts` (Rust PLAN_REDESIGNS §14.3.12, D370): the triggers that
// begin a run of it. Bounded for the reason MAX_PHASES is — a paste guard, not a
// policy, and ten is more than any event here has wanted.
const MAX_STARTS = 10
// How long after one trigger-started run another may start. Thirty minutes by
// default, zero (one run at a time is then the only bound) up to a week. It is
// the same one-integer-one-unit grammar as `after`, for the same proofreading
// reason, and normalised the same way.
const DEFAULT_COOLDOWN = '30m'
const MAX_COOLDOWN_SECONDS = 7 * 86_400
// Core's own lifecycle triggers cannot start a run (D370). `event.run.started`
// starting a run would be a run starting another, and the one-at-a-time rule is
// per definition, so it would not hold a chain of two definitions starting each
// other. Refused by prefix because every one of them is core's and named so.
const CORE_EVENT_PREFIX = 'event.'
// The four closed shapes of §E. `manual` is first because it is the default and
// what an unscheduled draft carries; the other three are recurrences the runner
// expands into occurrences ahead of time.
@@ -323,6 +344,105 @@ function validateAdvance(raw, path, errors) {
return { on: triggerId, where: checked.conditions, count, dormant: false }
}
/**
* Parse a cooldown into seconds, or answer null. `'0m'` (or a bare `0`) is
* allowed here and nowhere else in the duration grammar: no cooldown at all is a
* real choice, and then one run at a time is the only bound.
*/
function parseCooldown(raw) {
if (raw === 0 || raw === '0') return 0
const m = AFTER_RE.exec(String(raw ?? ''))
if (!m) return null
const seconds = Number(m[1]) * AFTER_UNIT_SECONDS[m[2]]
return seconds <= MAX_COOLDOWN_SECONDS ? seconds : null
}
const formatCooldown = (seconds) => (seconds === 0 ? '0m' : formatAfter(seconds))
/**
* Check a definition's `starts` and answer the normalised list (D370).
*
* Each entry is a phase gate's `on` and `where` — the same catalogue, the same
* grammar, the same dormant rule — plus a `cooldown`. A trigger nobody registers
* makes the entry dormant: it saves, and it refuses to publish.
*/
function validateStarts(raw, errors) {
if (raw === undefined || raw === null) return []
if (!Array.isArray(raw)) {
errors.push('spec.starts: expected an array')
return []
}
if (raw.length > MAX_STARTS) {
errors.push(`spec.starts: at most ${MAX_STARTS} triggers`)
return []
}
const out = []
raw.forEach((entry, i) => {
const path = `spec.starts[${i}]`
if (!isPlainObject(entry)) {
errors.push(`${path}: expected an object`)
return
}
const extra = Object.keys(entry).filter((k) => !['on', 'where', 'cooldown', 'dormant'].includes(k))
if (extra.length) {
errors.push(`${path}: unknown key(s) ${extra.join(', ')}`)
return
}
if (typeof entry.on === 'string' && entry.on.startsWith(CORE_EVENT_PREFIX)) {
errors.push(`${path}.on: "${entry.on}" is one of the event system's own triggers, and a run cannot start another`)
return
}
const cooldownSeconds = parseCooldown(entry.cooldown ?? DEFAULT_COOLDOWN)
if (cooldownSeconds === null) {
errors.push(
`${path}.cooldown: expected a duration like "30m" — a whole number of s, m, h or d, ` +
`from 0 to ${formatAfter(MAX_COOLDOWN_SECONDS)}`,
)
return
}
// The trigger and its predicate are a gate's, so a gate's validator answers
// for them: one grammar, one dormant rule, one set of messages.
const gate = validateAdvance({ on: entry.on, where: entry.where ?? null }, path, errors)
if (!gate) return
out.push({ on: gate.on, where: gate.where, cooldown: formatCooldown(cooldownSeconds), dormant: gate.dormant })
})
return out
}
/**
* A `multiple` param's value as the list it is (MODULE_API 1.14.0, Rust
* PLAN_REDESIGNS §12.3).
*
* `{ value: [...] }` or `{ error }`. **A bare value is a list of one**, so a
* spec written by hand or over the API before its author knew the param took
* several does not trip on it. Absent, `null`, `''` and `[]` are all the empty
* list, which the caller reads as unset — `required` means at least one.
*
* Each element is the type's literal, checked by the same `checkLiteral` a
* single value is; an empty string inside the list is refused rather than kept,
* because a blank chip is nothing anyone picked. Duplicates are dropped keeping
* the first, so the order the author picked in is the order the module reads.
*/
function checkList(p, raw) {
if (raw === undefined || raw === null || raw === '') return { value: [] }
const items = Array.isArray(raw) ? raw : [raw]
const out = []
const seen = new Set()
for (let i = 0; i < items.length; i++) {
if (items[i] === '') return { error: `item ${i + 1} is empty` }
const checked = checkLiteral(p.type, items[i])
if (checked.error) return { error: `item ${i + 1} ${checked.error}` }
if (seen.has(checked.value)) continue
seen.add(checked.value)
out.push(checked.value)
}
if (out.length > p.max) return { error: `holds ${out.length} values; the most is ${p.max}` }
return { value: out }
}
/**
* Check one authored param object against an action's declared params.
*
@@ -349,6 +469,14 @@ function checkParams(declaration, raw, path) {
for (const p of declaration.params || []) {
const value = given[p.name]
if (p.multiple) {
const listed = checkList(p, value)
if (listed.error) errors.push(`${path}.params: "${p.name}" ${listed.error}`)
else if (!listed.value.length) {
if (p.required) errors.push(`${path}.params: "${p.name}" is required`)
} else params[p.name] = listed.value
continue
}
if (value === undefined || value === null || value === '') {
if (p.required) errors.push(`${path}.params: "${p.name}" is required`)
continue
@@ -388,7 +516,7 @@ function validate(raw, { knownActionIds = [] } = {}) {
if (!isPlainObject(raw)) return { ok: false, errors: ['spec: expected an object'] }
const allowed = new Set(['schedule', 'phases'])
const allowed = new Set(['schedule', 'phases', 'starts'])
for (const key of Object.keys(raw)) {
if (!allowed.has(key)) {
errors.push(`spec: unknown key "${key}" (Phase 1 understands ${[...allowed].join(', ')})`)
@@ -413,6 +541,9 @@ function validate(raw, { knownActionIds = [] } = {}) {
schedule = validateSchedule(kind, rawSchedule, errors)
}
// ── starts (D370) ──
const starts = validateStarts(raw.starts, errors)
// ── phases ──
const rawPhases = raw.phases
const phases = []
@@ -566,7 +697,10 @@ function validate(raw, { knownActionIds = [] } = {}) {
if (totalSteps > MAX_STEPS) errors.push(`spec: at most ${MAX_STEPS} steps in one definition`)
if (errors.length) return { ok: false, errors }
return { ok: true, spec: { schedule, phases } }
// `starts` is omitted when empty, for the reason a phase's `advance` is: a spec
// carrying `"starts": []` everywhere is a version diff that says every
// definition changed the first time one of them gained a trigger.
return { ok: true, spec: starts.length ? { schedule, starts, phases } : { schedule, phases } }
}
/** Every action id a spec names, dormant ones included. */
@@ -591,10 +725,38 @@ function publishable(spec) {
const dormant = [
...phases.flatMap((p) => (p.steps || []).filter((s) => s.dormant).map((s) => s.actionId)),
...phases.filter((p) => p.advance?.dormant).map((p) => p.advance.on),
// A start on a trigger nothing registers, on the same rule (D370): a version
// that starts on something nobody can emit is one that never starts.
...(spec?.starts || []).filter((s) => s.dormant).map((s) => s.on),
]
return dormant.length ? { ok: false, dormant: [...new Set(dormant)] } : { ok: true, dormant: [] }
}
/** JSON with every object's keys sorted, so equal specs serialise equally. */
function canonical(value) {
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
if (value && typeof value === 'object') {
const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort()
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`
}
return JSON.stringify(value) ?? 'null'
}
/**
* A spec's fingerprint: the sha256 of its canonical JSON (W5, Rust
* PLAN_LAST_FIXES).
*
* What lets a dry run that passed on a draft count for the version published
* from it. The draft is fingerprinted when the dry run passes and the version
* when it is published, both from `validate`'s output, so an edit in between is
* a different fingerprint and nothing carries over. Key order is not part of it:
* a spec that round-trips through the database with its keys reordered is the
* same plan.
*/
function fingerprint(spec) {
return crypto.createHash('sha256').update(canonical(spec)).digest('hex')
}
/** An empty, valid spec — what a newly created draft carries. */
const emptySpec = () => ({
schedule: { kind: 'manual' },
@@ -603,13 +765,22 @@ const emptySpec = () => ({
module.exports = {
validate,
// An event template's inputs are params, and are checked as one (MODULE_API
// 1.13.0): eventDefinitions.model `buildFromTemplate`.
checkParams,
publishable,
fingerprint,
actionIdsIn,
emptySpec,
defaultOnFailure,
parseAfter,
formatAfter,
parseCooldown,
PHASE_KEY,
MAX_STARTS,
DEFAULT_COOLDOWN,
MAX_COOLDOWN_SECONDS,
CORE_EVENT_PREFIX,
SCHEDULE_KINDS,
ADVANCE_KINDS,
MAX_ADVANCE_COUNT,

210
server/src/events/starts.js Normal file
View File

@@ -0,0 +1,210 @@
// ── Starting a run from a trigger: the emit path's third subscriber ────────
//
// Rust PLAN_REDESIGNS §14.3.12 (D370), and EVENTS.md §E "Starting a run from a
// trigger". *"When anyone destroys Bradley on Main, start Launch Site
// Lockdown"*; *"when a champion's boss appears, start Champion's Call"*. Before
// this a trigger could only advance a phase of a run already going (`gates.js`)
// or send a notice (`engine.dispatch`); now a definition's published version may
// carry `starts`, and a firing that matches one creates a run of it, now.
//
// **Same seam and posture as the other two subscribers**: called from
// `engagementEmit.emit`, never awaited, never allowed to reject. Whether some
// event wanted to start is core's business, and a failure of core's must not
// become the module's control flow.
//
// **Four rules decide whether a matching firing starts anything:**
//
// 1. The version must have passed its dry run. A trigger start is unattended,
// as a scheduled one is, and §K's gate falls on the path where nobody is.
// 2. One run at a time per definition. A firing while a run of it is going
// starts nothing and is counted.
// 3. A cooldown after the last trigger-started run (default 30 minutes, 0 to
// 7 days). A firing inside it starts nothing and is counted. Rules 2 and 3
// are what stop twelve road Bradleys dying in a minute starting twelve runs.
// 4. Core's own `event.*` triggers never start a run (refused at save, and
// skipped here as well), so a run starting can never start another.
//
// Rules 2 and 3 are ONE conditional UPDATE (`eventTriggerStarts.db.claim`); the
// occurrence key `UNIQUE (definition_id, scope, scheduled_for)` with
// `scheduled_for` = the firing's time is what makes one firing reported twice
// (two instances ingesting one feed) create one run.
const definitionsDb = require('../model/events/eventDefinitions.db')
const startsDb = require('../model/events/eventTriggerStarts.db')
const runsModel = require('../model/events/eventRuns.model')
const conditions = require('../engagement/conditions')
const registries = require('../modules/registries')
const spec = require('./spec')
const gates = require('./gates')
const log = require('../utils/logger')('event-starts')
// What of a firing's data is kept on the run. The run row is admin-only, and it
// is the answer to "why did this run happen", so the trigger's DECLARED
// variables are kept and nothing else a module happened to send. Bounded, so
// one oversized payload cannot make the insert the thing that fails.
const MAX_ORIGIN_BYTES = 4096
/** The firing's time, in whole seconds (the column's precision), never in the future. */
function firingTime(occurredAt, now) {
const parsed = occurredAt ? new Date(occurredAt) : now
const at = Number.isNaN(parsed.getTime()) || parsed > now ? now : parsed
return new Date(Math.floor(at.getTime() / 1000) * 1000)
}
const sameSecond = (a, b) =>
a && b && Math.floor(new Date(a).getTime() / 1000) === Math.floor(new Date(b).getTime() / 1000)
/** The firing as the run will remember it. */
function originOf(event, declaration, start, at) {
const declared = (declaration?.variables || []).map((v) => v.name)
const data = event.data || {}
let variables = Object.fromEntries(declared.filter((n) => n in data).map((n) => [n, data[n]]))
if (Buffer.byteLength(JSON.stringify(variables), 'utf8') > MAX_ORIGIN_BYTES) {
// Too big to keep whole: keep the ones the start's condition named, which
// are the reason it matched, and say the rest were dropped.
const named = gates.variablesIn(start.where)
variables = Object.fromEntries(named.filter((n) => n in data).map((n) => [n, data[n]]))
variables._truncated = true
}
return {
kind: 'trigger',
triggerId: event.triggerId,
label: declaration?.label || event.triggerId,
occurredAt: at.toISOString(),
subject: event.subject ?? null,
// The start's condition in the builder's own words, so the console can say
// what it matched on without re-reading the version.
matched: gates.phrase(start.where),
variables,
}
}
/**
* Why a firing that matched did not get the claim. A duplicate of the firing
* that started the last run is not counted: it is the same thing happening once,
* reported twice.
*/
async function whyNot(definitionId, at, now) {
const state = await startsDb.forDefinition(definitionId)
if (state && sameSecond(state.last_started_at, at)) return 'duplicate'
if (await startsDb.isGoing(definitionId, now)) return 'running'
// A live claim is another firing starting it this instant: the run is going in
// every sense but the row.
if (state?.claimed_at && now - new Date(state.claimed_at) < startsDb.CLAIM_STALE_MS) return 'running'
return 'cooldown'
}
/** One definition, one firing. Answers what happened, for the summary and the tests. */
async function consider(definition, event, at, now) {
const starts = (definition.version_spec?.starts || []).filter((s) => s.on === event.triggerId && !s.dormant)
const start = starts.find((s) => conditions.evaluate(s.where, event.data || {}))
if (!start) return 'unmatched'
if (now - at > Number(definition.grace_seconds || 0) * 1000) {
// A firing older than the grace window would make a run the missed sweep
// takes on its next tick. Said, and not started.
log.warn('a firing too old to start a run', { definition: definition.id, trigger: event.triggerId, at })
return 'stale'
}
if (!definition.version_verified_at) {
await startsDb.ignore(definition.id, 'unverified', now)
log.warn('trigger start held: the published version has never been verified', {
definition: definition.id,
title: definition.title,
trigger: event.triggerId,
})
return 'unverified'
}
const cooldownSeconds = spec.parseCooldown(start.cooldown ?? spec.DEFAULT_COOLDOWN) ?? 0
if (!(await startsDb.claim(definition.id, { at, cooldownSeconds, now }))) {
const reason = await whyNot(definition.id, at, now)
if (reason !== 'duplicate') await startsDb.ignore(definition.id, reason, now)
return reason
}
const declaration = registries.eventTrigger(event.triggerId)
let result
try {
result = await runsModel.create(
definition.id,
// Scope is empty, as a scheduled occurrence's is (org lead, 2026-09-02): a
// scope registry does not exist, and the condition is what narrows a start
// to one server.
{ scope: '', scheduledFor: at, source: 'trigger', origin: originOf(event, declaration, start, at) },
null,
)
} catch (err) {
await startsDb.release(definition.id)
throw err
}
if (!result.ok) {
await startsDb.release(definition.id)
if (result.code === 'unverified') await startsDb.ignore(definition.id, 'unverified', now)
log.warn('trigger start refused', { definition: definition.id, errors: result.errors })
return 'refused'
}
if (!result.created) {
// The occurrence already existed: this firing was reported twice, and the
// claimant that made the run has recorded it. Nothing to count.
await startsDb.release(definition.id)
return 'duplicate'
}
await startsDb.started(definition.id, { at, runId: result.run.id })
log.info('run started by a trigger', { definition: definition.id, run: result.run.id, trigger: event.triggerId })
return 'started'
}
/**
* The trigger stream's third subscriber.
*
* The common answer is one indexed-by-nothing query over the ready definitions
* returning no rows: a deployment has tens of definitions, not thousands, and a
* JSON column cannot carry an index. If that ever changes, a side table of
* (trigger, definition) written at publish is the fix.
*/
async function observe(event, { now = new Date() } = {}) {
const summary = { definitions: 0, started: 0, ignored: 0 }
try {
if (!event?.triggerId || event.triggerId.startsWith(spec.CORE_EVENT_PREFIX)) return summary
const candidates = await definitionsDb.findStartingOn(event.triggerId)
summary.definitions = candidates.length
if (!candidates.length) return summary
const at = firingTime(event.occurredAt, now)
for (const definition of candidates) {
try {
const outcome = await consider(definition, event, at, now)
if (outcome === 'started') summary.started += 1
if (['running', 'cooldown', 'unverified'].includes(outcome)) summary.ignored += 1
} catch (err) {
log.error('trigger start failed', { definition: definition.id, trigger: event.triggerId, message: err.message })
}
}
} catch (err) {
log.error('trigger start observation failed', { trigger: event?.triggerId, message: err.message })
}
return summary
}
/**
* The editor's line about a definition's trigger starts, from the state row.
* Null when no trigger has ever tried to start it.
*/
function describeState(row) {
if (!row) return null
return {
lastStartedAt: row.last_started_at || null,
lastRunId: row.last_run_id || null,
ignoredRunning: Number(row.ignored_running || 0),
ignoredCooldown: Number(row.ignored_cooldown || 0),
ignoredUnverified: Number(row.ignored_unverified || 0),
lastIgnoredAt: row.last_ignored_at || null,
lastIgnoredReason: row.last_ignored_reason || null,
}
}
module.exports = { observe, consider, describeState, firingTime, originOf, MAX_ORIGIN_BYTES }

View File

@@ -36,6 +36,26 @@ const authorize = require('./authorize')
const settingsDb = require('../model/events/eventActionSettings.db')
const registries = require('../modules/registries')
/** The most advice lines one step's dry run carries, and the longest one. */
const ADVICE_MAX = 10
const ADVICE_CHARS = 500
/**
* A dry run's `detail.warnings`, as the lines a dry run shows: strings only,
* trimmed, the empty dropped, at most ten, each cut at 500 characters. Anything
* else a module put there is ignored rather than refused — advice that cannot be
* read is not a reason to fail a plan that would run.
*/
function adviceOf(detail) {
if (!detail || typeof detail !== 'object' || !Array.isArray(detail.warnings)) return []
return detail.warnings
.filter((w) => typeof w === 'string')
.map((w) => w.trim())
.filter(Boolean)
.slice(0, ADVICE_MAX)
.map((w) => (w.length > ADVICE_CHARS ? `${w.slice(0, ADVICE_CHARS - 1)}…` : w))
}
/**
* Dry-run a spec.
*
@@ -108,13 +128,21 @@ async function verifySpec(spec, { user = null, scope = '' } = {}) {
)
if (result.outcome === 'retry' || result.outcome === 'terminal') {
findings.push({ ...where, level: 'error', code: 'refused', message: result.error })
} else if (result.actionVersionDrift) {
findings.push({
...where,
level: 'warning',
code: 'version-drift',
message: `authored against version ${result.actionVersionDrift.authored}; ${step.actionId} is now version ${result.actionVersionDrift.registered}`,
})
} else {
if (result.actionVersionDrift) {
findings.push({
...where,
level: 'warning',
code: 'version-drift',
message: `authored against version ${result.actionVersionDrift.authored}; ${step.actionId} is now version ${result.actionVersionDrift.registered}`,
})
}
// MODULE_API 1.16.0: a module's ADVICE on a step that would run — the
// world as it stands now, which may change before the run does (Rust
// PLAN_REDESIGNS §11.4, D391). A warning, so it never fails the dry run.
for (const message of adviceOf(result.detail)) {
findings.push({ ...where, level: 'warning', code: 'advice', message })
}
}
}
@@ -150,4 +178,4 @@ async function verifySpec(spec, { user = null, scope = '' } = {}) {
}
}
module.exports = { verifySpec }
module.exports = { verifySpec, adviceOf }

View File

@@ -126,6 +126,18 @@ const markReady = (id, versionId, userId) =>
[versionId, userId, id],
)
/**
* Record a dry run that passed on the draft, with the fingerprint of the spec it
* passed on (W5). Publish reads it back through `getById`'s `d.*`.
*/
const markDraftVerified = (id, { at = new Date(), userId = null, hash }) =>
query(
`UPDATE event_definitions
SET draft_verified_at = ?, draft_verified_by = ?, draft_verified_hash = ?
WHERE id = ?`,
[at, userId, hash, id],
)
/**
* Every definition the runner should expand a recurrence for (Phase 4).
*
@@ -159,6 +171,30 @@ const findSchedulable = async ({ listedOnly = false } = {}) => {
return rows.map((row) => ({ ...row, version_spec: parseJson(row.version_spec, null) }))
}
/**
* Every `ready` definition whose PUBLISHED version starts on this trigger
* (Rust PLAN_REDESIGNS §14.3.12, D370). The emit path's question, asked on every
* firing, so it answers in one query and the common answer is no rows.
*
* Read from the version, never the working copy, for the reason the schedule
* expansion is: a start an author is midway through typing must not fire.
* `JSON_EXTRACT` over `$.starts[*].on` is an array, or NULL for a version with
* no starts, and `JSON_CONTAINS` of NULL is not true.
*/
const findStartingOn = async (triggerId) => {
const rows = await query(
`SELECT d.id, d.title, d.grace_seconds, d.current_version_id,
v.spec AS version_spec, v.verified_at AS version_verified_at
FROM event_definitions d
JOIN event_versions v ON v.id = d.current_version_id
WHERE d.state = 'ready'
AND JSON_CONTAINS(JSON_EXTRACT(v.spec, '$.starts[*].on'), JSON_QUOTE(?))
ORDER BY d.id`,
[triggerId],
)
return rows.map((row) => ({ ...row, version_spec: parseJson(row.version_spec, null) }))
}
/**
* One definition by slug, for the PUBLIC event page (Phase 14a).
*
@@ -206,8 +242,10 @@ module.exports = {
listPublicBySeries,
slugTaken,
findSchedulable,
findStartingOn,
insert,
update,
markReady,
markDraftVerified,
archive,
}

View File

@@ -29,8 +29,11 @@ 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')
const MAX_TITLE = 200
const MAX_SUMMARY = 500
@@ -180,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) }
}
@@ -202,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) }
}
@@ -227,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 {
@@ -248,6 +260,16 @@ async function publish(id, userId) {
const versionId = await versionsDb.insert(id, version, checked.spec, userId)
await db.markReady(id, versionId, userId)
// A dry run that passed on this exact draft counts for the version made from
// it (W5), stamped with that pass's reviewer and time rather than the
// publisher's and now: "who last looked at this before it ran unattended" is
// the person who ran the check. Any edit since is a different fingerprint.
const verified = Boolean(
existing.draft_verified_hash &&
existing.draft_verified_hash === spec.fingerprint(checked.spec) &&
(await versionsDb.markVerified(versionId, existing.draft_verified_by || null, existing.draft_verified_at || new Date())),
)
// Occurrences already materialised ahead of their instant move to the new
// version; ones that have begun do not (org lead, 2026-09-02). Logged per run
// rather than only counted, because "which version did this run actually use"
@@ -269,9 +291,18 @@ async function publish(id, userId) {
by: userId,
},
})
// The held occurrence is now going to start, and the line saying why
// belongs on it, as it does when the version is dry-run by hand.
if (verified) {
await logDb.write({
runId: run.id,
kind: 'version.verified',
detail: { versionId, version, by: existing.draft_verified_by || null, fromDraft: true },
})
}
}
return { ok: true, versionId, version, repinned, definition: await db.getById(id) }
return { ok: true, versionId, version, repinned, verified, definition: await db.getById(id) }
}
/**
@@ -345,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).
@@ -372,6 +434,19 @@ async function verify(id, user) {
const report = await verifier.verifySpec(target, { user })
// A pass on the draft is kept with the fingerprint of the spec it passed on,
// for publish to compare (W5). Fingerprinted from `validate`'s output, as
// publish fingerprints the spec it freezes; a draft that no longer validates
// could not be published anyway, so there is nothing to keep.
let draftRecorded = false
if (!version && report.ok) {
const checked = spec.validate(existing.spec, { knownActionIds: spec.actionIdsIn(existing.spec) })
if (checked.ok) {
await db.markDraftVerified(id, { userId: user?.id || null, hash: spec.fingerprint(checked.spec) })
draftRecorded = true
}
}
if (version && report.ok) {
await versionsDb.markVerified(version.id, user?.id || null)
// Written to every run already pinned to this version, because that is where
@@ -396,17 +471,123 @@ async function verify(id, user) {
versionId: version?.id || null,
version: version?.version || null,
recorded: Boolean(version && report.ok),
// A pass on the draft, kept so that publishing this spec unchanged makes a
// verified version (W5).
draftRecorded,
}
}
// How long a template's `build()` may take. It may read the module's own data or
// ask a sidecar, and an author is waiting on the other side of the click.
const TEMPLATE_BUILD_MS = 10_000
/**
* Fill an event template in (MODULE_API 1.13.0; Rust PLAN_REDESIGNS §2.5, D192).
*
* `{ ok: true, draft: { title, summary, spec } }`, or `{ ok: false, status,
* errors }`. **Nothing is saved.** The draft goes back to the editor, which opens
* it like any new event; the author saves it through `create()` and its rules,
* so a template cannot make a definition the save path would refuse.
*
* The module is not trusted at either end. The inputs are checked against the
* template's declaration before `build()` sees them, and what `build()` returns
* is validated as a spec after. A `build()` that refuses says why in
* `{ ok: false, error }` — the author's sentence — and one that throws or hangs
* is logged and answered with a sentence of core's, because a stack trace is
* not something to put on an author's screen.
*/
async function buildFromTemplate(templateId, rawInputs) {
const template = registries.eventTemplate(String(templateId || ''))
if (!template) {
return { ok: false, status: 404, errors: [`no module registers the event template "${templateId}"`] }
}
const checked = spec.checkParams({ id: template.id, params: template.inputs }, rawInputs, 'inputs')
if (checked.errors.length) {
return { ok: false, status: 400, errors: checked.errors.map((e) => e.replace(/^inputs\.params: /, '')) }
}
let built
let timer
try {
built = await Promise.race([
Promise.resolve(template.build({ inputs: checked.params })),
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`build() took longer than ${TEMPLATE_BUILD_MS} ms`)), TEMPLATE_BUILD_MS)
}),
])
} catch (err) {
log.error('event template build failed', { template: template.id, owner: template.owner, message: err.message })
return { ok: false, status: 502, errors: [`"${template.label}" could not be filled in — the module that provides it failed`] }
} finally {
clearTimeout(timer)
}
if (!built || typeof built !== 'object') {
return { ok: false, status: 502, errors: [`"${template.label}" answered with no draft`] }
}
if (built.ok === false) {
return { ok: false, status: 400, errors: [String(built.error || `"${template.label}" refused those inputs`)] }
}
const checkedSpec = spec.validate(built.spec)
if (!checkedSpec.ok) {
// The module's mistake, not the author's, and named as such: the author
// cannot fix a template by retyping its inputs.
log.error('event template built an invalid spec', { template: template.id, errors: checkedSpec.errors })
return {
ok: false,
status: 502,
errors: [`"${template.label}" made a plan core cannot accept`, ...checkedSpec.errors],
}
}
return {
ok: true,
draft: {
title: String(built.title || template.label).slice(0, MAX_TITLE),
summary: built.summary ? String(built.summary).slice(0, MAX_SUMMARY) : null,
spec: checkedSpec.spec,
},
}
}
/**
* Does the working copy differ from the published version? (Rust PLAN_REDESIGNS
* §2.3, U-5.)
*
* A run is pinned to the PUBLISHED version, so an author who edited, saved and
* pressed Start ran the old plan without being told. The editor asks only when
* this is true. Compared as data, keys sorted, because the two are stored as
* JSON by different writes and a key order is not an edit. A definition never
* published has nothing to be ahead of, and answers false.
*/
async function draftAhead(row) {
if (!row || !row.current_version_id) return false
const version = await versionsDb.getById(row.current_version_id)
if (!version) return false
return stableJson(row.spec) !== stableJson(version.spec)
}
function stableJson(value) {
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`
if (value && typeof value === 'object') {
return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${stableJson(value[k])}`).join(',')}}`
}
return JSON.stringify(value === undefined ? null : value)
}
module.exports = {
validate,
create,
buildFromTemplate,
draftAhead,
save,
publish,
archive,
verify,
checkRoleFloor,
checkDemoRefusals,
isTimezone,
MIN_GRACE_SECONDS,
MAX_GRACE_SECONDS,

View File

@@ -36,6 +36,7 @@ const versionsDb = require('./eventVersions.db')
const participantsDb = require('./eventRunParticipants.db')
const calendarModel = require('./eventCalendar.model')
const recurrence = require('../../events/recurrence')
const progress = require('../../events/progress')
// The public calendar's window when a caller names neither end: a few days BACK
// through a month out. A visitor arriving at /site/events wants "what is on", and
@@ -91,21 +92,36 @@ const isLive = (status) => publicStatus(status) === 'live'
/**
* The label of the phase a run is in, resolved from the PINNED version's spec.
*
* A phase id is a slug an author typed and the label is what they meant it to
* read as, so a page rendering the id would show `phase-2` to the public. A
* A phase key is a slug an author typed and the label is what they meant it to
* read as, so a page rendering the key would show `phase-2` to the public. A
* phase the spec does not name answers null and the page shows nothing, which is
* the right answer for a version edited since: the run pinned the old spec and
* the old spec is what it is executing.
*
* **A run's `current_phase` is the phase's `key`**, which is what `spec.js`
* validates and the engine writes. This matched `id` until RunicNPC stage 9
* (D314), so every live run read "Under way" since Events Phase 14a; `id` is
* still accepted second, for a spec stored in that older shape.
*/
function phaseLabel(spec, phaseId) {
if (!phaseId || !spec || !Array.isArray(spec.phases)) return null
const phase = spec.phases.find((p) => p && p.id === phaseId)
return (phase && (phase.label || phase.id)) || null
function phaseLabel(spec, phaseKey) {
if (!phaseKey || !spec || !Array.isArray(spec.phases)) return null
const phase =
spec.phases.find((p) => p && p.key === phaseKey) || spec.phases.find((p) => p && p.id === phaseKey)
return (phase && (phase.label || phase.key || phase.id)) || null
}
/** One calendar entry, from a materialised run. */
/**
* One calendar entry, from a materialised run.
*
* **`runId` is published because the event page already publishes it** on every
* occurrence, and `?run=` takes it. The calendar was the one public shape that
* named a run without saying which, so a client holding a run id from elsewhere
* (a module's map marker) had no way to find its event but to fetch every event
* page. A projection has none: nothing is committed to it.
*/
const publicRunEntry = (run) => ({
kind: 'run',
runId: run.id,
title: run.definition_title,
slug: run.definition_slug,
seriesName: run.series_name || null,
@@ -280,7 +296,7 @@ const publicOccurrence = (run, spec) => ({
* turn a stale link in a months-old mail into a dead end rather than a page
* about the thing the mail was about.
*/
async function event(slug, { runId = null } = {}) {
async function event(slug, { runId = null, userId = null } = {}) {
const definition = await definitionsDb.getPublicBySlug(String(slug || ''))
if (!definition) return { ok: false, status: 404, errors: ['Not found'] }
@@ -330,6 +346,13 @@ async function event(slug, { runId = null } = {}) {
const specRun = named || live[0] || null
const spec = specRun ? (await versionsDb.getById(specRun.version_id))?.spec || null : null
// How the live occurrence stands, in the words its steps' modules chose
// (MODULE_API 1.12.0). Only for a live run: a finished one has results, and a
// scheduled one has nothing to count yet. `progress.forRun` never rejects and
// answers `[]` when nobody has anything to say.
const current = live[0] ? publicOccurrence(live[0], spec) : null
if (current) current.progress = await progress.forRun(live[0], { viewer: { userId } })
return {
ok: true,
status: 200,
@@ -342,7 +365,7 @@ async function event(slug, { runId = null } = {}) {
timezone: definition.timezone,
series: series ? { name: series.name, slug: series.slug } : null,
live: live.length > 0,
current: live[0] ? publicOccurrence(live[0], spec) : null,
current,
next: next ? publicOccurrence(next, spec) : null,
upcoming: upcoming.map((r) => publicOccurrence(r, spec)),
past: past.map((r) => publicOccurrence(r, spec)),

View File

@@ -53,6 +53,9 @@ const KINDS = [
// question is about the world.
'resource.recorded', // a step reported what it created or borrowed, and it is ledgered
'resource.orphaned', // a module reports a ledgered resource is no longer in force
// MODULE_API 1.11.0's one (Rust PLAN_FIXES D183): the game ended a resource at
// its own deadline, which is the plan working rather than something vanishing.
'resource.expired', // a module reports the game ended a ledgered resource by itself
'cleanup.reverted', // a group of resources came back
'cleanup.failed', // a group did not, with the reason and how it was left
'cleanup.swept', // one pass over a run's ledger, and what it found

View File

@@ -327,6 +327,42 @@ async function markOrphaned(id, detail = null) {
)
}
/**
* The rows one module holds for one target that the game may have let go of on
* its own — what `ctx.events.expired` looks for (MODULE_API 1.11.0).
*
* `orphaned` is included on purpose: a reconcile that ran before the module heard
* the expiry filed the row as vanished, and the expiry is the better answer.
* `reverting` is not: a revert already in flight finds the thing gone, which §L
* calls a success, and it settles as `reverted` on its own.
*/
async function expirableByTarget(owner, kind, ref) {
const rows = await query(
`SELECT ${COLUMNS} FROM event_run_resources
WHERE owner_module = ? AND kind = ? AND ref = ?
AND status IN ('pending', 'confirmed', 'orphaned')
ORDER BY id`,
[owner, kind, ref],
)
return rows.map(hydrate)
}
/**
* The game ended it by itself, as it was told to — a zone reaching its deadline.
* Terminal like `reverted`, so teardown never tries to give it back, and unlike
* `orphaned`, which is a thing that vanished with nobody asking. Guarded on the
* status so a row a revert has just claimed is left to that revert.
*/
async function markExpired(id, detail = null) {
const result = await query(
`UPDATE event_run_resources
SET status = 'expired', last_error = ?
WHERE id = ? AND status IN ('pending', 'confirmed', 'orphaned')`,
[detail === null ? null : String(detail).slice(0, 500), id],
)
return (result.affectedRows || 0) > 0
}
/**
* Terminal runs that still owe the world something — the cleanup leg's scan.
*
@@ -366,8 +402,30 @@ async function runsNeedingCleanup(limit = 25, maxAttempts = 3) {
)
}
/**
* Finished runs still marked `pending` that have no unresolved row at all — a
* run whose last resource the game expired while it was still going (MODULE_API
* 1.11.0). The sweep settles them `complete`; `runsNeedingCleanup` cannot see
* them because it joins on an unresolved row.
*/
async function finishedRunsWithNothingLeft(limit = 25) {
const rows = await query(
`SELECT r.id FROM event_runs r
WHERE r.status IN ('completed', 'cancelled', 'failed', 'missed')
AND r.cleanup_status = 'pending'
AND NOT EXISTS (
SELECT 1 FROM event_run_resources res
WHERE res.run_id = r.id AND res.status IN (?, ?, ?, ?, ?))
ORDER BY r.id
LIMIT ?`,
[...UNRESOLVED, Number(limit)],
)
return rows.map((row) => Number(row.id))
}
module.exports = {
STEP_KIND,
finishedRunsWithNothingLeft,
HELD,
UNRESOLVED,
reserve,
@@ -385,5 +443,7 @@ module.exports = {
liveForModule,
modulesWithLiveRows,
markOrphaned,
expirableByTarget,
markExpired,
runsNeedingCleanup,
}

View File

@@ -15,7 +15,13 @@
const { query } = require('../../utils/db')
const { parseJson } = require('./eventJson')
const hydrate = (row) => row && { ...row, params: parseJson(row.params, null), rehearsal: Boolean(row.rehearsal) }
const hydrate = (row) =>
row && {
...row,
params: parseJson(row.params, null),
origin: parseJson(row.origin, null),
rehearsal: Boolean(row.rehearsal),
}
// The statuses a run never leaves. A transition INTO one of these stamps
// `ended_at` and drops the claim, and only rows in one of them are eligible for
@@ -90,8 +96,8 @@ const materialise = async (run) => {
const result = await query(
`INSERT IGNORE INTO event_runs
(definition_id, version_id, scope, scheduled_for, timezone, concurrency_key,
params, rehearsal, started_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
params, rehearsal, started_by, origin)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
run.definition_id,
run.version_id,
@@ -102,6 +108,8 @@ const materialise = async (run) => {
run.params === null || run.params === undefined ? null : JSON.stringify(run.params),
run.rehearsal ? 1 : 0,
run.started_by,
// Why the run exists when a trigger started it (D370); NULL otherwise.
run.origin ? JSON.stringify(run.origin) : null,
],
)
return Number(result?.affectedRows || 0) === 1 ? result.insertId : null

View File

@@ -69,7 +69,7 @@ function renderConcurrencyKey(template, params) {
*/
async function create(
definitionId,
{ scope = '', scheduledFor = null, rehearsal = false, params = null, source = 'manual' } = {},
{ scope = '', scheduledFor = null, rehearsal = false, params = null, source = 'manual', origin = null } = {},
userId,
) {
const definition = await definitionsDb.getById(definitionId)
@@ -97,7 +97,10 @@ async function create(
// nobody is. A version is immutable, so a dry run that passed against it stays
// true, which is what makes the pass a property of the version rather than
// something re-earned every occurrence.
if (source === 'schedule' && !version.verified_at) {
//
// **A trigger start is unattended too** (D370): a boss dying at four in the
// morning starts the run with nobody watching, exactly as a schedule does.
if ((source === 'schedule' || source === 'trigger') && !version.verified_at) {
return {
ok: false,
status: 409,
@@ -124,6 +127,7 @@ async function create(
params,
rehearsal,
started_by: userId,
origin,
})
if (runId === null) {
@@ -146,8 +150,11 @@ async function create(
// a recurrence (Phase 4). Both produce the same row, and the log is the
// only place the difference is recorded — `started_by` is NULL for both a
// scheduled occurrence and one started by a since-deleted account.
// 'trigger' is a game firing (D370), and it is the one source that also
// says WHAT: the origin goes into the audit line as well as on the row.
source,
by: userId,
...(origin ? { origin } : {}),
},
})

View File

@@ -0,0 +1,106 @@
// ── event_trigger_starts — SQL only ────────────────────────────────────────
//
// Rust PLAN_REDESIGNS §14.3.12 (D370), and EVENTS.md §E "Starting a run from a
// trigger". One row per definition a trigger has tried to start. `events/starts.js`
// decides; this file holds the claim and the counts.
//
// **The claim is one statement with every rule inside it.** A firing may start a
// run only when no claim is live, the last trigger-started run is out of its
// cooldown, and no run of the definition is going. Read-then-write would let two
// firings arriving together each see a quiet definition and both start, which is
// exactly the twelve-Bradleys case the rule exists for.
const { query } = require('../../utils/db')
// A claim older than this belongs to a process that died between the claim and
// the run row existing. A minute is far longer than an insert takes.
const CLAIM_STALE_MS = 60_000
// What "a run is going" means for the one-at-a-time rule: started and not ended,
// or scheduled and already due — a trigger-started run is `scheduled` for up to
// one tick before the runner claims it, and it is going from the moment it exists.
const GOING_SQL = `
SELECT 1 FROM event_runs r
WHERE r.definition_id = t.definition_id
AND (r.status IN ('starting','running','paused','ending')
OR (r.status = 'scheduled' AND r.scheduled_for <= ?))`
const REASONS = ['running', 'cooldown', 'unverified']
/** The row, or null when no trigger has ever tried to start this definition. */
const forDefinition = async (definitionId) => {
const [row] = await query('SELECT * FROM event_trigger_starts WHERE definition_id = ?', [definitionId])
return row || null
}
/**
* Try to own the start. `true` when this caller may create the run.
*
* `at` is the firing's time (whole seconds), `cooldownSeconds` the matching
* start's own. The row is made first with `INSERT IGNORE`, so the UPDATE always
* has something to guard.
*/
async function claim(definitionId, { at, cooldownSeconds, now }) {
await query('INSERT IGNORE INTO event_trigger_starts (definition_id) VALUES (?)', [definitionId])
const result = await query(
`UPDATE event_trigger_starts t
SET t.claimed_at = ?
WHERE t.definition_id = ?
AND (t.claimed_at IS NULL OR t.claimed_at < ?)
AND (t.last_started_at IS NULL OR t.last_started_at <= ?)
AND NOT EXISTS (${GOING_SQL})`,
[
now,
definitionId,
new Date(now.getTime() - CLAIM_STALE_MS),
new Date(at.getTime() - cooldownSeconds * 1000),
now,
],
)
return Number(result?.affectedRows || 0) === 1
}
/** The run exists: record it, drop the claim, and start the counts again. */
const started = (definitionId, { at, runId }) =>
query(
`UPDATE event_trigger_starts
SET claimed_at = NULL, last_started_at = ?, last_run_id = ?,
ignored_running = 0, ignored_cooldown = 0, ignored_unverified = 0,
last_ignored_at = NULL, last_ignored_reason = NULL
WHERE definition_id = ?`,
[at, runId, definitionId],
)
/** The claim came to nothing (the create refused): give it back. */
const release = (definitionId) =>
query('UPDATE event_trigger_starts SET claimed_at = NULL WHERE definition_id = ?', [definitionId])
/**
* Count a firing that started nothing, by why. `reason` is one of REASONS and
* names its own column, so it is checked against the list before it reaches SQL.
*/
async function ignore(definitionId, reason, now) {
if (!REASONS.includes(reason)) throw new Error(`unknown ignore reason "${reason}"`)
await query('INSERT IGNORE INTO event_trigger_starts (definition_id) VALUES (?)', [definitionId])
await query(
`UPDATE event_trigger_starts
SET ignored_${reason} = ignored_${reason} + 1, last_ignored_at = ?, last_ignored_reason = ?
WHERE definition_id = ?`,
[now, reason, definitionId],
)
}
/** Is a run of this definition going right now? The ignore path's "why". */
const isGoing = async (definitionId, now) => {
const rows = await query(
`SELECT 1 FROM event_runs r
WHERE r.definition_id = ?
AND (r.status IN ('starting','running','paused','ending')
OR (r.status = 'scheduled' AND r.scheduled_for <= ?))
LIMIT 1`,
[definitionId, now],
)
return rows.length > 0
}
module.exports = { forDefinition, claim, started, release, ignore, isGoing, REASONS, CLAIM_STALE_MS }

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,175 @@
// 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'
}
}
/**
* Everyone who has blocked this account, as account blocks. The reverse of
* `blocksFor`, for the notification fan-out: a post goes out to a Team, and the
* members who block its author are the ones it must skip.
*/
async function blockersOf(blockedUserId) {
const rows = await query(
'SELECT blocker_user_id FROM user_blocks WHERE blocked_user_id = ?',
[blockedUserId],
)
return rows.map((r) => Number(r.blocker_user_id))
}
/** 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,
blockersOf,
deleteBlock,
staffAmong,
userById,
hiddenFor,
insertHidden,
deleteHidden,
}

View File

@@ -0,0 +1,326 @@
// ── 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
}
/**
* The members whose blocks cover this author, for a notification about something
* the author wrote: a forum post is pushed, mailed and put in the inbox of every
* member of its Team, and a member who blocks the author must not be told about it
* (the forum itself already leaves the post out for them).
*
* S8 applies here too: a staff author is blocked by nobody, so this answers empty
* and the post reaches everyone. Answers account blocks only. A game block on the
* author's Steam id that S14 has not yet rewritten counts from the next time its
* owner reads the forum or their blocks, which is when the rewrite happens.
*/
async function blockersOf(authorUserId) {
const id = Number(authorUserId)
if (!Number.isInteger(id) || id <= 0) return []
const author = await db.userById(id)
if (!author || isStaffRole(author.role)) return []
return db.blockersOf(id)
}
/** `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,
blockersOf,
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) })),
}
}
@@ -270,7 +318,14 @@ async function createPost({ team, threadId, actor, body }) {
// what a recipient needs to know is which conversation moved. `type` is always
// 'discussion' here (an announcement takes no replies) and is carried anyway so
// the controller has one shape to hand the fan-out from both routes.
return { ok: true, threadId, postId, notify: { threadId, title: thread.title, type: thread.type, bodyHtml: cleaned } }
return {
ok: true,
threadId,
postId,
// `threadAuthorUserId`: a member who blocks whoever started the thread cannot
// open it (leftOutThread), so they are not told about replies in it either.
notify: { threadId, title: thread.title, type: thread.type, bodyHtml: cleaned, threadAuthorUserId: thread.created_by ?? null },
}
}
/**
@@ -434,4 +489,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,8 +197,8 @@ 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,
th.title, th.type
`SELECT po.id, po.thread_id, po.body_html, po.created_at, po.author_user_id, po.author_username,
th.title, th.type, th.created_by AS thread_created_by
FROM team_forum_posts po
JOIN team_forum_threads th ON th.id = po.thread_id
WHERE th.team_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

@@ -13,21 +13,45 @@ async function findByProviderSubject(provider, subject) {
// All identities linked to a given internal user (for the Account page).
async function listForUser(userId) {
return query(
'SELECT id, provider, subject, email, created_at FROM user_identities WHERE user_id = ? ORDER BY provider',
`SELECT id, provider, subject, email, display_name, avatar_url, created_at
FROM user_identities WHERE user_id = ? ORDER BY provider, created_at`,
[userId],
)
}
async function insert({ userId, provider, subject, email = null }) {
async function insert({ userId, provider, subject, email = null, displayName = null, avatarUrl = null }) {
const res = await query(
'INSERT INTO user_identities (user_id, provider, subject, email) VALUES (?, ?, ?, ?)',
[userId, provider, subject, email],
`INSERT INTO user_identities (user_id, provider, subject, email, display_name, avatar_url)
VALUES (?, ?, ?, ?, ?, ?)`,
[userId, provider, subject, email, displayName, avatarUrl],
)
return res.insertId
}
// Remove a user's link to a provider. Returns rows deleted.
async function deleteForUserProvider(userId, provider) {
// Refresh the name and avatar a provider reported at sign-in (Steam, D394). Only
// what the provider sent is written: a lookup that failed leaves the last good
// values in place rather than blanking them.
async function updateProfile(id, { displayName, avatarUrl }) {
const sets = []
const vals = []
if (displayName) { sets.push('display_name = ?'); vals.push(displayName) }
if (avatarUrl) { sets.push('avatar_url = ?'); vals.push(avatarUrl) }
if (!sets.length) return 0
const res = await query(`UPDATE user_identities SET ${sets.join(', ')} WHERE id = ?`, [...vals, id])
return Number(res.affectedRows || 0)
}
// Remove a user's link to a provider — every identity of it, or, given an
// identity id, that one only (a user may hold several Steam accounts). Returns
// rows deleted.
async function deleteForUserProvider(userId, provider, identityId = null) {
if (identityId != null) {
const res = await query(
'DELETE FROM user_identities WHERE user_id = ? AND provider = ? AND id = ?',
[userId, provider, identityId],
)
return Number(res.affectedRows || 0)
}
const res = await query(
'DELETE FROM user_identities WHERE user_id = ? AND provider = ?',
[userId, provider],
@@ -35,4 +59,4 @@ async function deleteForUserProvider(userId, provider) {
return Number(res.affectedRows || 0)
}
module.exports = { findByProviderSubject, listForUser, insert, deleteForUserProvider }
module.exports = { findByProviderSubject, listForUser, insert, updateProfile, deleteForUserProvider }

View File

@@ -15,13 +15,21 @@ async function listForUser(userId) {
// Link an external identity to an internal user. Returns the new row id. The
// (provider, subject) UNIQUE constraint enforces one-identity-one-user at the DB.
async function link({ userId, provider, subject, email }) {
return db.insert({ userId, provider, subject, email })
// `displayName` / `avatarUrl` are set by providers that report them (Steam);
// the others leave them null.
async function link({ userId, provider, subject, email, displayName, avatarUrl }) {
return db.insert({ userId, provider, subject, email, displayName, avatarUrl })
}
// Unlink a provider from a user. Returns rows removed (0 if nothing was linked).
async function unlink(userId, provider) {
return db.deleteForUserProvider(userId, provider)
// Refresh a linked identity's name and avatar from a sign-in's profile.
async function refreshProfile(identityId, { displayName, avatarUrl } = {}) {
return db.updateProfile(identityId, { displayName, avatarUrl })
}
module.exports = { findByProviderSubject, listForUser, link, unlink }
// Unlink a provider from a user: every identity of it, or the one `identityId`
// names. Returns rows removed (0 if nothing was linked).
async function unlink(userId, provider, identityId = null) {
return db.deleteForUserProvider(userId, provider, identityId)
}
module.exports = { findByProviderSubject, listForUser, link, refreshProfile, unlink }

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