57 Commits

Author SHA1 Message Date
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
121 changed files with 10257 additions and 487 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)
}
@@ -133,8 +138,12 @@ 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'),
@@ -518,6 +527,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

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,47 @@
import { useEffect, useState } from 'react'
import { onDemoLocked } from '../lib/demoNotice.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)), [])
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,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,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 ''
}

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,7 +3,7 @@
//
// 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.
@@ -31,7 +31,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) {

View File

@@ -11,6 +11,37 @@
// 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.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 +105,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.19.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,7 @@ 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 { 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 +16,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 +35,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 +49,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 +57,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 +70,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>

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

@@ -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

@@ -14,7 +14,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

View File

@@ -5,6 +5,7 @@ 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 { IdentitySummary, linkBanner, linkableProviders, unlinkPrompt } from '../../components/security/linkedIdentities.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { api } from '../../api/client.js'
@@ -224,13 +225,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 +242,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 +256,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 +269,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>

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,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', () => {

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

@@ -179,7 +179,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 +203,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),
@@ -2560,7 +2562,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 +2589,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 +2608,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 +2718,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;

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

@@ -0,0 +1,254 @@
// ── `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).
//
// 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
}
// ── 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('; ')}`)
}
}
log.info('content written', {
posts: (content.posts || []).length,
wiki: (content.wiki || []).length,
pages: (content.pages || []).length,
events: (content.events || []).length,
})
}
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, 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.19.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",
@@ -1825,7 +1834,7 @@
{
"method": "DELETE",
"path": "/api/v1/auth/me/account/identities/:provider",
"handlers": 3,
"handlers": 4,
"gates": [
"noindex",
"requireAuth",
@@ -2464,9 +2473,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"

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,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.',
},
],

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

@@ -0,0 +1,246 @@
// ── 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 },
// 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

@@ -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

@@ -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

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

View File

@@ -17,6 +17,11 @@
// through modules/install.js, the same fetch-verify-unpack path the admin panel
// uses, under the same host allowlist.
//
// **A site runs one module** (org lead, 2026-09-23), and install.js enforces it
// for this path too: a declaration naming a second module installs the first,
// and the second is refused — logged and kept for the admin screen like any
// other failed entry, never fatal to the boot.
//
// Three things this file deliberately does not do:
//
// - **It does not decide whether a module RUNS.** Resolution owns what is on

View File

@@ -290,6 +290,28 @@ function isInstalled(id) {
}
}
/**
* Every module on the volume, by id — the directories the loader would scan.
*
* The loader's own rule, restated: a directory whose name is a module id and
* which holds a `module.json`. That excludes an install's scratch directory
* (`.install-*`) and a swap's aside copy (`<id>.replaced-*`), neither of which
* is a module and both of which can briefly exist beside one.
*/
function installedIds() {
let entries
try {
entries = fs.readdirSync(loader.dir(), { withFileTypes: true })
} catch {
return [] // no modules directory is the normal case for a bare core
}
return entries
.filter((e) => e.isDirectory() && ID.test(e.name))
.filter((e) => fs.existsSync(path.join(loader.dir(), e.name, 'module.json')))
.map((e) => e.name)
.sort()
}
/**
* The absolute path of a module's `purge.sql`, or null.
*
@@ -348,6 +370,25 @@ async function install({ url, hosts, expect = null, fetchImpl = fetch }) {
)
}
// One module per site (org lead, 2026-09-23). A site is one game, and the
// contract has singletons that assume it: `registerTeamProvider` holds ONE
// value per deployment, and a second module registering one fails its whole
// load — with modules loaded alphabetically, installing `rust` beside `uo`
// would have taken `uo` down, not `rust`. So an install is an UPGRADE of the
// module already here, or it is refused before a byte is downloaded.
//
// Refused here rather than in the admin controller so the declared module set
// (modules/declared.js) gets the same answer: an environment naming two
// modules installs the first and is told why the second was not.
const others = installedIds().filter((id) => id !== manifest.id)
if (others.length) {
throw new InstallError(
`this site already runs the module "${others.join('", "')}", and a site runs one module. ` +
`Upgrade it with its own release, or remove it before installing "${manifest.id}".`,
{ status: 409 },
)
}
const target = moduleDir(manifest.id)
const scratch = await fsp.mkdtemp(path.join(loader.dir(), `.install-${manifest.id}-`))
const tarball = path.join(scratch, 'bundle.tar.gz')
@@ -444,6 +485,7 @@ module.exports = {
removeDir,
moduleDir,
isInstalled,
installedIds,
purgeFile,
MAX_MANIFEST_BYTES,
MAX_ARTIFACT_BYTES,

View File

@@ -41,6 +41,8 @@ const { MODULE_API_VERSION } = require('./version')
const semver = require('./semver')
const registries = require('./registries')
const { splitStatements } = require('../utils/sqlStatements')
const demoPaths = require('../demo/paths')
const { isDemo } = require('../demo/mode')
const log = require('../utils/logger')('modules')
@@ -62,9 +64,15 @@ const TIERS = ['public', 'admin', 'player']
const MANIFEST_KEYS = new Set([
'id', 'name', 'version', 'coreApi', 'server', 'client',
'schema', 'purge', 'mounts', 'extensions', 'capabilities',
'schema', 'purge', 'mounts', 'extensions', 'capabilities', 'demoLocks',
])
// What a `demoLocks` entry may lock (API 1.18.0, DEMO_MODE.md §3.2): writes
// only. Reads are never locked by declaration — the one read the demo refuses
// (a plugin config that holds credentials) depends on WHICH file is asked for,
// so the module decides it behind `ctx.demoMode` rather than by route.
const DEMO_LOCK_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE']
// Extension slots are declared by core, at require time, in the router that owns
// the resource (registries.declareSlot). The loader asks the registry which exist
// rather than keeping a list, for the same reason the prefix check probes the
@@ -261,6 +269,27 @@ function buildCtx(id, moduleRoot) {
(err) => { log.error('ctx.events.reconcile failed', { module: id, message: err.message }) },
)
},
// MODULE_API 1.11.0 (Rust PLAN_FIXES D183). The game ended one of this
// module's ledgered resources at its own deadline — a Rust zone erased when
// its time was up. Recorded as `expired`: terminal, never given back, and a
// different sentence on the console from `orphaned`, which is reconcile's
// word for a thing that vanished with nobody asking.
//
// `id` bound, fire-and-forget and returns undefined, all three for
// `reconcile`'s reasons directly above. A `{ kind, ref }` that names no
// live row is not an error: a module hears expiries of things no run ever
// ledgered, and telling it so would be noise it can do nothing with.
expired: (resource) => {
// eslint-disable-next-line global-require
require('../events/cleanup')
.expireResource(id, resource || {})
.then(
(summary) => {
if (summary && summary.expired) log.info('event resource expired', { module: id, ...summary })
},
(err) => { log.error('ctx.events.expired failed', { module: id, message: err.message }) },
)
},
},
// The in-app sink (§5.1) — a module writing the inbox directly, without a
// rule. Live from Phase 7; it threw until the `user_notifications` table
@@ -290,6 +319,26 @@ function buildCtx(id, moduleRoot) {
// exports creation, role changes and password handling, none of which is a
// module's business.
users: { getById: users.getById },
// API 1.17.0 (STEAM_SIGNIN.md §4). READ only, one provider at a time: what a
// user has linked, as core verified it — the Steam id a module can trust is
// the one Steam's own `check_authentication` proved, never one a player typed.
// Linking and unlinking stay core's (the hooks on `api` are how a module takes
// part), and the row id and email are core's business, so they are not here.
identities: {
list: async (userId, provider) => {
// eslint-disable-next-line global-require
const rows = await require('../model/userIdentities/userIdentities.model').listForUser(userId)
return rows
.filter((r) => r.provider === provider)
.map((r) => ({
provider: r.provider,
subject: r.subject,
displayName: r.display_name || null,
avatarUrl: r.avatar_url || null,
linkedAt: r.created_at,
}))
},
},
// Where this deployment is reachable, for a module that has to build an
// absolute link (an announcement carries one into a game window or a chat
// message, where a relative path means nothing). §2.7 forbids a module
@@ -297,6 +346,12 @@ function buildCtx(id, moduleRoot) {
// configuration, so core answers it. A getter, not a captured string: the
// value is read per call, so it cannot go stale against the env.
site: { get baseUrl() { return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '') } },
// API 1.18.0 (DEMO_MODE.md §3.4, §3.5). True on the public demo. For the
// locks a route list cannot express — a console command by its first word,
// a config file by which plugin owns it — which the module enforces in its
// own handlers. A boolean, read once: the flag is the deployment's, and a
// deployment does not change what it is while it runs.
demoMode: isDemo(),
}
// A guard against accident, not against a hostile module — the boundary is
// organisational, not a security boundary (MODULE_SYSTEM.md §2.2).
@@ -438,12 +493,36 @@ function buildApi(record) {
once('registerEventOptionSources')
record.staged.registerEventOptionSources(sources)
},
// API 1.13.0. A named starting point for a new event (Rust PLAN_REDESIGNS
// §2.5): inputs in, an ordinary draft out. Its own id space, like the four
// above, and `once` for the same reason.
registerEventTemplates(templates) {
once('registerEventTemplates')
record.staged.registerEventTemplates(templates)
},
// The two lifecycle hooks (§2.5). Registered here, dispatched from
// lifecycle.js — this file runs with no database and the hooks run with one.
// Both are optional: a module with no warm-up and nothing to close simply
// never calls them.
onBoot: hook('onBoot'),
onShutdown: hook('onShutdown'),
// API 1.17.0 (STEAM_SIGNIN.md §4). Core calling the module about an external
// identity a signed-in user is linking, dispatched from auth/identityHooks.js.
//
// `onIdentityLinking` answers before anything is written and can only REFUSE
// (D396: a Steam id already another account's Rust link). It runs inside the
// link flow of a user who is already signed in, so it has no path to a session
// and cannot let through a link core would refuse. `onIdentityLinked` hears a
// link after it is written. There is no unlink hook: D393 leaves a module
// nothing to undo when a sign-in identity goes.
onIdentityLinking: hook('onIdentityLinking'),
onIdentityLinked: hook('onIdentityLinked'),
// API 1.18.0 (DEMO_MODE.md §7.3, DM15). Core's `seed:demo` handing the
// module its part of the seed fixture, so the module writes its own rows —
// a game server link, a player's game link — through its own model. Core
// never writes a module's tables. Called from the seed script only, never
// by the running server, and only with DEMO_MODE on.
onDemoSeed: hook('onDemoSeed'),
}
}
@@ -682,6 +761,8 @@ function readManifest(dir, id, tierRouters) {
if (!registries.hasSlot(slot)) fail('extensions', `unknown extension slot "${slot}"`)
}
checkDemoLocks(manifest)
if (manifest.client !== undefined) {
if (typeof manifest.client !== 'object' || manifest.client === null || Array.isArray(manifest.client)) {
fail('manifest', 'client must be an object')
@@ -702,6 +783,40 @@ function readManifest(dir, id, tierRouters) {
return manifest
}
/**
* Validate `demoLocks` (API 1.18.0, DEMO_MODE.md §3.2), on every boot and not
* only on the demo: a module whose declaration is wrong should find out on the
* developer's machine, not on the one deployment where it is read.
*
* Each entry is `{ method, path }`, the path relative to /api/v1 and under one
* of the module's own declared mounts — a module may lock its own routes and
* nobody else's, the same boundary `mounts` draws for serving them.
*/
function checkDemoLocks(manifest) {
if (manifest.demoLocks === undefined) return
if (!Array.isArray(manifest.demoLocks)) fail('manifest', 'demoLocks must be an array')
for (const entry of manifest.demoLocks) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
fail('manifest', 'each demoLocks entry must be an object')
}
for (const key of Object.keys(entry)) {
if (key !== 'method' && key !== 'path') fail('manifest', `unknown key "demoLocks.${key}" in module.json`)
}
if (!DEMO_LOCK_METHODS.includes(entry.method)) {
fail('manifest', `demoLocks method "${entry.method}" must be one of ${DEMO_LOCK_METHODS.join(', ')}`)
}
try {
demoPaths.compile(entry.path)
} catch (err) {
fail('manifest', `demoLocks: ${err.message}`)
}
const [, tier, prefix] = entry.path.split('/')
if (!(manifest.mounts?.[tier] || []).includes(`/${prefix}`)) {
fail('mounts', `demoLocks path ${entry.path} is outside the module's mounts`)
}
}
}
// What the module registered must equal what it declared — in both directions.
function checkDeclared(record) {
const declared = record.manifest.mounts || {}
@@ -754,10 +869,26 @@ function load(tierRouters) {
return // no modules directory is the normal case for a bare core
}
// One module per site, at boot as at install (`install.js`, org lead
// 2026-09-23; W19 in Rust PLAN_LAST_FIXES, 2026-10-22). Install already
// refuses a second module, so two on the volume means someone copied one in by
// hand. The contract's singletons assume one: Module-uo and Module-Rust both
// fill `admin.users.detail`, and the second used to fail at register with a
// slot collision that named neither the rule nor the fix. Now the first in
// scan order is the site's module, whether or not it then loads, and every
// other is refused before a line of its code is required.
let holder = null
for (const id of entries) {
const dir = path.join(MODULES_DIR, id)
if (!fs.existsSync(path.join(dir, 'module.json'))) continue
if (holder) {
refuseSecond(id, dir, holder)
continue
}
holder = id
const record = {
id,
dir,
@@ -766,7 +897,9 @@ function load(tierRouters) {
staged: registries.stage(id),
tables: new Set(),
called: new Set(),
hooks: { onBoot: null, onShutdown: null },
hooks: {
onBoot: null, onShutdown: null, onIdentityLinking: null, onIdentityLinked: null, onDemoSeed: null,
},
client: null,
ctx: null,
state: 'installed',
@@ -827,8 +960,10 @@ function load(tierRouters) {
for (const record of modules.values()) {
if (record.state !== 'registered') continue
try {
// Commit what this module staged. Collisions with core or with an earlier
// module surface here, in scan order, and cost only this module.
// Commit what this module staged. Collisions with core surface here and
// cost only this module. (An earlier module is no longer possible — one
// module per site, above — and the module-against-module checks stay as
// the second line behind that rule.)
registries.apply(record.staged.staged)
} catch (err) {
record.state = 'startup_failed'
@@ -843,6 +978,45 @@ function load(tierRouters) {
}
}
/**
* Record a module refused because another already holds the site (W19).
*
* `startup_failed` at the `manifest` stage, like any module that never reached
* register: no routes, no nav, no tables, and Admin → Modules says why. The
* name and version are read for that panel and nothing else; a module.json that does not
* parse is still refused for the same reason, since the reason is not its
* contents.
*/
function refuseSecond(id, dir, holder) {
const manifest = { id, version: 'unknown' }
try {
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'module.json'), 'utf8'))
if (raw && typeof raw.version === 'string') manifest.version = raw.version
if (raw && typeof raw.name === 'string') manifest.name = raw.name
} catch {
// the panel shows "unknown"; the refusal stands either way
}
const reason =
`this site already runs the module "${holder}", and a site runs one module. ` +
`Remove "${id}" or "${holder}" from the modules directory; the one that sorts first by name is the one that starts.`
modules.set(id, {
id,
dir,
manifest,
routes: { public: new Map(), admin: new Map(), player: new Map() },
staged: null,
tables: new Set(),
called: new Set(),
hooks: { onBoot: null, onShutdown: null, onIdentityLinking: null, onIdentityLinked: null },
client: null,
ctx: null,
state: 'startup_failed',
stage: 'manifest',
reason,
})
log.error(`module "${id}" was not started — continuing without it`, { stage: 'manifest', reason })
}
/**
* Mount one module's routers onto the tier routers, behind the dispatch guard.
*
@@ -994,6 +1168,73 @@ function shutdownHooks() {
.reverse()
}
/**
* The identity hooks of one name (`onIdentityLinking` | `onIdentityLinked`), in
* registration order (API 1.17.0).
*
* `started` only, the rule every dispatch here follows: a module that is
* disabled, or whose `onBoot` threw, is not serving and has no say in a link.
* Empty in a process that never loaded modules (the seed script, unit tests), so
* the link flow there behaves exactly as a bare core.
*
* @returns {{id: string, hook: Function}[]}
*/
function identityHooks(name) {
if (!loaded) return []
return [...modules.values()]
.filter((r) => r.state === 'started' && r.hooks[name])
.map((r) => ({ id: r.id, hook: r.hooks[name] }))
}
/**
* Every installed module's `demoLocks`, compiled, for the demo lock in
* demo/locks.js (API 1.18.0).
*
* Every module whose manifest validated, whatever its state. A disabled or
* failed module's routes already answer 404 or 503, so its locks change
* nothing — and a lock that disappeared whenever its module was between states
* is a lock with a gap in it. Empty before load() has run, rather than throwing:
* the middleware is mounted before the loader and asks per request.
*
* @returns {{methods: string[], path: string, compiled: object, source: string}[]}
*/
let demoLockCache = null
function demoLocks() {
if (!loaded) return []
// Manifests are read once, in load(), which is synchronous — so the answer
// cannot change after the first request asks, and is compiled once.
if (demoLockCache) return demoLockCache
const out = []
for (const record of modules.values()) {
for (const entry of record.manifest?.demoLocks || []) {
out.push({
methods: [entry.method],
path: entry.path,
compiled: demoPaths.compile(entry.path),
source: record.id,
})
}
}
demoLockCache = out
return out
}
/**
* The `onDemoSeed` hooks, for `npm run seed:demo` (API 1.18.0, DM15).
*
* `registered` or `started`: the seed is its own process, where nothing calls
* onBoot, so its modules never get past `registered`. A module that failed to
* load is not handed a fixture for tables it may not have.
*
* @returns {{id: string, hook: Function, ctx: object}[]}
*/
function demoSeedHooks() {
assertLoaded('demoSeedHooks')
return [...modules.values()]
.filter((r) => (r.state === 'registered' || r.state === 'started') && r.hooks.onDemoSeed)
.map((r) => ({ id: r.id, hook: r.hooks.onDemoSeed, ctx: r.ctx }))
}
/**
* One module's `onShutdown`, for stopping it on its own rather than at exit.
*
@@ -1119,6 +1360,9 @@ module.exports = {
fragments,
bootable,
shutdownHooks,
identityHooks,
demoLocks,
demoSeedHooks,
stopHook,
clientChunks,
clientEntryUrls,

View File

@@ -176,6 +176,16 @@ const eventLeases = new Map()
// is worse than the typo the dropdown existed to prevent.
const eventOptionSources = new Map()
// template id → { owner, id, label, description, inputs, build } (MODULE_API
// 1.13.0; Rust PLAN_REDESIGNS §2.5, D192).
//
// **An authoring shortcut and nothing else.** A template turns a handful of
// inputs into an ordinary draft — phases, steps, params — and the draft is the
// only thing that survives: no column records which template made it, and the
// runner never sees one. That is what lets a template change, or its module go,
// without touching a single definition it was ever used for.
const eventTemplates = new Map()
// owner → { templates: [...], ruleGroups: [...] } (ENGAGEMENT.md Phase 11b,
// decision 7). What a module ships as CONTENT rather than as contract: the
// bodies its triggers render through, and the rules an operator switches on.
@@ -435,14 +445,14 @@ async function resolveAudience(id, params = {}) {
/**
* Every declaration WITHOUT its callables — what the admin catalog serves.
*
* `perform`, `revert`, `reconcile` and `cost` are stripped for the same reason `resolve` is
* `perform`, `revert`, `reconcile`, `cost`, `progress` and `demoRefuses` are stripped for the same reason `resolve` is
* stripped from an audience and `handler` from a slash command: this is the
* object that leaves the process, and the browser's whole relationship with an
* action is naming one by id. §F's "a module registers actions server-side and
* adds no routes for them" is only true if the functions never ride out.
*/
const allEventActions = () =>
[...eventActions.values()].map(({ perform, revert, reconcile, cost, ...rest }) => rest)
[...eventActions.values()].map(({ perform, revert, reconcile, cost, progress, demoRefuses, ...rest }) => rest)
/** One declaration, callables included. The runner's lookup (Phase 2). */
const eventAction = (id) => eventActions.get(id) || null
@@ -531,6 +541,9 @@ const allEventOptionSources = () =>
// still answers something, where refusing would blank a form mid-keystroke.
const MAX_OPTION_QUERY = 120
// The longest note an option may carry, in characters.
const MAX_OPTION_NOTE = 300
/**
* Resolve one option source, or say why not. Never throws.
*
@@ -584,6 +597,12 @@ async function resolveOptionSource(id, { q = '' } = {}) {
if (o.value === undefined || o.value === null || o.value === '') continue
const option = { value: String(o.value), label: String(o.label ?? o.value) }
if (o.group) option.group = String(o.group)
// A sentence the editor shows under the field while this option is the
// chosen one (MODULE_API 1.13.0; Rust PLAN_REDESIGNS §2.6) — advice about
// the value, like "9 of 41 linked players have used this kit up". Bounded,
// because it is rendered as-is and one option's essay is a form nobody
// can read.
if (o.note) option.note = String(o.note).slice(0, MAX_OPTION_NOTE)
options.push(option)
}
// `searchable` is reported so the authoring form knows which field is a
@@ -1005,9 +1024,30 @@ const DEFAULT_BUDGET_MS = 10_000
// and Phase 2's lease has to be longer than this to mean anything.
const MAX_BUDGET_MS = 3_600_000
function checkActionParam(actionId, entry, seen) {
const { name, type, required, example, description, source } = entry || {}
const where = `registerEventActions: ${actionId}`
// A param that is a length of time says which unit it is in (MODULE_API 1.13.0;
// Rust PLAN_REDESIGNS §2.2). One unit, minutes, because every timed param
// registered so far is one — a lease's `minutes`, a zone's `minutes` — and a
// vocabulary of one is a vocabulary that can grow without a version.
const PARAM_DURATIONS = ['minutes']
// A param that holds several values (MODULE_API 1.14.0; Rust PLAN_REDESIGNS
// §12.3, D344). Two types, because the one step that needed it picks strings
// from a source and an int list is the same check one type over; a list of
// booleans, dates or links is a shape nothing has asked for, and every control
// the editor draws for a list is a picker or a tag box.
const MULTIPLE_PARAM_TYPES = ['string', 'int']
// `max` when the declaration leaves it out, and the most it may say. The ceiling
// is a bound on one step's params, not a number anyone should reach: a list of a
// hundred is a step nobody can read back.
const DEFAULT_MULTIPLE_MAX = 20
const MAX_MULTIPLE_MAX = 100
// `call` names the registration in the error, because a template's inputs are
// checked by this same function (they ARE params: one control each, with a
// name, a type, an example and maybe a source).
function checkActionParam(actionId, entry, seen, call = 'registerEventActions') {
const { name, type, required, example, description, source, duration, multiple, max } = entry || {}
const where = `${call}: ${actionId}`
if (!PARAM_NAME.test(name || '')) throw new Error(`${where}: bad param name "${name}"`)
if (seen.has(name)) throw new Error(`${where}: param "${name}" declared twice`)
seen.add(name)
@@ -1030,6 +1070,49 @@ function checkActionParam(actionId, entry, seen) {
if (source !== undefined && !ACTION_ID.test(source || '')) {
throw new Error(`${where}: param "${name}" has a bad option source "${source}"`)
}
// **A duration is what lets the editor see a timed step** (§2.2). A phase that
// ends before its longest timed step gives the thing back early — a lease held
// "for 10 minutes" in a phase that ends at once lasted one second — and the
// editor can only say so about a param that says it is a length of time.
// Numeric only: a duration typed as a string would be a number the editor
// has to parse with rules nobody declared.
if (duration !== undefined && duration !== null) {
if (!PARAM_DURATIONS.includes(duration)) {
throw new Error(`${where}: param "${name}" has duration "${duration}"; the one unit is ${PARAM_DURATIONS.join(', ')}`)
}
if (type !== 'int' && type !== 'float') {
throw new Error(`${where}: param "${name}" is a duration and must be int or float, not ${type}`)
}
}
// **`multiple` makes the value a list** (1.14.0, §12.3). The example stays ONE
// value — it is the placeholder of the box a value is typed into — and the
// value a step stores is a JSON array of them (`events/spec.js`).
//
// Refused on a duration, because a list of lengths of time is not a hold the
// editor can keep a phase open for, and on a template's inputs, because
// `build()` is a module's own code reading `inputs.x` as one value and this
// version does not change what it is handed.
if (multiple !== undefined && multiple !== null && multiple !== false) {
if (multiple !== true) throw new Error(`${where}: param "${name}" multiple must be true or left out`)
if (call === 'registerEventTemplates') {
throw new Error(`${where}: input "${name}" cannot be multiple; a template's inputs are single values`)
}
if (!MULTIPLE_PARAM_TYPES.includes(type)) {
throw new Error(`${where}: param "${name}" is multiple and must be ${MULTIPLE_PARAM_TYPES.join(' or ')}, not ${type}`)
}
if (duration !== undefined && duration !== null) {
throw new Error(`${where}: param "${name}" cannot be both multiple and a duration`)
}
if (Array.isArray(example)) {
throw new Error(`${where}: param "${name}" needs ONE example, not a list (it is the placeholder of one value)`)
}
}
if (max !== undefined && max !== null) {
if (multiple !== true) throw new Error(`${where}: param "${name}" has a max but is not multiple`)
if (!Number.isInteger(max) || max < 1 || max > MAX_MULTIPLE_MAX) {
throw new Error(`${where}: param "${name}" max must be 1..${MAX_MULTIPLE_MAX}, got "${max}"`)
}
}
return {
name,
type,
@@ -1037,11 +1120,15 @@ function checkActionParam(actionId, entry, seen) {
example,
source: source === undefined ? null : source,
description: description || '',
...(duration ? { duration } : {}),
// Absent rather than `false` on every other param, as `duration` is, so a
// module that never says it reads back exactly the declaration it wrote.
...(multiple === true ? { multiple: true, max: max ?? DEFAULT_MULTIPLE_MAX } : {}),
}
}
/**
* `registerEventActions([{ id, label, risk, reversible, version, budgetMs, cost, params, perform, revert, reconcile }])`.
* `registerEventActions([{ id, label, risk, reversible, version, budgetMs, cost, params, perform, revert, reconcile, progress, demoRefuses }])`.
*
* A typed verb core may ask a registrant to carry out. Everything decidable from
* the argument alone is decided here, at the call; the collision — is this id
@@ -1118,6 +1205,19 @@ function checkEventActionShape(entry) {
if (a.cost !== undefined && typeof a.cost !== 'function') {
throw new Error(`registerEventActions: ${a.id} cost must be a function of its params`)
}
// 1.12.0, optional: how a live run's step stands, for its public page
// (`events/progress.js`). Any action may declare it; an action that ledgers
// nothing can still say something about the world it changed.
if (a.progress !== undefined && typeof a.progress !== 'function') {
throw new Error(`registerEventActions: ${a.id} progress must be a function`)
}
// 1.19.0, optional: the public demo's save-time refusal (DEMO_MODE.md §3.4,
// DM18). Asked only with DEMO_MODE on, by the definition save path; a
// sentence back refuses the save `403 DEMO_LOCKED`. The module still checks
// at run, because a definition can predate the list it is checked against.
if (a.demoRefuses !== undefined && typeof a.demoRefuses !== 'function') {
throw new Error(`registerEventActions: ${a.id} demoRefuses must be a function of its params`)
}
const version = a.version === undefined ? 1 : a.version
if (!Number.isInteger(version) || version < 1) {
@@ -1150,6 +1250,8 @@ function checkEventActionShape(entry) {
perform: a.perform,
revert: a.revert || null,
reconcile: a.reconcile || null,
progress: a.progress || null,
demoRefuses: a.demoRefuses || null,
}
}
@@ -1323,6 +1425,19 @@ function checkEventLeaseShape(entry) {
values = l.values.map((v) => String(v))
}
// **The lease's own example for its value** (MODULE_API 1.13.0; Rust
// PLAN_REDESIGNS §2.1, U-3). `core.lease`'s `value` param has one example for
// every lease there is, and a Rust admin borrowing a group permission was shown
// a skill-gain rate. Optional; a scalar, because it is a placeholder in one
// field. Not range-checked: it is shown, never sent.
let example = null
if (l.example !== undefined && l.example !== null && l.example !== '') {
if (!['string', 'number', 'boolean'].includes(typeof l.example)) {
throw new Error(`registerEventLeases: ${l.id} example must be a single value`)
}
example = l.example
}
return {
id: l.id,
label: l.label,
@@ -1331,6 +1446,7 @@ function checkEventLeaseShape(entry) {
min,
max,
values,
example,
target,
maxDurationMs,
read: l.read,
@@ -1377,6 +1493,56 @@ function checkEventOptionSourceShape(entry) {
}
}
// The most inputs a template may ask for. A template is a shortcut past the
// editor, and one that asks for a dozen things is the editor with fewer labels.
const MAX_TEMPLATE_INPUTS = 8
/**
* `registerEventTemplates([{ id, label, description, inputs, build }])`
* (MODULE_API 1.13.0; Rust PLAN_REDESIGNS §2.5, D192).
*
* A named starting point for a new event: the author fills in `inputs`, core
* calls `build({ inputs })`, and what comes back is a DRAFT the editor opens like
* any other — `{ title, summary?, spec: { phases } }`. Nothing about the template
* survives into the definition.
*
* **`inputs` are params, checked by the same function**, because they are drawn
* by the same widget: a name, a type, an example, maybe a source. That gives an
* input the dropdown, the search box and the duration unit for free, and gives
* the author one form language rather than two.
*
* `build` may be async and may read the module's own data (the kit weekend needs
* the kit's permission). It is never trusted: core checks the inputs before the
* call and validates the spec after it, and a throw is the author's error
* message rather than a 500 (`buildEventTemplate` in eventDefinitions.model).
*/
function checkEventTemplateShape(entry) {
const t = entry || {}
if (!EVENT_ID.test(t.id || '')) throw new Error(`registerEventTemplates: bad template id "${t.id}"`)
if (!t.label) throw new Error(`registerEventTemplates: template "${t.id}" has no label`)
if (typeof t.build !== 'function') throw new Error(`registerEventTemplates: ${t.id} has no build()`)
const rawInputs = t.inputs === undefined ? [] : t.inputs
if (!Array.isArray(rawInputs)) throw new Error(`registerEventTemplates: ${t.id} inputs must be an array`)
if (rawInputs.length > MAX_TEMPLATE_INPUTS) {
throw new Error(`registerEventTemplates: ${t.id} asks for ${rawInputs.length} inputs; the most is ${MAX_TEMPLATE_INPUTS}`)
}
const seen = new Set()
return {
id: t.id,
label: t.label,
description: t.description || '',
inputs: rawInputs.map((p) => checkActionParam(t.id, p, seen, 'registerEventTemplates')),
build: t.build,
}
}
/** Every template WITHOUT its `build` — what the catalog serves. */
const allEventTemplates = () =>
[...eventTemplates.values()].map(({ build, ...rest }) => rest)
/** One template, `build` included. */
const eventTemplate = (id) => eventTemplates.get(id) || null
// ── Engagement seeds (Phase 11b, decision 7) ───────────────────────────────
//
@@ -1621,6 +1787,7 @@ function stage(owner) {
eventBudgets: [],
eventLeases: [],
eventOptionSources: [],
eventTemplates: [],
}
return {
staged,
@@ -1679,6 +1846,12 @@ function stage(owner) {
if (!Array.isArray(entries)) throw new Error('registerEventOptionSources: expected an array')
for (const e of entries) staged.eventOptionSources.push(checkEventOptionSourceShape(e))
},
// MODULE_API 1.13.0. Core registers none; the first is Module-Rust's kit
// weekend (Rust PLAN_REDESIGNS §2.5).
registerEventTemplates(entries) {
if (!Array.isArray(entries)) throw new Error('registerEventTemplates: expected an array')
for (const e of entries) staged.eventTemplates.push(checkEventTemplateShape(e))
},
registerEngagementSeeds(entry) {
staged.engagementSeeds.push(checkEngagementSeeds(owner, entry))
},
@@ -1708,6 +1881,7 @@ function apply({
eventBudgets: newEventBudgets = [],
eventLeases: newEventLeases = [],
eventOptionSources: newEventOptionSources = [],
eventTemplates: newEventTemplates = [],
engagementSeeds: newSeeds = [],
}) {
// ── validate ──
@@ -1812,6 +1986,17 @@ function apply({
seenLeases.add(l.id)
}
const seenTemplates = new Set()
for (const t of newEventTemplates) {
const held = eventTemplates.get(t.id)
if (held) throw new Error(`event template "${t.id}" is already registered by "${held.owner}"`)
if (seenTemplates.has(t.id)) throw new Error(`event template "${t.id}" registered twice`)
if (!namespaced(owner, t.id, {})) {
throw new Error(`event template "${t.id}" is not namespaced "${owner}."`)
}
seenTemplates.add(t.id)
}
const seenSources = new Set()
for (const s of newEventOptionSources) {
const held = eventOptionSources.get(s.id)
@@ -1891,6 +2076,7 @@ function apply({
for (const b of newEventBudgets) eventBudgets.set(b.id, { owner, ...b })
for (const l of newEventLeases) eventLeases.set(l.id, { owner, ...l })
for (const s of newEventOptionSources) eventOptionSources.set(s.id, { owner, ...s })
for (const t of newEventTemplates) eventTemplates.set(t.id, { owner, ...t })
for (const seeds of newSeeds) engagementSeeds.set(owner, seeds)
}
@@ -1986,6 +2172,7 @@ function _reset() {
eventBudgets.clear()
eventLeases.clear()
eventOptionSources.clear()
eventTemplates.clear()
engagementSeeds.clear()
coreRegistered = false
}
@@ -2026,6 +2213,12 @@ module.exports = {
parseLeaseRef,
leaseRef,
allEventOptionSources,
allEventTemplates,
eventTemplate,
PARAM_DURATIONS,
MULTIPLE_PARAM_TYPES,
DEFAULT_MULTIPLE_MAX,
MAX_MULTIPLE_MAX,
resolveOptionSource,
allEngagementSeeds,
engagementSeedsFor,

View File

@@ -9,6 +9,105 @@
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
// has nothing to say about a website module) and from any module's own version.
// 1.19.0 — the public demo refuses a step at SAVE (docs/website/DEMO_MODE.md
// §3.4, DM18). An event action may declare `demoRefuses(params)`, returning a
// sentence or null. With DEMO_MODE on, core asks it for every step when a
// definition is created, saved or published, and a sentence refuses the whole
// write `403 DEMO_LOCKED` with that sentence as `message`; a hook that throws
// refuses too. Off the demo it is validated and never called. One optional
// action member, so minor. Module-uo declares `^1.10.0` and declares none.
// Module-Rust's console-command deny list is the first user.
//
// 1.18.0 — the public demo (docs/website/DEMO_MODE.md §3.2, §3.4, §7.3; website#222).
// Three additions, each optional:
// • `demoLocks` in module.json — `[{ method, path }]`, writes only, each path
// under one of the module's own mounts. With DEMO_MODE on, core answers a
// matching request `403 DEMO_LOCKED` before the module's handler runs; with
// it off the list is validated and otherwise unread;
// • `ctx.demoMode` — true on the demo, for the locks a route cannot express,
// which the module enforces in its own handlers;
// • `api.onDemoSeed(fn)` — `npm run seed:demo` hands the module its part of
// the seed fixture (DM15), so it writes its own rows.
// Additions only, so minor. Module-uo declares `^1.10.0` and uses none of them.
// A module that DECLARES `demoLocks` needs `^1.18.0`, because older cores refuse
// a manifest key they do not know.
//
// 1.17.0 — a module may take part in linking an external sign-in identity
// (docs/website/STEAM_SIGNIN.md §4; Rust PLAN_REDESIGNS §15, D392 and D396).
// Three additions, each optional:
// • `ctx.identities.list(userId, provider)` — the identities a user has linked
// for one provider, as core verified them (a Steam id proved by Steam's own
// `check_authentication`), with the display name and avatar when there are;
// • `api.onIdentityLinking(fn)` — asked before a link is written, and able only
// to refuse it: `{ ok: false, reason }`, where `reason: 'in_use'` reads to the
// player as "already linked to another account" and anything else as a
// plain refusal. A module's own text is logged, never shown;
// • `api.onIdentityLinked(fn)` — told after a link is written.
// No unlink hook (D393). Additions only, so minor. Module-uo declares `^1.10.0`
// and uses none of them: with no hook registered a link is checked and written
// exactly as before.
//
// 1.16.0 — a dry run carries a module's advice: an action that answers
// `verify: true` with `{ ok: true, detail: { warnings: [...] } }` has each line
// shown as a WARNING finding (code `advice`) that does not fail the dry run
// (Rust PLAN_REDESIGNS §11.4, D391: a raid base's spot is checked against the
// world as it stands now, which may change before the run). Strings only, at most
// ten, each cut at 500 characters. One optional reading of an existing member, so
// minor. Module-uo declares `^1.10.0` and says no warnings: its dry runs read
// exactly as they did. STEAM_SIGNIN.md's claim moves to the next minor again.
//
// 1.15.0 — a failed step may say when to try again: `{ ok: false, retry: true,
// retryAfterMs }` (Rust PLAN_REDESIGNS §14.3.13, D371; docs/website/EVENTS.md §F).
// Clamped to one minute–thirty minutes, never refused; absent, the flat
// `EVENT_STEP_RETRY_MS` as before. It is one attempt of `EVENT_STEP_MAX_ATTEMPTS`
// like any retry, and ignored beside `retry: false`. One optional envelope
// member, so minor. Module-uo declares `^1.10.0` and answers none: an envelope
// without it is classified exactly as it was. This took 1.15.0 ahead of
// STEAM_SIGNIN.md, which had claimed it and moves to the next minor.
//
// 1.14.0 —an action param may hold several values: `multiple: true`, with an
// optional `max` (1–100, 20 if left out), on a `string` or `int` param (Rust
// PLAN_REDESIGNS §12.3, D344; docs/website/EVENTS.md §F). The step stores a JSON
// array; a bare value is read as a list of one; `required` means at least one;
// `perform()` and `cost()` are handed the array. Refused on a duration and on a
// template's inputs. The editor draws it as a multi-select.
// One optional flag, so minor. Module-uo declares `^1.10.0` and uses none of it:
// a param that does not say `multiple` is checked, stored and drawn exactly as it
// was, and the declaration the registry hands back does not grow the key.
//
// 1.13.0 — the event step editor (Rust PLAN_REDESIGNS §2; docs/website/EVENTS.md
// §F, §I). Four additions, each optional:
// • `api.registerEventTemplates([{ id, label, description, inputs, build }])` —
// a named starting point for a new event; inputs in, an ordinary draft out,
// and nothing about the template kept in the definition (D192);
// • a param's `duration: 'minutes'`, so the editor can see a timed step and
// keep its phase open long enough for it;
// • a lease's `example` for its value, so a lease step shows that lease's
// example rather than core's one for every lease;
// • an option row's `note`, shown under the field while it is the chosen one.
// Additions only, so minor. Module-uo declares `^1.10.0` and uses none of them:
// its actions, leases and sources come out of the registry exactly as they went
// in, and its steps render with the new controls and nothing else changed.
//
// 1.12.0 — an event action may declare `progress(envelope)`: how a live run's
// step stands, for its public page (docs/website/EVENTS.md §I; RunicNPC D304,
// D305). Core accepts two shapes, `{ label, left, of }` and `{ label, percent }`,
// puts them in the public run as `progress` and renders them as text it does not
// interpret. Optional and additive, so minor: Module-uo declares `^1.10.0` and
// registers no action with it, so its actions and pages are unchanged.
//
// 1.11.0 — `ctx.events.expired({ kind, ref })`, and `expired` as a resource
// ledger status (docs/website/EVENTS.md §L; Rust PLAN_FIXES D183). A game that
// ends something at its own deadline — a Rust zone erased when its time is up —
// could until now 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: terminal, green, never taken back.
//
// Additions only, so minor, and checked against the one module already built on
// 1.10.0: Module-uo never calls it, reads no ledger status, and every existing
// status keeps its meaning — `expired` joins neither `HELD` nor `UNRESOLVED`, so
// the sweep, the manual retry and `cleanup_status` see exactly what they saw.
// 1.10.0 — the event contract opens to modules: `api.registerEventActions`,
// `api.registerEventBudgets`, `api.registerEventLeases` and
// `api.registerEventOptionSources` (docs/website/EVENTS.md §F, EVENTS_PLAN.md
@@ -155,6 +254,6 @@
// an admin action a module performs belongs in core's one audit log, the
// extension slot needs the user its prefix names, and §2.7 forbids a module
// reading core's `APP_BASE_URL` for itself. Additions only, so minor.
const MODULE_API_VERSION = '1.10.0'
const MODULE_API_VERSION = '1.19.0'
module.exports = { MODULE_API_VERSION }

View File

@@ -1,6 +1,6 @@
// ── Admin: auth provider configuration ─────────────────────────────────────
//
// CRUD for SSO providers. Built-ins (google, discord) are configured here too but
// CRUD for SSO providers. Built-ins (google, discord, steam) are configured here too but
// can only be enabled/disabled and given a client id/secret — their kind, name,
// and endpoints are fixed in code and cannot be edited or deleted. Custom
// (oidc/oauth2) providers are fully editable.
@@ -80,6 +80,18 @@ async function update(req, res) {
// Built-ins: kind/name/priority are fixed; only enable + credentials change.
const meta = registry.BUILTINS.find((x) => x.id === id)
fields = { kind: meta.kind, name: meta.name, priority: meta.priority, enabled: b.enabled, clientId: b.clientId, secret: b.secret }
if (meta.kind === 'steam') {
// Steam has no client id; its secret is the Steam Web API key, and it
// cannot be enabled without one (D394) — refused here, with a sentence,
// rather than saved as an "Incomplete" provider nobody can use.
fields.clientId = undefined
if (b.enabled === true && !b.secret) {
const current = await authProviders.get(id)
if (!current || !current.client_secret_enc) {
return res.status(400).json({ message: 'Steam needs a Steam Web API key before it can be enabled.' })
}
}
}
} else {
if (!(await authProviders.get(id))) {
return res.status(404).json({ message: 'Provider not found.' })

View File

@@ -59,11 +59,12 @@ providersRouter.put(
'/providers/:id',
// #swagger.tags = ['Admin · Auth Providers']
// #swagger.summary = 'Update an SSO provider (admin only)'
// #swagger.description = 'Built-ins (google, discord, steam) take only enabled + credentials. Steam has no client id: its secret is a Steam Web API key, and enabling Steam without one stored or given is refused with 400 (STEAM_SIGNIN.md D394).'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' }
/* #swagger.requestBody = { content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderCreateRequest" } } } } */
/* #swagger.responses[200] = { description: 'Updated provider', content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderConfig" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[400] = { description: 'Validation error, or Steam enabled without a Steam Web API key', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'Provider not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */

View File

@@ -34,12 +34,24 @@ const activity = require('../../../model/activity/activity.model')
const settingsDb = require('../../../model/events/eventActionSettings.db')
const authorize = require('../../../events/authorize')
const price = require('../../../events/price')
const starts = require('../../../events/starts')
const triggerStartsDb = require('../../../model/events/eventTriggerStarts.db')
const asId = (raw) => {
const n = Number(raw)
return Number.isInteger(n) && n > 0 ? n : null
}
/**
* A refused save's body: the errors, plus `code` and `message` when the model
* set them. The demo's step refusal does (DM18), so the client's one
* `DEMO_LOCKED` toast fires as it does for a route lock.
*/
const refusal = (result) => ({
errors: result.errors,
...(result.code ? { code: result.code, message: result.message } : {}),
})
/**
* The shape a definition takes on the wire.
*
@@ -78,6 +90,19 @@ const shapeDefinition = (d) => ({
updatedAt: d.updated_at,
})
/**
* The definition as the EDITOR reads it: the shape above, and whether the working
* copy has moved on from the published version (Rust PLAN_REDESIGNS §2.3). One
* extra read, so only the three routes the editor loads from carry it.
*/
const shapeEditable = async (row) => ({
...shapeDefinition(row),
draftAhead: await definitions.draftAhead(row),
// What its trigger starts have done lately (D370): the last run one started
// and the firings ignored since, by why. Null until a trigger first tries.
triggerStarts: starts.describeState(await triggerStartsDb.forDefinition(row.id)),
})
const shapeRun = (r) => ({
id: r.id,
definitionId: r.definition_id,
@@ -95,6 +120,10 @@ const shapeRun = (r) => ({
concurrencyKey: r.concurrency_key,
params: r.params,
rehearsal: r.rehearsal,
// What started it, when a game firing did (D370): the trigger, when, its
// subject, the condition it matched and its declared variables. Null for a
// run started by hand or by the schedule.
origin: r.origin || null,
startedAt: r.started_at,
endedAt: r.ended_at,
// When the results table was ranked and published (Phase 10). On the LIST as
@@ -203,6 +232,9 @@ exports.catalog = (_req, res) => {
budgets: registries.allEventBudgets(),
leases: registries.allEventLeases(),
optionSources: registries.allEventOptionSources(),
// MODULE_API 1.13.0 (Rust PLAN_REDESIGNS §2.5). Without `build`, like every
// callable above; filling one in is its own request.
templates: registries.allEventTemplates(),
limits: {
maxPhases: spec.MAX_PHASES,
maxStepsPerPhase: spec.MAX_STEPS_PER_PHASE,
@@ -210,6 +242,9 @@ exports.catalog = (_req, res) => {
defaultBudgetMs: registries.DEFAULT_BUDGET_MS,
maxAdvanceCount: spec.MAX_ADVANCE_COUNT,
maxAfterSeconds: spec.MAX_AFTER_SECONDS,
// A definition's `starts` (D370): how many, and the cooldown's ceiling.
maxStarts: spec.MAX_STARTS,
maxCooldownSeconds: spec.MAX_COOLDOWN_SECONDS,
},
})
}
@@ -363,7 +398,7 @@ exports.get = async (req, res) => {
if (!id) return res.status(400).json({ error: 'bad event id' })
const row = await definitionsDb.getById(id)
if (!row) return res.status(404).json({ error: 'no such event definition' })
return res.json({ event: shapeDefinition(row) })
return res.json({ event: await shapeEditable(row) })
}
/** GET /api/v1/admin/events/:id/versions */
@@ -388,7 +423,7 @@ exports.listVersions = async (req, res) => {
/** POST /api/v1/admin/events */
exports.create = async (req, res) => {
const result = await definitions.create(req.body, req.user.id, { role: req.user.role })
if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
if (!result.ok) return res.status(result.status || 400).json(refusal(result))
await activity.log({
req,
action: 'event.definition.created',
@@ -402,13 +437,13 @@ exports.update = async (req, res) => {
const id = asId(req.params.id)
if (!id) return res.status(400).json({ error: 'bad event id' })
const result = await definitions.save(id, req.body, req.user.id, { role: req.user.role })
if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
if (!result.ok) return res.status(result.status || 400).json(refusal(result))
await activity.log({
req,
action: 'event.definition.updated',
detail: { id, title: result.definition.title },
})
return res.json({ event: shapeDefinition(result.definition) })
return res.json({ event: await shapeEditable(result.definition) })
}
/** POST /api/v1/admin/events/:id/publish */
@@ -416,20 +451,29 @@ exports.publish = async (req, res) => {
const id = asId(req.params.id)
if (!id) return res.status(400).json({ error: 'bad event id' })
const result = await definitions.publish(id, req.user.id)
if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
if (!result.ok) return res.status(result.status || 400).json(refusal(result))
await activity.log({
req,
action: 'event.definition.published',
detail: { id, version: result.version, versionId: result.versionId, repinned: result.repinned },
detail: {
id,
version: result.version,
versionId: result.versionId,
repinned: result.repinned,
verified: result.verified,
},
})
return res.json({
event: shapeDefinition(result.definition),
event: await shapeEditable(result.definition),
version: result.version,
versionId: result.versionId,
// How many already-materialised occurrences moved to this version. The
// screen says so, because "my fix did not reach next Friday" is otherwise
// found out on Friday.
repinned: result.repinned,
// Whether a dry run that passed on this exact draft made the version
// verified at birth (W5), so the screen can say it needs no second one.
verified: result.verified,
})
}
@@ -467,6 +511,7 @@ exports.verify = async (req, res) => {
versionId: result.versionId,
version: result.version,
recorded: result.recorded,
draftRecorded: result.draftRecorded,
report: result.report,
})
}
@@ -585,6 +630,19 @@ exports.options = async (req, res) => {
return res.json(result)
}
/**
* POST /api/v1/admin/events/catalog/templates/:templateId — fill a template in.
*
* MODULE_API 1.13.0 (Rust PLAN_REDESIGNS §2.5). Answers a draft and writes
* nothing, so there is nothing to put in the activity log: the definition the
* draft becomes is logged when it is created, by the route that creates it.
*/
exports.buildTemplate = async (req, res) => {
const result = await definitions.buildFromTemplate(req.params.templateId, req.body?.inputs)
if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
return res.json({ draft: result.draft })
}
/**
* PUT /api/v1/admin/events/actions — set one action's switch and caps.
*

View File

@@ -51,7 +51,7 @@ eventsRouter.get(
// #swagger.summary = 'List every registered event action, with its param schema, risk class and reversibility'
// #swagger.description = 'Served from the module registries, not from a table: an action is declared in code by core or by an installed module, so this is whatever registered on this boot, and an uninstalled module simply stops appearing. Core always declares core.announce, core.wait and core.cue. Also carries the closed vocabularies the authoring form renders — risk classes, reversibility classes, param types, failure dispositions and the spec size limits — so the editor offers exactly the set the save path checks against. Phase 5 added `triggers` and `operators`: the trigger catalog a module already ships IS the catalog of things a phase can advance on, and it is served here rather than borrowed from /admin/engagement/triggers because that route is admin-only while an event definition is authored by admin AND editor. Each trigger is reduced to its id, label and declared variables — a trigger's audience and ceiling are about who gets mailed, which is not this screen's question. Phase 7 added `budgets`, `leases` and `optionSources`: the other three registrations of the module contract, served beside the actions because the step editor needs all four to draw ONE step — the action says what params it takes, a param source names a dropdown, and a cap box is a budget label and unit. A source resolves its VALUES on a request of its own (/options/:sourceId), because a source can be slow or down and must not take the catalog with it.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The registered actions and triggers, and the vocabularies over them', content: { "application/json": { schema: { type: "object", properties: { actions: { type: "array", items: { type: "object", additionalProperties: true } }, triggers: { type: "array", items: { type: "object", additionalProperties: true } }, operators: { type: "array", items: { type: "object", additionalProperties: true } }, risks: { type: "array", items: { type: "string" } }, reversible: { type: "array", items: { type: "string" } }, paramTypes: { type: "array", items: { type: "string" } }, onFailure: { type: "array", items: { type: "string" } }, onFailureByRisk: { type: "object", additionalProperties: true }, scheduleKinds: { type: "array", items: { type: "string" } }, advanceKinds: { type: "array", items: { type: "string" } }, limits: { type: "object", additionalProperties: true } } } } } } */
/* #swagger.responses[200] = { description: 'The registered actions and triggers, and the vocabularies over them', content: { "application/json": { schema: { type: "object", properties: { actions: { type: "array", description: "Each action with its declared params. MODULE_API 1.14.0: a param may carry multiple: true and max (1 to 100, 20 when left out), and its value is then a JSON array of at most max values.", items: { type: "object", additionalProperties: true } }, triggers: { type: "array", items: { type: "object", additionalProperties: true } }, operators: { type: "array", items: { type: "object", additionalProperties: true } }, risks: { type: "array", items: { type: "string" } }, reversible: { type: "array", items: { type: "string" } }, paramTypes: { type: "array", items: { type: "string" } }, onFailure: { type: "array", items: { type: "string" } }, onFailureByRisk: { type: "object", additionalProperties: true }, scheduleKinds: { type: "array", items: { type: "string" } }, advanceKinds: { type: "array", items: { type: "string" } }, limits: { type: "object", additionalProperties: true }, templates: { type: "array", description: "Event templates (MODULE_API 1.13.0): id, label, description, owner and inputs (each shaped like an action param). build() is never served.", items: { type: "object", additionalProperties: true } } } } } } } */
/* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
controller.catalog,
)
@@ -78,11 +78,35 @@ eventsRouter.get(
// object schema whose properties are `type` and `maxLength`, which documents a
// query parameter that takes a JSON object. The bound is stated in the description.
/* #swagger.parameters['q'] = { in: 'query', description: 'Narrow the list. Honoured only by a source that declares itself searchable; ignored, never refused, by the rest. Bounded to 120 characters.', required: false, schema: { type: 'string' } } */
/* #swagger.responses[200] = { description: 'The options, or the reason there are none', content: { "application/json": { schema: { type: "object", properties: { ok: { type: "boolean" }, id: { type: "string" }, label: { type: "string" }, owner: { type: "string" }, searchable: { type: "boolean" }, q: { type: "string" }, reason: { type: "string" }, options: { type: "array", items: { type: "object", properties: { value: { type: "string" }, label: { type: "string" }, group: { type: "string" } } } } } } } } } */
/* #swagger.responses[200] = { description: 'The options, or the reason there are none', content: { "application/json": { schema: { type: "object", properties: { ok: { type: "boolean" }, id: { type: "string" }, label: { type: "string" }, owner: { type: "string" }, searchable: { type: "boolean" }, q: { type: "string" }, reason: { type: "string" }, options: { type: "array", items: { type: "object", properties: { value: { type: "string" }, label: { type: "string" }, group: { type: "string" }, note: { type: "string", description: "Advice shown under the field while this option is chosen (MODULE_API 1.13.0). At most 300 characters." } } } } } } } } } */
/* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
controller.options,
)
// ── Event templates (MODULE_API 1.13.0; Rust PLAN_REDESIGNS §2.5) ─────────
//
// Under `/catalog` for the reason the option sources are: a template is catalog
// data, and the nesting keeps it out of `/:id`'s way. A POST because the inputs
// are a body, not because anything is written — the answer is a draft the
// editor opens, and saving it is `POST /` with every rule that route has.
// `admin, editor`, because filling a template in is authoring.
eventsRouter.post(
'/catalog/templates/:templateId',
adminOrEditor,
// #swagger.tags = ['Admin · Events']
// #swagger.summary = 'Fill an event template in, and get back a draft'
// #swagger.description = 'A module may register named event templates (MODULE_API 1.13.0). The catalog lists them with their inputs; this route checks the inputs against the template, calls the module to build the plan, validates the plan as a spec and answers it as a draft: a title, a summary and a spec. Nothing is saved, and nothing about the template is kept: the editor opens the draft like any new event and the author saves it through POST /admin/events. A refusal of the inputs is a 400 naming each problem; a module that fails or builds a plan core cannot accept is a 502.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { inputs: { type: "object", additionalProperties: true, description: "One value per declared input, keyed by its name" } } } } } } */
/* #swagger.responses[200] = { description: 'The draft', content: { "application/json": { schema: { type: "object", properties: { draft: { type: "object", properties: { title: { type: "string" }, summary: { type: "string", nullable: true }, spec: { type: "object", additionalProperties: true } } } } } } } } */
/* #swagger.responses[400] = { description: 'The inputs were refused', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
/* #swagger.responses[403] = { description: 'Not an admin or editor', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'No module registers that template', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
/* #swagger.responses[502] = { description: 'The module failed to build the plan, or built one core cannot accept', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
controller.buildTemplate,
)
// ── The live cap meter (Phase 13) ──────────────────────────────────
//
// A literal path for the same reason `/actions` is one, and `admin, editor` for
@@ -237,7 +261,7 @@ eventsRouter.get(
'/runs/:runId',
// #swagger.tags = ['Admin · Events']
// #swagger.summary = 'One run: its status, health, cleanup state and every step with its params and idempotency key'
// #swagger.description = 'The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder’s own words — `gte` as "is at least", `present` as "is present" — with the tally, how long it has waited, and the last related firing whether or not it matched. A phase is waiting on its gate only once every one of its steps is terminal; `stalled` means an `on` gate has waited past EVENT_PHASE_STALL_MS, which is visibility and never a timeout — nothing advances a phase but its condition or a human. `budget` is the cap meter (Phase 6), and `resources` is the cleanup ledger (Phase 8): every object this run created and every value it borrowed, with what became of each — `confirmed` is still out there, `reverted` came back, `drifted` means somebody moved it and core left it alone, and `orphaned` means the module reports it is gone. `unresolvedResources` counts the ones still wanting something, including a placeholder left standing by a lost acknowledgement, which is why it can exceed the length of the list. `participants` is who took part (Phase 10), best first, as a module reported them: `memberKey` is module-opaque, `userId` is filled in only where the module could link the player to an account, and `rank` is null until `core.results.publish` has ranked them — a run whose participants are collected but unranked is a real and visible state, not an error. The run itself carries `resultsPublishedAt`, which is when that table was last published.'
// #swagger.description = 'The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder’s own words — `gte` as "is at least", `present` as "is present" — with the tally, how long it has waited, and the last related firing whether or not it matched. A phase is waiting on its gate only once every one of its steps is terminal; `stalled` means an `on` gate has waited past EVENT_PHASE_STALL_MS, which is visibility and never a timeout — nothing advances a phase but its condition or a human. `budget` is the cap meter (Phase 6), and `resources` is the cleanup ledger (Phase 8): every object this run created and every value it borrowed, with what became of each — `confirmed` is still out there, `reverted` came back, `drifted` means somebody moved it and core left it alone, `orphaned` means the module reports it is gone, and `expired` means the game ended it at its own deadline, as it was told to (terminal, like `reverted`). `unresolvedResources` counts the ones still wanting something, including a placeholder left standing by a lost acknowledgement, which is why it can exceed the length of the list. `participants` is who took part (Phase 10), best first, as a module reported them: `memberKey` is module-opaque, `userId` is filled in only where the module could link the player to an account, and `rank` is null until `core.results.publish` has ranked them — a run whose participants are collected but unranked is a real and visible state, not an error. The run itself carries `resultsPublishedAt`, which is when that table was last published, and `origin` when a game firing started it (Rust PLAN_REDESIGNS 14.3.12): the trigger, its label, when it fired, its subject, the start condition it matched in the builder words, and its declared variables. Null for a run started by hand or by the schedule.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The run, its steps, the status counts, the phase gates, the cap meter, the resource ledger and the participants', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true }, steps: { type: "array", items: { type: "object", additionalProperties: true } }, counts: { type: "object", additionalProperties: true }, gates: { type: "array", items: { type: "object", additionalProperties: true } }, budget: { type: "array", items: { type: "object", additionalProperties: true } }, resources: { type: "array", items: { type: "object", additionalProperties: true } }, unresolvedResources: { type: "integer" }, participants: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
/* #swagger.responses[404] = { description: 'No such run', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
@@ -415,7 +439,7 @@ eventsRouter.get(
'/:id',
// #swagger.tags = ['Admin · Events']
// #swagger.summary = 'One event definition, including its working spec'
// #swagger.description = 'The editor reads this. The list route serves a summary; this is the whole authored tree, phases and steps included.'
// #swagger.description = 'The editor reads this. The list route serves a summary; this is the whole authored tree, phases and steps included. The spec may carry `starts` (Rust PLAN_REDESIGNS 14.3.12): the triggers that begin a run of the published version, each a phase gate `on` and `where` plus a `cooldown` (0m to 7d, 30m when left out); core own event.* triggers are refused. `triggerStarts` is what they have done lately: the last run a trigger started and the firings ignored since, by why (a run was going, inside the cooldown, or the published version is not verified). Null until a trigger first tries.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The definition', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true } } } } } } */
/* #swagger.responses[404] = { description: 'No such definition', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
@@ -451,9 +475,9 @@ eventsRouter.post(
'/:id/verify',
// #swagger.tags = ['Admin · Events']
// #swagger.summary = 'Dry run: dispatch every step with verify true, change nothing, and report the cost against the caps'
// #swagger.description = 'EVENTS.md I. admin AND editor rather than admin, deliberately: a dry run dispatches nothing, and the author who wrote the definition is exactly who should be able to price it before asking an admin to publish it. What is verified follows the state -- a ready definition is checked against its PUBLISHED version, which is the only thing that ever actually runs, and a draft against the working spec the author is still holding; `target` says which. A pass against a version is RECORDED on it, and that is EVENTS.md K last bound: a scheduled occurrence of a version that has never been verified is held rather than started unattended. Findings come back with a 200 -- the request succeeded, the plan has problems -- and the whole-plan cost check is the one thing no other path makes: three steps each spawning 15 under a cap of 30 pass every individual check and breach it on the third, at two in the morning, with the world half-changed.'
// #swagger.description = 'EVENTS.md I. admin AND editor rather than admin, deliberately: a dry run dispatches nothing, and the author who wrote the definition is exactly who should be able to price it before asking an admin to publish it. What is verified follows the state -- a ready definition is checked against its PUBLISHED version, which is the only thing that ever actually runs, and a draft against the working spec the author is still holding; `target` says which. A pass against a version is RECORDED on it, and that is EVENTS.md K last bound: a scheduled occurrence of a version that has never been verified is held rather than started unattended. A pass against a draft is kept with the fingerprint of the spec it passed on (`draftRecorded`), and publishing that spec unchanged makes a version that is verified from the start; an edit in between changes the fingerprint, and nothing carries over. Findings come back with a 200 -- the request succeeded, the plan has problems -- and the whole-plan cost check is the one thing no other path makes: three steps each spawning 15 under a cap of 30 pass every individual check and breach it on the third, at two in the morning, with the world half-changed.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The report: findings per step, and the total cost per budget dimension', content: { "application/json": { schema: { type: "object", properties: { target: { type: "string", example: "version" }, versionId: { type: "integer" }, version: { type: "integer" }, recorded: { type: "boolean" }, report: { type: "object", properties: { ok: { type: "boolean" }, steps: { type: "integer" }, findings: { type: "array", items: { type: "object", additionalProperties: true } }, cost: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } } } */
/* #swagger.responses[200] = { description: 'The report: findings per step, and the total cost per budget dimension', content: { "application/json": { schema: { type: "object", properties: { target: { type: "string", example: "version" }, versionId: { type: "integer" }, version: { type: "integer" }, recorded: { type: "boolean" }, draftRecorded: { type: "boolean" }, report: { type: "object", properties: { ok: { type: "boolean" }, steps: { type: "integer" }, findings: { type: "array", items: { type: "object", additionalProperties: true } }, cost: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } } } */
/* #swagger.responses[404] = { description: 'No such definition', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'The definition is archived, or has no phases to verify', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
/* #swagger.responses[403] = { description: 'Not an admin or editor', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
@@ -465,9 +489,9 @@ eventsRouter.post(
'/:id/publish',
// #swagger.tags = ['Admin · Events']
// #swagger.summary = 'Snapshot the working spec into an immutable version and mark the definition ready'
// #swagger.description = 'Admin only, deliberately, and not the same gate as the live run controls: publishing commits a definition that a schedule will later start unattended. The spec is re-validated against the registries as they stand right now rather than trusted from the save that wrote it, so a module uninstalled in between blocks the publish instead of producing a run that fails at dispatch. Publishing also RE-PINS every occurrence of this definition that is still scheduled and has not started, and `repinned` says how many moved: occurrences are materialised a fortnight ahead, so without this an edit would reach none of the runs already on the calendar. A run that has begun keeps the version it pinned.'
// #swagger.description = 'Admin only, deliberately, and not the same gate as the live run controls: publishing commits a definition that a schedule will later start unattended. The spec is re-validated against the registries as they stand right now rather than trusted from the save that wrote it, so a module uninstalled in between blocks the publish instead of producing a run that fails at dispatch. Publishing also RE-PINS every occurrence of this definition that is still scheduled and has not started, and `repinned` says how many moved: occurrences are materialised a fortnight ahead, so without this an edit would reach none of the runs already on the calendar. A run that has begun keeps the version it pinned. `verified` is true when a dry run that passed on this exact draft made the new version verified, with that dry run reviewer and time, so it needs no second dry run before it starts unattended.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The definition, now ready, the version that was cut, and how many scheduled occurrences moved to it', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true }, version: { type: "integer" }, versionId: { type: "integer" }, repinned: { type: "integer" } } } } } } */
/* #swagger.responses[200] = { description: 'The definition, now ready, the version that was cut, and how many scheduled occurrences moved to it', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true }, version: { type: "integer" }, versionId: { type: "integer" }, repinned: { type: "integer" }, verified: { type: "boolean" } } } } } } */
/* #swagger.responses[400] = { description: 'The spec is invalid, or no phase has any steps', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
/* #swagger.responses[409] = { description: 'A step names an action no module registers, or the definition is archived', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */

View File

@@ -41,11 +41,12 @@ modulesRouter.post(
'/',
// #swagger.tags = ['Admin · Modules']
// #swagger.summary = 'Install or upgrade a module from a release install-manifest URL'
// #swagger.description = 'Downloads the artifact the manifest names, verifies its sha256, inspects the archive in full and unpacks it onto the modules volume. The module mounts on the next restart.'
// #swagger.description = 'Downloads the artifact the manifest names, verifies its sha256, inspects the archive in full and unpacks it onto the modules volume. The module mounts on the next restart. A site runs ONE module: installing a module other than the one already on the volume is refused with 409 before anything is downloaded, and only an upgrade of the installed module is accepted.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["url"], properties: { url: { type: "string", description: "https URL of the release install manifest, on an allowed host" } } } } } } */
/* #swagger.responses[201] = { description: 'Installed — restart to mount it', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[400] = { description: 'The URL, the manifest, the hash or the archive was refused', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'A different module is already installed; a site runs one module', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[502] = { description: 'The source host could not be reached or answered badly', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
body('url').isString().trim().isLength({ min: 1, max: 2048 }),

View File

@@ -339,20 +339,36 @@ async function totpDisable(req, res) {
async function listIdentities(req, res) {
try {
const rows = await userIdentities.listForUser(req.user.id)
return res.json(rows.map((r) => ({ provider: r.provider, email: r.email, linked_at: r.created_at })))
// `id` lets one identity be unlinked when a provider has several (a player
// may link more than one Steam account). `subject` is the user's OWN external
// id — for Steam, the SteamID64 shown beside the persona name (D394).
return res.json(
rows.map((r) => ({
id: r.id,
provider: r.provider,
subject: r.subject,
email: r.email,
display_name: r.display_name || null,
avatar_url: r.avatar_url || null,
linked_at: r.created_at,
})),
)
} catch (err) {
log.error('listIdentities', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// Remove a linked SSO identity from the current user's account.
// Remove a linked SSO identity from the current user's account: every identity
// of that provider, or with `?id=` the one identity named. Only the sign-in goes —
// a module's own link made from it (Rust's, D392) is the module's to remove (D393).
async function unlinkIdentity(req, res) {
const { provider } = req.params
const identityId = req.query.id != null ? Number(req.query.id) : null
try {
const removed = await userIdentities.unlink(req.user.id, provider)
const removed = await userIdentities.unlink(req.user.id, provider, identityId)
if (!removed) return res.status(404).json({ message: 'No linked account for that provider.' })
await activity.log({ req, action: 'auth.sso.unlink', detail: { provider } })
await activity.log({ req, action: 'auth.sso.unlink', detail: { provider, ...(identityId != null ? { identityId } : {}) } })
log.info('sso identity unlinked', { provider, id: req.user.id })
return res.json({ unlinked: true })
} catch (err) {

View File

@@ -18,7 +18,7 @@
// expect. Mounted at /me by auth/index.js, so paths below are /auth/me/account*.
const express = require('express')
const { body, param } = require('express-validator')
const { body, param, query } = require('express-validator')
const account = require('./account.controller')
const { requireAuth } = require('../../../auth/session.middleware')
@@ -177,10 +177,14 @@ meRouter.delete(
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Unlink an SSO identity (self)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.description = 'Removes every identity of the provider, or with ?id= the one identity named (a user may link several Steam accounts). Only the sign-in identity goes: a game module link made from it stays (STEAM_SIGNIN.md D393).'
// #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' }
// #swagger.parameters['id'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'One identity to unlink (its id from GET /auth/me/account/identities).' }
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { $ref: "#/components/schemas/UnlinkedFlag" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[404] = { description: 'No linked account for that provider', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('provider').matches(/^[a-z0-9-]+$/),
query('id').optional().isInt({ min: 1 }),
validate,
account.unlinkIdentity,
)

View File

@@ -1,6 +1,8 @@
// ── SSO (OAuth2 / OIDC) controller ─────────────────────────────────────────
//
// Drives the redirect flow for built-in (Google, Discord) and custom providers:
// Drives the redirect flow for built-in (Google, Discord, Steam) and custom
// providers. Steam is OpenID 2.0 rather than OAuth2 (STEAM_SIGNIN.md §3); the
// difference is confined to readCallback() and the provider class:
// GET /auth/providers → public discovery (enabled + valid providers)
// GET /auth/sso/:provider/start → begin login (redirect to the IdP)
// GET /auth/sso/:provider/link → begin account linking (requireAuth)
@@ -17,6 +19,8 @@ const authProviders = require('../../../model/authProviders/authProviders.model'
const userIdentities = require('../../../model/userIdentities/userIdentities.model')
const settings = require('../../../model/settings/settings.model')
const registry = require('../../../auth/providers/registry')
const SteamProvider = require('../../../auth/providers/steam.provider')
const identityHooks = require('../../../auth/identityHooks')
const sessionService = require('../../../auth/session.service')
const mobileBridge = require('../../../model/mobileAuthBridge/mobileAuthBridge.model')
const ssoState = require('../../../auth/ssoState')
@@ -148,20 +152,43 @@ async function beginFlow(req, res, mode) {
const start = (req, res) => beginFlow(req, res, 'login')
const linkStart = (req, res) => beginFlow(req, res, 'link')
// The callback's own answer from the IdP, read the way its protocol writes it.
// OAuth2/OIDC sends `code` (or `error`); Steam's OpenID 2.0 sends `openid.*`
// fields, with `openid.mode=cancel` when the person declined (STEAM_SIGNIN.md §3).
// Our `state` arrives as a query parameter either way — for Steam because it
// rides inside the `return_to` we sent.
function readCallback(req, providerId) {
const q = req.query || {}
if (registry.isOpenId(providerId)) {
const mode = typeof q['openid.mode'] === 'string' ? q['openid.mode'] : ''
return { state: q.state, denied: mode === 'cancel' || mode === 'error', present: mode === 'id_res' }
}
return { state: q.state, denied: Boolean(q.error), present: Boolean(q.code), error: q.error }
}
// What the provider's handleCallback needs. Steam takes the whole query, and its
// `return_to` must match what we sent (the callback URL plus this very state)
// byte for byte; OAuth2 takes the code and the PKCE verifier.
function callbackParams(req, providerId, tx, answer) {
const redirectUri = redirectUriFor(req, providerId)
if (registry.isOpenId(providerId)) return { query: req.query, redirectUri, state: answer.state }
return { code: req.query.code, redirectUri, codeVerifier: tx.verifier }
}
// GET /auth/sso/:provider/callback
async function callback(req, res) {
const providerId = req.params.provider
const txToken = req.cookies && req.cookies[ssoState.TX_COOKIE]
const { code, state, error: oauthError } = req.query
const answer = readCallback(req, providerId)
// The tx cookie is single-use — clear it no matter the outcome.
res.clearCookie(ssoState.TX_COOKIE, token.cookieOptions(req))
if (oauthError) {
log.warn('sso callback: provider returned error', { provider: providerId, error: String(oauthError).slice(0, 60) })
if (answer.denied) {
log.warn('sso callback: provider returned error', { provider: providerId, error: String(answer.error || 'cancel').slice(0, 60) })
return res.redirect(loginError('denied'))
}
const tx = ssoState.verifyTx(txToken, state)
if (!tx || tx.provider !== providerId || !code) {
const tx = ssoState.verifyTx(txToken, answer.state)
if (!tx || tx.provider !== providerId || !answer.present) {
log.warn('sso callback: bad state', { provider: providerId })
return res.redirect(loginError('bad_state'))
}
@@ -175,20 +202,34 @@ async function callback(req, res) {
return res.redirect(failFor('unavailable'))
}
const provider = registry.instantiate(row)
const profile = await provider.handleCallback({
code,
redirectUri: redirectUriFor(req, providerId),
codeVerifier: tx.verifier,
})
const profile = await provider.handleCallback(callbackParams(req, providerId, tx, answer))
if (tx.mode === 'link') return finishLink(req, res, providerId, tx, profile)
if (tx.mode === 'mobile') return finishMobileLogin(req, res, providerId, row.kind, tx, profile)
return finishLogin(req, res, providerId, row.kind, tx, profile)
} catch (err) {
// A Steam assertion that failed one of the §3 checks is a refused sign-in, not
// a server fault: it is what a replayed, forged or tampered callback looks like.
if (err instanceof SteamProvider.SteamAssertionError) {
log.warn('sso callback: steam assertion refused', { provider: providerId, reason: err.reason })
return res.redirect(failFor('bad_state'))
}
log.error('sso callback', err)
return res.redirect(failFor('error'))
}
}
// A profile's name and avatar written back onto an identity the user already
// holds, when the provider reports them (Steam, at every sign-in). Decoration:
// a failure here is logged and never stops the sign-in it rides on.
async function refreshIdentityProfile(identity, profile) {
if (!identity || !(profile.displayName || profile.avatarUrl)) return
try {
await userIdentities.refreshProfile(identity.id, { displayName: profile.displayName, avatarUrl: profile.avatarUrl })
} catch (err) {
log.warn('sso: could not refresh a linked identity profile', { provider: identity.provider, message: err.message })
}
}
// Auto-provision a `player` from an SSO profile when no identity is linked yet
// and registration allows SSO sign-up. Derives a unique username (reserved-name
// safe) with a bounded retry against the UNIQUE index, captures the provider
@@ -288,10 +329,12 @@ async function finishLogin(req, res, providerId, kind, tx, profile) {
if (identity) {
user = await users.getById(identity.user_id)
if (!user) return res.redirect(loginError('not_linked', portal))
await refreshIdentityProfile(identity, profile)
} else {
// Unknown identity: auto-provision only if registration opts into SSO sign-up.
// Unknown identity: auto-provision only if registration opts into SSO sign-up,
// and never through a provider that signs nobody up (Steam, D395).
const mode = await settings.getRegistrationMode()
if (mode !== 'sso' && mode !== 'both') {
if ((mode !== 'sso' && mode !== 'both') || registry.neverProvisions(kind)) {
log.warn('sso login refused: no linked account', { provider: providerId })
return res.redirect(loginError('not_linked', portal))
}
@@ -397,7 +440,7 @@ async function requireValidBridgeSession(res, tx, providerId) {
// Resolve the linked user for a mobile SSO login (link-only, with opt-in
// provisioning when registration mode allows it). On refusal, sends the redirect
// and returns null.
async function resolveMobileSsoUser(req, res, sess, providerId, profile) {
async function resolveMobileSsoUser(req, res, sess, providerId, kind, profile) {
const identity = await userIdentities.findByProviderSubject(providerId, profile.subject)
if (identity) {
const user = await users.getById(identity.user_id)
@@ -405,10 +448,11 @@ async function resolveMobileSsoUser(req, res, sess, providerId, profile) {
res.redirect(appError(sess, 'not_linked'))
return null
}
await refreshIdentityProfile(identity, profile)
return user
}
const mode = await settings.getRegistrationMode()
if (mode !== 'sso' && mode !== 'both') {
if ((mode !== 'sso' && mode !== 'both') || registry.neverProvisions(kind)) {
log.warn('mobile sso login refused: no linked account', { provider: providerId })
res.redirect(appError(sess, 'not_linked'))
return null
@@ -425,7 +469,7 @@ async function finishMobileLogin(req, res, providerId, kind, tx, profile) {
const sess = await requireValidBridgeSession(res, tx, providerId)
if (!sess) return
const user = await resolveMobileSsoUser(req, res, sess, providerId, profile)
const user = await resolveMobileSsoUser(req, res, sess, providerId, kind, profile)
if (!user) return
if (user.status && user.status !== 'active') {
@@ -555,11 +599,38 @@ async function finishLink(req, res, providerId, tx, profile) {
if (existing && existing.user_id !== userId) {
return res.redirect(accountError('in_use', portal)) // external identity belongs to another account
}
if (!existing) {
await userIdentities.link({ userId, provider: providerId, subject: profile.subject, email: profile.email })
await activity.log({ req, userId, action: 'auth.sso.link', detail: { provider: providerId } })
log.info('sso account linked', { provider: providerId, userId })
if (existing) {
// Already theirs: linking twice is not an error, and it is a fresh look at
// the name and avatar.
await refreshIdentityProfile(existing, profile)
return res.redirect(`${accountPath(portal)}?linked=${providerId}`)
}
// Modules may refuse before anything is written (API 1.17.0) — Rust, for a
// Steam id that is already another account's Rust link (D396). Only ever a
// refusal: this user is already signed in, and this decides a link, not a session.
const check = await identityHooks.checkLink({ userId, provider: providerId, subject: profile.subject })
if (!check.ok) {
await activity.log({ req, userId, action: 'auth.sso.link.refused', detail: { provider: providerId, module: check.module, reason: check.reason } })
return res.redirect(accountError(check.reason, portal))
}
await userIdentities.link({
userId,
provider: providerId,
subject: profile.subject,
email: profile.email,
displayName: profile.displayName || null,
avatarUrl: profile.avatarUrl || null,
})
await activity.log({ req, userId, action: 'auth.sso.link', detail: { provider: providerId } })
log.info('sso account linked', { provider: providerId, userId })
await identityHooks.notifyLinked({
userId,
provider: providerId,
subject: profile.subject,
displayName: profile.displayName || null,
})
return res.redirect(`${accountPath(portal)}?linked=${providerId}`)
}

View File

@@ -52,10 +52,13 @@ ssoRouter.get(
'/sso/:provider/callback',
// #swagger.tags = ['Auth · SSO']
// #swagger.summary = 'OAuth redirect target — completes login or linking'
// #swagger.description = 'The provider redirects here with code + state. On success sets the session cookie (login) or links the identity (link), then 302-redirects into /admin. Login is link-only: unknown identities are refused (sso_error=not_linked).'
// #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id (e.g. google, discord).' }
// #swagger.parameters['code'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'OAuth authorization code.' }
// #swagger.parameters['state'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'OAuth state (matched against the tx cookie).' }
// #swagger.description = 'The provider redirects here with code + state. On success sets the session cookie (login) or links the identity (link), then 302-redirects into /admin. Login is link-only: unknown identities are refused (sso_error=not_linked). Steam (OpenID 2.0) sends openid.* fields instead of code; the server verifies them with Steam (check_authentication) before anything else, and Steam never signs anybody up (STEAM_SIGNIN.md D395). A module may refuse a link (link_error=in_use or refused; MODULE_API 1.17.0).'
// #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id (e.g. google, discord, steam).' }
// #swagger.parameters['code'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'OAuth authorization code (OAuth2/OIDC providers).' }
// #swagger.parameters['state'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'OAuth state (matched against the tx cookie). For Steam it rides inside openid.return_to and comes back as this parameter.' }
// #swagger.parameters['error'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'OAuth error (the user declined, or the IdP refused).' }
// #swagger.parameters['openid.mode'] = { in: 'query', required: false, schema: { type: 'string', enum: ['id_res', 'cancel'] }, description: 'Steam only: id_res with an assertion, or cancel. The other openid.* fields of the assertion (ns, op_endpoint, claimed_id, identity, return_to, response_nonce, assoc_handle, signed, sig) are passed back to Steam to verify.' }
// #swagger.parameters['openid.claimed_id'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Steam only: https://steamcommunity.com/openid/id/<SteamID64>.' }
/* #swagger.responses[302] = { description: 'Redirect into /admin on success, or back to login/account with an error code' } */
ctrl.callback,
)

View File

@@ -50,7 +50,12 @@ async function getEvent(req, res) {
// string: the model matches it against this definition's own runs and
// ignores anything else, so a garbage value renders the page rather than an
// error. See the model's note on why it is not refused.
const result = await events.event(req.params.slug, { runId: req.query.run || null })
// `userId` is the reader's, when they sent a session (`optionalAuth`): a
// module's progress line may show a signed-in reader more than a stranger.
const result = await events.event(req.params.slug, {
runId: req.query.run || null,
userId: req.user && req.user.id != null ? req.user.id : null,
})
return answer(res, result)
} catch (err) {
return fail(res, err, 'public event')

View File

@@ -15,6 +15,7 @@ const express = require('express')
const ctrl = require('./events.controller')
const siteMode = require('../../../middleware/siteMode')
const { optionalAuth } = require('../../../auth/session.middleware')
const eventsRouter = express.Router()
@@ -48,12 +49,14 @@ eventsRouter.get(
'/:slug',
// #swagger.tags = ['Public · Events']
// #swagger.summary = 'One event'
// #swagger.description = 'The storyline, the arc it belongs to, what is live, what is next, what happened recently, and a results table once one has been published. A draft, an archived definition and an unlisted one all answer 404, indistinguishable from a slug that never existed. The plan behind the event — phases, steps, actions and their params — is never published; a live run carries the LABEL of the phase it is in and nothing more.'
// #swagger.description = 'The storyline, the arc it belongs to, what is live, what is next, what happened recently, and a results table once one has been published. A draft, an archived definition and an unlisted one all answer 404, indistinguishable from a slug that never existed. The plan behind the event — phases, steps, actions and their params — is never published; a live run carries the LABEL of the phase it is in and nothing more, and `progress`: how its steps stand, each line a label with either `left` of `of` or a `percent`, in the words the steps\' modules chose. What a module shows may depend on who is reading, so sending a session is optional and may widen it.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The event slug.' }
// #swagger.parameters['run'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Which occurrence the results are about — what an announcement\'s link carries, so a mail about last Friday does not open next Friday\'s. A run that does not belong to this event is ignored rather than refused.' }
// #swagger.security = [{}, { "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The event', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicEvent" } } } } */
/* #swagger.responses[404] = { description: 'No such public event', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
siteMode,
optionalAuth,
ctrl.getEvent,
)

View File

@@ -24,6 +24,7 @@ const registries = require('../modules/registries')
const ceilings = require('../modules/ceilings')
const engine = require('../engagement/engine')
const eventGates = require('../events/gates')
const eventStarts = require('../events/starts')
const scopedPrefs = require('../engagement/scopedPrefs')
const createLogger = require('./logger')
@@ -320,6 +321,15 @@ function emit(owner, triggerId, envelope = {}) {
.observe(event)
.catch((err) => log.error('gate observation rejected', { trigger: triggerId, message: err.message }))
// **The third subscriber** (Rust PLAN_REDESIGNS §14.3.12, D370): a definition
// whose published version starts on this trigger gets a run, now. Separate
// from the gate observer for the same reason that one is separate from
// `dispatch` — starting a run and advancing one decide different things, and
// neither may fail the other.
eventStarts
.observe(event)
.catch((err) => log.error('trigger start rejected', { trigger: triggerId, message: err.message }))
return { ok: true, event }
}

View File

@@ -428,13 +428,28 @@ async function drainStep(run, step, now, carry = {}) {
}
if (result.outcome === 'retry' && step.attempts + 1 < MAX_ATTEMPTS) {
await stepsDb.reschedule(step.id, new Date(now.getTime() + RETRY_MS), result.error)
// The module's `retryAfterMs` when it gave one (already clamped by the
// dispatcher), else the flat delay. **It is still one attempt** of
// MAX_ATTEMPTS: a hint changes when, never how many, so a module that answers
// "not yet" for ever fails the step on schedule like any other. A run paused
// or cancelled in the wait is not picked up — `findDue` reads only running
// runs — and the staff Retry on a paused step is unaffected.
const delayMs = result.retryAfterMs || RETRY_MS
const retryAt = new Date(now.getTime() + delayMs)
await stepsDb.reschedule(step.id, retryAt, result.error)
await logDb.write({
runId: run.id,
stepId: step.id,
kind: 'step.retry',
phase: step.phase,
detail: { action: step.action_id, attempt: step.attempts + 1, of: MAX_ATTEMPTS, error: result.error },
detail: {
action: step.action_id,
attempt: step.attempts + 1,
of: MAX_ATTEMPTS,
error: result.error,
retryAt: retryAt.toISOString(),
...(result.retryAfterMs ? { retryAfterMs: result.retryAfterMs } : {}),
},
})
// Degraded from the FIRST retry, not from the eventual failure. An event
// whose announcements are landing on the second attempt is having trouble

View File

@@ -3,7 +3,7 @@
"info": {
"title": "Runic Gateway API",
"version": "1.0.0",
"description": "REST API for the Runic Gateway website, wiki and admin panel.\n\nThis document is core. Installed modules add their own paths, tags and schemas to it at request time from the fragment each one ships, so `/api/docs.json` on a running instance describes more than `npm run swagger` generates here (docs/website/MODULE_API.md §6.1a).\n\n### Authentication\n- **Web / admin panel** uses an httpOnly session cookie (`rg_token`) issued by `POST /api/v1/auth/login` (plus `/login/totp` when 2FA is enabled).\n- **Native / mobile clients** use bearer access tokens from `POST /api/v1/auth/mobile/login`, refreshed via `/auth/mobile/refresh`.\n\nEndpoints under `/api/v1/admin/**` require a valid session; some are further restricted to the `admin` role (editors are limited to content)."
"description": "REST API for the Runic Gateway website, wiki and admin panel.\n\nThis document is core. Installed modules add their own paths, tags and schemas to it at request time from the fragment each one ships, so `/api/docs.json` on a running instance describes more than `npm run swagger` generates here (docs/website/MODULE_API.md §6.1a).\n\n### Authentication\n- **Web / admin panel** uses an httpOnly session cookie (`rg_token`) issued by `POST /api/v1/auth/login` (plus `/login/totp` when 2FA is enabled).\n- **Native / mobile clients** use bearer access tokens from `POST /api/v1/auth/mobile/login`, refreshed via `/auth/mobile/refresh`.\n\nEndpoints under `/api/v1/admin/**` require a valid session; some are further restricted to the `admin` role (editors are limited to content).\n\n### The public demo\nA deployment run with `DEMO_MODE=true` (the public demo, docs/website/DEMO_MODE.md) refuses a fixed set of write routes, and any `multipart/*` request, before the route runs. **Any write route may then answer** `403` with the `DemoLocked` body (`code: \"DEMO_LOCKED\"`); clients recognise the refusal by `code`, not by status. Reads are never refused this way. With the flag unset no route answers it."
},
"servers": [
{
@@ -352,7 +352,7 @@
"Admin · Auth Providers"
],
"summary": "Update an SSO provider (admin only)",
"description": "",
"description": "Built-ins (google, discord, steam) take only enabled + credentials. Steam has no client id: its secret is a Steam Web API key, and enabling Steam without one stored or given is refused with 400 (STEAM_SIGNIN.md D394).",
"parameters": [
{
"name": "id",
@@ -376,7 +376,7 @@
}
},
"400": {
"description": "Validation error",
"description": "Validation error, or Steam enabled without a Steam Web API key",
"content": {
"application/json": {
"schema": {
@@ -3869,6 +3869,7 @@
"properties": {
"actions": {
"type": "array",
"description": "Each action with its declared params. MODULE_API 1.14.0: a param may carry multiple: true and max (1 to 100, 20 when left out), and its value is then a JSON array of at most max values.",
"items": {
"type": "object",
"additionalProperties": true
@@ -3931,6 +3932,14 @@
"limits": {
"type": "object",
"additionalProperties": true
},
"templates": {
"type": "array",
"description": "Event templates (MODULE_API 1.13.0): id, label, description, owner and inputs (each shaped like an action param). build() is never served.",
"items": {
"type": "object",
"additionalProperties": true
}
}
}
}
@@ -4026,6 +4035,10 @@
},
"group": {
"type": "string"
},
"note": {
"type": "string",
"description": "Advice shown under the field while this option is chosen (MODULE_API 1.13.0). At most 300 characters."
}
}
}
@@ -4056,6 +4069,144 @@
]
}
},
"/api/v1/admin/events/catalog/templates/{templateId}": {
"post": {
"tags": [
"Admin · Events"
],
"summary": "Fill an event template in, and get back a draft",
"description": "A module may register named event templates (MODULE_API 1.13.0). The catalog lists them with their inputs; this route checks the inputs against the template, calls the module to build the plan, validates the plan as a spec and answers it as a draft: a title, a summary and a spec. Nothing is saved, and nothing about the template is kept: the editor opens the draft like any new event and the author saves it through POST /admin/events. A refusal of the inputs is a 400 naming each problem; a module that fails or builds a plan core cannot accept is a 502.",
"parameters": [
{
"name": "templateId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The draft",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"draft": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"summary": {
"type": "string",
"nullable": true
},
"spec": {
"type": "object",
"additionalProperties": true
}
}
}
}
}
}
}
},
"400": {
"description": "The inputs were refused",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"errors": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
}
}
},
"403": {
"description": "Not an admin or editor",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No module registers that template",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"errors": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
}
}
},
"502": {
"description": "The module failed to build the plan, or built one core cannot accept",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"errors": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"inputs": {
"type": "object",
"additionalProperties": true,
"description": "One value per declared input, keyed by its name"
}
}
}
}
}
}
}
},
"/api/v1/admin/events/price": {
"post": {
"tags": [
@@ -4271,7 +4422,7 @@
"Admin · Events"
],
"summary": "One run: its status, health, cleanup state and every step with its params and idempotency key",
"description": "The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder’s own words — `gte` as \"is at least\", `present` as \"is present\" — with the tally, how long it has waited, and the last related firing whether or not it matched. A phase is waiting on its gate only once every one of its steps is terminal; `stalled` means an `on` gate has waited past EVENT_PHASE_STALL_MS, which is visibility and never a timeout — nothing advances a phase but its condition or a human. `budget` is the cap meter (Phase 6), and `resources` is the cleanup ledger (Phase 8): every object this run created and every value it borrowed, with what became of each — `confirmed` is still out there, `reverted` came back, `drifted` means somebody moved it and core left it alone, and `orphaned` means the module reports it is gone. `unresolvedResources` counts the ones still wanting something, including a placeholder left standing by a lost acknowledgement, which is why it can exceed the length of the list. `participants` is who took part (Phase 10), best first, as a module reported them: `memberKey` is module-opaque, `userId` is filled in only where the module could link the player to an account, and `rank` is null until `core.results.publish` has ranked them — a run whose participants are collected but unranked is a real and visible state, not an error. The run itself carries `resultsPublishedAt`, which is when that table was last published.",
"description": "The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder’s own words — `gte` as \"is at least\", `present` as \"is present\" — with the tally, how long it has waited, and the last related firing whether or not it matched. A phase is waiting on its gate only once every one of its steps is terminal; `stalled` means an `on` gate has waited past EVENT_PHASE_STALL_MS, which is visibility and never a timeout — nothing advances a phase but its condition or a human. `budget` is the cap meter (Phase 6), and `resources` is the cleanup ledger (Phase 8): every object this run created and every value it borrowed, with what became of each — `confirmed` is still out there, `reverted` came back, `drifted` means somebody moved it and core left it alone, `orphaned` means the module reports it is gone, and `expired` means the game ended it at its own deadline, as it was told to (terminal, like `reverted`). `unresolvedResources` counts the ones still wanting something, including a placeholder left standing by a lost acknowledgement, which is why it can exceed the length of the list. `participants` is who took part (Phase 10), best first, as a module reported them: `memberKey` is module-opaque, `userId` is filled in only where the module could link the player to an account, and `rank` is null until `core.results.publish` has ranked them — a run whose participants are collected but unranked is a real and visible state, not an error. The run itself carries `resultsPublishedAt`, which is when that table was last published, and `origin` when a game firing started it (Rust PLAN_REDESIGNS 14.3.12): the trigger, its label, when it fired, its subject, the start condition it matched in the builder words, and its declared variables. Null for a run started by hand or by the schedule.",
"parameters": [
{
"name": "runId",
@@ -5552,7 +5703,7 @@
"Admin · Events"
],
"summary": "One event definition, including its working spec",
"description": "The editor reads this. The list route serves a summary; this is the whole authored tree, phases and steps included.",
"description": "The editor reads this. The list route serves a summary; this is the whole authored tree, phases and steps included. The spec may carry `starts` (Rust PLAN_REDESIGNS 14.3.12): the triggers that begin a run of the published version, each a phase gate `on` and `where` plus a `cooldown` (0m to 7d, 30m when left out); core own event.* triggers are refused. `triggerStarts` is what they have done lately: the last run a trigger started and the firings ignored since, by why (a run was going, inside the cooldown, or the published version is not verified). Null until a trigger first tries.",
"parameters": [
{
"name": "id",
@@ -5811,7 +5962,7 @@
"Admin · Events"
],
"summary": "Snapshot the working spec into an immutable version and mark the definition ready",
"description": "Admin only, deliberately, and not the same gate as the live run controls: publishing commits a definition that a schedule will later start unattended. The spec is re-validated against the registries as they stand right now rather than trusted from the save that wrote it, so a module uninstalled in between blocks the publish instead of producing a run that fails at dispatch. Publishing also RE-PINS every occurrence of this definition that is still scheduled and has not started, and `repinned` says how many moved: occurrences are materialised a fortnight ahead, so without this an edit would reach none of the runs already on the calendar. A run that has begun keeps the version it pinned.",
"description": "Admin only, deliberately, and not the same gate as the live run controls: publishing commits a definition that a schedule will later start unattended. The spec is re-validated against the registries as they stand right now rather than trusted from the save that wrote it, so a module uninstalled in between blocks the publish instead of producing a run that fails at dispatch. Publishing also RE-PINS every occurrence of this definition that is still scheduled and has not started, and `repinned` says how many moved: occurrences are materialised a fortnight ahead, so without this an edit would reach none of the runs already on the calendar. A run that has begun keeps the version it pinned. `verified` is true when a dry run that passed on this exact draft made the new version verified, with that dry run reviewer and time, so it needs no second dry run before it starts unattended.",
"parameters": [
{
"name": "id",
@@ -5842,6 +5993,9 @@
},
"repinned": {
"type": "integer"
},
"verified": {
"type": "boolean"
}
}
}
@@ -6037,7 +6191,7 @@
"Admin · Events"
],
"summary": "Dry run: dispatch every step with verify true, change nothing, and report the cost against the caps",
"description": "EVENTS.md I. admin AND editor rather than admin, deliberately: a dry run dispatches nothing, and the author who wrote the definition is exactly who should be able to price it before asking an admin to publish it. What is verified follows the state -- a ready definition is checked against its PUBLISHED version, which is the only thing that ever actually runs, and a draft against the working spec the author is still holding; `target` says which. A pass against a version is RECORDED on it, and that is EVENTS.md K last bound: a scheduled occurrence of a version that has never been verified is held rather than started unattended. Findings come back with a 200 -- the request succeeded, the plan has problems -- and the whole-plan cost check is the one thing no other path makes: three steps each spawning 15 under a cap of 30 pass every individual check and breach it on the third, at two in the morning, with the world half-changed.",
"description": "EVENTS.md I. admin AND editor rather than admin, deliberately: a dry run dispatches nothing, and the author who wrote the definition is exactly who should be able to price it before asking an admin to publish it. What is verified follows the state -- a ready definition is checked against its PUBLISHED version, which is the only thing that ever actually runs, and a draft against the working spec the author is still holding; `target` says which. A pass against a version is RECORDED on it, and that is EVENTS.md K last bound: a scheduled occurrence of a version that has never been verified is held rather than started unattended. A pass against a draft is kept with the fingerprint of the spec it passed on (`draftRecorded`), and publishing that spec unchanged makes a version that is verified from the start; an edit in between changes the fingerprint, and nothing carries over. Findings come back with a 200 -- the request succeeded, the plan has problems -- and the whole-plan cost check is the one thing no other path makes: three steps each spawning 15 under a cap of 30 pass every individual check and breach it on the third, at two in the morning, with the world half-changed.",
"parameters": [
{
"name": "id",
@@ -6069,6 +6223,9 @@
"recorded": {
"type": "boolean"
},
"draftRecorded": {
"type": "boolean"
},
"report": {
"type": "object",
"properties": {
@@ -7211,7 +7368,7 @@
"Admin · Modules"
],
"summary": "Install or upgrade a module from a release install-manifest URL",
"description": "Downloads the artifact the manifest names, verifies its sha256, inspects the archive in full and unpacks it onto the modules volume. The module mounts on the next restart.",
"description": "Downloads the artifact the manifest names, verifies its sha256, inspects the archive in full and unpacks it onto the modules volume. The module mounts on the next restart. A site runs ONE module: installing a module other than the one already on the volume is refused with 409 before anything is downloaded, and only an upgrade of the installed module is accepted.",
"responses": {
"201": {
"description": "Installed — restart to mount it",
@@ -7234,6 +7391,16 @@
}
}
},
"409": {
"description": "A different module is already installed; a site runs one module",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error"
},
@@ -12928,7 +13095,7 @@
"Auth · Me"
],
"summary": "Unlink an SSO identity (self)",
"description": "",
"description": "Removes every identity of the provider, or with ?id= the one identity named (a user may link several Steam accounts). Only the sign-in identity goes: a game module link made from it stays (STEAM_SIGNIN.md D393).",
"parameters": [
{
"name": "provider",
@@ -12938,6 +13105,15 @@
"type": "string"
},
"description": "Provider id."
},
{
"name": "id",
"in": "query",
"required": false,
"schema": {
"type": "integer"
},
"description": "One identity to unlink (its id from GET /auth/me/account/identities)."
}
],
"responses": {
@@ -12952,7 +13128,14 @@
}
},
"400": {
"description": "Bad Request"
"description": "Validation error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidationError"
}
}
}
},
"401": {
"description": "Unauthorized"
@@ -15306,7 +15489,7 @@
"Auth · SSO"
],
"summary": "OAuth redirect target — completes login or linking",
"description": "The provider redirects here with code + state. On success sets the session cookie (login) or links the identity (link), then 302-redirects into /admin. Login is link-only: unknown identities are refused (sso_error=not_linked).",
"description": "The provider redirects here with code + state. On success sets the session cookie (login) or links the identity (link), then 302-redirects into /admin. Login is link-only: unknown identities are refused (sso_error=not_linked). Steam (OpenID 2.0) sends openid.* fields instead of code; the server verifies them with Steam (check_authentication) before anything else, and Steam never signs anybody up (STEAM_SIGNIN.md D395). A module may refuse a link (link_error=in_use or refused; MODULE_API 1.17.0).",
"parameters": [
{
"name": "provider",
@@ -15315,14 +15498,7 @@
"schema": {
"type": "string"
},
"description": "Provider id (e.g. google, discord)."
},
{
"name": "error",
"in": "query",
"schema": {
"type": "string"
}
"description": "Provider id (e.g. google, discord, steam)."
},
{
"name": "code",
@@ -15331,7 +15507,7 @@
"schema": {
"type": "string"
},
"description": "OAuth authorization code."
"description": "OAuth authorization code (OAuth2/OIDC providers)."
},
{
"name": "state",
@@ -15340,7 +15516,50 @@
"schema": {
"type": "string"
},
"description": "OAuth state (matched against the tx cookie)."
"description": "OAuth state (matched against the tx cookie). For Steam it rides inside openid.return_to and comes back as this parameter."
},
{
"name": "error",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "OAuth error (the user declined, or the IdP refused)."
},
{
"name": "openid.mode",
"in": "query",
"required": false,
"schema": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"enum": {
"type": "array",
"example": [
"id_res",
"cancel"
],
"items": {
"type": "string"
}
}
}
},
"description": "Steam only: id_res with an assertion, or cancel. The other openid.* fields of the assertion (ns, op_endpoint, claimed_id, identity, return_to, response_nonce, assoc_handle, signed, sig) are passed back to Steam to verify."
},
{
"name": "openid.claimed_id",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "Steam only: https://steamcommunity.com/openid/id/<SteamID64>."
}
],
"responses": {
@@ -17338,7 +17557,7 @@
"Public · Events"
],
"summary": "One event",
"description": "The storyline, the arc it belongs to, what is live, what is next, what happened recently, and a results table once one has been published. A draft, an archived definition and an unlisted one all answer 404, indistinguishable from a slug that never existed. The plan behind the event — phases, steps, actions and their params — is never published; a live run carries the LABEL of the phase it is in and nothing more.",
"description": "The storyline, the arc it belongs to, what is live, what is next, what happened recently, and a results table once one has been published. A draft, an archived definition and an unlisted one all answer 404, indistinguishable from a slug that never existed. The plan behind the event — phases, steps, actions and their params — is never published; a live run carries the LABEL of the phase it is in and nothing more, and `progress`: how its steps stand, each line a label with either `left` of `of` or a `percent`, in the words the steps\\' modules chose. What a module shows may depend on who is reading, so sending a session is optional and may widen it.",
"parameters": [
{
"name": "slug",
@@ -17383,7 +17602,16 @@
"503": {
"description": "Service Unavailable"
}
}
},
"security": [
{},
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/public/modules": {
@@ -18316,6 +18544,119 @@
}
}
},
"DemoLocked": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "The public demo refused a write (DEMO_MODE.md §3). Only on a deployment with `DEMO_MODE=true`. `keys` is present on `PUT /admin/settings` and names the settings the demo keeps. `errors` is present when an event save or publish is refused because a step's action refused its params (MODULE_API 1.19.0 `demoRefuses`, DM18); `message` is then the module's sentence."
},
"properties": {
"type": "object",
"properties": {
"message": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "This is turned off on the demo site."
}
}
},
"code": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"enum": {
"type": "array",
"example": [
"DEMO_LOCKED"
],
"items": {
"type": "string"
}
}
}
},
"keys": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
}
}
},
"example": {
"type": "array",
"example": [
"site_mode"
],
"items": {
"type": "string"
}
}
}
},
"errors": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
}
}
},
"example": {
"type": "array",
"example": [
"\"quit\" is turned off on the demo site."
],
"items": {
"type": "string"
}
}
}
}
}
},
"required": {
"type": "array",
"example": [
"message",
"code"
],
"items": {
"type": "string"
}
}
}
},
"ValidationError": {
"type": "object",
"properties": {
@@ -19657,7 +19998,7 @@
},
"description": {
"type": "string",
"example": "Icon hint — the provider kind ('google' | 'discord' | 'oidc' | 'oauth2')."
"example": "Icon hint — the provider kind ('google' | 'discord' | 'steam' | 'oidc' | 'oauth2')."
},
"example": {
"type": "string",
@@ -19738,6 +20079,7 @@
"example": [
"google",
"discord",
"steam",
"oidc",
"oauth2"
],
@@ -19795,7 +20137,7 @@
},
"description": {
"type": "string",
"example": "Whether a client secret is stored (the secret itself is never returned)."
"example": "Whether a client secret is stored (the secret itself is never returned). For steam, the Steam Web API key it cannot be enabled without."
},
"example": {
"type": "boolean",
@@ -19877,7 +20219,7 @@
},
"description": {
"type": "string",
"example": "True for the fixed google/discord providers."
"example": "True for the fixed google/discord/steam providers."
},
"example": {
"type": "boolean",
@@ -24570,6 +24912,23 @@
"properties": {
"type": "object",
"properties": {
"id": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"description": {
"type": "string",
"example": "Identity id, for unlinking one of several (DELETE ...?id=)."
},
"example": {
"type": "number",
"example": 12
}
}
},
"provider": {
"type": "object",
"properties": {
@@ -24604,6 +24963,65 @@
}
}
},
"subject": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"description": {
"type": "string",
"example": "The user’s own external id at the provider; for steam, the 17-digit SteamID64."
},
"example": {
"type": "string",
"example": "76561197960287930"
}
}
},
"display_name": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"description": {
"type": "string",
"example": "Persona name the provider reported (steam only)."
},
"example": {
"type": "string",
"example": "CoolGuy99"
}
}
},
"avatar_url": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"format": {
"type": "string",
"example": "uri"
},
"nullable": {
"type": "boolean",
"example": true
},
"description": {
"type": "string",
"example": "Avatar on the provider’s CDN (steam only)."
}
}
},
"linked_at": {
"type": "object",
"properties": {
@@ -25295,6 +25713,111 @@
}
}
}
},
"demo": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "Present only on the public demo (`DEMO_MODE=true`, DEMO_MODE.md §4, §6). `resetsAt` is when the next wipe is due, for the banner; `logins` are the published static sign-ins — public by design, not secrets. Both are written by `npm run seed:demo` at every wipe."
},
"properties": {
"type": "object",
"properties": {
"resetsAt": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"format": {
"type": "string",
"example": "date-time"
},
"nullable": {
"type": "boolean",
"example": true
},
"example": {
"type": "string",
"example": "2026-10-24T14:00:00.000Z"
}
}
},
"logins": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"username": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "demo-admin"
}
}
},
"password": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "demo-admin"
}
}
},
"role": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"example": {
"type": "string",
"example": "admin"
}
}
}
}
}
}
}
}
}
}
}
}
}
}
},
@@ -26295,6 +26818,23 @@
}
}
},
"runId": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 3692
},
"description": {
"type": "string",
"example": "Runs only: the run this entry is, the same id `PublicEventOccurrence.runId` carries and `/events/{slug}?run=` takes. A projected entry has none, because nothing is committed to it."
}
}
},
"title": {
"type": "object",
"properties": {
@@ -26724,6 +27264,100 @@
"example": true
}
}
},
"progress": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"description": {
"type": "string",
"example": "On the live occurrence only: how its steps stand, in step order, in the words their modules chose (MODULE_API 1.12.0). Each line is a `label` with either `left` of `of` (\"Bandits: 3 of 8 left\") or a `percent` (\"The Juggernaut: 62%\"). Core renders them as text and interprets nothing. Empty when no step has anything to say, or its module did not answer in time."
},
"items": {
"$ref": "#/components/schemas/PublicEventProgressLine"
}
}
}
}
}
}
},
"PublicEventProgressLine": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "One line of a live run’s progress: a `label` with either `left` of `of`, or a `percent`. Never both."
},
"properties": {
"type": "object",
"properties": {
"label": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "Bandits"
}
}
},
"left": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 3
}
}
},
"of": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 8
}
}
},
"percent": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"minimum": {
"type": "number",
"example": 0
},
"maximum": {
"type": "number",
"example": 100
},
"example": {
"type": "number",
"example": 62
}
}
}
}
}

View File

@@ -44,7 +44,13 @@ const doc = {
'- **Native / mobile clients** use bearer access tokens from ' +
'`POST /api/v1/auth/mobile/login`, refreshed via `/auth/mobile/refresh`.\n\n' +
'Endpoints under `/api/v1/admin/**` require a valid session; some are further ' +
'restricted to the `admin` role (editors are limited to content).',
'restricted to the `admin` role (editors are limited to content).\n\n' +
'### The public demo\n' +
'A deployment run with `DEMO_MODE=true` (the public demo, docs/website/DEMO_MODE.md) ' +
'refuses a fixed set of write routes, and any `multipart/*` request, before the ' +
'route runs. **Any write route may then answer** `403` with the `DemoLocked` body ' +
'(`code: "DEMO_LOCKED"`); clients recognise the refusal by `code`, not by status. ' +
'Reads are never refused this way. With the flag unset no route answers it.',
},
servers: [
{ url: '/', description: 'Same-origin (current host)' },
@@ -100,6 +106,18 @@ const doc = {
type: 'object',
properties: { message: { type: 'string', example: 'Not found' } },
},
DemoLocked: {
type: 'object',
description:
'The public demo refused a write (DEMO_MODE.md §3). Only on a deployment with `DEMO_MODE=true`. `keys` is present on `PUT /admin/settings` and names the settings the demo keeps. `errors` is present when an event save or publish is refused because a step\'s action refused its params (MODULE_API 1.19.0 `demoRefuses`, DM18); `message` is then the module\'s sentence.',
properties: {
message: { type: 'string', example: 'This is turned off on the demo site.' },
code: { type: 'string', enum: ['DEMO_LOCKED'] },
keys: { type: 'array', items: { type: 'string' }, example: ['site_mode'] },
errors: { type: 'array', items: { type: 'string' }, example: ['"quit" is turned off on the demo site.'] },
},
required: ['message', 'code'],
},
ValidationError: {
type: 'object',
properties: {
@@ -294,7 +312,7 @@ const doc = {
name: { type: 'string', example: 'Google' },
icon: {
type: 'string',
description: "Icon hint — the provider kind ('google' | 'discord' | 'oidc' | 'oauth2').",
description: "Icon hint — the provider kind ('google' | 'discord' | 'steam' | 'oidc' | 'oauth2').",
example: 'google',
},
loginUrl: {
@@ -313,17 +331,17 @@ const doc = {
type: 'object',
properties: {
id: { type: 'string', example: 'okta' },
kind: { type: 'string', enum: ['google', 'discord', 'oidc', 'oauth2'], example: 'oidc' },
kind: { type: 'string', enum: ['google', 'discord', 'steam', 'oidc', 'oauth2'], example: 'oidc' },
name: { type: 'string', example: 'Okta' },
enabled: { type: 'boolean', example: true },
clientId: { type: 'string' },
hasSecret: { type: 'boolean', description: 'Whether a client secret is stored (the secret itself is never returned).', example: true },
hasSecret: { type: 'boolean', description: 'Whether a client secret is stored (the secret itself is never returned). For steam, the Steam Web API key it cannot be enabled without.', example: true },
authorizeUrl: { type: 'string', format: 'uri' },
tokenUrl: { type: 'string', format: 'uri' },
userinfoUrl: { type: 'string', format: 'uri' },
scopes: { type: 'string', example: 'openid email profile' },
priority: { type: 'integer', example: 10 },
builtin: { type: 'boolean', description: 'True for the fixed google/discord providers.', example: false },
builtin: { type: 'boolean', description: 'True for the fixed google/discord/steam providers.', example: false },
health: { $ref: '#/components/schemas/ProviderHealth' },
},
},
@@ -961,8 +979,12 @@ const doc = {
LinkedIdentity: {
type: 'object',
properties: {
id: { type: 'integer', description: 'Identity id, for unlinking one of several (DELETE ...?id=).', example: 12 },
provider: { type: 'string', example: 'google' },
email: { type: 'string', format: 'email', nullable: true, example: 'user@example.com' },
subject: { type: 'string', description: 'The user’s own external id at the provider; for steam, the 17-digit SteamID64.', example: '76561197960287930' },
display_name: { type: 'string', nullable: true, description: 'Persona name the provider reported (steam only).', example: 'CoolGuy99' },
avatar_url: { type: 'string', format: 'uri', nullable: true, description: 'Avatar on the provider’s CDN (steam only).' },
linked_at: { type: 'string', format: 'date-time' },
},
},
@@ -1076,6 +1098,25 @@ const doc = {
ntfyUrl: { type: 'string', nullable: true, example: 'https://ntfy.example.com' },
},
},
demo: {
type: 'object',
description:
'Present only on the public demo (`DEMO_MODE=true`, DEMO_MODE.md §4, §6). `resetsAt` is when the next wipe is due, for the banner; `logins` are the published static sign-ins — public by design, not secrets. Both are written by `npm run seed:demo` at every wipe.',
properties: {
resetsAt: { type: 'string', format: 'date-time', nullable: true, example: '2026-10-24T14:00:00.000Z' },
logins: {
type: 'array',
items: {
type: 'object',
properties: {
username: { type: 'string', example: 'demo-admin' },
password: { type: 'string', example: 'demo-admin' },
role: { type: 'string', nullable: true, example: 'admin' },
},
},
},
},
},
},
additionalProperties: true,
},
@@ -1247,6 +1288,12 @@ const doc = {
'One calendar entry. `kind` says which of two things it is: a `run` is a materialised occurrence, a `projected` entry is arithmetic past the materialisation horizon — a forecast with nothing committed to it, which a client should draw as such.',
properties: {
kind: { type: 'string', enum: ['run', 'projected'], example: 'run' },
runId: {
type: 'integer',
example: 3692,
description:
'Runs only: the run this entry is, the same id `PublicEventOccurrence.runId` carries and `/events/{slug}?run=` takes. A projected entry has none, because nothing is committed to it.',
},
title: { type: 'string', example: 'The Yew Invasion' },
slug: { type: 'string', example: 'the-yew-invasion' },
seriesName: { type: 'string', nullable: true, example: 'The Yew Campaign' },
@@ -1312,6 +1359,23 @@ const doc = {
'The LABEL of the phase a live run is in, resolved from the version the run pinned. Null unless it is live. The plan behind the event — phases, steps, actions and their params — is never published.',
},
resultsPublishedAt: { type: 'string', format: 'date-time', nullable: true },
progress: {
type: 'array',
description:
'On the live occurrence only: how its steps stand, in step order, in the words their modules chose (MODULE_API 1.12.0). Each line is a `label` with either `left` of `of` ("Bandits: 3 of 8 left") or a `percent` ("The Juggernaut: 62%"). Core renders them as text and interprets nothing. Empty when no step has anything to say, or its module did not answer in time.',
items: { $ref: '#/components/schemas/PublicEventProgressLine' },
},
},
},
PublicEventProgressLine: {
type: 'object',
description:
'One line of a live run’s progress: a `label` with either `left` of `of`, or a `percent`. Never both.',
properties: {
label: { type: 'string', example: 'Bandits' },
left: { type: 'integer', example: 3 },
of: { type: 'integer', example: 8 },
percent: { type: 'integer', minimum: 0, maximum: 100, example: 62 },
},
},
PublicEventParticipant: {

View File

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

View File

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

View File

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

View File

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

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