Compare commits
115 Commits
feature/en
...
10efafbae5
| Author | SHA1 | Date | |
|---|---|---|---|
| 10efafbae5 | |||
| 36ee31896b | |||
| 28d0b76f13 | |||
| 673701c91b | |||
| f13b8fe1fe | |||
| 41a1e84b51 | |||
| 7bd93b8bb0 | |||
| d1ae8f943f | |||
| 222bcb02fc | |||
| ebedee146f | |||
| 47a79d841c | |||
| 62eaa7693d | |||
| 0f05098d8f | |||
| 8ec7f399a7 | |||
| 38aef5964e | |||
| 9408fee3c1 | |||
| c18250eb07 | |||
| 61feca50a8 | |||
| 7aafd0dd74 | |||
| 3b9998539f | |||
| 3c1c174337 | |||
| 247323ff05 | |||
| d48d8c473c | |||
| 2a6e307f16 | |||
| c7a24a186c | |||
| c87563bc32 | |||
| 69998ec548 | |||
| cee4ab8aea | |||
| c6f53a1509 | |||
| 4ad746c43e | |||
| b5e0d01a91 | |||
| 8012a457fc | |||
| 6890a74c7c | |||
| 56c6a279dc | |||
| f40dd765c3 | |||
| 9a1654fc54 | |||
| 02897030e5 | |||
| 5e398d8acd | |||
| 6ba2ed90ed | |||
| 8c30cbbddd | |||
| 0f29f0acb2 | |||
| c9e992588b | |||
| eb773693d3 | |||
| 769794d183 | |||
| a981b2b854 | |||
| 743f1bc8b2 | |||
| ca7ec53b14 | |||
| e58857c133 | |||
| 3e243ce772 | |||
| 43f706c003 | |||
| ef93c73d2c | |||
| 0a37a44164 | |||
| 9ddb59a326 | |||
| f0e7d2aa2a | |||
| 224e2b4dbc | |||
| cc1f49af29 | |||
| 1a76b98d76 | |||
| a169a463a5 | |||
| 90ba8cca16 | |||
| b5616f359c | |||
| e4f088e90b | |||
| 702ab89ae2 | |||
| 9e1f591b25 | |||
| efa9db7330 | |||
| 720103e3d4 | |||
| f373f2e897 | |||
| 61dc692088 | |||
| 655fbf3f69 | |||
| baa4f7d5ba | |||
| 7d7840eb6b | |||
| b92b85c3a9 | |||
| 6dd4e5e3eb | |||
| af9f4e191c | |||
| e46842a28c | |||
| 2e9ed50e21 | |||
| eb167558e3 | |||
| 1667e636bd | |||
| 6e6c24065c | |||
| 8453762e3b | |||
| db8e01e868 | |||
| 37f4623068 | |||
| d0178c6419 | |||
| 809426ad73 | |||
| aba8d1e43a | |||
| 7d3d6d5abd | |||
| d4516739b4 | |||
| 82a50e5e04 | |||
| 4a91d74085 | |||
| fdc118166c | |||
| 57d183e921 | |||
| fd9fb50351 | |||
| 429e657239 | |||
| 4077c4e79e | |||
| 4ac917c3a3 | |||
| 9bc0bf5a3d | |||
| 9c23c5fd0e | |||
| 6e73660b52 | |||
| a481248bc0 | |||
| 7b570c8ea1 | |||
| 2ba397eff7 | |||
| 2e964cfeee | |||
| d88906e43c | |||
| 8e03497eb3 | |||
| 6331b36c45 | |||
| 5779d15150 | |||
| e59a68c152 | |||
| eec7dbf785 | |||
| 66bb3b9a3f | |||
| 52eac24d17 | |||
| c8d45733b6 | |||
| c3783f56f1 | |||
| 40ab1ce8d2 | |||
| 0a9149a04f | |||
| cfd1cb3c3c | |||
| 81e0338a69 |
42
.gitea/workflows/demo-baseline.yml
Normal file
42
.gitea/workflows/demo-baseline.yml
Normal 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
|
||||
117
.gitea/workflows/demo-wipe.yml
Normal file
117
.gitea/workflows/demo-wipe.yml
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine
|
||||
FROM node:22.23.4-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine
|
||||
FROM node:22.23.4-alpine
|
||||
|
||||
WORKDIR /app/bot
|
||||
|
||||
|
||||
@@ -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'
|
||||
@@ -17,6 +19,9 @@ import FiveOnFriday from './routes/public/FiveOnFriday.jsx'
|
||||
import Newsletter from './routes/public/Newsletter.jsx'
|
||||
import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
|
||||
import About from './routes/public/About.jsx'
|
||||
import Events from './routes/public/Events.jsx'
|
||||
import EventPage from './routes/public/EventPage.jsx'
|
||||
import EventSeries from './routes/public/EventSeries.jsx'
|
||||
import Status from './routes/public/Status.jsx'
|
||||
import Wiki from './routes/wiki/Wiki.jsx'
|
||||
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
||||
@@ -48,6 +53,12 @@ import EngagementTemplates from './routes/admin/views/EngagementTemplates.jsx'
|
||||
import EngagementTriggers from './routes/admin/views/EngagementTriggers.jsx'
|
||||
import EngagementSendLog from './routes/admin/views/EngagementSendLog.jsx'
|
||||
import EngagementSuppressions from './routes/admin/views/EngagementSuppressions.jsx'
|
||||
import EngagementRetention from './routes/admin/views/EngagementRetention.jsx'
|
||||
import EventsAdmin from './routes/admin/views/EventsAdmin.jsx'
|
||||
import EventsCalendar from './routes/admin/views/EventsCalendar.jsx'
|
||||
import EventEditor from './routes/admin/views/EventEditor.jsx'
|
||||
import EventRun from './routes/admin/views/EventRun.jsx'
|
||||
import EventActions from './routes/admin/views/EventActions.jsx'
|
||||
import TeamsAdmin from './routes/admin/views/TeamsAdmin.jsx'
|
||||
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
||||
import Moderation from './routes/admin/views/Moderation.jsx'
|
||||
@@ -68,6 +79,7 @@ import PlayerNotifications from './routes/player/PlayerNotifications.jsx'
|
||||
import PlayerInbox from './routes/player/PlayerInbox.jsx'
|
||||
import Unsubscribe from './routes/player/Unsubscribe.jsx'
|
||||
import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
|
||||
import PlayerEvents from './routes/player/PlayerEvents.jsx'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -80,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
|
||||
@@ -101,6 +117,17 @@ export default function App() {
|
||||
<Route path="/site/newsletter" element={<Newsletter />} />
|
||||
<Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
|
||||
<Route path="/site/about" element={<About />} />
|
||||
{/* Events (Phase 14a). `series/:slug` is declared before `:slug`
|
||||
although it could not be shadowed by it — two segments against
|
||||
one. It stays above because the ranking surprise this feature
|
||||
has already shipped once was exactly here: a static segment
|
||||
outranks a dynamic one whatever the source order, which is what
|
||||
made `/admin/events/new` unreachable from Phase 6 to Phase 13.
|
||||
Nothing static shares a segment with `:slug`, so nothing here
|
||||
repeats it. */}
|
||||
<Route path="/site/events" element={<Events />} />
|
||||
<Route path="/site/events/series/:slug" element={<EventSeries />} />
|
||||
<Route path="/site/events/:slug" element={<EventPage />} />
|
||||
<Route path="/site/status" element={<Status />} />
|
||||
<Route path="/wiki" element={<Wiki />} />
|
||||
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
||||
@@ -191,6 +218,27 @@ export default function App() {
|
||||
actions that publish a game-written name is applied per request
|
||||
on the server, from the caller's live role (TEAMS.md 2.9). */}
|
||||
<Route path="teams" element={<TeamsAdmin />} />
|
||||
{/* Events (EVENTS.md §I, Phase 3). Staff-wide, unlike Engagement:
|
||||
§K makes every read here `staff`, and the moderator's whole
|
||||
power over this feature is the run console — cancelling a run
|
||||
that is doing something wrong at 2am. The narrower gates are
|
||||
applied per action instead: authoring is admin+editor, publish
|
||||
and start are admin only (§N2), and each button follows the
|
||||
route it calls. `runs/:runId` is declared before `:id` so the
|
||||
literal segment is never read as a definition id. */}
|
||||
<Route path="events" element={<EventsAdmin />} />
|
||||
<Route path="events/calendar" element={<EventsCalendar />} />
|
||||
{/* The switchboard (Phase 6). A literal segment, declared before
|
||||
`events/:id` the way the router declares `/actions` before
|
||||
`/:id` — the same collision, on the other side of the wire. */}
|
||||
<Route path="events/actions" element={<EventActions />} />
|
||||
<Route path="events/runs/:runId" element={<EventRun />} />
|
||||
{/* ONE route, and `new` is a value of `:id` rather than a
|
||||
path beside it. A static `events/new` outranks the dynamic
|
||||
segment in React Router whatever the order, so the editor
|
||||
was handed no `id` at all and asked the API for
|
||||
`/admin/events/undefined`. */}
|
||||
<Route path="events/:id" element={<EventEditor />} />
|
||||
{/* Engagement (ENGAGEMENT.md Phases 4b and 5b). Admin-only, matching the
|
||||
server: every route under /admin/engagement re-gates to `admin`
|
||||
on top of the group's staff gate, because this is the group that
|
||||
@@ -210,6 +258,7 @@ export default function App() {
|
||||
<Route path="triggers" element={<EngagementTriggers />} />
|
||||
<Route path="sends" element={<EngagementSendLog />} />
|
||||
<Route path="suppressions" element={<EngagementSuppressions />} />
|
||||
<Route path="retention" element={<EngagementRetention />} />
|
||||
</Route>
|
||||
<Route path="account" element={<AccountAdmin />} />
|
||||
{/* Staff have an inbox and channel preferences like anyone else —
|
||||
@@ -219,6 +268,15 @@ export default function App() {
|
||||
two paths; `lib/notificationPaths.js` is the one mapping. */}
|
||||
<Route path="notifications" element={<PlayerInbox />} />
|
||||
<Route path="notifications/settings" element={<PlayerNotifications />} />
|
||||
{/* And participation history, for the same reason and by the same
|
||||
arrangement (Phase 14a): `/player/events/history` is behind
|
||||
requireAuth alone, so a staff member has one — but
|
||||
`RequirePlayer` sends them out of `/account`. Declared BEFORE
|
||||
`events/:id`, though it need not be: a static segment outranks
|
||||
a dynamic one whatever the order, which is the rule that made
|
||||
`events/new` unreachable for seven phases. Written in the order
|
||||
it resolves. */}
|
||||
<Route path="events/mine" element={<PlayerEvents />} />
|
||||
{/* Installed modules' admin pages, at /admin/<id>/…, already inside
|
||||
RequireAuth + AdminLayout. A module cannot supply its own auth
|
||||
wrapper — only an optional { roles }, which core applies as the
|
||||
@@ -262,6 +320,11 @@ export default function App() {
|
||||
<Route path="/player" element={<PlayerIndex />} />
|
||||
<Route path="/account" element={<PlayerAccount />} />
|
||||
<Route path="/account/appeals" element={<PlayerAppeals />} />
|
||||
{/* Participation history (Phase 14a). Under /account rather than
|
||||
/player because it is role-agnostic self-service: staff are a
|
||||
superset of players and an admin reading their own attendance
|
||||
is as ordinary as anyone else doing it. */}
|
||||
<Route path="/account/events" element={<PlayerEvents />} />
|
||||
{/* The inbox took `/account/notifications` in engagement Phase 7
|
||||
and the preferences screen moved under it. Content and
|
||||
settings are different kinds of thing, and the plain word
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Thin fetch wrapper. Always sends cookies (httpOnly JWT) and talks to the
|
||||
// same-origin API (/api/v1) — proxied to the Express server in dev.
|
||||
import { reportIfDemoLocked } from '../lib/demoNotice.js'
|
||||
|
||||
const BASE = '/api/v1'
|
||||
|
||||
// Prefix a non-empty query string with "?" (and nothing when it is empty), so
|
||||
@@ -28,6 +30,9 @@ async function req(path, { method = 'GET', body, headers, raw } = {}) {
|
||||
const text = await res.text()
|
||||
const data = text ? safeParse(text) : null
|
||||
if (!res.ok) {
|
||||
// The public demo's lock (DEMO_MODE.md §3.1): shown once, as a toast, for
|
||||
// every caller. The ApiError below still goes to the form as usual.
|
||||
reportIfDemoLocked(data)
|
||||
const message = (data && data.message) || res.statusText || 'Request failed'
|
||||
throw new ApiError(res.status, message, data)
|
||||
}
|
||||
@@ -112,6 +117,10 @@ export const api = {
|
||||
// The change endpoints re-issue the session cookie server-side, so the caller
|
||||
// stays signed in.
|
||||
myAccount: () => req('/auth/me/account'),
|
||||
// Delete the signed-in account (ACCOUNT_DELETION.md §4.1). The password always,
|
||||
// and `code` or `recoveryCode` when two-factor is on; refusals carry a `code`.
|
||||
deleteMyAccount: ({ username, password, code, recoveryCode }) =>
|
||||
req('/auth/me/account', { method: 'DELETE', body: { username, password, code, recoveryCode } }),
|
||||
changeUsername: (username) =>
|
||||
req('/auth/me/account/username', { method: 'PATCH', body: { username } }),
|
||||
changePassword: (newPassword, currentPassword) =>
|
||||
@@ -133,14 +142,32 @@ export const api = {
|
||||
totpDisable: (code) => req('/auth/me/account/totp/disable', { method: 'POST', body: { code } }),
|
||||
// Linked SSO identities (self-service). Linking starts at /auth/sso/:id/link.
|
||||
myIdentities: () => req('/auth/me/account/identities'),
|
||||
unlinkIdentity: (provider) =>
|
||||
req(`/auth/me/account/identities/${encodeURIComponent(provider)}`, { method: 'DELETE' }),
|
||||
// With an identity id, only that one goes (a person may hold several Steam accounts).
|
||||
unlinkIdentity: (provider, id) =>
|
||||
req(
|
||||
`/auth/me/account/identities/${encodeURIComponent(provider)}${id != null ? `?id=${encodeURIComponent(id)}` : ''}`,
|
||||
{ method: 'DELETE' },
|
||||
),
|
||||
// Recovery (backup) codes. status → remaining count; generate → a fresh set,
|
||||
// returned ONCE (password step-up for accounts that have a password).
|
||||
recoveryCodesStatus: () => req('/auth/me/account/recovery-codes/status'),
|
||||
generateRecoveryCodes: (currentPassword) =>
|
||||
req('/auth/me/account/recovery-codes/generate', { method: 'POST', body: { currentPassword } }),
|
||||
|
||||
// Report, block and "Hide for me" (USER_SAFETY.md §4.1). A block and a report
|
||||
// name an ITEM — `{ targetType, targetId }` — never a person: the server reads
|
||||
// who wrote it. Saved on the account, so they apply on every device.
|
||||
reportItem: ({ targetType, targetId, reason, detail }) =>
|
||||
req('/player/reports', { method: 'POST', body: { targetType, targetId, reason, detail } }),
|
||||
myBlocks: () => req('/auth/me/blocks'),
|
||||
blockAuthor: ({ targetType, targetId }) => req('/auth/me/blocks', { method: 'POST', body: { targetType, targetId } }),
|
||||
unblock: (id) => req(`/auth/me/blocks/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
myHidden: () => req('/auth/me/hidden'),
|
||||
hideItem: ({ targetType, targetId }) => req('/auth/me/hidden', { method: 'POST', body: { targetType, targetId } }),
|
||||
unhideItem: ({ targetType, targetId }) =>
|
||||
req(`/auth/me/hidden/${encodeURIComponent(targetType)}/${encodeURIComponent(targetId)}`, { method: 'DELETE' }),
|
||||
clearHidden: () => req('/auth/me/hidden', { method: 'DELETE' }),
|
||||
|
||||
// ----- settings (any authenticated account) -----
|
||||
// Nav overrides for the layouts the caller's own role renders, and the theme
|
||||
// catalog the appearance form is built from. A fifth group, not part of
|
||||
@@ -256,6 +283,24 @@ export const api = {
|
||||
// their mail, not signed in. Always resolves 200 whatever the token was.
|
||||
unsubscribeTeam: (token) =>
|
||||
req(`/public/teams/unsubscribe/${encodeURIComponent(token)}`, { method: 'POST' }),
|
||||
// ----- Events (EVENTS.md § API surface, Phase 14a) -----
|
||||
//
|
||||
// The anonymous surface. `from`/`to` are optional — the server defaults to now
|
||||
// through a month out, so the calendar's first render need not compute a window
|
||||
// before it can ask for anything.
|
||||
publicEvents: ({ from, to, seriesId } = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (from) qs.set('from', from)
|
||||
if (to) qs.set('to', to)
|
||||
if (seriesId) qs.set('seriesId', String(seriesId))
|
||||
return req(`/public/events${withQs(qs.toString())}`)
|
||||
},
|
||||
// `run` is what an announcement's link carries, so a mail about last Friday's
|
||||
// occurrence opens last Friday's results rather than next Friday's.
|
||||
publicEvent: (slug, run = null) =>
|
||||
req(`/public/events/${encodeURIComponent(slug)}${run ? `?run=${encodeURIComponent(run)}` : ''}`),
|
||||
publicEventSeries: (slug) => req(`/public/events/series/${encodeURIComponent(slug)}`),
|
||||
|
||||
wikiTags: () => req('/public/wiki/tags'),
|
||||
wikiPage: (slug) => req(`/public/wiki/${slug}`),
|
||||
// CMS pages (block-based). Published-only for the public; a draft-preview link
|
||||
@@ -437,8 +482,14 @@ export const api = {
|
||||
// Suppressions (Phase 9). `unsuppressAddress` sends the address in the BODY
|
||||
// of a DELETE rather than in the path, and that is not style: a path
|
||||
// parameter lands in the access log, the browser history and every proxy in
|
||||
// front of the deployment, and this one is a real person's address. The list
|
||||
// never returns a hash to use instead.
|
||||
// front of the deployment, and this one is a real person's address.
|
||||
//
|
||||
// **Phase 14 added the second form, and it is the one the row uses.** The
|
||||
// list now returns each row's `address_hash`, so the Lift button on a row
|
||||
// needs no address at all — the operator is looking at a mask and has never
|
||||
// been told the address. `unsuppressAddress` stays for the address the
|
||||
// operator types, which is the only way to reach a row that is not on the
|
||||
// page in front of them.
|
||||
listEngagementSuppressions: ({ limit, offset, reason, channel, search } = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (limit) qs.set('limit', String(limit))
|
||||
@@ -452,6 +503,117 @@ export const api = {
|
||||
req('/admin/engagement/suppressions', { method: 'POST', body: { address, detail } }),
|
||||
unsuppressAddress: (address, channel) =>
|
||||
req('/admin/engagement/suppressions', { method: 'DELETE', body: { address, channel } }),
|
||||
unsuppressByHash: (hash, channel) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (channel) qs.set('channel', channel)
|
||||
return req(`/admin/engagement/suppressions/by-hash/${hash}${withQs(qs.toString())}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
},
|
||||
|
||||
// Retention (Phase 14). Three horizons, one screen; `engagement_suppressions`
|
||||
// is not among them because a suppression does not expire.
|
||||
getEngagementRetention: () => req('/admin/engagement/retention'),
|
||||
setEngagementRetention: (body) =>
|
||||
req('/admin/engagement/retention', { method: 'PUT', body }),
|
||||
|
||||
// Events (docs/website/EVENTS.md, Phase 3). Reads are staff-wide; authoring
|
||||
// is admin+editor, publish and start are admin ONLY, and the six live
|
||||
// controls are admin+moderator — the one gate in this feature wider than
|
||||
// admin, because stopping a run at 2am is incident response and starting
|
||||
// one is not (§N2). The buttons follow the same split, and the server
|
||||
// re-checks every one of them.
|
||||
listEvents: (state) => req(`/admin/events${state ? `?state=${encodeURIComponent(state)}` : ''}`),
|
||||
getEvent: (id) => req(`/admin/events/${id}`),
|
||||
createEvent: (body) => req('/admin/events', { method: 'POST', body }),
|
||||
updateEvent: (id, body) => req(`/admin/events/${id}`, { method: 'PUT', body }),
|
||||
publishEvent: (id) => req(`/admin/events/${id}/publish`, { method: 'POST' }),
|
||||
archiveEvent: (id) => req(`/admin/events/${id}`, { method: 'DELETE' }),
|
||||
listEventVersions: (id) => req(`/admin/events/${id}/versions`),
|
||||
eventCatalog: () => req('/admin/events/catalog'),
|
||||
// Phase 7. The values behind a param's `source` — resolved by the module that
|
||||
// registered the source, on a request of its own rather than inside the
|
||||
// catalog, because a source can be slow or down and must not take the whole
|
||||
// editor with it. A refusal comes back 200 with `ok: false`, so this never
|
||||
// throws for the case the screen is meant to render: the field degrades to
|
||||
// free text with the reason beside it.
|
||||
// Phase 12b made a source SEARCHABLE and Phase 13 is what asks. `q` is
|
||||
// ignored, never refused, by a source that does not declare itself
|
||||
// searchable — so passing it is always safe and the field decides whether
|
||||
// it is a typeahead by reading `searchable` off the answer.
|
||||
eventOptions: (sourceId, q) => {
|
||||
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
|
||||
// — the request succeeded, the plan has problems.
|
||||
verifyEvent: (id) => req(`/admin/events/${id}/verify`, { method: 'POST' }),
|
||||
// Phase 13's live cap meter, and NOT a lighter dry run — it dispatches
|
||||
// nothing, so it knows nothing a module knows. It takes the spec in the
|
||||
// body rather than an id because the plan it prices is the one in the
|
||||
// author's hands, which is unsaved between keystrokes, and it records
|
||||
// nothing, which is what makes it safe to call on a debounce.
|
||||
priceEvent: (body) => req('/admin/events/price', { method: 'POST', body }),
|
||||
// The switchboard, admin only in BOTH directions: reading which actions a
|
||||
// deployment permits is as much configuration as writing it (§K). One action
|
||||
// per write rather than the whole board, so an action that appeared between
|
||||
// the read and the write cannot be overwritten with a default.
|
||||
eventActions: () => req('/admin/events/actions'),
|
||||
saveEventAction: (body) => req('/admin/events/actions', { method: 'PUT', body }),
|
||||
eventSeries: () => req('/admin/events/series'),
|
||||
// Series writes are admin+editor rather than admin: naming an arc is
|
||||
// authoring, and §N2's narrow gate is about committing the deployment to a
|
||||
// run. The delete is a real delete and answers with how many definitions it
|
||||
// detached — `series_id` is ON DELETE SET NULL, so nothing is destroyed.
|
||||
createEventSeries: (body) => req('/admin/events/series', { method: 'POST', body }),
|
||||
updateEventSeries: (id, body) => req(`/admin/events/series/${id}`, { method: 'PUT', body }),
|
||||
deleteEventSeries: (id) => req(`/admin/events/series/${id}`, { method: 'DELETE' }),
|
||||
// The calendar. `from`/`to` are UTC instants the caller computes from the
|
||||
// month it is showing, in the READER's zone — the server never guesses it.
|
||||
// A `status` or `scope` filter suppresses projections, which is why the
|
||||
// month view sends neither.
|
||||
eventCalendar: ({ from, to, status, scope, seriesId } = {}) => {
|
||||
const qs = new URLSearchParams({ from, to })
|
||||
if (status) qs.set('status', status)
|
||||
if (scope) qs.set('scope', scope)
|
||||
if (seriesId) qs.set('seriesId', String(seriesId))
|
||||
return req(`/admin/events/calendar?${qs.toString()}`)
|
||||
},
|
||||
startEventRun: (id, body) => req(`/admin/events/${id}/runs`, { method: 'POST', body }),
|
||||
listEventRuns: ({ definitionId, status, limit } = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (definitionId) qs.set('definitionId', String(definitionId))
|
||||
if (status) qs.set('status', status)
|
||||
if (limit) qs.set('limit', String(limit))
|
||||
const suffix = qs.toString()
|
||||
return req(`/admin/events/runs${suffix ? `?${suffix}` : ''}`)
|
||||
},
|
||||
getEventRun: (runId) => req(`/admin/events/runs/${runId}`),
|
||||
getEventRunLog: (runId, limit) =>
|
||||
req(`/admin/events/runs/${runId}/log${limit ? `?limit=${Number(limit)}` : ''}`),
|
||||
pauseEventRun: (runId, reason) =>
|
||||
req(`/admin/events/runs/${runId}/pause`, { method: 'POST', body: { reason } }),
|
||||
resumeEventRun: (runId) => req(`/admin/events/runs/${runId}/resume`, { method: 'POST' }),
|
||||
// `cleanup` defaults to true server-side and has to be asked out of: EVENTS.md
|
||||
// §L makes cancelling WITHOUT cleanup the separate, admin-only, logged action,
|
||||
// so an absent flag means "give back what this run took".
|
||||
cancelEventRun: (runId, reason, cleanup = true) =>
|
||||
req(`/admin/events/runs/${runId}/cancel`, { method: 'POST', body: { reason, cleanup } }),
|
||||
cleanupEventRun: (runId) => req(`/admin/events/runs/${runId}/cleanup`, { method: 'POST' }),
|
||||
advanceEventRun: (runId, reason) =>
|
||||
req(`/admin/events/runs/${runId}/advance`, { method: 'POST', body: { reason } }),
|
||||
confirmEventStep: (runId, stepId, note) =>
|
||||
req(`/admin/events/runs/${runId}/steps/${stepId}/confirm`, { method: 'POST', body: { note } }),
|
||||
skipEventStep: (runId, stepId, reason) =>
|
||||
req(`/admin/events/runs/${runId}/steps/${stepId}/skip`, { method: 'POST', body: { reason } }),
|
||||
retryEventStep: (runId, stepId) =>
|
||||
req(`/admin/events/runs/${runId}/steps/${stepId}/retry`, { method: 'POST' }),
|
||||
|
||||
// Teams (docs/website/TEAMS.md §2.11). Three of these mean something
|
||||
// different depending on who calls them: for a moderator, unhide and
|
||||
@@ -508,6 +670,10 @@ export const api = {
|
||||
},
|
||||
handleContentReport: (id, body) =>
|
||||
req(`/admin/moderation/reports/${id}/handle`, { method: 'POST', body }),
|
||||
// "Hide for everyone" (USER_SAFETY.md §4.1): hides the item through its own
|
||||
// moderation and actions the report.
|
||||
hideReportedItem: (id, body = {}) =>
|
||||
req(`/admin/moderation/reports/${id}/hide`, { method: 'POST', body }),
|
||||
modRecent: (params = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.type) qs.set('type', params.type)
|
||||
@@ -595,6 +761,18 @@ export const api = {
|
||||
getEligibleAppeals: () => req('/player/appeals/eligible'),
|
||||
submitAppeal: (data) => req('/player/appeals', { method: 'POST', body: data }),
|
||||
withdrawAppeal: (id) => req(`/player/appeals/${id}/withdraw`, { method: 'POST' }),
|
||||
|
||||
// ----- event participation (Phase 14a) -----
|
||||
//
|
||||
// Self-scoped on the session and nothing else — there is no id to pass.
|
||||
// `before` is a keyset cursor (the last entry's `id`), not an offset: the
|
||||
// list gains a row every time the reader attends something.
|
||||
eventHistory: ({ limit, before } = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (limit) qs.set('limit', String(limit))
|
||||
if (before) qs.set('before', String(before))
|
||||
return req(`/player/events/history${withQs(qs.toString())}`)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
47
client/src/components/DemoBanner.jsx
Normal file
47
client/src/components/DemoBanner.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
51
client/src/components/DemoLogins.jsx
Normal file
51
client/src/components/DemoLogins.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
51
client/src/components/DemoToast.jsx
Normal file
51
client/src/components/DemoToast.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { onDemoLocked } from '../lib/demoNotice.js'
|
||||
import { onNotice } from '../lib/notice.js'
|
||||
|
||||
// The one toast for a write the public demo refused (DEMO_MODE.md §3.1). Fed
|
||||
// by api/client.js through lib/demoNotice.js, so every screen — and every
|
||||
// installed module's screens — gets it without handling the code itself. On a
|
||||
// real shard nothing is ever refused that way, so it never appears.
|
||||
const SHOW_MS = 5000
|
||||
|
||||
export default function DemoToast() {
|
||||
const [message, setMessage] = useState(null)
|
||||
|
||||
useEffect(() => onDemoLocked((text) => setMessage(text)), [])
|
||||
// Also the site's one passing notice (lib/notice.js), on any deployment: the
|
||||
// account page's "Your account has been deleted." after it has navigated home.
|
||||
useEffect(() => onNotice((text) => setMessage(text)), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!message) return undefined
|
||||
const t = setTimeout(() => setMessage(null), SHOW_MS)
|
||||
return () => clearTimeout(t)
|
||||
}, [message])
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
style={{ position: 'fixed', left: 16, right: 16, bottom: 20, zIndex: 1000, display: 'flex', justifyContent: 'center', pointerEvents: 'none' }}
|
||||
>
|
||||
{message && (
|
||||
<div
|
||||
className="sans"
|
||||
style={{
|
||||
maxWidth: 480,
|
||||
padding: '11px 16px',
|
||||
borderRadius: 10,
|
||||
fontSize: '0.88rem',
|
||||
color: 'var(--head)',
|
||||
background: 'var(--bg-deep)',
|
||||
border: '1px solid var(--accent)',
|
||||
boxShadow: '0 12px 32px rgba(0,0,0,0.45)',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { useFeatureGate } from '../modules/features.jsx'
|
||||
export const NAV = [
|
||||
{ label: 'Home', to: '/', end: true },
|
||||
{ label: 'News', to: '/site/news' },
|
||||
{ label: 'Events', to: '/site/events' },
|
||||
{ label: 'Screenshots', to: '/site/screenshots' },
|
||||
{ label: 'Five on Friday', to: '/site/five-on-friday' },
|
||||
{ label: 'Newsletter', to: '/site/newsletter' },
|
||||
|
||||
119
client/src/components/security/BlockedPanel.jsx
Normal file
119
client/src/components/security/BlockedPanel.jsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api } from '../../api/client.js'
|
||||
import { blockSubtitle } from '../../lib/userSafety.js'
|
||||
|
||||
// Account → Blocked (docs/website/USER_SAFETY.md §1, §4.1). The members and
|
||||
// players this account has blocked, each with Unblock, and the items it hid for
|
||||
// itself, with one "Show them again".
|
||||
//
|
||||
// Blocks and hides are saved on the account, so this list is the same one the
|
||||
// app shows, and undoing one here undoes it everywhere. The role-agnostic
|
||||
// /auth/me surface, so the same panel serves players and staff.
|
||||
//
|
||||
// Two kinds of block, and the panel names which: an account (a forum author, or
|
||||
// a chat speaker whose game account is linked — S12) or a game identity nobody
|
||||
// has linked yet (a Steam id). A block whose target has since become staff no
|
||||
// longer applies (S8), and the row says so rather than the block vanishing.
|
||||
|
||||
export default function BlockedPanel() {
|
||||
const [blocks, setBlocks] = useState(null)
|
||||
const [hiddenCount, setHiddenCount] = useState(0)
|
||||
const [error, setError] = useState('')
|
||||
const [msg, setMsg] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [b, h] = await Promise.all([api.myBlocks(), api.myHidden()])
|
||||
setBlocks(b.blocks || [])
|
||||
setHiddenCount((h.hidden || []).length)
|
||||
} catch {
|
||||
setError('Could not load your blocks.')
|
||||
setBlocks([])
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
async function unblock(block) {
|
||||
setBusy(true)
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
await api.unblock(block.id)
|
||||
setMsg(block.name ? `${block.name} is unblocked.` : 'Unblocked.')
|
||||
await load()
|
||||
} catch {
|
||||
setError('Could not unblock that.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function showHidden() {
|
||||
setBusy(true)
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
await api.clearHidden()
|
||||
setMsg('Everything you hid is shown again.')
|
||||
await load()
|
||||
} catch {
|
||||
setError('Could not show those again.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!blocks) return null
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 28 }}>
|
||||
<h2 className="display" style={{ marginTop: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
|
||||
Blocked
|
||||
</h2>
|
||||
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
|
||||
You don’t see anything a blocked member or player writes — forum posts and in-game chat — on this
|
||||
site or in the app. They aren’t told. Staff can’t be blocked.
|
||||
</p>
|
||||
|
||||
{blocks.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
|
||||
{blocks.map((b) => (
|
||||
<div key={b.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>{b.name || 'Unknown player'}</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.76rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{blockSubtitle(b)}
|
||||
</div>
|
||||
{!b.applies && b.reason === 'staff' && (
|
||||
<div className="sans" style={{ fontSize: '0.76rem', color: '#d9b884', marginTop: 2 }}>
|
||||
Not applied: this account is now a staff member, and staff can’t be blocked.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => unblock(b)} disabled={busy} className="pill">
|
||||
Unblock
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="sans dim" style={{ fontSize: '0.86rem', margin: '14px 0' }}>You haven’t blocked anyone.</p>
|
||||
)}
|
||||
|
||||
{hiddenCount > 0 && (
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<span className="sans dim" style={{ fontSize: '0.86rem' }}>
|
||||
You’ve hidden {hiddenCount} {hiddenCount === 1 ? 'item' : 'items'} for yourself.
|
||||
</span>
|
||||
<button onClick={showHidden} disabled={busy} className="pill">Show them again</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{msg && <p className="sans" style={{ marginTop: 14, color: '#7fd0a4', fontSize: '0.86rem' }}>{msg}</p>}
|
||||
{error && <p className="sans" style={{ marginTop: 14, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
178
client/src/components/security/DeleteAccountPanel.jsx
Normal file
178
client/src/components/security/DeleteAccountPanel.jsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
import { showNotice } from '../../lib/notice.js'
|
||||
import {
|
||||
WHAT_GOES, WHAT_STAYS, STAFF_NAME_NOTE, isStaffRole, usernameMatches, isSharedDemoLogin, refusalMessage,
|
||||
} from '../../lib/accountDeletion.js'
|
||||
|
||||
// "Delete my account" (docs/website/ACCOUNT_DELETION.md §4.2). Last on the
|
||||
// Account page and set apart from the sections above it.
|
||||
//
|
||||
// The account goes at once — there is no grace period (AD4) — so the safeguard
|
||||
// is the confirm step: the typed username, the password, and a two-factor code
|
||||
// when two-factor is on. Delete stays off until the username matches.
|
||||
//
|
||||
// On success the server has already dropped every session. This screen sends
|
||||
// the browser home, re-reads the session (which is now gone) and says so.
|
||||
|
||||
const DANGER = '#d98b84'
|
||||
|
||||
export default function DeleteAccountPanel({ account }) {
|
||||
const { refresh } = useAuth()
|
||||
const { settings } = useSite()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [typed, setTyped] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
const [useRecovery, setUseRecovery] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const demoLocked = isSharedDemoLogin(settings, account.username)
|
||||
const noPassword = account.has_password === false
|
||||
const needsCode = Boolean(account.totp_enabled)
|
||||
const ready = usernameMatches(typed, account.username) && password.length > 0 && (!needsCode || code.trim().length > 0)
|
||||
|
||||
function close() {
|
||||
setOpen(false)
|
||||
setTyped('')
|
||||
setPassword('')
|
||||
setCode('')
|
||||
setUseRecovery(false)
|
||||
setError('')
|
||||
}
|
||||
|
||||
async function confirm(e) {
|
||||
e.preventDefault()
|
||||
if (!ready || busy) return
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.deleteMyAccount({
|
||||
username: typed.trim(),
|
||||
password,
|
||||
...(needsCode ? (useRecovery ? { recoveryCode: code.trim() } : { code: code.trim() }) : {}),
|
||||
})
|
||||
navigate('/', { replace: true })
|
||||
showNotice('Your account has been deleted.')
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setError(refusalMessage(err))
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="delete-account-heading"
|
||||
style={{ borderTop: `1px solid ${DANGER}`, paddingTop: 26, marginTop: 40 }}
|
||||
>
|
||||
<h2 id="delete-account-heading" className="display" style={{ marginTop: 0, fontSize: '1.15rem', color: DANGER }}>
|
||||
Delete my account
|
||||
</h2>
|
||||
<div className="sans" style={{ color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6, maxWidth: 640 }}>
|
||||
<p style={{ marginTop: 0 }}>
|
||||
Your account is deleted straight away and cannot be restored. These go with it:
|
||||
</p>
|
||||
<ul style={{ margin: '0 0 12px', paddingLeft: 20 }}>
|
||||
{WHAT_GOES.map((line) => <li key={line}>{line}</li>)}
|
||||
</ul>
|
||||
<p style={{ margin: '0 0 6px' }}>What stays:</p>
|
||||
<ul style={{ margin: '0 0 12px', paddingLeft: 20 }}>
|
||||
{WHAT_STAYS.map((line) => <li key={line}>{line}</li>)}
|
||||
</ul>
|
||||
{isStaffRole(account.role) && <p style={{ margin: '0 0 12px' }}>{STAFF_NAME_NOTE}</p>}
|
||||
</div>
|
||||
|
||||
{noPassword && (
|
||||
<p className="sans" style={{ color: DANGER, fontSize: '0.86rem' }}>
|
||||
This account has no password yet. Set one in the Password section above first; the password is how you
|
||||
confirm a deletion.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!open && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={demoLocked || noPassword}
|
||||
className="btn btn-sq"
|
||||
style={{ borderColor: DANGER, color: DANGER }}
|
||||
>
|
||||
Delete my account…
|
||||
</button>
|
||||
{demoLocked && (
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', margin: '8px 0 0' }}>
|
||||
This is turned off on the demo site.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<form
|
||||
onSubmit={confirm}
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 360, padding: 16, border: `1px solid ${DANGER}`, borderRadius: 10 }}
|
||||
>
|
||||
<label>
|
||||
<span className="field-label">
|
||||
Type <strong style={{ color: 'var(--head)' }}>{account.username}</strong> to confirm
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={typed}
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
className="input"
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Password</span>
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="input" autoComplete="current-password" />
|
||||
</label>
|
||||
{needsCode && (
|
||||
<label>
|
||||
<span className="field-label">{useRecovery ? 'Recovery code' : 'Authenticator code'}</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode={useRecovery ? 'text' : 'numeric'}
|
||||
autoComplete="one-time-code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
className="input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="pill"
|
||||
style={{ marginTop: 8 }}
|
||||
onClick={() => { setUseRecovery((v) => !v); setCode('') }}
|
||||
>
|
||||
{useRecovery ? 'Use an authenticator code instead' : 'Use a recovery code instead'}
|
||||
</button>
|
||||
</label>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!ready || busy}
|
||||
className="btn btn-sq"
|
||||
style={{ background: ready ? DANGER : undefined, borderColor: DANGER, color: ready ? '#1a1a1a' : DANGER }}
|
||||
>
|
||||
{busy ? 'Deleting…' : 'Delete my account'}
|
||||
</button>
|
||||
<button type="button" onClick={close} disabled={busy} className="pill">Cancel</button>
|
||||
</div>
|
||||
{error && <p role="alert" className="sans" style={{ margin: 0, color: DANGER, fontSize: '0.85rem' }}>{error}</p>}
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
66
client/src/components/security/linkedIdentities.jsx
Normal file
66
client/src/components/security/linkedIdentities.jsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
69
client/src/lib/accountDeletion.js
Normal file
69
client/src/lib/accountDeletion.js
Normal file
@@ -0,0 +1,69 @@
|
||||
// What the Account page's "Delete my account" section says and decides
|
||||
// (docs/website/ACCOUNT_DELETION.md §4.1, §4.2). Plain JS with tests, so the
|
||||
// words and the confirm rule can be checked without a browser.
|
||||
//
|
||||
// The server is the guard: it checks the username, the password and the code
|
||||
// again whatever this file decides. What is here only keeps a mis-tap from
|
||||
// reaching it and turns its refusal codes into sentences.
|
||||
|
||||
/** What goes with the account. The same list as BACKEND_DESIGN.md's. */
|
||||
export const WHAT_GOES = [
|
||||
'your sign-in, password, email address, two-factor settings and recovery codes',
|
||||
'every signed-in device, trusted device and push registration',
|
||||
'your linked sign-ins, such as Google, Discord or Steam',
|
||||
'your game-account links, and anything this site granted in the game through them',
|
||||
'your notification settings and inbox',
|
||||
]
|
||||
|
||||
/** What stays, and who sees what of it. */
|
||||
export const WHAT_STAYS = [
|
||||
'What you wrote where others read it, such as forum posts, stays. Other members see it as written by “[deleted player]”.',
|
||||
'Reports you filed and appeals you made stay as moderation records. Staff can still see who it was, and the activity log keeps its IP addresses.',
|
||||
'Event results stay with the score and the in-game name, with no site account behind them.',
|
||||
'A game account stays on the game server. Only its link to this site goes.',
|
||||
]
|
||||
|
||||
/** Said only to staff: their name is never free again (AD9). */
|
||||
export const STAFF_NAME_NOTE =
|
||||
'You are staff, so your username can never be used again, by anyone, once this account is deleted.'
|
||||
|
||||
/** True for every role but player. */
|
||||
export const isStaffRole = (role) => Boolean(role) && role !== 'player'
|
||||
|
||||
/** The confirm button is live once the typed name matches, ignoring case. */
|
||||
export function usernameMatches(typed, username) {
|
||||
return typeof typed === 'string'
|
||||
&& typeof username === 'string'
|
||||
&& typed.trim().length > 0
|
||||
&& typed.trim().toLowerCase() === username.toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the signed-in account is one of the demo's shared logins, which the
|
||||
* server refuses to delete. The section still shows (Play's reviewer must find
|
||||
* it); only its button is off.
|
||||
*/
|
||||
export function isSharedDemoLogin(settings, username) {
|
||||
const logins = settings?.demo?.logins
|
||||
if (!Array.isArray(logins) || typeof username !== 'string') return false
|
||||
const name = username.toLowerCase()
|
||||
return logins.some((l) => typeof l?.username === 'string' && l.username.toLowerCase() === name)
|
||||
}
|
||||
|
||||
const BY_CODE = {
|
||||
username_mismatch: 'Type your username exactly as it is shown.',
|
||||
wrong_password: 'Your password is incorrect.',
|
||||
code_required: 'Enter a code from your authenticator app, or a recovery code.',
|
||||
wrong_code: 'That code is not valid. Try again.',
|
||||
password_required: 'This account has no password yet. Set one in the Password section above, then delete the account.',
|
||||
last_admin: 'You are the only admin. Make someone else an admin before you delete your account.',
|
||||
DEMO_LOCKED: 'This is turned off on the demo site.',
|
||||
}
|
||||
|
||||
/** A refusal from DELETE /auth/me/account, as one sentence. */
|
||||
export function refusalMessage(err) {
|
||||
const code = err?.body?.code
|
||||
if (code && BY_CODE[code]) return BY_CODE[code]
|
||||
if (err?.status === 429) return 'Too many attempts. Wait a few minutes and try again.'
|
||||
return err?.message || 'Could not delete your account. Try again.'
|
||||
}
|
||||
25
client/src/lib/deletedAccount.js
Normal file
25
client/src/lib/deletedAccount.js
Normal file
@@ -0,0 +1,25 @@
|
||||
// How a deleted account's name is shown (ACCOUNT_DELETION.md AD5, AD6).
|
||||
//
|
||||
// The server decides WHAT name a viewer gets (server/src/utils/displayAuthor.js):
|
||||
// a member is sent "[deleted player]", staff are sent the stored name and a
|
||||
// `…Deleted` flag. All this file does is mark the staff case, so a moderator
|
||||
// reading "Ravenna" knows that account no longer exists.
|
||||
|
||||
export const DELETED_PLAYER = '[deleted player]'
|
||||
|
||||
/** "Ravenna (deleted account)" for staff; the placeholder is left as it is. */
|
||||
export function markDeleted(name, deleted) {
|
||||
if (!name) return name
|
||||
return deleted && name !== DELETED_PLAYER ? `${name} (deleted account)` : name
|
||||
}
|
||||
|
||||
/**
|
||||
* The same for an admin view that is handed raw ledger rows (`actor_user_id`,
|
||||
* `actor_username`): an id that is NULL beside a snapshot that is not is a
|
||||
* deleted account, and nothing else clears those ids. Only an explicit null
|
||||
* counts — a row that does not carry the id column at all says nothing.
|
||||
*/
|
||||
export function rowName(userId, username, fallback = '—') {
|
||||
if (!username) return fallback
|
||||
return markDeleted(username, userId === null)
|
||||
}
|
||||
57
client/src/lib/demoNotice.js
Normal file
57
client/src/lib/demoNotice.js
Normal 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`
|
||||
}
|
||||
1153
client/src/lib/eventAuthoring.js
Normal file
1153
client/src/lib/eventAuthoring.js
Normal file
File diff suppressed because it is too large
Load Diff
109
client/src/lib/eventCalendar.js
Normal file
109
client/src/lib/eventCalendar.js
Normal file
@@ -0,0 +1,109 @@
|
||||
// Rendering an event's instant, shared by the public event screens.
|
||||
//
|
||||
// **The split these two functions make is EVENTS.md §I's, and it is the one
|
||||
// thing about event times that is easy to get wrong.** The server returns UTC
|
||||
// instants and never guesses the reader's zone. The client places them:
|
||||
//
|
||||
// • the DAY an entry is filed under is the reader's own — "what is on this
|
||||
// month" is a question about the month the person reading is living in;
|
||||
// • the TIME beside it is always the EVENT's zone, carried on the entry —
|
||||
// because every listing this feature replaces is written in the shard's
|
||||
// local zone, and "8pm" means the shard's evening to everyone reading it.
|
||||
//
|
||||
// Rendering the time in the reader's zone instead would be defensible and is
|
||||
// wrong here: a player in Berlin told an American shard's event is at "02:00"
|
||||
// has been told something true and useless, and told it in a way that makes the
|
||||
// shard's own announcement look like a mistake.
|
||||
|
||||
/** The event's own wall clock, with the zone named so it misreads as nothing. */
|
||||
export function eventTime(instant, timezone) {
|
||||
try {
|
||||
const time = new Intl.DateTimeFormat(undefined, {
|
||||
timeZone: timezone,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).format(new Date(instant))
|
||||
return `${time} ${shortZone(timezone)}`
|
||||
} catch {
|
||||
// An unknown IANA name throws rather than falling back, and an event whose
|
||||
// timezone column holds a typo must still render. UTC off the instant is the
|
||||
// honest answer when the zone cannot be honoured.
|
||||
return `${new Date(instant).toISOString().slice(11, 16)} UTC`
|
||||
}
|
||||
}
|
||||
|
||||
/** The zone as a reader recognises it: `America/New_York` → `New York`. */
|
||||
function shortZone(timezone) {
|
||||
if (!timezone) return 'UTC'
|
||||
const tail = String(timezone).split('/').pop()
|
||||
return tail.replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
/** The reader's own day, for the heading an entry is filed under. */
|
||||
export function readerDayLabel(instant) {
|
||||
const d = new Date(instant)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: d.getFullYear() === new Date().getFullYear() ? undefined : 'numeric',
|
||||
}).format(d)
|
||||
}
|
||||
|
||||
/** The event's own day and time together, for a page that shows one occurrence. */
|
||||
export function eventDateTime(instant, timezone) {
|
||||
const d = new Date(instant)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
try {
|
||||
return `${new Intl.DateTimeFormat(undefined, {
|
||||
timeZone: timezone,
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).format(d)} ${shortZone(timezone)}`
|
||||
} catch {
|
||||
return `${d.toISOString().slice(0, 16).replace('T', ' ')} UTC`
|
||||
}
|
||||
}
|
||||
|
||||
// The word beside an entry, for the four public statuses.
|
||||
//
|
||||
// **`cancelled` needs the instant, and that is the whole reason this is a
|
||||
// function rather than a lookup table.** The server publishes `failed` and
|
||||
// `missed` as `cancelled` too — to a visitor those three are one event, and the
|
||||
// difference between them is about the deployment — but the three do not share
|
||||
// one English sentence. "Did not happen" is right for a past occurrence and a
|
||||
// plain falsehood for a future one, and a run four days out that an operator has
|
||||
// called off is exactly the common case: the calendar was saying *did not
|
||||
// happen* about next Friday.
|
||||
//
|
||||
// So the tense follows the clock, not the status. A future call-off reads
|
||||
// **Cancelled**; a past one reads **Did not happen**, which is also the honest
|
||||
// word for the failed and missed runs folded in with it.
|
||||
const WORDS = {
|
||||
live: 'Happening now',
|
||||
scheduled: 'Scheduled',
|
||||
completed: 'Finished',
|
||||
}
|
||||
|
||||
export function statusWord(status, scheduledFor, now = Date.now()) {
|
||||
if (WORDS[status]) return WORDS[status]
|
||||
if (status !== 'cancelled') return status
|
||||
const at = new Date(scheduledFor).getTime()
|
||||
return Number.isNaN(at) || at <= now ? 'Did not happen' : 'Cancelled'
|
||||
}
|
||||
|
||||
// A live run's progress line (MODULE_API 1.12.0), as text. Core accepts two
|
||||
// shapes from a module and interprets neither: a count, "Bandits: 3 of 8 left",
|
||||
// and a percentage, "The Juggernaut: 62%". Anything else renders nothing.
|
||||
export function progressText(line) {
|
||||
if (!line || typeof line.label !== 'string' || !line.label) return ''
|
||||
if (Number.isInteger(line.left) && Number.isInteger(line.of)) return `${line.label}: ${line.left} of ${line.of} left`
|
||||
if (typeof line.percent === 'number') return `${line.label}: ${line.percent}%`
|
||||
return ''
|
||||
}
|
||||
27
client/src/lib/notice.js
Normal file
27
client/src/lib/notice.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// A one-line notice for the site-wide toast (components/DemoToast.jsx), for the
|
||||
// rare moment a screen has to say something after it has navigated away — the
|
||||
// account page cannot show "Your account has been deleted." on itself, because
|
||||
// the account it would be shown on no longer exists.
|
||||
//
|
||||
// The same shape as lib/demoNotice.js and drawn by the same toast, so the site
|
||||
// has one place a passing message appears.
|
||||
|
||||
const listeners = new Set()
|
||||
|
||||
/** Subscribe to notices. Returns the unsubscribe function. */
|
||||
export function onNotice(fn) {
|
||||
listeners.add(fn)
|
||||
return () => listeners.delete(fn)
|
||||
}
|
||||
|
||||
/** Show one notice. */
|
||||
export function showNotice(message) {
|
||||
if (typeof message !== 'string' || !message) return
|
||||
for (const fn of listeners) {
|
||||
try {
|
||||
fn(message)
|
||||
} catch {
|
||||
// A broken listener must not stop the caller.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,3 +19,17 @@ export const inboxPath = (user) => (isStaff(user) ? '/admin/notifications' : '/a
|
||||
/** The per-channel preferences screen. */
|
||||
export const notificationSettingsPath = (user) =>
|
||||
isStaff(user) ? '/admin/notifications/settings' : '/account/notifications/settings'
|
||||
|
||||
/**
|
||||
* This account's own event participation (events Phase 14a).
|
||||
*
|
||||
* The third screen to need this mapping, and it needed it for exactly the reason
|
||||
* the two above did: `GET /player/events/history` is behind `requireAuth` alone,
|
||||
* self-scoped on `req.user.id` — a staff member has a participation history like
|
||||
* anyone else, and the group's own header says staff are a superset of players.
|
||||
* The WEB is what disagrees, because `RequirePlayer` sends them to the login
|
||||
* page. Found the same way the notifications pair was: signed in as an admin,
|
||||
* the screen simply redirected.
|
||||
*/
|
||||
export const eventHistoryPath = (user) =>
|
||||
isStaff(user) ? '/admin/events/mine' : '/account/events'
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
//
|
||||
// Plain JS with tests, following lib/moduleAdmin.js. The reason it is worth
|
||||
// splitting here specifically: this screen's job is to tell an operator the
|
||||
// difference between "the shard has no Teams" and "core has not been able to ask
|
||||
// difference between "the game server has no Teams" and "core has not been able to ask
|
||||
// for two hours", and those two produce almost the same page. Getting that
|
||||
// wording right is logic, not markup.
|
||||
|
||||
import { markDeleted, rowName } from './deletedAccount.js'
|
||||
|
||||
/** Tones the screen uses. Names, not colours — the view maps them. */
|
||||
export const TONE = { ok: 'ok', warn: 'warn', bad: 'bad', idle: 'idle' }
|
||||
|
||||
@@ -31,7 +33,7 @@ export function freshnessOf(sync = {}) {
|
||||
return {
|
||||
tone: TONE.bad,
|
||||
label: 'Never synced',
|
||||
detail: 'Core has never had an answer it could trust. What is shown below is not a confirmed empty shard.',
|
||||
detail: 'Core has never had an answer it could trust. What is shown below does not confirm that the game server has no Teams.',
|
||||
}
|
||||
}
|
||||
if (sync.stale) {
|
||||
@@ -91,7 +93,7 @@ export const GATED_NOTE =
|
||||
/** A one-line description of a queued request, for the approval queue. */
|
||||
export function describeRequest(request = {}) {
|
||||
const payload = parsePayload(request.payload)
|
||||
const who = request.requested_username || 'a deleted user'
|
||||
const who = rowName(request.requested_by, request.requested_username, 'a deleted user')
|
||||
switch (request.action) {
|
||||
case 'unhide':
|
||||
return `${who} asks to publish “${request.team_name}”`
|
||||
@@ -133,7 +135,7 @@ export function leadershipOf(member = {}) {
|
||||
return {
|
||||
isLeader: granted,
|
||||
overridden: true,
|
||||
note: `${granted ? 'Granted' : 'Denied'} by ${member.leaderOverride.by || 'a deleted user'}`
|
||||
note: `${granted ? 'Granted' : 'Denied'} by ${markDeleted(member.leaderOverride.by, member.leaderOverride.byDeleted) || 'a deleted user'}`
|
||||
+ `${member.leaderOverride.reason ? ` — ${member.leaderOverride.reason}` : ''}`
|
||||
+ ` (the game says ${member.isLeaderSynced ? 'leader' : 'not a leader'})`,
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
// `created_at` when the write arrives. A client that granted one would be
|
||||
// deciding a time-bounded permission against the clock of the party it bounds.
|
||||
|
||||
import { markDeleted } from './deletedAccount.js'
|
||||
|
||||
export const REPORT_REASONS = [
|
||||
['abuse', 'Abusive or harassing'],
|
||||
['spam', 'Spam'],
|
||||
@@ -75,7 +77,7 @@ export function stripToText(html) {
|
||||
export function threadSummary(thread) {
|
||||
const parts = []
|
||||
if (thread.type === 'announcement') parts.push('Announcement')
|
||||
parts.push(thread.author)
|
||||
parts.push(markDeleted(thread.author, thread.authorDeleted))
|
||||
if (thread.type === 'discussion' && thread.postCount > 1) {
|
||||
const replies = thread.postCount - 1
|
||||
parts.push(`${replies} ${replies === 1 ? 'reply' : 'replies'}`)
|
||||
|
||||
35
client/src/lib/userSafety.js
Normal file
35
client/src/lib/userSafety.js
Normal file
@@ -0,0 +1,35 @@
|
||||
// Report, block and hide — the parts of the screens that are rules rather than
|
||||
// markup (docs/website/USER_SAFETY.md §1, §4.1). Plain JS so the client's
|
||||
// node --test suite can hold them.
|
||||
|
||||
function fmtDate(s) {
|
||||
if (!s) return ''
|
||||
const d = new Date(s)
|
||||
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
/**
|
||||
* The second line of a row on Account → Blocked: what was blocked, and when.
|
||||
* An account block is the normal one (S12); a game block names the identity
|
||||
* and says it is not linked to an account yet, since that is why it is a Steam
|
||||
* id and not a username.
|
||||
*/
|
||||
export function blockSubtitle(block) {
|
||||
const parts = []
|
||||
if (block.kind === 'game' && block.identity) {
|
||||
parts.push(`${block.identity.label} ${block.identity.id}, not linked to an account`)
|
||||
} else {
|
||||
parts.push('Account')
|
||||
}
|
||||
const when = fmtDate(block.createdAt)
|
||||
if (when) parts.push(`blocked ${when}`)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
/**
|
||||
* May the Reports queue offer "Hide for everyone" on this report? Not on an
|
||||
* upload — it is shown inside a post, and hiding that post is the act — and not
|
||||
* once this report has already hidden its item.
|
||||
*/
|
||||
export const canHideForEveryone = (report) =>
|
||||
report.targetType !== 'team_forum_upload' && report.actionedWith !== 'hide'
|
||||
@@ -5,6 +5,8 @@ import { api } from '../api/client.js'
|
||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../contexts/SiteContext.jsx'
|
||||
import { REPORT_REASONS, editOfferOpen, stripToText, threadSummary } from '../lib/teamForum.js'
|
||||
// A member is sent "[deleted player]"; staff the stored name, which this marks.
|
||||
import { markDeleted } from '../lib/deletedAccount.js'
|
||||
|
||||
// Core's Team forum, rendered into a second slot a MODULE declares
|
||||
// (TEAMS.md Part 5, and the phase 3 amendment to §3.4).
|
||||
@@ -124,6 +126,10 @@ export default function TeamForumPanel({ externalId, moduleId }) {
|
||||
imageMode={imageMode}
|
||||
onBack={() => openThread(null)}
|
||||
onChanged={() => loadThread(team.slug, thread.id)}
|
||||
onGone={async () => {
|
||||
await loadThreads(team.slug)
|
||||
openThread(null)
|
||||
}}
|
||||
onModerate={async (action) => {
|
||||
await api.teamForumModerate(team.slug, thread.id, { action })
|
||||
await loadThreads(team.slug)
|
||||
@@ -309,7 +315,7 @@ function GuestManager({ slug }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ThreadView({ slug, thread, canModerate, imageMode, onBack, onChanged, onModerate }) {
|
||||
function ThreadView({ slug, thread, canModerate, imageMode, onBack, onChanged, onGone, onModerate }) {
|
||||
// A clock that ticks, so an edit control whose deadline passed while the page
|
||||
// sat open goes away instead of becoming a button that fails. It only ever
|
||||
// REMOVES an offer — the server decides whether an edit happens, and re-derives
|
||||
@@ -332,8 +338,7 @@ function ThreadView({ slug, thread, canModerate, imageMode, onBack, onChanged, o
|
||||
</h2>
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 14px' }}>
|
||||
{thread.type === 'announcement' ? 'Announcement · ' : ''}
|
||||
{thread.author}
|
||||
{thread.authorDeleted && ' (account removed)'}
|
||||
{markDeleted(thread.author, thread.authorDeleted)}
|
||||
{thread.locked && ' · locked'}
|
||||
</p>
|
||||
|
||||
@@ -385,6 +390,15 @@ function ThreadView({ slug, thread, canModerate, imageMode, onBack, onChanged, o
|
||||
targetId={thread.id}
|
||||
label="Report this thread"
|
||||
/>
|
||||
{/* A thread this reader hides, or whose author they block, is gone
|
||||
from their view, so the panel goes back to the list. */}
|
||||
<SafetyControls
|
||||
targetType="team_forum_thread"
|
||||
targetId={thread.id}
|
||||
author={thread.author}
|
||||
canBlock={thread.canBlock}
|
||||
onDone={onGone}
|
||||
/>
|
||||
{canModerate && (
|
||||
<>
|
||||
<button type="button" className="pill" onClick={() => onModerate(thread.pinned ? 'unpin' : 'pin')}>
|
||||
@@ -448,8 +462,7 @@ function PostView({ slug, post, canModerate, now, onChanged }) {
|
||||
return (
|
||||
<article style={{ marginBottom: 16 }}>
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 2px' }}>
|
||||
{post.author}
|
||||
{post.authorDeleted && ' (account removed)'}
|
||||
{markDeleted(post.author, post.authorDeleted)}
|
||||
{post.editedAt && ' · edited'}
|
||||
{post.status === 'hidden' && ' · hidden'}
|
||||
</p>
|
||||
@@ -515,6 +528,15 @@ function PostView({ slug, post, canModerate, now, onChanged }) {
|
||||
label="Report"
|
||||
/>
|
||||
)}
|
||||
{!post.mine && (
|
||||
<SafetyControls
|
||||
targetType="team_forum_post"
|
||||
targetId={post.id}
|
||||
author={post.author}
|
||||
canBlock={post.canBlock}
|
||||
onDone={onChanged}
|
||||
/>
|
||||
)}
|
||||
{canModerate && (
|
||||
<>
|
||||
<button type="button" className="pill" onClick={() => moderate(post.status === 'hidden' ? 'unhide' : 'hide')}>
|
||||
@@ -529,6 +551,76 @@ function PostView({ slug, post, canModerate, now, onChanged }) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* "Hide for me" and "Block author" (USER_SAFETY.md §1).
|
||||
*
|
||||
* Both are the reader's own tools and both are saved on their account, so they
|
||||
* hold on every device and in the app. Neither files a report or changes the
|
||||
* item for anyone else, and the blocked person is not told (S9) — the
|
||||
* confirmation says so, because a member deciding whether to block someone is
|
||||
* usually deciding whether it will start something.
|
||||
*
|
||||
* `canBlock` is the server's answer (S8: never yourself, a deleted account or
|
||||
* staff); this offers Block only when it is true. "Hide for me" works on any
|
||||
* single item, a staff member's included.
|
||||
*/
|
||||
function SafetyControls({ targetType, targetId, author, canBlock, onDone }) {
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const run = async (call) => {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await call({ targetType, targetId })
|
||||
setConfirming(false)
|
||||
await onDone()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not do that')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (confirming) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid', gap: 8, marginTop: 8, padding: 12, width: '100%',
|
||||
border: '1px solid var(--rule, #ccc)', borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
<p className="sans" style={{ fontSize: '0.85rem', margin: 0 }}>
|
||||
Block <strong>{author}</strong>? You won’t see anything they write, here or in the app.
|
||||
They aren’t told. You can undo it from <em>Account → Blocked</em>.
|
||||
</p>
|
||||
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem', margin: 0 }}>{error}</p>}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={() => run(api.blockAuthor)}>
|
||||
Block
|
||||
</button>
|
||||
<button type="button" className="pill" onClick={() => setConfirming(false)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" className="pill" disabled={busy} onClick={() => run(api.hideItem)}>
|
||||
Hide for me
|
||||
</button>
|
||||
{canBlock && (
|
||||
<button type="button" className="pill" onClick={() => setConfirming(true)}>
|
||||
Block author
|
||||
</button>
|
||||
)}
|
||||
{error && <span className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.8rem' }}>{error}</span>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The report control — the first user-facing report flow this site has ever had.
|
||||
*
|
||||
|
||||
@@ -11,6 +11,62 @@
|
||||
// that the two files can drift, so a test asserts they agree
|
||||
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
|
||||
// both.
|
||||
// 1.21.0 — report, block and hide reach a module's content (USER_SAFETY.md
|
||||
// §4.1): `api.registerReportTargets`, `api.registerGameIdentities`,
|
||||
// `ctx.safety`. Server-side: a module filters its own feeds per reader on the
|
||||
// server (S11), and its client half calls core's ordinary `/player/reports` and
|
||||
// `/auth/me/blocks` routes through the shared request primitive. This file bumps
|
||||
// for the reason at the top.
|
||||
// 1.20.0 — `api.onUserDeleting` (ACCOUNT_DELETION.md §4.1, AD10): a module
|
||||
// unlinks a person's game links before their account is deleted. Server-side
|
||||
// only. This file bumps for the reason at the top.
|
||||
// 1.19.0 — an event action may declare `demoRefuses(params)` (DEMO_MODE.md §3.4,
|
||||
// DM18): the demo refuses a step at save. Server-side only; the refusal reaches
|
||||
// the browser as an ordinary DEMO_LOCKED answer. This file bumps for the reason at the top.
|
||||
// 1.18.0 — the public demo (DEMO_MODE.md §3.2): `demoLocks` in module.json,
|
||||
// `ctx.demoMode`, `api.onDemoSeed`. Server-side; the client half is core's own
|
||||
// banner and refusal toast, which an installed module's requests reach through
|
||||
// the shared request primitive. This file bumps for the reason at the top.
|
||||
// 1.17.0 — a module may take part in linking a sign-in identity (STEAM_SIGNIN.md
|
||||
// §4): `ctx.identities.list`, `api.onIdentityLinking`, `api.onIdentityLinked`.
|
||||
// Server-side; the client half is core's own account pages showing Steam. This
|
||||
// file bumps for the reason at the top.
|
||||
// 1.16.0 — a dry run's `detail.warnings` (Rust PLAN_REDESIGNS §11.4, D391): a
|
||||
// module's advice, shown by core's own event editor as warning findings.
|
||||
// Server-side; this file bumps for the reason at the top.
|
||||
// 1.15.0 — a failed step's `retryAfterMs` (Rust PLAN_REDESIGNS §14.3.13): when
|
||||
// to try again. Server-side; the run console's retry line now says when. This
|
||||
// file bumps for the reason at the top.
|
||||
// 1.14.0 —an action param's `multiple` (Rust PLAN_REDESIGNS §12.3): a param
|
||||
// that holds several values, drawn by core's step editor as a multi-select.
|
||||
// Server-side declaration; this file bumps for the reason at the top.
|
||||
// 1.13.0 — the event step editor (Rust PLAN_REDESIGNS §2): event templates, a
|
||||
// param's `duration`, a lease's `example` and an option's `note`. All four are
|
||||
// server-side declarations the editor, core's own page, renders. This file bumps
|
||||
// for the reason at the top.
|
||||
// 1.12.0 — an event action's `progress()`: a live run's public page shows how
|
||||
// its steps stand. Server-side registration; the client half is core's own event
|
||||
// page rendering the lines. This file bumps for the reason at the top.
|
||||
// 1.11.0 — `ctx.events.expired({ kind, ref })` and the ledger's `expired` status
|
||||
// (Rust PLAN_FIXES D183): a module may say the game ended a ledgered resource at
|
||||
// its own deadline. Server-side only; the run console, which is core's own page,
|
||||
// shows the new status. This file bumps for the reason at the top.
|
||||
// 1.10.0 — the event contract opens to modules (EVENTS.md §F, EVENTS_PLAN.md
|
||||
// Phase 7): a module may register event actions, budget dimensions, leases and
|
||||
// param option sources. All four are server-side registrations and nothing on
|
||||
// `window.__rg` changed — but what they produce is met on this half, in the step
|
||||
// editor: an option source is what turns a param from a text box into a dropdown
|
||||
// of real values, and a budget's label and unit are what the switchboard's cap
|
||||
// box says beside its number. This file bumps for the reason at the top: the two
|
||||
// halves state ONE version, and a module declares one `coreApi` range against
|
||||
// both.
|
||||
// 1.9.0 - a module may ship its own message bodies and rules:
|
||||
// `api.registerEngagementSeeds({ templates, ruleGroups })` (ENGAGEMENT.md Phase
|
||||
// 11b, decision 7). Nothing on this half changed - a seed is server-side data
|
||||
// and core's seeders write it on the boot path - but the bodies it ships are
|
||||
// edited through the template editor this half already renders, and an operator
|
||||
// meets them there. This file bumps for the reason at the top: the two halves
|
||||
// state ONE version, and a module declares one `coreApi` range against both.
|
||||
// 1.8.0 - the ceiling lattice gains `admin` (ENGAGEMENT.md Phase 11). Nothing on
|
||||
// this half changed: a ceiling is declared on the server's `api` and enforced
|
||||
// there, and the admin screens that render one read the vocabulary from
|
||||
@@ -58,4 +114,4 @@
|
||||
// but the two halves state ONE version: a module declares a single coreApi range
|
||||
// and is served one chunk, so a client that claimed 1.0.0 while the server
|
||||
// answered 1.1.0 would be two answers to one question.
|
||||
export const MODULE_API_VERSION = '1.8.0'
|
||||
export const MODULE_API_VERSION = '1.21.0'
|
||||
|
||||
@@ -53,6 +53,7 @@ const IconList = () => <Icon><path d="M8 6h13M8 12h13M8 18h13" /><circle cx="4"
|
||||
const IconTemplate = () => <Icon><rect x="4" y="3" width="16" height="18" rx="2" /><path d="M8 8h8M8 12h8M8 16h4" /></Icon>
|
||||
const IconSpark = () => <Icon><path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z" /><path d="M18 16l.9 2.1L21 19l-2.1.9L18 22l-.9-2.1L15 19l2.1-.9z" /></Icon>
|
||||
const IconLog = () => <Icon><path d="M4 5h16v14H4z" /><path d="M8 9h8M8 12h8M8 15h5" /></Icon>
|
||||
const IconCalendar = () => <Icon><rect x="3" y="5" width="18" height="16" rx="2" /><path d="M3 10h18M8 3v4M16 3v4" /><circle cx="12" cy="15" r="1.4" /></Icon>
|
||||
|
||||
// Nav is grouped into collapsible categories. A group with no `title` renders
|
||||
// its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles`
|
||||
@@ -113,6 +114,40 @@ export const NAV = [
|
||||
// "did that message go out", and this answers "why is this person not
|
||||
// getting any" - and it is the only screen that can lift a suppression.
|
||||
{ to: '/admin/engagement/suppressions', label: 'Suppressions', icon: IconLog, roles: ['admin'] },
|
||||
// Last in the group because it is the one screen nobody visits weekly, and
|
||||
// beside Suppressions on purpose: it is where the reader is told that the
|
||||
// fourth engagement table does NOT expire, which is otherwise a silence
|
||||
// that reads as an oversight.
|
||||
{ to: '/admin/engagement/retention', label: 'Retention', icon: IconGear, roles: ['admin'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
// Its own top-level group rather than a row under Content, and staff-wide
|
||||
// rather than admin-only. Both follow EVENTS.md §K: every read here is
|
||||
// `staff`, and the moderator's entire power over this feature is the run
|
||||
// console — the thing they open when an event is doing something wrong at
|
||||
// 2am. Hiding it from them would leave the one role that exists for incident
|
||||
// response unable to see the incident. The narrower gates live on the
|
||||
// actions: authoring is admin+editor and publish/start are admin only, both
|
||||
// enforced server-side and mirrored on the buttons.
|
||||
title: 'Events',
|
||||
items: [
|
||||
{ to: '/admin/events', label: 'Events', icon: IconCalendar, roles: ['admin', 'editor', 'moderator'] },
|
||||
// Phase 4. The same staff gate as the list beside it: a calendar is a read,
|
||||
// and the arcs it manages are authoring gated on the buttons rather than
|
||||
// on the row.
|
||||
{ to: '/admin/events/calendar', label: 'Calendar', icon: IconCalendar, roles: ['admin', 'editor', 'moderator'] },
|
||||
// Phase 6, and the one row in this group that is NOT staff-wide. §K puts
|
||||
// the switchboard in the same row as the world-changing actions it
|
||||
// governs: what a deployment permits at all is configuration, not a read,
|
||||
// and the server gates both the GET and the PUT on `admin`.
|
||||
{ to: '/admin/events/actions', label: 'Actions', icon: IconGear, roles: ['admin'] },
|
||||
// Phase 14a, and the one row here that is not about running the
|
||||
// deployment: it is this staff member's OWN attendance, the same screen
|
||||
// and the same route a player reads at /account/events. It has no `roles`
|
||||
// because it needs none — every account has a participation history, and
|
||||
// the server scopes it to the caller.
|
||||
{ to: '/admin/events/mine', label: 'My participation', icon: IconCalendar },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -196,6 +231,12 @@ const TITLES = {
|
||||
'/admin/engagement/triggers': 'Triggers',
|
||||
'/admin/engagement/suppressions': 'Suppressions',
|
||||
'/admin/engagement/sends': 'Send Log',
|
||||
'/admin/engagement/retention': 'Retention',
|
||||
'/admin/events': 'Events',
|
||||
'/admin/events/calendar': 'Event calendar',
|
||||
'/admin/events/actions': 'Event actions',
|
||||
'/admin/events/mine': 'My participation',
|
||||
'/admin/events/new': 'New event',
|
||||
}
|
||||
|
||||
// An installed module's admin pages are not in TITLES and cannot be — core does
|
||||
@@ -216,6 +257,10 @@ function sectionTitle(pathname) {
|
||||
if (pathname.startsWith('/admin/moderation')) return 'Moderation'
|
||||
if (pathname.startsWith('/admin/users/')) return 'User'
|
||||
if (pathname.startsWith('/admin/engagement')) return 'Engagement'
|
||||
// /admin/events/:id and /admin/events/runs/:runId are both dynamic, and both
|
||||
// belong to the same section as far as the page title is concerned.
|
||||
if (pathname.startsWith('/admin/events/runs/')) return 'Event run'
|
||||
if (pathname.startsWith('/admin/events/')) return 'Event'
|
||||
return 'Admin'
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,9 @@ import RecoveryCodesDisplay from '../../../components/security/RecoveryCodesDisp
|
||||
import TrustedDevicesPanel from '../../../components/security/TrustedDevicesPanel.jsx'
|
||||
import RecoveryCodesPanel from '../../../components/security/RecoveryCodesPanel.jsx'
|
||||
import EmailAddressPanel from '../../../components/security/EmailAddressPanel.jsx'
|
||||
import DeleteAccountPanel from '../../../components/security/DeleteAccountPanel.jsx'
|
||||
import BlockedPanel from '../../../components/security/BlockedPanel.jsx'
|
||||
import { IdentitySummary, linkBanner, linkableProviders, unlinkPrompt } from '../../../components/security/linkedIdentities.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Link/unlink external SSO identities to this account. Linking redirects through
|
||||
@@ -15,13 +18,7 @@ function LinkedAccounts() {
|
||||
const [available, setAvailable] = useState([])
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const banner = (() => {
|
||||
const q = new URLSearchParams(window.location.search)
|
||||
if (q.get('linked')) return { ok: true, text: 'Account linked.' }
|
||||
if (q.get('link_error') === 'in_use') return { ok: false, text: 'That external account is already linked to another user.' }
|
||||
if (q.get('link_error')) return { ok: false, text: 'Could not link that account. Please try again.' }
|
||||
return null
|
||||
})()
|
||||
const banner = linkBanner(window.location.search)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -40,12 +37,11 @@ function LinkedAccounts() {
|
||||
}, [load])
|
||||
|
||||
const nameFor = (id) => available.find((p) => p.id === id)?.name || id.charAt(0).toUpperCase() + id.slice(1)
|
||||
const iconFor = (id) => (id === 'google' || id === 'discord' ? id : 'oidc')
|
||||
|
||||
async function unlink(provider) {
|
||||
if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return
|
||||
async function unlink(identity) {
|
||||
if (!window.confirm(unlinkPrompt(identity, nameFor(identity.provider)))) return
|
||||
try {
|
||||
await api.unlinkIdentity(provider)
|
||||
await api.unlinkIdentity(identity.provider, identity.id)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not unlink.')
|
||||
@@ -55,8 +51,7 @@ function LinkedAccounts() {
|
||||
if (error) return <ErrorState message={error} />
|
||||
if (!linked) return null
|
||||
|
||||
const linkedIds = new Set(linked.map((i) => i.provider))
|
||||
const linkable = available.filter((p) => !linkedIds.has(p.id))
|
||||
const linkable = linkableProviders(available, linked)
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 28 }}>
|
||||
@@ -64,7 +59,7 @@ function LinkedAccounts() {
|
||||
Linked accounts
|
||||
</h2>
|
||||
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
|
||||
Link a Google, Discord, or other SSO account so you can sign in with it. SSO can only sign in
|
||||
Link a Google, Discord, Steam, or other SSO account so you can sign in with it. SSO can only sign in
|
||||
to an account it is linked to — linking here is what grants that access.
|
||||
</p>
|
||||
|
||||
@@ -77,15 +72,9 @@ function LinkedAccounts() {
|
||||
{linked.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
|
||||
{linked.map((i) => (
|
||||
<div key={i.provider} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||
<span style={{ display: 'inline-flex', width: 20, height: 20 }}>
|
||||
<ProviderIcon icon={iconFor(i.provider)} size={20} />
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>{nameFor(i.provider)}</div>
|
||||
{i.email && <div className="sans dim" style={{ fontSize: '0.78rem' }}>{i.email}</div>}
|
||||
</div>
|
||||
<button onClick={() => unlink(i.provider)} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
|
||||
<div key={i.id ?? i.provider} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||
<IdentitySummary identity={i} providerName={nameFor(i.provider)} />
|
||||
<button onClick={() => unlink(i)} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
|
||||
Unlink
|
||||
</button>
|
||||
</div>
|
||||
@@ -328,6 +317,14 @@ export default function AccountAdmin() {
|
||||
{account && <EmailAddressPanel account={account} reload={load} />}
|
||||
|
||||
<LinkedAccounts />
|
||||
|
||||
{/* Blocks and hides are the member's own (USER_SAFETY.md §1); staff are
|
||||
members too, and the list is the one the app shows. */}
|
||||
<BlockedPanel />
|
||||
|
||||
{/* The same section the player portal shows: every member can delete
|
||||
themself, staff included (ACCOUNT_DELETION.md §1). */}
|
||||
{account && <DeleteAccountPanel account={account} />}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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's domain. It is used only to show a linked account'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>
|
||||
)
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { useAsync } from '../../../lib/useAsync.js'
|
||||
import { ago, dateTime } from '../../../lib/format.js'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { markDeleted } from '../../../lib/deletedAccount.js'
|
||||
import { canHideForEveryone } from '../../../lib/userSafety.js'
|
||||
|
||||
// The member-raised content-report queue (TEAMS.md §5.6).
|
||||
//
|
||||
@@ -20,10 +22,20 @@ import { api } from '../../../api/client.js'
|
||||
// row here rather than as a new screen.
|
||||
//
|
||||
// **Handling a report is bookkeeping about the REPORT, not moderation of the
|
||||
// content.** Acting on the content itself is the ordinary forum moderation
|
||||
// control, or a site-wide sanction against the account. Keeping those separate is
|
||||
// what stops "report" from becoming a way for any member to hide anything, so
|
||||
// this screen deliberately offers no hide/delete button of its own.
|
||||
// content.** Keeping those separate is what stops "report" from becoming a way
|
||||
// for any member to hide anything.
|
||||
//
|
||||
// **The one exception is "Hide for everyone"** (USER_SAFETY.md §4.1, S3), and it
|
||||
// is a separate button with its own words, not a status. It hides the item
|
||||
// through the item's OWN moderation — the forum's hide, or a game module's for a
|
||||
// chat line — so it is a staff moderation recorded where that content's
|
||||
// moderation is recorded, and the report is actioned with that named. Undoing it
|
||||
// happens where the item lives, where staff still see it marked (S10). Delete
|
||||
// stays the forum's.
|
||||
//
|
||||
// Since 1.21.0 a report can be about a game module's item (a Rust chat line),
|
||||
// and every new report carries a snapshot of what the item said, which is what
|
||||
// this screen shows once the item itself is gone.
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ key: 'open_work', label: 'Open work', param: undefined },
|
||||
@@ -70,12 +82,44 @@ const bytes = (n) => {
|
||||
* was gone" is a fact worth seeing, and dropping it would hide the pattern of a
|
||||
* member deleting their own content the moment it is reported.
|
||||
*/
|
||||
function SnapshotLine({ snapshot }) {
|
||||
// An unlinked chat speaker is named by their in-game name and Steam id; the
|
||||
// queue is staff-only, and the id is what a staffer acts on in game.
|
||||
const who = markDeleted(snapshot.author, snapshot.authorDeleted) || 'unknown'
|
||||
const idNote = snapshot.authorIdentity ? ` (${snapshot.authorIdentity.kind} ${snapshot.authorIdentity.id})` : ''
|
||||
return (
|
||||
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
|
||||
{snapshot.label ? `${snapshot.label} · ` : ''}{who}{idNote}
|
||||
{snapshot.where?.server ? ` on ${snapshot.where.server}` : ''}
|
||||
{snapshot.at ? ` · ${dateTime(snapshot.at)}` : ''}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function TargetCell({ report }) {
|
||||
const t = report.target
|
||||
const snap = report.snapshot
|
||||
if (!t) {
|
||||
return (
|
||||
<span style={{ color: 'var(--muted)' }}>
|
||||
{report.targetType.replace('team_forum_', '')} #{report.targetId} — no longer exists
|
||||
{/* What it said when it was reported, kept on the report (1.21.0). */}
|
||||
{snap && (
|
||||
<>
|
||||
<span style={{ display: 'block', color: 'var(--text)' }}>{snap.text || <em>(no text)</em>}</span>
|
||||
<SnapshotLine snapshot={snap} />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (t.kind === 'module') {
|
||||
return (
|
||||
<span>
|
||||
{t.text || <em className="dim">(no text)</em>}
|
||||
{snap ? <SnapshotLine snapshot={snap} /> : (
|
||||
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>{t.label}</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -84,7 +128,7 @@ function TargetCell({ report }) {
|
||||
<span>
|
||||
<a href={t.url} target="_blank" rel="noopener noreferrer" className="link-accent">{t.filename}</a>
|
||||
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
|
||||
{t.uploader || 'unknown'} · {t.mimetype} · {bytes(t.byteSize)}
|
||||
{markDeleted(t.uploader, t.uploaderDeleted) || 'unknown'} · {t.mimetype} · {bytes(t.byteSize)}
|
||||
{t.deleted && ' · removed'}
|
||||
</span>
|
||||
</span>
|
||||
@@ -95,7 +139,7 @@ function TargetCell({ report }) {
|
||||
<span>
|
||||
<strong>{t.title}</strong>
|
||||
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
|
||||
{t.type} by {t.author || 'unknown'}
|
||||
{t.type} by {markDeleted(t.author, t.authorDeleted) || 'unknown'}
|
||||
{t.status !== 'visible' && ` · ${t.status}`}
|
||||
</span>
|
||||
</span>
|
||||
@@ -105,7 +149,7 @@ function TargetCell({ report }) {
|
||||
<span>
|
||||
{t.excerpt || <em className="dim">(no text)</em>}
|
||||
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
|
||||
{t.author || 'unknown'} in “{t.threadTitle}”
|
||||
{markDeleted(t.author, t.authorDeleted) || 'unknown'} in “{t.threadTitle}”
|
||||
{t.status !== 'visible' && ` · ${t.status}`}
|
||||
</span>
|
||||
</span>
|
||||
@@ -133,10 +177,11 @@ export default function ContentReports() {
|
||||
return (
|
||||
<section>
|
||||
<p className="sans dim" style={{ margin: '0 0 14px', fontSize: '0.85rem', maxWidth: 720 }}>
|
||||
Reports raised by members about Team forum content. They come to site staff and are not visible
|
||||
to a Team’s own leaders — a leader moderates their own forum, so a report about a leader
|
||||
has to reach someone above them. Handling a report records a decision about the report; hiding
|
||||
or removing the content itself is done from the forum, or as a sanction against the account.
|
||||
Reports raised by members about Team forum content and in-game chat. They come to site staff and
|
||||
are not visible to a Team’s own leaders — a leader moderates their own forum, so a report
|
||||
about a leader has to reach someone above them. Handling a report records a decision about the
|
||||
report. <em>Hide for everyone</em> also hides the item, for everyone but staff; removing it is
|
||||
done from the forum, or as a sanction against the account.
|
||||
{typeof data?.openCount === 'number' && ` ${data.openCount} open.`}
|
||||
</p>
|
||||
|
||||
@@ -192,13 +237,16 @@ export default function ContentReports() {
|
||||
<span className="badge">{REASON_LABEL[r.reason] || r.reason}</span>
|
||||
</td>
|
||||
<td className="adm-td dim" style={{ maxWidth: 260 }}>{r.detail || '—'}</td>
|
||||
<td className="adm-td dim">{r.reporter}</td>
|
||||
<td className="adm-td dim">{markDeleted(r.reporter, r.reporterDeleted)}</td>
|
||||
<td className="adm-td dim" title={dateTime(r.createdAt)}>{ago(r.createdAt)}</td>
|
||||
<td className="adm-td">
|
||||
<span className="badge" style={STATUS_STYLE[r.status]}>{STATUS_LABEL[r.status] || r.status}</span>
|
||||
{r.actionedWith === 'hide' && (
|
||||
<span className="dim" style={{ display: 'block', fontSize: '0.75rem' }}>Hidden for everyone</span>
|
||||
)}
|
||||
{r.handledBy && (
|
||||
<span className="dim" style={{ display: 'block', fontSize: '0.75rem' }}>
|
||||
{r.handledBy}
|
||||
{markDeleted(r.handledBy, r.handledByDeleted)}
|
||||
{r.handledNote ? ` — ${r.handledNote}` : ''}
|
||||
</span>
|
||||
)}
|
||||
@@ -222,9 +270,9 @@ export default function ContentReports() {
|
||||
<HandleModal
|
||||
report={handling}
|
||||
onCancel={() => setHandling(null)}
|
||||
onDone={() => {
|
||||
onDone={(text = 'Report updated.') => {
|
||||
setHandling(null)
|
||||
setNotice({ text: 'Report updated.', tone: 'ok' })
|
||||
setNotice({ text, tone: 'ok' })
|
||||
reload()
|
||||
}}
|
||||
onError={(message) => setNotice({ text: message, tone: 'error' })}
|
||||
@@ -257,6 +305,17 @@ function HandleModal({ report, onCancel, onDone, onError }) {
|
||||
}
|
||||
}
|
||||
|
||||
const hideForEveryone = async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.admin.hideReportedItem(report.id, { note: note || undefined })
|
||||
onDone('Hidden for everyone, and the report is actioned.')
|
||||
} catch (err) {
|
||||
onError(err.message || 'Could not hide that.')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`Report #${report.id}`}
|
||||
@@ -264,6 +323,11 @@ function HandleModal({ report, onCancel, onDone, onError }) {
|
||||
footer={(
|
||||
<>
|
||||
<button className="pill" onClick={onCancel}>Cancel</button>
|
||||
{canHideForEveryone(report) && (
|
||||
<button className="pill" onClick={hideForEveryone} disabled={busy} style={{ color: '#d98b84', borderColor: '#d98b84' }}>
|
||||
Hide for everyone
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-primary btn-sq" onClick={submit} disabled={busy}>
|
||||
{busy ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
@@ -272,8 +336,12 @@ function HandleModal({ report, onCancel, onDone, onError }) {
|
||||
>
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>
|
||||
This records a decision about the report. It does not hide, delete or restore the content —
|
||||
do that from the forum itself, or against the account.
|
||||
<strong>Save</strong> records a decision about the report and changes nothing about the content.
|
||||
{' '}
|
||||
{canHideForEveryone(report)
|
||||
? <><strong>Hide for everyone</strong> hides the item for everyone but staff, who still see it marked
|
||||
where it is and can unhide it there, and actions this report.</>
|
||||
: 'Deleting or restoring the content is done from the forum itself, or against the account.'}
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
230
client/src/routes/admin/views/EngagementRetention.jsx
Normal file
230
client/src/routes/admin/views/EngagementRetention.jsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Admin → Engagement → Retention (ENGAGEMENT.md Phase 14).
|
||||
//
|
||||
// Three of the four engagement tables grew on every fire and nothing had ever
|
||||
// deleted from any of them. This screen is the policy: how long the deployment
|
||||
// keeps a cooldown row, a finished outbox row and a send-log entry.
|
||||
//
|
||||
// **Why it is a screen, when the other two retention workers in this codebase
|
||||
// (`team_activity`, `user_notifications`) are invisible settings rows.** The
|
||||
// send-log horizon changes what an operator-facing page is *able to show* — the
|
||||
// Send Log is the only answer to "was this person told" — so an operator has to
|
||||
// be able to see it and set it, not discover it by finding rows missing. Having
|
||||
// made one visible, hiding the other two would be the worse split: "what does
|
||||
// this deployment keep" is one question and deserves one answer.
|
||||
//
|
||||
// **The fourth table is on this page as prose, not as a control.** Suppressions
|
||||
// do not expire (org lead, 2026-09-01), and saying so here is the point: an
|
||||
// operator reading a retention screen that lists three tables would reasonably
|
||||
// assume the fourth was an oversight.
|
||||
|
||||
const FIELDS = [
|
||||
{
|
||||
name: 'sends',
|
||||
label: 'Send log',
|
||||
table: 'engagement_sends',
|
||||
// The one horizon the org lead asked to be pickable rather than typed —
|
||||
// and `custom` stays, because a deployment with a compliance answer to
|
||||
// give should not be limited to three numbers somebody chose.
|
||||
presets: [90, 180, 365],
|
||||
help:
|
||||
'One row per delivery attempt. This is what Admin → Engagement → Send Log reads, so the '
|
||||
+ 'horizon is also how far back "was this person told" can be answered. The per-rule hourly '
|
||||
+ 'ceiling counts this table too, which is why it can never go below a week.',
|
||||
},
|
||||
{
|
||||
name: 'cooldowns',
|
||||
label: 'Cooldowns',
|
||||
table: 'engagement_cooldowns',
|
||||
presets: [7, 30, 90],
|
||||
help:
|
||||
'One row per rule, user, subject and channel, written on every fire. Deleting a row that '
|
||||
+ 'is still in force makes the next fire count as a first fire — that is a duplicate '
|
||||
+ 'message — so this must stay longer than the longest cooldown on any enabled rule.',
|
||||
},
|
||||
{
|
||||
name: 'outbox',
|
||||
label: 'Outbox',
|
||||
table: 'engagement_outbox',
|
||||
presets: [7, 30, 90],
|
||||
help:
|
||||
'Only finished rows are ever removed: sent, failed, cancelled and not-sent. A scheduled '
|
||||
+ 'row is a message this deployment still intends to send and is never swept, however old '
|
||||
+ 'the horizon.',
|
||||
},
|
||||
]
|
||||
|
||||
export default function EngagementRetention() {
|
||||
const [policy, setPolicy] = useState(null)
|
||||
const [limits, setLimits] = useState({})
|
||||
const [warnings, setWarnings] = useState([])
|
||||
const [longestCooldown, setLongestCooldown] = useState(0)
|
||||
const [draft, setDraft] = useState({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [note, setNote] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const apply = useCallback((result) => {
|
||||
setPolicy(result.retention)
|
||||
setDraft(result.retention)
|
||||
setLimits(result.limits || {})
|
||||
setWarnings(result.warnings || [])
|
||||
setLongestCooldown(result.longestCooldownSeconds || 0)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
;(async () => {
|
||||
try {
|
||||
const result = await api.admin.getEngagementRetention()
|
||||
if (alive) apply(result)
|
||||
} catch (err) {
|
||||
if (alive) setError(err.message)
|
||||
} finally {
|
||||
if (alive) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => { alive = false }
|
||||
}, [apply])
|
||||
|
||||
async function save() {
|
||||
setSaving(true)
|
||||
setNote(null)
|
||||
try {
|
||||
// The whole draft, not the changed field: this screen is the one place the
|
||||
// three are set together, and a partial save would leave the warning line
|
||||
// (which is computed from the cooldown horizon) describing a policy that is
|
||||
// half saved. The route itself is sparse, so sending three is legal.
|
||||
const result = await api.admin.setEngagementRetention(draft)
|
||||
apply(result)
|
||||
setNote('Saved.')
|
||||
} catch (err) {
|
||||
setNote(err.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
const dirty = policy && FIELDS.some((f) => Number(draft[f.name]) !== Number(policy[f.name]))
|
||||
|
||||
return (
|
||||
<section>
|
||||
<p className="sans dim" style={{ fontSize: '0.88rem', maxWidth: 720, marginTop: 0 }}>
|
||||
How long this deployment keeps the engagement system’s own records. A nightly sweep
|
||||
removes anything older, in batches, and skips a table it cannot read rather than failing
|
||||
the run.
|
||||
</p>
|
||||
|
||||
{warnings.map((w) => (
|
||||
<p
|
||||
key={w}
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.85rem',
|
||||
maxWidth: 720,
|
||||
padding: '10px 12px',
|
||||
borderLeft: '3px solid #d98b84',
|
||||
background: 'rgba(217, 139, 132, 0.08)',
|
||||
}}
|
||||
>
|
||||
{w}
|
||||
</p>
|
||||
))}
|
||||
|
||||
<div style={{ display: 'grid', gap: 22, maxWidth: 720, marginTop: 20 }}>
|
||||
{FIELDS.map((f) => {
|
||||
const spec = limits[f.name] || {}
|
||||
const value = draft[f.name] ?? ''
|
||||
const isPreset = f.presets.includes(Number(value))
|
||||
return (
|
||||
<div key={f.name}>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'baseline', flexWrap: 'wrap' }}>
|
||||
<span className="field-label" style={{ fontWeight: 600 }}>{f.label}</span>
|
||||
<code className="dim" style={{ fontSize: '0.74rem' }}>{f.table}</code>
|
||||
</div>
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', margin: '4px 0 8px' }}>
|
||||
{f.help}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<label>
|
||||
<span className="field-label">Keep for</span>
|
||||
<select
|
||||
className="select"
|
||||
value={isPreset ? String(value) : 'custom'}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value
|
||||
// Choosing "custom" must not blank the field — the number
|
||||
// box below is what the operator is about to edit, and an
|
||||
// empty one would post NaN.
|
||||
if (next === 'custom') return
|
||||
setDraft({ ...draft, [f.name]: Number(next) })
|
||||
}}
|
||||
>
|
||||
{f.presets.map((d) => (
|
||||
<option key={d} value={String(d)}>{d} days</option>
|
||||
))}
|
||||
<option value="custom">Custom…</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Days</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={spec.min ?? 2}
|
||||
max={spec.max ?? 3650}
|
||||
style={{ width: 110 }}
|
||||
value={value}
|
||||
onChange={(e) => setDraft({ ...draft, [f.name]: e.target.value === '' ? '' : Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
{spec.min !== undefined && (
|
||||
<span className="sans dim" style={{ fontSize: '0.78rem', paddingBottom: 8 }}>
|
||||
{spec.min}–{spec.max} days
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 24 }}>
|
||||
<button type="button" className="pill" disabled={!dirty || saving} onClick={save}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
{dirty && (
|
||||
<button type="button" className="pill" disabled={saving} onClick={() => setDraft(policy)}>
|
||||
Discard
|
||||
</button>
|
||||
)}
|
||||
{note && <span className="sans" style={{ fontSize: '0.82rem' }}>{note}</span>}
|
||||
</div>
|
||||
|
||||
<div style={{ maxWidth: 720, marginTop: 32 }}>
|
||||
<h3 className="sans" style={{ fontSize: '0.95rem', marginBottom: 6 }}>
|
||||
Suppressed addresses do not expire
|
||||
</h3>
|
||||
<p className="sans dim" style={{ fontSize: '0.84rem', margin: 0 }}>
|
||||
A suppression is a standing decision, not a record of something that happened. Ageing one
|
||||
out would re-mail an address that already hard-bounced or asked to be left alone, which is
|
||||
how a sender loses a domain’s reputation. The way out of that list stays a
|
||||
deliberate act:{' '}
|
||||
<strong>Lift</strong> on the row, in Admin → Engagement → Suppressions.
|
||||
</p>
|
||||
{longestCooldown > 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.84rem', marginBottom: 0 }}>
|
||||
The longest cooldown on an enabled rule right now is {longestCooldown} seconds.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -43,6 +43,11 @@ export default function EngagementSendLog() {
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [status, setStatus] = useState('')
|
||||
const [testTrigger, setTestTrigger] = useState('')
|
||||
// Phase 14. `total` is now a truncated number, and a screen that shows a total
|
||||
// without saying so is quietly wrong about the deployment's own history — this
|
||||
// is the fix for that, and the reason the horizon got an operator-facing
|
||||
// control rather than the invisible settings row the other two sweeps use.
|
||||
const [retainDays, setRetainDays] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
@@ -55,6 +60,15 @@ export default function EngagementSendLog() {
|
||||
setRows(result.sends || [])
|
||||
setTotal(result.total || 0)
|
||||
setTestTrigger(result.testSendTrigger || '')
|
||||
// Best-effort and non-blocking: the log is worth showing even if the policy
|
||||
// cannot be read, so a failure here leaves the note off rather than the
|
||||
// screen empty.
|
||||
try {
|
||||
const policy = await api.admin.getEngagementRetention()
|
||||
setRetainDays(policy?.retention?.sends ?? null)
|
||||
} catch {
|
||||
setRetainDays(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -153,6 +167,7 @@ export default function EngagementSendLog() {
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 14 }}>
|
||||
<span className="sans dim" style={{ fontSize: '0.82rem' }}>
|
||||
{offset + 1}–{to} of {total}
|
||||
{retainDays ? ` · entries older than ${retainDays} days are removed automatically` : ''}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }}
|
||||
|
||||
@@ -126,6 +126,26 @@ export default function EngagementSuppressions() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-row Lift (Phase 14). No address is asked for and none is needed: the
|
||||
* row carries its own `address_hash`, which is the only handle this screen has
|
||||
* ever been able to have — the address itself is stored one-way.
|
||||
*
|
||||
* No confirm dialog, deliberately. Lifting is reversible in one click (the
|
||||
* Suppress field above is right there), and a browser modal blocks the whole
|
||||
* tab, which is the failure mode the automation notes in this repo warn about.
|
||||
*/
|
||||
async function liftRow(row) {
|
||||
setNote(null)
|
||||
try {
|
||||
await api.admin.unsuppressByHash(row.address_hash, row.channel)
|
||||
setNote(`${row.address_masked || 'That address'} can be mailed again.`)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setNote(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && rows.length === 0 && !applied && !reason) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
@@ -211,6 +231,7 @@ export default function EngagementSuppressions() {
|
||||
<th className="adm-th">Detail</th>
|
||||
<th className="adm-th">Channel</th>
|
||||
<th className="adm-th">Since</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -231,6 +252,20 @@ export default function EngagementSuppressions() {
|
||||
<td className="adm-td" style={{ whiteSpace: 'nowrap', fontSize: '0.8rem' }}>
|
||||
{new Date(r.created_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="pill"
|
||||
style={{ fontSize: '0.72rem' }}
|
||||
disabled={!r.address_hash}
|
||||
title={r.address_hash
|
||||
? 'Let this address be mailed again'
|
||||
: 'This row has no handle to act on'}
|
||||
onClick={() => liftRow(r)}
|
||||
>
|
||||
Lift
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
280
client/src/routes/admin/views/EventActions.jsx
Normal file
280
client/src/routes/admin/views/EventActions.jsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Admin → Events → Actions — the deployment's switchboard (EVENTS.md §K, Phase 6).
|
||||
//
|
||||
// **This screen is the whole of the permission model beyond the role.** A module
|
||||
// declaring `uo.creature.spawn` is code the operator installed; it is not a
|
||||
// permission they granted. Enablement is the grant, and the cap is how much of
|
||||
// it — so this is the one screen in the feature where an operator decides what
|
||||
// the deployment *can do at all*, rather than what it is going to do tonight.
|
||||
//
|
||||
// **Nothing above `notify` and `inspect` arrives enabled.** Installing a module
|
||||
// must never start doing things, which is the posture a seeded engagement rule
|
||||
// already takes by arriving `enabled = 0`. The line falls between `inspect` and
|
||||
// `change` (org lead, 2026-09-03): an `inspect` action reads state and writes
|
||||
// nothing, so a deployment gains no risk by having it on, and `core.wait` — which
|
||||
// is `inspect` — arriving off would break every published event that waits.
|
||||
//
|
||||
// **A row with no stored setting is not "off".** It is "the default for its risk
|
||||
// class", computed on the server by the same function the runner asks. The screen
|
||||
// says which it is looking at, because "an admin turned this on" and "this has
|
||||
// always been on" are different facts and only one of them is a decision.
|
||||
//
|
||||
// **Admin only in both directions**, including the read: §K puts the switchboard
|
||||
// in the same row as the world-changing actions it governs, and knowing exactly
|
||||
// what a deployment permits is not a staff-wide read.
|
||||
|
||||
const RISK_WORD = {
|
||||
notify: 'Tells people something',
|
||||
inspect: 'Reads the world',
|
||||
change: 'Changes the world',
|
||||
irreversible: 'Changes the world irreversibly',
|
||||
}
|
||||
|
||||
const RISK_COLOR = {
|
||||
notify: 'var(--muted)',
|
||||
inspect: 'var(--muted)',
|
||||
change: '#d9c184',
|
||||
irreversible: '#d98b84',
|
||||
}
|
||||
|
||||
const REVERSIBLE_WORD = {
|
||||
none: 'nothing to undo',
|
||||
self: 'undoes itself',
|
||||
ledger: 'undone from the ledger at teardown',
|
||||
override: 'restores a baseline',
|
||||
}
|
||||
|
||||
export default function EventActions() {
|
||||
const [actions, setActions] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(null)
|
||||
const [problem, setProblem] = useState(null)
|
||||
const [notice, setNotice] = useState(null)
|
||||
// Cap edits are held here until they are saved, keyed `actionId:dimension`.
|
||||
// A cap is a number somebody types digit by digit, and writing on every
|
||||
// keystroke would put "3" in the database on the way to "30".
|
||||
const [drafts, setDrafts] = useState({})
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const data = await api.admin.eventActions()
|
||||
setActions(data.actions || [])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await load()
|
||||
if (alive) setError(null)
|
||||
} catch (err) {
|
||||
if (alive) setError(err.message)
|
||||
} finally {
|
||||
if (alive) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [load])
|
||||
|
||||
/**
|
||||
* Write one action's row.
|
||||
*
|
||||
* The whole row goes every time — the switch and every cap — because the route
|
||||
* takes one action per request and a sparse write would have to decide what an
|
||||
* omitted cap means. Here it can only mean one thing, so it is sent.
|
||||
*/
|
||||
const save = async (action, { enabled = action.enabled, caps } = {}) => {
|
||||
setBusy(action.id)
|
||||
setProblem(null)
|
||||
setNotice(null)
|
||||
const nextCaps = caps !== undefined ? caps : capsOf(action)
|
||||
try {
|
||||
await api.admin.saveEventAction({ actionId: action.id, enabled, caps: nextCaps })
|
||||
await load()
|
||||
setDrafts((d) => {
|
||||
const next = { ...d }
|
||||
for (const d of action.dimensions) delete next[`${action.id}:${d.id}`]
|
||||
return next
|
||||
})
|
||||
setNotice(`Saved ${action.label}.`)
|
||||
} catch (err) {
|
||||
setProblem(err.message)
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
/** The caps this row would save: the drafts on top of what is stored. */
|
||||
const capsOf = (action) => {
|
||||
const out = {}
|
||||
for (const { id: dimension } of action.dimensions) {
|
||||
const draft = drafts[`${action.id}:${dimension}`]
|
||||
const value = draft !== undefined ? draft : action.caps[dimension]
|
||||
if (value === '' || value === undefined || value === null) continue
|
||||
out[dimension] = Number(value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const capValue = (action, dimension) => {
|
||||
const draft = drafts[`${action.id}:${dimension}`]
|
||||
if (draft !== undefined) return draft
|
||||
const stored = action.caps[dimension]
|
||||
return stored === undefined || stored === null ? '' : String(stored)
|
||||
}
|
||||
|
||||
const dirty = (action) =>
|
||||
action.dimensions.some((d) => drafts[`${action.id}:${d.id}`] !== undefined)
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="sans" style={{ margin: '0 0 4px' }}>Event actions</h2>
|
||||
<p className="sans dim" style={{ margin: '0 0 14px', fontSize: '0.85rem', maxWidth: '62ch' }}>
|
||||
What this deployment permits an event to do, and how much of it per run. Anything that changes
|
||||
the world arrives switched off — installing a module declares a verb, it does not grant
|
||||
permission to use it. Caps are copied into a run when the run is created, so moving a switch
|
||||
never changes what a run already in flight is allowed.
|
||||
</p>
|
||||
|
||||
{problem && (
|
||||
<div className="panel-flat" style={{ padding: 10, marginBottom: 12, borderLeft: '3px solid #d98b84' }}>
|
||||
<span className="sans" style={{ fontSize: '0.85rem' }}>{problem}</span>
|
||||
</div>
|
||||
)}
|
||||
{notice && (
|
||||
<div className="panel-flat" style={{ padding: 10, marginBottom: 12, borderLeft: '3px solid #8fc79a' }}>
|
||||
<span className="sans" style={{ fontSize: '0.85rem' }}>{notice}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actions.length === 0 && (
|
||||
<div className="panel-flat" style={{ padding: 14 }}>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.85rem' }}>
|
||||
No module registers an event action. Core always declares its own three, so an empty list
|
||||
here means the registry did not load.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actions.map((action) => (
|
||||
<div
|
||||
key={action.id}
|
||||
className="panel-flat"
|
||||
style={{
|
||||
padding: 14,
|
||||
marginBottom: 10,
|
||||
borderLeft: `3px solid ${action.enabled ? RISK_COLOR[action.risk] || 'var(--rule)' : 'var(--rule)'}`,
|
||||
opacity: action.enabled ? 1 : 0.75,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
<div style={{ flex: '1 1 320px', minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'baseline', flexWrap: 'wrap' }}>
|
||||
<strong className="sans" style={{ fontSize: '0.95rem' }}>{action.label}</strong>
|
||||
<code className="dim" style={{ fontSize: '0.78rem' }}>{action.id}</code>
|
||||
</div>
|
||||
{action.description && (
|
||||
<p className="sans dim" style={{ margin: '4px 0 0', fontSize: '0.82rem' }}>{action.description}</p>
|
||||
)}
|
||||
<p className="sans dim" style={{ margin: '4px 0 0', fontSize: '0.78rem' }}>
|
||||
<span style={{ color: RISK_COLOR[action.risk] }}>{RISK_WORD[action.risk] || action.risk}</span>
|
||||
{' · '}
|
||||
{REVERSIBLE_WORD[action.reversible] || action.reversible}
|
||||
{/* Which of the two facts this is. A default is not a decision, and
|
||||
an operator auditing their own deployment needs to see the
|
||||
difference without reading the risk table in their head. */}
|
||||
{' · '}
|
||||
{action.configured
|
||||
? `set by ${action.updatedBy || 'an administrator'}`
|
||||
: 'never configured — showing the default for its risk class'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="sans" style={{ display: 'flex', gap: 6, alignItems: 'center', fontSize: '0.85rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={action.enabled}
|
||||
disabled={busy === action.id}
|
||||
onChange={(e) => save(action, { enabled: e.target.checked })}
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{action.dimensions.length > 0 && (
|
||||
<div style={{ marginTop: 10, paddingTop: 10, borderTop: '1px solid var(--rule)' }}>
|
||||
<p className="sans dim" style={{ margin: '0 0 6px', fontSize: '0.78rem' }}>
|
||||
Per-run caps. Blank is uncapped — the run still counts what it spends, nothing bounds
|
||||
it. Where another enabled action spends the same thing, the tightest cap is the one a
|
||||
run gets.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||
{action.dimensions.map((d) => (
|
||||
<label key={d.id} className="sans" style={{ fontSize: '0.8rem' }}>
|
||||
{/*
|
||||
The LABEL, with the unit beside the box — both from the module's
|
||||
`registerEventBudgets` declaration (Phase 7). Before it, this said
|
||||
`uo.creatures` over an unlabelled number, which is ambiguous in exactly
|
||||
the case that matters: 30 of what?
|
||||
*/}
|
||||
<span className="dim" style={{ display: 'block', marginBottom: 2 }}>
|
||||
{d.registered ? d.label : d.id}
|
||||
</span>
|
||||
<span style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
style={{ width: 110 }}
|
||||
value={capValue(action, d.id)}
|
||||
disabled={busy === action.id || !d.registered}
|
||||
onChange={(e) =>
|
||||
setDrafts((s) => ({ ...s, [`${action.id}:${d.id}`]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
{d.registered && d.unit && (
|
||||
<span className="dim" style={{ fontSize: '0.75rem' }}>{d.unit}</span>
|
||||
)}
|
||||
</span>
|
||||
{/*
|
||||
A dimension nobody declares is SHOWN rather than hidden. The action is
|
||||
refused when it is saved into a step and again if it is ever dispatched,
|
||||
so the operator needs to be told which module is incomplete — hiding the
|
||||
row would make a broken module look like a cheap one.
|
||||
*/}
|
||||
{!d.registered && (
|
||||
<span
|
||||
className="sans"
|
||||
style={{ display: 'block', marginTop: 2, fontSize: '0.72rem', color: '#d98b84' }}
|
||||
>
|
||||
No module declares this as a budget, so a step using this action is
|
||||
refused. It cannot be capped until one does.
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={busy === action.id || !dirty(action)}
|
||||
onClick={() => save(action)}
|
||||
>
|
||||
Save caps
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
2295
client/src/routes/admin/views/EventEditor.jsx
Normal file
2295
client/src/routes/admin/views/EventEditor.jsx
Normal file
File diff suppressed because it is too large
Load Diff
737
client/src/routes/admin/views/EventRun.jsx
Normal file
737
client/src/routes/admin/views/EventRun.jsx
Normal file
@@ -0,0 +1,737 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import {
|
||||
runStatusWord,
|
||||
isTerminalRun,
|
||||
isParked,
|
||||
runControlsFor,
|
||||
stepControlsFor,
|
||||
describeLogLine,
|
||||
describeParams,
|
||||
describeOrigin,
|
||||
} from '../../../lib/eventAuthoring.js'
|
||||
|
||||
// Admin → Events → the run console (EVENTS.md §I, Phase 3).
|
||||
//
|
||||
// One run: where it is, what each of its steps did, what a human can still do
|
||||
// about it, and the diagnostic log underneath. Staff-wide to read; the six
|
||||
// controls are `admin` + `moderator`, and the server re-checks every one of them
|
||||
// against the run's live status — this screen predicts, it does not decide.
|
||||
//
|
||||
// **It polls rather than streaming.** A run changes on the runner's tick, which
|
||||
// is a fifteen-second clock, and a console watched for the length of an event is
|
||||
// a tab left open for two hours: an SSE channel for that is a connection held
|
||||
// per staff member for a screen that could not use the latency. The poll stops
|
||||
// the moment the run reaches a terminal status, because a completed run has
|
||||
// nothing further to say.
|
||||
//
|
||||
// **The parked step is the thing this screen exists to make impossible to
|
||||
// miss.** A run waiting on a GM cue is `running` and healthy-looking, and it will
|
||||
// stay that way for ever unless somebody presses confirm. It is called out above
|
||||
// the step list rather than being one row in it.
|
||||
//
|
||||
// **Phase 5 gave it a second one of those, and the panel is this phase's real
|
||||
// deliverable** (§ Observability): a phase whose steps have all finished and
|
||||
// whose advance condition has not been met is also `running` and also
|
||||
// healthy-looking. *"Why didn't phase 3 start?"* is answered here, above the
|
||||
// steps, in the condition builder's own words — and the sentence is the
|
||||
// SERVER'S. `gates[].where` arrives already rendered, because those labels are
|
||||
// defined in the condition grammar and a second renderer in the browser would
|
||||
// be a second opinion about what `gte` reads as.
|
||||
//
|
||||
// **Phase 8 gave it a third, and it is the one that outlives the event.** The
|
||||
// resource ledger is what this run changed in the world and what became of it,
|
||||
// and its unresolved rows are the reason a `completed` run can still need a
|
||||
// person — EVENTS.md §L: a run reaches `completed` with `cleanup_status =
|
||||
// 'incomplete'` rather than being held open, because a tidy `completed` row over
|
||||
// a shard full of orphaned monsters is the failure that would end this feature's
|
||||
// credibility on its first bad night. The panel is shown on finished runs for
|
||||
// exactly that reason, and it is the only panel here whose empty state matters.
|
||||
|
||||
const POLL_MS = 5000
|
||||
|
||||
const STATUS_COLOR = {
|
||||
failed: '#d98b84',
|
||||
missed: '#d98b84',
|
||||
paused: '#d9c184',
|
||||
cancelled: 'var(--muted)',
|
||||
running: '#8fc79a',
|
||||
completed: '#8fc79a',
|
||||
}
|
||||
|
||||
// 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
|
||||
// come back and core kept asking".
|
||||
const RESOURCE_COLOR = {
|
||||
reverted: '#8fc79a',
|
||||
confirmed: '#d9c184',
|
||||
pending: '#d9c184',
|
||||
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 = {
|
||||
pending: 'recorded, unconfirmed',
|
||||
confirmed: 'still out there',
|
||||
reverting: 'being given back',
|
||||
reverted: 'given back',
|
||||
orphaned: 'gone',
|
||||
drifted: 'someone else moved it',
|
||||
expired: 'ended by the game on time',
|
||||
}
|
||||
|
||||
const STEP_COLOR = {
|
||||
done: '#8fc79a',
|
||||
failed: '#d98b84',
|
||||
refused: '#d9c184',
|
||||
skipped: 'var(--muted)',
|
||||
cancelled: 'var(--muted)',
|
||||
}
|
||||
|
||||
const when = (v) => (v ? new Date(v).toLocaleString() : '—')
|
||||
const clock = (v) => (v ? new Date(v).toLocaleTimeString() : '')
|
||||
|
||||
// How many participants the console renders before it stops and counts the rest.
|
||||
// A run's participants are people and a busy event has hundreds; this panel is a
|
||||
// check that the collection worked and that the ranking looks right, not the
|
||||
// results page — that is Phase 14's, and it is public.
|
||||
const PARTICIPANTS_SHOWN = 50
|
||||
|
||||
/**
|
||||
* Seconds as an operator reads them — the same vocabulary the spec authors a
|
||||
* gate in, so "28 min" on this screen and `after: '30m'` in the editor are
|
||||
* obviously the same kind of thing.
|
||||
*/
|
||||
function elapsed(seconds) {
|
||||
const s = Math.max(0, Number(seconds) || 0)
|
||||
if (s < 60) return `${s} sec`
|
||||
if (s < 3600) return `${Math.floor(s / 60)} min`
|
||||
const h = Math.floor(s / 3600)
|
||||
const m = Math.floor((s % 3600) / 60)
|
||||
return m ? `${h} hr ${m} min` : `${h} hr`
|
||||
}
|
||||
|
||||
/**
|
||||
* One phase gate, as the panel draws it.
|
||||
*
|
||||
* The satisfied ones are drawn too, and dimmed: "phase 2 waited 41 minutes and
|
||||
* was released by the third boss" is the same question as the live one, asked
|
||||
* after the fact, and it is the one an operator asks the morning after.
|
||||
*/
|
||||
function GateRow({ gate, current }) {
|
||||
const colour = gate.satisfied ? 'var(--muted)' : gate.stalled ? '#d98b84' : '#d9c184'
|
||||
return (
|
||||
<div style={{ padding: '8px 0', borderTop: '1px solid var(--rule)' }}>
|
||||
<div className="sans" style={{ fontSize: '0.86rem', color: colour }}>
|
||||
Phase <strong>{gate.phase}</strong>
|
||||
{/* 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>
|
||||
<dl className="sans" style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '2px 12px', margin: '6px 0 0', fontSize: '0.8rem' }}>
|
||||
{gate.kind === 'after' ? (
|
||||
<>
|
||||
<dt className="dim">waiting for</dt>
|
||||
<dd style={{ margin: 0 }}>{elapsed(gate.after)} from the start of the phase</dd>
|
||||
<dt className="dim">until</dt>
|
||||
<dd style={{ margin: 0 }}>{when(gate.dueAt)}</dd>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<dt className="dim">waiting on</dt>
|
||||
<dd style={{ margin: 0 }}>
|
||||
<code>{gate.waitingOn}</code>
|
||||
{gate.where ? <> where <em>{gate.where}</em></> : <span className="dim"> — any firing</span>}
|
||||
</dd>
|
||||
<dt className="dim">seen so far</dt>
|
||||
<dd style={{ margin: 0 }}>{gate.seen} of {gate.needed}</dd>
|
||||
</>
|
||||
)}
|
||||
<dt className="dim">since</dt>
|
||||
<dd style={{ margin: 0 }}>{when(gate.since)} ({elapsed(gate.elapsedSeconds)})</dd>
|
||||
{gate.kind === 'on' && gate.lastEvent && (
|
||||
<>
|
||||
<dt className="dim">last related event</dt>
|
||||
<dd style={{ margin: 0 }}>
|
||||
<code>{gate.lastEvent.trigger}</code> at {clock(gate.lastEventAt)}
|
||||
{' — '}
|
||||
{/* The near miss is the valuable half: "the boss did spawn, in
|
||||
Britain" and "no boss has spawned" are different answers and
|
||||
look identical without this line. */}
|
||||
{gate.lastEvent.matched ? 'counted' : 'did not count'}
|
||||
{Object.keys(gate.lastEvent.variables || {}).length > 0 && (
|
||||
<span className="dim">
|
||||
{' ('}
|
||||
{Object.entries(gate.lastEvent.variables).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(', ')}
|
||||
{')'}
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function EventRun() {
|
||||
const { runId } = useParams()
|
||||
const [run, setRun] = useState(null)
|
||||
const [steps, setSteps] = useState([])
|
||||
const [counts, setCounts] = useState({})
|
||||
const [gates, setGates] = useState([])
|
||||
// The caps this run was given and what it has spent of them (Phase 6). Copied
|
||||
// into the run when it was created, so this is what THIS run is allowed rather
|
||||
// than what the switchboard says today.
|
||||
const [budget, setBudget] = useState([])
|
||||
// What this run created or borrowed, and what became of each (Phase 8).
|
||||
const [resources, setResources] = useState([])
|
||||
const [unresolved, setUnresolved] = useState(0)
|
||||
// Who took part, best first (Phase 10). Present whether or not the results
|
||||
// have been published; `run.resultsPublishedAt` is what says which.
|
||||
const [participants, setParticipants] = useState([])
|
||||
const [lines, setLines] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [problem, setProblem] = useState(null)
|
||||
const [notes, setNotes] = useState({})
|
||||
const [reason, setReason] = useState('')
|
||||
const alive = useRef(true)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [detail, log] = await Promise.all([
|
||||
api.admin.getEventRun(runId),
|
||||
api.admin.getEventRunLog(runId, 200),
|
||||
])
|
||||
if (!alive.current) return
|
||||
setRun(detail.run)
|
||||
setSteps(detail.steps || [])
|
||||
setCounts(detail.counts || {})
|
||||
setGates(detail.gates || [])
|
||||
setBudget(detail.budget || [])
|
||||
setResources(detail.resources || [])
|
||||
setUnresolved(detail.unresolvedResources || 0)
|
||||
setParticipants(detail.participants || [])
|
||||
setLines(log.log || [])
|
||||
}, [runId])
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await load()
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
if (alive.current) setError(err.message)
|
||||
} finally {
|
||||
if (alive.current) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
alive.current = false
|
||||
}
|
||||
}, [load])
|
||||
|
||||
// The poll, and its own off switch. A terminal run is not re-read: it cannot
|
||||
// change, and a console left open on last night's completed event should not
|
||||
// be a request every five seconds until the tab is closed.
|
||||
useEffect(() => {
|
||||
if (!run || isTerminalRun(run.status)) return undefined
|
||||
const timer = setInterval(() => {
|
||||
load().catch(() => {})
|
||||
}, POLL_MS)
|
||||
return () => clearInterval(timer)
|
||||
}, [run, load])
|
||||
|
||||
/** Every control goes through here: press, reload, and surface a refusal. */
|
||||
const act = async (fn) => {
|
||||
setBusy(true)
|
||||
setProblem(null)
|
||||
try {
|
||||
await fn()
|
||||
await load()
|
||||
} catch (err) {
|
||||
// A 409 is the ordinary answer to a button pressed against a run that has
|
||||
// moved on since the screen was drawn, so it is shown as a sentence rather
|
||||
// than as an error state — and the reload above has already re-drawn the
|
||||
// controls as they now stand.
|
||||
setProblem(err.body?.errors?.[0] || err.message)
|
||||
await load().catch(() => {})
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && !run) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
if (!run) return <ErrorState message="No such run." />
|
||||
|
||||
const controls = runControlsFor(run, gates, steps)
|
||||
const waiting = gates.find((g) => g.phase === run.currentPhase && !g.satisfied)
|
||||
const parked = steps.filter(isParked)
|
||||
const summary = Object.entries(counts).map(([k, n]) => `${n} ${k}`).join(' · ')
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<h2 className="sans" style={{ margin: 0, fontSize: '1.05rem' }}>
|
||||
<Link to={`/admin/events/${run.definitionId}`}>{run.definitionTitle}</Link>{' '}
|
||||
<span className="dim" style={{ fontWeight: 400 }}>v{run.version}</span>
|
||||
</h2>
|
||||
<p className="sans dim" style={{ margin: '4px 0 0', fontSize: '0.8rem' }}>
|
||||
Occurrence {when(run.scheduledFor)}
|
||||
{run.scope ? ` · scope ${run.scope}` : ''}
|
||||
{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 }}>
|
||||
{runStatusWord(run.status)}
|
||||
{run.currentPhase && <span className="dim" style={{ fontSize: '0.82rem' }}> · {run.currentPhase}</span>}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.78rem' }}>
|
||||
{run.health !== 'ok' && <span style={{ color: '#d9c184' }}>{run.health} · </span>}
|
||||
{summary || 'no steps'}
|
||||
{!isTerminalRun(run.status) && <span> · refreshing</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Health is not status, which is the whole reason the two are separate
|
||||
columns — but the sentence has to agree with the status it sits beside.
|
||||
A degraded RUNNING run is the interesting case: still going, already in
|
||||
trouble. A degraded PAUSED run is not "still running", and saying so on
|
||||
the one screen an operator opens to find out what stopped it would be
|
||||
the console contradicting itself. Found in the browser walk. */}
|
||||
{run.health === 'degraded' && !isTerminalRun(run.status) && (
|
||||
<p className="sans" style={{ fontSize: '0.82rem', color: '#d9c184', marginTop: 10 }}>
|
||||
{run.status === 'paused' ? (
|
||||
<>
|
||||
Something in this run failed, and it is waiting for a person. Resuming carries the phase
|
||||
past the failed step; <em>Retry & resume</em> puts that step back in the queue first.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Something in this run has already had to be retried. It is still running — this is
|
||||
what “degraded” means, and the log below says what happened.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{run.lastError && (
|
||||
<p className="sans" style={{ fontSize: '0.82rem', color: '#d98b84', marginTop: 6 }}>{run.lastError}</p>
|
||||
)}
|
||||
|
||||
{problem && (
|
||||
<p className="sans" style={{ fontSize: '0.82rem', color: '#d98b84', marginTop: 6 }}>{problem}</p>
|
||||
)}
|
||||
|
||||
{/* ── The run controls ── */}
|
||||
<div className="panel-flat" style={{ padding: '12px 14px', margin: '14px 0', display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<label style={{ flex: '1 1 240px' }}>
|
||||
<span className="field-label">Reason (recorded with your name)</span>
|
||||
<input className="input" value={reason} onChange={(e) => setReason(e.target.value)} placeholder="optional" />
|
||||
</label>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || !controls.pause}
|
||||
onClick={() => act(() => api.admin.pauseEventRun(run.id, reason))}>
|
||||
Pause
|
||||
</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || !controls.resume}
|
||||
onClick={() => act(() => api.admin.resumeEventRun(run.id))}>
|
||||
Resume
|
||||
</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || !controls.advance}
|
||||
onClick={() => act(() => api.admin.advanceEventRun(run.id, reason))}>
|
||||
Advance phase
|
||||
</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || !controls.cancel}
|
||||
onClick={() => act(() => api.admin.cancelEventRun(run.id, reason))}>
|
||||
Cancel run
|
||||
</button>
|
||||
{/* The separate, admin-only decision (§L). It is a second button rather
|
||||
than a checkbox on the first because the two are not variants of one
|
||||
action: one gives the world back, the other deliberately leaves it
|
||||
changed. A checkbox next to Cancel is a thing an operator unticks by
|
||||
accident at two in the morning. The server refuses this to a
|
||||
moderator, and the refusal arrives as a sentence in `problem`. */}
|
||||
{controls.cancel && (
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy}
|
||||
onClick={() => act(() => api.admin.cancelEventRun(run.id, reason, false))}>
|
||||
Cancel, leave changes up
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isTerminalRun(run.status) && (
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem' }}>
|
||||
This run is over ({runStatusWord(run.status)} at {when(run.endedAt)}). Nothing can change it
|
||||
— a run pins the version it started from so that it can still be explained later.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── 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
|
||||
must say so before they have to read a log. */}
|
||||
{gates.length > 0 && (
|
||||
<div
|
||||
className="panel-flat"
|
||||
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 advanced' : 'Phase advance conditions'}
|
||||
</h3>
|
||||
<p className="sans dim" style={{ margin: '0 0 4px', fontSize: '0.8rem' }}>
|
||||
{waiting ? (
|
||||
<>
|
||||
Every step of this phase has finished. It advances when the condition below is met —
|
||||
nothing times out, and <em>Advance phase</em> is how a person overrides it.
|
||||
{waiting.stalled && ' This one has been waiting long enough that the run is marked stalled.'}
|
||||
</>
|
||||
) : (
|
||||
'What each phase of this run waited for, and what released it.'
|
||||
)}
|
||||
</p>
|
||||
{gates.map((gate) => (
|
||||
<GateRow key={gate.phase} gate={gate} current={gate.phase === run.currentPhase} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── What this run is allowed, and what it has spent ──
|
||||
A meter rather than a sentence: a cap is two numbers and a name, and
|
||||
unlike a gate it needs no grammar rendered to be read. It is shown for
|
||||
every run that has a budget at all, finished ones included — "how much
|
||||
did last night's invasion actually spawn" is the same question asked
|
||||
the morning after. */}
|
||||
{budget.length > 0 && (
|
||||
<div className="panel-flat" style={{ padding: 14, marginBottom: 14 }}>
|
||||
<h3 className="sans" style={{ margin: '0 0 6px', fontSize: '0.92rem' }}>Caps</h3>
|
||||
<table className="sans" style={{ fontSize: '0.82rem', borderCollapse: 'collapse', width: '100%' }}>
|
||||
<tbody>
|
||||
{budget.map((b) => {
|
||||
const spent = b.cap === null ? 0 : Math.min(b.consumed / b.cap, 1)
|
||||
const full = b.cap !== null && b.consumed >= b.cap
|
||||
return (
|
||||
<tr key={b.dimension}>
|
||||
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap' }}>
|
||||
<code style={{ fontSize: '0.78rem' }}>{b.dimension}</code>
|
||||
</td>
|
||||
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap', color: full ? '#d9c184' : undefined }}>
|
||||
{b.cap === null ? `${b.consumed} spent` : `${b.consumed} of ${b.cap}`}
|
||||
</td>
|
||||
<td style={{ width: '100%', padding: '3px 0' }}>
|
||||
{b.cap === null ? (
|
||||
<span className="dim" style={{ fontSize: '0.78rem' }}>no cap</span>
|
||||
) : (
|
||||
<span style={{ display: 'block', height: 6, background: 'var(--rule)', borderRadius: 3 }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
height: 6,
|
||||
width: `${Math.round(spent * 100)}%`,
|
||||
background: full ? '#d9c184' : '#8fc79a',
|
||||
borderRadius: 3,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
{/* Which switch set the number, so an operator can trace a cap
|
||||
back to a thing they can change rather than wondering
|
||||
where 30 came from. */}
|
||||
<td className="dim" style={{ padding: '3px 0 3px 12px', whiteSpace: 'nowrap', fontSize: '0.78rem' }}>
|
||||
{b.from || ''}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── What this run changed in the world (Phase 8) ──
|
||||
The WHOLE ledger, reverted rows included: "how much did last night's
|
||||
invasion actually spawn, and did all of it come back" is one question
|
||||
with two halves, and a list of only the failures answers neither.
|
||||
Shown on finished runs for the same reason the caps meter is. */}
|
||||
{(resources.length > 0 || run.cleanupStatus === 'incomplete') && (
|
||||
<div
|
||||
className="panel-flat"
|
||||
style={{
|
||||
padding: 14,
|
||||
marginBottom: 14,
|
||||
borderLeft: `3px solid ${unresolved > 0 ? '#d9c184' : 'var(--rule)'}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 12, flexWrap: 'wrap' }}>
|
||||
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>
|
||||
What this run changed
|
||||
</h3>
|
||||
{/* The manual retry. Offered only on a terminal run, because a run
|
||||
still in flight has a ledger that is still growing and reverting a
|
||||
resource the next step is about to use would be undoing an event
|
||||
while it is happening. */}
|
||||
{isTerminalRun(run.status) && unresolved > 0 && (
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy}
|
||||
onClick={() => act(() => api.admin.cleanupEventRun(run.id))}>
|
||||
Try cleanup again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="sans dim" style={{ margin: '0 0 10px', fontSize: '0.8rem' }}>
|
||||
{unresolved > 0 ? (
|
||||
<>
|
||||
{unresolved} of these {unresolved === 1 ? 'is' : 'are'} still unresolved. The runner
|
||||
gives them back on its own and stops asking after a few tries;{' '}
|
||||
<em>Try cleanup again</em> clears that count and asks once more.
|
||||
</>
|
||||
) : (
|
||||
'Everything this run created or borrowed has been given back.'
|
||||
)}
|
||||
</p>
|
||||
{resources.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>
|
||||
Nothing named — a step changed the world and its answer never arrived, so core kept the
|
||||
record it wrote beforehand and will ask the module to undo it by key.
|
||||
</p>
|
||||
) : (
|
||||
<table className="sans" style={{ fontSize: '0.82rem', borderCollapse: 'collapse', width: '100%' }}>
|
||||
<tbody>
|
||||
{resources.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap' }}>
|
||||
<code style={{ fontSize: '0.78rem' }}>{r.kind}</code>{' '}
|
||||
<code className="dim" style={{ fontSize: '0.78rem' }}>{r.ref}</code>
|
||||
</td>
|
||||
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap', color: RESOURCE_COLOR[r.status] }}>
|
||||
{RESOURCE_WORD[r.status] || r.status}
|
||||
</td>
|
||||
<td className="dim" style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap', fontSize: '0.78rem' }}>
|
||||
{r.module}
|
||||
{r.leaseUntil ? ` · until ${clock(r.leaseUntil)}` : ''}
|
||||
{r.revertAttempts > 0 ? ` · ${r.revertAttempts} attempt${r.revertAttempts === 1 ? '' : 's'}` : ''}
|
||||
</td>
|
||||
<td className="dim" style={{ width: '100%', padding: '3px 0', fontSize: '0.78rem' }}>
|
||||
{r.lastError || ''}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Who took part (Phase 10) ──
|
||||
Shown whenever a module has reported anybody, published or not — and the
|
||||
difference between the two is the whole point of the line under the
|
||||
heading. A run whose participants are collected and unranked is a real
|
||||
state, not an error: an author has not placed a `core.results.publish`
|
||||
step, or has not run it yet. Saying "not published yet" is what stops
|
||||
somebody reading this table as the final standings. */}
|
||||
{participants.length > 0 && (
|
||||
<div className="panel-flat" style={{ padding: 14, marginBottom: 14 }}>
|
||||
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>
|
||||
Who took part
|
||||
</h3>
|
||||
<p className="sans dim" style={{ margin: '0 0 10px', fontSize: '0.8rem' }}>
|
||||
{run.resultsPublishedAt ? (
|
||||
<>Results published {clock(run.resultsPublishedAt)}. Ranked best first.</>
|
||||
) : (
|
||||
<>
|
||||
{participants.length} recorded, and the results have not been published — nothing
|
||||
outside this page shows them, and nobody has a rank yet. Publishing is a{' '}
|
||||
<code style={{ fontSize: '0.78rem' }}>core.results.publish</code> step in the event
|
||||
itself.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<table className="sans" style={{ fontSize: '0.82rem', borderCollapse: 'collapse', width: '100%' }}>
|
||||
<tbody>
|
||||
{participants.slice(0, PARTICIPANTS_SHOWN).map((p) => (
|
||||
<tr key={p.memberKey}>
|
||||
<td className="dim" style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap', width: 34, textAlign: 'right' }}>
|
||||
{p.rank ?? ''}
|
||||
</td>
|
||||
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap' }}>
|
||||
<code style={{ fontSize: '0.78rem' }}>{p.memberKey}</code>
|
||||
</td>
|
||||
{/* A participant with no `userId` is not a defect: it is
|
||||
somebody who turned up without a linked website account,
|
||||
and the module is the only thing that could have known
|
||||
otherwise. Saying so beats a blank cell. */}
|
||||
<td className="dim" style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap', fontSize: '0.78rem' }}>
|
||||
{p.userId ? `account ${p.userId}` : 'no linked account'}
|
||||
</td>
|
||||
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap' }}>{p.score}</td>
|
||||
<td className="dim" style={{ width: '100%', padding: '3px 0', fontSize: '0.78rem' }}>
|
||||
{clock(p.joinedAt)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{participants.length > PARTICIPANTS_SHOWN && (
|
||||
<p className="sans dim" style={{ margin: '8px 0 0', fontSize: '0.78rem' }}>
|
||||
and {participants.length - PARTICIPANTS_SHOWN} more.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Waiting on a person ── */}
|
||||
{parked.length > 0 && (
|
||||
<div className="panel-flat" style={{ padding: 14, marginBottom: 14, borderLeft: '3px solid #d9c184' }}>
|
||||
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>Waiting on a person</h3>
|
||||
<p className="sans dim" style={{ margin: '0 0 10px', fontSize: '0.8rem' }}>
|
||||
Nothing else in this phase runs until each of these is confirmed. There is no timeout —
|
||||
a cue posted on Friday is still waiting on Monday.
|
||||
</p>
|
||||
{parked.map((step) => (
|
||||
<div key={step.id} style={{ marginBottom: 10 }}>
|
||||
<p className="sans" style={{ margin: '0 0 6px', fontSize: '0.86rem' }}>
|
||||
{step.params?.instruction || step.actionId}
|
||||
{step.params?.assignee && <span className="dim"> — for {step.params.assignee}</span>}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<label style={{ flex: '1 1 240px' }}>
|
||||
<span className="field-label">What you did (optional)</span>
|
||||
<input className="input" value={notes[step.id] || ''}
|
||||
onChange={(e) => setNotes((n) => ({ ...n, [step.id]: e.target.value }))} />
|
||||
</label>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy}
|
||||
onClick={() => act(() => api.admin.confirmEventStep(run.id, step.id, notes[step.id]))}>
|
||||
Confirm — done
|
||||
</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy}
|
||||
onClick={() => act(() => api.admin.skipEventStep(run.id, step.id, notes[step.id]))}>
|
||||
Skip it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── The steps ── */}
|
||||
<h3 className="sans" style={{ fontSize: '0.95rem', margin: '0 0 8px' }}>Steps</h3>
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Phase</th>
|
||||
<th className="adm-th">#</th>
|
||||
<th className="adm-th">Action</th>
|
||||
<th className="adm-th">Status</th>
|
||||
<th className="adm-th">Attempts</th>
|
||||
<th className="adm-th">Detail</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{steps.map((step) => {
|
||||
const c = stepControlsFor(run, step, steps)
|
||||
return (
|
||||
<tr key={step.id}>
|
||||
<td className="adm-td" style={{ fontSize: '0.8rem' }}>
|
||||
{step.phase}
|
||||
{step.phase === run.currentPhase && <span className="dim"> ·now</span>}
|
||||
</td>
|
||||
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>{step.seq + 1}</td>
|
||||
<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' }}>
|
||||
{describeParams(step.params)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem', color: STEP_COLOR[step.status] || undefined }}>
|
||||
{isParked(step) ? <span style={{ color: '#d9c184' }}>waiting</span> : step.status}
|
||||
</td>
|
||||
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>
|
||||
{step.attempts}
|
||||
{step.dueAt && new Date(step.dueAt) > new Date() && (
|
||||
<div style={{ fontSize: '0.74rem' }}>due {clock(step.dueAt)}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.78rem', maxWidth: 280, overflowWrap: 'anywhere' }}>
|
||||
{step.lastError || ''}
|
||||
</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
{c.retry && (
|
||||
<button type="button" className="pill" style={{ fontSize: '0.7rem', marginLeft: 4 }} disabled={busy}
|
||||
onClick={() => act(() => api.admin.retryEventStep(run.id, step.id))}>
|
||||
Retry & resume
|
||||
</button>
|
||||
)}
|
||||
{c.skip && !isParked(step) && (
|
||||
<button type="button" className="pill" style={{ fontSize: '0.7rem', marginLeft: 4 }} disabled={busy}
|
||||
onClick={() => act(() => api.admin.skipEventStep(run.id, step.id, reason))}>
|
||||
Skip
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{steps.length === 0 && (
|
||||
<tr><td className="adm-td dim" colSpan={7}>No steps have been materialised yet.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', marginTop: 8 }}>
|
||||
Steps run strictly in order within a phase, and the phase ends when every one of them has
|
||||
finished. A failed step is not retried by the runner past its attempt limit — resuming a
|
||||
paused run carries the phase past it, and <em>Retry & resume</em> puts the step the run is
|
||||
stopped at back in the queue.
|
||||
</p>
|
||||
|
||||
{/* ── The log ── */}
|
||||
<h3 className="sans" style={{ fontSize: '0.95rem', margin: '22px 0 8px' }}>Log</h3>
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 8px' }}>
|
||||
The run’s own diagnostic record, newest first — this is what answers “why didn’t phase 3
|
||||
start?” without reading server logs. Who published or started what is recorded separately, in
|
||||
the activity log.
|
||||
</p>
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<tbody>
|
||||
{lines.map((line) => (
|
||||
<tr key={line.id}>
|
||||
<td className="adm-td dim" style={{ fontSize: '0.76rem', whiteSpace: 'nowrap' }}>{clock(line.at)}</td>
|
||||
<td className="adm-td dim" style={{ fontSize: '0.76rem' }}>{line.phase || ''}</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.8rem' }}>{describeLogLine(line)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{lines.length === 0 && <tr><td className="adm-td dim">Nothing logged yet.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
265
client/src/routes/admin/views/EventsAdmin.jsx
Normal file
265
client/src/routes/admin/views/EventsAdmin.jsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { useAuth } from '../../../contexts/AuthContext.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { runStatusWord, isTerminalRun } from '../../../lib/eventAuthoring.js'
|
||||
|
||||
// Admin → Events (EVENTS.md §I, Phase 3).
|
||||
//
|
||||
// Two tables on one screen: the definitions an operator authors, and the runs
|
||||
// those definitions have produced. They are together rather than on two nav rows
|
||||
// because the question this screen exists to answer is one question — "what is
|
||||
// scheduled, and what is happening right now" — and the second half of it is the
|
||||
// one somebody opens at 8pm on a Friday.
|
||||
//
|
||||
// **The waiting badge is the whole reason the run table is here rather than
|
||||
// buried a click away.** A run parked on a GM cue looks perfectly healthy: it is
|
||||
// `running`, nothing has failed, and it will stay that way for ever because it
|
||||
// is waiting for a person who does not know they are being waited for. The count
|
||||
// comes from the run row itself (`waitingSteps`), so a run needs nobody to open
|
||||
// it before it can say so.
|
||||
//
|
||||
// **The calendar is a separate screen, not a third table here.** It answers
|
||||
// "when", this one answers "what" — and Phase 4, which built it, also made a
|
||||
// definition able to carry a recurrence, so the two questions stopped having the
|
||||
// same answer the moment an occurrence could exist before anybody pressed Start.
|
||||
|
||||
const STATE_WORD = { draft: 'Draft', ready: 'Ready', archived: 'Archived' }
|
||||
|
||||
const STATUS_COLOR = {
|
||||
failed: '#d98b84',
|
||||
missed: '#d98b84',
|
||||
paused: '#d9c184',
|
||||
cancelled: 'var(--muted)',
|
||||
running: '#8fc79a',
|
||||
}
|
||||
|
||||
const HEALTH_COLOR = { degraded: '#d9c184', stalled: '#d98b84' }
|
||||
|
||||
const when = (value) => (value ? new Date(value).toLocaleString() : '—')
|
||||
|
||||
export default function EventsAdmin() {
|
||||
const { user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [events, setEvents] = useState([])
|
||||
const [runs, setRuns] = useState([])
|
||||
const [state, setState] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [notice, setNotice] = useState(null)
|
||||
|
||||
const isAdmin = user?.role === 'admin'
|
||||
const mayAuthor = isAdmin || user?.role === 'editor'
|
||||
|
||||
const load = useCallback(async (nextState) => {
|
||||
const [defs, runList] = await Promise.all([
|
||||
api.admin.listEvents(nextState || undefined),
|
||||
api.admin.listEventRuns({ limit: 50 }),
|
||||
])
|
||||
setEvents(defs.events || [])
|
||||
setRuns(runList.runs || [])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await load(state)
|
||||
if (alive) setError(null)
|
||||
} catch (err) {
|
||||
if (alive) setError(err.message)
|
||||
} finally {
|
||||
if (alive) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [load, state])
|
||||
|
||||
// "Start now" is an occurrence whose instant is the present, not a separate
|
||||
// concept — the same route a scheduled occurrence will use in Phase 4. Admin
|
||||
// only, deliberately (§N2): starting commits the deployment to everything the
|
||||
// definition contains, unattended.
|
||||
const startNow = async (event) => {
|
||||
setBusy(true)
|
||||
setNotice(null)
|
||||
try {
|
||||
const result = await api.admin.startEventRun(event.id, {})
|
||||
navigate(`/admin/events/runs/${result.run.id}`)
|
||||
} catch (err) {
|
||||
setNotice(err.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && !events.length && !runs.length) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
const live = runs.filter((r) => !isTerminalRun(r.status))
|
||||
const waiting = live.filter((r) => r.waitingSteps > 0)
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
<p className="sans" style={{ margin: 0, fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 620 }}>
|
||||
Scheduled, bounded, audited changes to the live world. A definition is authored as a draft,
|
||||
published as an immutable version, and every occurrence of it runs against the version it
|
||||
pinned. A definition can repeat — once, weekly, or on the nth weekday of the month, in its
|
||||
own timezone — and the <Link to="/admin/events/calendar">calendar</Link> is where those
|
||||
occurrences are read.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
|
||||
<label>
|
||||
<span className="field-label">Show</span>
|
||||
<select className="select" value={state} onChange={(e) => setState(e.target.value)}>
|
||||
<option value="">All definitions</option>
|
||||
<option value="draft">Drafts</option>
|
||||
<option value="ready">Ready</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
</label>
|
||||
<Link className="pill" style={{ fontSize: '0.74rem' }} to="/admin/events/calendar">
|
||||
Calendar
|
||||
</Link>
|
||||
{mayAuthor && (
|
||||
<Link className="pill" style={{ fontSize: '0.74rem' }} to="/admin/events/new">
|
||||
New event
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notice && (
|
||||
<p className="sans" style={{ fontSize: '0.84rem', color: '#d98b84', marginTop: 0 }}>{notice}</p>
|
||||
)}
|
||||
|
||||
{waiting.length > 0 && (
|
||||
<div className="panel-flat" style={{ padding: '12px 14px', marginBottom: 16, borderLeft: '3px solid #d9c184' }}>
|
||||
<p className="sans" style={{ margin: 0, fontSize: '0.86rem' }}>
|
||||
<strong>{waiting.length === 1 ? 'One run is' : `${waiting.length} runs are`} waiting on a
|
||||
person.</strong>{' '}
|
||||
<span className="dim">
|
||||
A cue holds its phase until somebody confirms it was done in-client — nothing else will
|
||||
move it.
|
||||
</span>
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 8 }}>
|
||||
{waiting.map((r) => (
|
||||
<Link key={r.id} className="pill" style={{ fontSize: '0.74rem' }} to={`/admin/events/runs/${r.id}`}>
|
||||
{r.definitionTitle} · {r.waitingSteps} waiting
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="sans" style={{ fontSize: '0.95rem', margin: '0 0 8px' }}>Definitions</h3>
|
||||
{events.length === 0 ? (
|
||||
<p className="sans dim" style={{ fontSize: '0.85rem' }}>
|
||||
{state ? 'Nothing matches that filter.' : 'No events have been authored yet.'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Event</th>
|
||||
<th className="adm-th">State</th>
|
||||
<th className="adm-th">Version</th>
|
||||
<th className="adm-th">Series</th>
|
||||
<th className="adm-th">Updated</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map((e) => (
|
||||
<tr key={e.id}>
|
||||
<td className="adm-td" style={{ fontSize: '0.85rem' }}>
|
||||
<Link to={`/admin/events/${e.id}`}>{e.title}</Link>
|
||||
<div className="dim" style={{ fontSize: '0.76rem' }}>{e.slug}</div>
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>{STATE_WORD[e.state] || e.state}</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
|
||||
{e.currentVersion ? `v${e.currentVersion}` : <span className="dim">unpublished</span>}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
|
||||
{e.seriesName || <span className="dim">—</span>}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.8rem', whiteSpace: 'nowrap' }}>{when(e.updatedAt)}</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right' }}>
|
||||
{/* Start is admin only and the button follows the route: an
|
||||
editor sees the definition and cannot commit the
|
||||
deployment to running it. */}
|
||||
{isAdmin && e.state === 'ready' && (
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem' }}
|
||||
disabled={busy} onClick={() => startNow(e)}>
|
||||
Start now
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="sans" style={{ fontSize: '0.95rem', margin: '22px 0 8px' }}>
|
||||
Recent runs
|
||||
{live.length > 0 && <span className="dim" style={{ fontWeight: 400 }}> · {live.length} in flight</span>}
|
||||
</h3>
|
||||
{runs.length === 0 ? (
|
||||
<p className="sans dim" style={{ fontSize: '0.85rem' }}>Nothing has run yet.</p>
|
||||
) : (
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Occurrence</th>
|
||||
<th className="adm-th">Event</th>
|
||||
<th className="adm-th">Status</th>
|
||||
<th className="adm-th">Phase</th>
|
||||
<th className="adm-th">Health</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className="adm-td" style={{ fontSize: '0.8rem', whiteSpace: 'nowrap' }}>
|
||||
<Link to={`/admin/events/runs/${r.id}`}>{when(r.scheduledFor)}</Link>
|
||||
{r.rehearsal && <span className="dim" style={{ fontSize: '0.74rem' }}> · rehearsal</span>}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
|
||||
{r.definitionTitle} <span className="dim">v{r.version}</span>
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem', color: STATUS_COLOR[r.status] || undefined }}>
|
||||
{runStatusWord(r.status)}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
|
||||
{r.currentPhase || <span className="dim">—</span>}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem', color: HEALTH_COLOR[r.health] || undefined }}>
|
||||
{r.health === 'ok' ? <span className="dim">ok</span> : r.health}
|
||||
</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right', fontSize: '0.78rem' }}>
|
||||
{r.waitingSteps > 0 && (
|
||||
<span style={{ color: '#d9c184' }}>
|
||||
waiting on {r.waitingSteps === 1 ? 'a person' : `${r.waitingSteps} people`}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
457
client/src/routes/admin/views/EventsCalendar.jsx
Normal file
457
client/src/routes/admin/views/EventsCalendar.jsx
Normal file
@@ -0,0 +1,457 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { useAuth } from '../../../contexts/AuthContext.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { runStatusWord, isProjected } from '../../../lib/eventAuthoring.js'
|
||||
|
||||
// Admin → Events → Calendar (EVENTS.md §I, Phase 4).
|
||||
//
|
||||
// **This screen is the deliverable.** What this feature replaces is a WordPress
|
||||
// calendar plugin with no series field, no recurrence and no results — so a
|
||||
// month grid that knows about arcs, repeats and local time is not decoration
|
||||
// here, it is the point.
|
||||
//
|
||||
// **Two kinds of entry, drawn differently on purpose.** A solid one is a *run*:
|
||||
// a real row with a status, a pinned version and a console, and somebody can
|
||||
// cancel it. A dashed one is a *projection*: arithmetic past the runner's
|
||||
// fourteen-day horizon, with no row behind it, nothing committed and nothing to
|
||||
// open. An operator who treats a forecast as a booking has been misled by the
|
||||
// UI, not by the server, so the difference is drawn rather than merely stated —
|
||||
// and the legend says which is which.
|
||||
//
|
||||
// **The grid's date axis is the READER's zone; each entry's time is the
|
||||
// EVENT's.** §E gives the timezone to the event because every listing this
|
||||
// replaces is written in the shard's local zone, but "what is happening this
|
||||
// month" is a question about the month the person reading is living in. So the
|
||||
// cell an event lands in is the reader's date, and the time beside it always
|
||||
// carries the event's own zone — `20:00 Europe/Berlin` misreads as nothing.
|
||||
|
||||
const DAY_MS = 86_400_000
|
||||
|
||||
const STATUS_COLOR = {
|
||||
failed: '#d98b84',
|
||||
missed: '#d98b84',
|
||||
paused: '#d9c184',
|
||||
cancelled: 'var(--muted)',
|
||||
running: '#8fc79a',
|
||||
}
|
||||
|
||||
/** The event's own wall clock, which is the only time worth showing beside it. */
|
||||
function localTime(instant, timezone) {
|
||||
try {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
timeZone: timezone,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).format(new Date(instant))
|
||||
} catch {
|
||||
return new Date(instant).toISOString().slice(11, 16)
|
||||
}
|
||||
}
|
||||
|
||||
/** The reader's own date key, which is what places an entry in a cell. */
|
||||
const readerDayKey = (instant) => {
|
||||
const d = new Date(instant)
|
||||
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The six-week grid a month view draws, Monday first.
|
||||
*
|
||||
* Always six weeks rather than however many the month needs: a grid that
|
||||
* changes height as you page through it is a grid whose rows move under the
|
||||
* cursor.
|
||||
*/
|
||||
function monthGrid(year, month) {
|
||||
const first = new Date(year, month, 1)
|
||||
const offset = (first.getDay() + 6) % 7
|
||||
const start = new Date(year, month, 1 - offset)
|
||||
return Array.from({ length: 42 }, (_, i) => new Date(start.getTime() + i * DAY_MS))
|
||||
}
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December',
|
||||
]
|
||||
|
||||
export default function EventsCalendar() {
|
||||
const { user } = useAuth()
|
||||
const today = useMemo(() => new Date(), [])
|
||||
const [year, setYear] = useState(today.getFullYear())
|
||||
const [month, setMonth] = useState(today.getMonth())
|
||||
const [view, setView] = useState('month')
|
||||
const [seriesId, setSeriesId] = useState('')
|
||||
const [series, setSeries] = useState([])
|
||||
const [data, setData] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [managingSeries, setManagingSeries] = useState(false)
|
||||
|
||||
const mayAuthor = user?.role === 'admin' || user?.role === 'editor'
|
||||
|
||||
// The window is the grid's own span, not the month's: an entry in the leading
|
||||
// or trailing week of the grid belongs to a neighbouring month and still has
|
||||
// to be fetched, or the first row of every month renders empty.
|
||||
const grid = useMemo(() => monthGrid(year, month), [year, month])
|
||||
const window = useMemo(() => {
|
||||
if (view === 'month') {
|
||||
return { from: grid[0], to: new Date(grid[41].getTime() + DAY_MS) }
|
||||
}
|
||||
// The list view answers a different question — "what is coming" — so it runs
|
||||
// forward from now rather than over a calendar month.
|
||||
const from = new Date()
|
||||
return { from, to: new Date(from.getTime() + 60 * DAY_MS) }
|
||||
}, [view, grid])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [calendar, seriesList] = await Promise.all([
|
||||
api.admin.eventCalendar({
|
||||
from: window.from.toISOString(),
|
||||
to: window.to.toISOString(),
|
||||
seriesId: seriesId || undefined,
|
||||
}),
|
||||
api.admin.eventSeries(),
|
||||
])
|
||||
setData(calendar)
|
||||
setSeries(seriesList.series || [])
|
||||
} catch (err) {
|
||||
setError(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [window.from, window.to, seriesId])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const byDay = useMemo(() => {
|
||||
const map = new Map()
|
||||
for (const entry of data?.entries || []) {
|
||||
const key = readerDayKey(entry.scheduledFor)
|
||||
if (!map.has(key)) map.set(key, [])
|
||||
map.get(key).push(entry)
|
||||
}
|
||||
return map
|
||||
}, [data])
|
||||
|
||||
const step = (delta) => {
|
||||
const next = new Date(year, month + delta, 1)
|
||||
setYear(next.getFullYear())
|
||||
setMonth(next.getMonth())
|
||||
}
|
||||
|
||||
if (loading && !data) return <Loading />
|
||||
if (error && !data) return <ErrorState error={error} onRetry={load} />
|
||||
|
||||
const horizon = data?.horizon ? new Date(data.horizon) : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'center', marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
{/*
|
||||
The stepper belongs to the MONTH view only. The list answers "what is
|
||||
coming" and runs sixty days forward from now whatever month is
|
||||
selected -- so paging it would be three controls that visibly do
|
||||
nothing, which is the one thing this feature has refused since Phase
|
||||
1. The heading says which question is being asked instead.
|
||||
*/}
|
||||
{view === 'month' && (
|
||||
<>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} onClick={() => step(-1)}>
|
||||
←
|
||||
</button>
|
||||
<strong className="sans" style={{ fontSize: '0.95rem', minWidth: 150, textAlign: 'center' }}>
|
||||
{`${MONTH_NAMES[month]} ${year}`}
|
||||
</strong>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} onClick={() => step(1)}>
|
||||
→
|
||||
</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem' }}
|
||||
disabled={year === today.getFullYear() && month === today.getMonth()}
|
||||
onClick={() => { setYear(today.getFullYear()); setMonth(today.getMonth()) }}>
|
||||
Today
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{view === 'list' && (
|
||||
<strong className="sans" style={{ fontSize: '0.95rem' }}>The next 60 days</strong>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, marginLeft: 'auto', alignItems: 'center' }}>
|
||||
<select className="select" value={seriesId} onChange={(e) => setSeriesId(e.target.value)}
|
||||
style={{ minWidth: 170 }}>
|
||||
<option value="">Every series</option>
|
||||
{series.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
<button type="button" className="pill" aria-pressed={view === 'month'}
|
||||
style={{ fontSize: '0.72rem', opacity: view === 'month' ? 1 : 0.5 }}
|
||||
onClick={() => setView('month')}>
|
||||
Month
|
||||
</button>
|
||||
<button type="button" className="pill" aria-pressed={view === 'list'}
|
||||
style={{ fontSize: '0.72rem', opacity: view === 'list' ? 1 : 0.5 }}
|
||||
onClick={() => setView('list')}>
|
||||
List
|
||||
</button>
|
||||
{mayAuthor && (
|
||||
<button type="button" className="pill" aria-pressed={managingSeries}
|
||||
style={{ fontSize: '0.72rem', opacity: managingSeries ? 1 : 0.6 }}
|
||||
onClick={() => setManagingSeries((v) => !v)}>
|
||||
Series
|
||||
</button>
|
||||
)}
|
||||
<Link to="/admin/events" className="pill" style={{ fontSize: '0.72rem' }}>Events</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* The legend is not optional. The whole screen rests on the reader
|
||||
knowing that a dashed entry is not a booking. */}
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 12px' }}>
|
||||
<span style={{ ...chip, borderStyle: 'solid' }}>Scheduled run</span> is a real occurrence with
|
||||
a console — it can be opened, paused and cancelled.{' '}
|
||||
<span style={{ ...chip, borderStyle: 'dashed', opacity: 0.7 }}>Forecast</span> is what the
|
||||
recurrence works out to beyond the {data?.horizonDays ?? 14}-day horizon: nothing is
|
||||
committed yet and there is nothing to open.
|
||||
{horizon && ` Everything up to ${horizon.toLocaleDateString()} is real.`}
|
||||
</p>
|
||||
|
||||
{managingSeries && <SeriesManager series={series} onChanged={load} />}
|
||||
|
||||
{data?.truncated && (
|
||||
<p className="sans" style={{ fontSize: '0.8rem', color: '#d9c184' }}>
|
||||
This window has more than the calendar will draw. Narrow it by series, or page to a
|
||||
shorter span.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{view === 'month' ? (
|
||||
<div className="panel-flat" style={{ padding: 10 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7,1fr)', gap: 4 }}>
|
||||
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((d) => (
|
||||
<div key={d} className="sans dim" style={{ fontSize: '0.72rem', textAlign: 'center', padding: '2px 0' }}>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
{grid.map((day) => {
|
||||
const entries = byDay.get(readerDayKey(day)) || []
|
||||
const outside = day.getMonth() !== month
|
||||
const isToday = readerDayKey(day) === readerDayKey(today)
|
||||
return (
|
||||
<div key={day.toISOString()}
|
||||
style={{
|
||||
minHeight: 84,
|
||||
padding: 4,
|
||||
borderRadius: 4,
|
||||
border: isToday ? '1px solid var(--accent, #8fc79a)' : '1px solid transparent',
|
||||
background: outside ? 'transparent' : 'rgba(255,255,255,0.03)',
|
||||
opacity: outside ? 0.4 : 1,
|
||||
}}>
|
||||
<div className="sans dim" style={{ fontSize: '0.7rem', marginBottom: 3 }}>
|
||||
{day.getDate()}
|
||||
</div>
|
||||
{entries.map((entry) => <EntryChip key={entryKey(entry)} entry={entry} />)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="panel-flat" style={{ padding: 4 }}>
|
||||
{(data?.entries || []).length === 0 ? (
|
||||
<p className="sans dim" style={{ padding: 14, margin: 0, fontSize: '0.84rem' }}>
|
||||
Nothing is scheduled in the next sixty days.{' '}
|
||||
{mayAuthor && <Link to="/admin/events/new">Author an event</Link>}
|
||||
</p>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Event</th>
|
||||
<th>Series</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(data?.entries || []).map((entry) => (
|
||||
<tr key={entryKey(entry)} style={{ opacity: isProjected(entry) ? 0.7 : 1 }}>
|
||||
<td className="sans" style={{ fontSize: '0.82rem', whiteSpace: 'nowrap' }}>
|
||||
{new Date(entry.scheduledFor).toLocaleDateString()}{' '}
|
||||
<span className="dim">
|
||||
{localTime(entry.scheduledFor, entry.timezone)} {entry.timezone}
|
||||
</span>
|
||||
</td>
|
||||
<td className="sans" style={{ fontSize: '0.84rem' }}>
|
||||
{entry.runId ? (
|
||||
<Link to={`/admin/events/runs/${entry.runId}`}>{entry.title}</Link>
|
||||
) : (
|
||||
<Link to={`/admin/events/${entry.definitionId}`}>{entry.title}</Link>
|
||||
)}
|
||||
{entry.adjusted === 'gap' && (
|
||||
<span className="dim" title="Daylight saving skips the time this was authored at, so it moves forward to the next one that exists">
|
||||
{' '}(clocks change)
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="sans dim" style={{ fontSize: '0.8rem' }}>{entry.seriesName || '—'}</td>
|
||||
<td className="sans" style={{ fontSize: '0.8rem', color: STATUS_COLOR[entry.status] }}>
|
||||
{isProjected(entry) ? <span className="dim">Forecast</span> : runStatusWord(entry.status)}
|
||||
{entry.waitingSteps > 0 && (
|
||||
<span style={{ color: '#d9c184' }}> · waiting on a person</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// A projection has no run id, so the definition and the instant are its
|
||||
// identity — the same pair the server dedupes projections against.
|
||||
const entryKey = (entry) =>
|
||||
entry.runId ? `run-${entry.runId}` : `proj-${entry.definitionId}-${entry.scheduledFor}`
|
||||
|
||||
const chip = {
|
||||
display: 'inline-block',
|
||||
padding: '0 5px',
|
||||
borderRadius: 3,
|
||||
borderWidth: 1,
|
||||
border: '1px solid var(--muted)',
|
||||
fontSize: '0.72rem',
|
||||
}
|
||||
|
||||
/**
|
||||
* The arcs, managed where they are used.
|
||||
*
|
||||
* A series is a label, not authored content — nothing pins one and no run
|
||||
* references one — so this is a small inline panel rather than a screen of its
|
||||
* own, and it lives on the calendar because the calendar is what makes an arc
|
||||
* visible in the first place. §I: *"Royal Spy Mission → Risky Partner → Message
|
||||
* From the Void" is continuity that exists nowhere in the tooling this replaces.*
|
||||
*
|
||||
* The delete is a real delete, and it says what it will detach before it
|
||||
* happens: `series_id` is ON DELETE SET NULL, so the definitions survive without
|
||||
* an arc and re-attaching one is a dropdown in the editor. Nothing is destroyed,
|
||||
* which is why this is the one delete in this feature that is not an archive.
|
||||
*/
|
||||
function SeriesManager({ series, onChanged }) {
|
||||
const [name, setName] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [problem, setProblem] = useState(null)
|
||||
|
||||
const run = async (fn) => {
|
||||
setBusy(true)
|
||||
setProblem(null)
|
||||
try {
|
||||
await fn()
|
||||
await onChanged()
|
||||
} catch (err) {
|
||||
setProblem(err?.body?.errors?.join('; ') || err?.message || 'That did not work')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panel-flat" style={{ padding: 14, marginBottom: 12 }}>
|
||||
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>Series</h3>
|
||||
<p className="sans dim" style={{ margin: '0 0 10px', fontSize: '0.78rem' }}>
|
||||
An arc several events form together. The order here is where a series sits among the
|
||||
others; where an event sits <em>within</em> its arc is that event’s own order, in the
|
||||
editor.
|
||||
</p>
|
||||
|
||||
{problem && (
|
||||
<p className="sans" style={{ fontSize: '0.8rem', color: '#d98b84' }}>{problem}</p>
|
||||
)}
|
||||
|
||||
{series.map((s) => (
|
||||
<div key={s.id} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}>
|
||||
<input className="input" defaultValue={s.name} disabled={busy} style={{ flex: 1 }}
|
||||
onBlur={(e) => {
|
||||
const next = e.target.value.trim()
|
||||
if (next && next !== s.name) {
|
||||
run(() => api.admin.updateEventSeries(s.id, { name: next, description: s.description, ordering: s.ordering }))
|
||||
}
|
||||
}} />
|
||||
<input className="input" type="number" defaultValue={s.ordering} disabled={busy}
|
||||
style={{ width: 72 }} aria-label={`Order of ${s.name}`}
|
||||
onBlur={(e) => {
|
||||
const next = Number(e.target.value)
|
||||
if (Number.isInteger(next) && next !== s.ordering) {
|
||||
run(() => api.admin.updateEventSeries(s.id, { name: s.name, description: s.description, ordering: next }))
|
||||
}
|
||||
}} />
|
||||
<span className="sans dim" style={{ fontSize: '0.76rem', minWidth: 70 }}>
|
||||
{s.definitionCount} event{s.definitionCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
<button type="button" className="pill" disabled={busy} style={{ fontSize: '0.7rem' }}
|
||||
onClick={() => {
|
||||
// The count is in the question, because the consequence of this
|
||||
// delete is entirely about the rows it does not delete.
|
||||
const ask = s.definitionCount
|
||||
? `Delete "${s.name}"? ${s.definitionCount} event(s) will keep their content and lose this series.`
|
||||
: `Delete "${s.name}"?`
|
||||
// eslint-disable-next-line no-alert
|
||||
if (window.confirm(ask)) run(() => api.admin.deleteEventSeries(s.id))
|
||||
}}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
|
||||
<input className="input" value={name} placeholder="New series name" disabled={busy}
|
||||
style={{ flex: 1 }} onChange={(e) => setName(e.target.value)} />
|
||||
<button type="button" className="pill" disabled={busy || !name.trim()} style={{ fontSize: '0.72rem' }}
|
||||
onClick={() => run(async () => {
|
||||
await api.admin.createEventSeries({ name: name.trim() })
|
||||
setName('')
|
||||
})}>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EntryChip({ entry }) {
|
||||
const projected = isProjected(entry)
|
||||
const to = entry.runId ? `/admin/events/runs/${entry.runId}` : `/admin/events/${entry.definitionId}`
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="sans"
|
||||
title={`${entry.title} — ${localTime(entry.scheduledFor, entry.timezone)} ${entry.timezone}${projected ? ' (forecast)' : ` — ${runStatusWord(entry.status)}`}`}
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '0.7rem',
|
||||
padding: '1px 4px',
|
||||
marginBottom: 2,
|
||||
borderRadius: 3,
|
||||
borderLeft: `2px ${projected ? 'dashed' : 'solid'} ${STATUS_COLOR[entry.status] || 'var(--accent, #8fc79a)'}`,
|
||||
background: projected ? 'transparent' : 'rgba(255,255,255,0.05)',
|
||||
opacity: projected ? 0.7 : 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
textDecoration: 'none',
|
||||
}}>
|
||||
<span className="dim">{localTime(entry.scheduledFor, entry.timezone)}</span> {entry.title}
|
||||
{entry.waitingSteps > 0 && <span style={{ color: '#d9c184' }}> ●</span>}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { dateTime } from '../../../lib/format.js'
|
||||
import { rowName } from '../../../lib/deletedAccount.js'
|
||||
import {
|
||||
freshnessOf, statusOf, gateLabelFor, describeRequest, leadershipOf, GATED_NOTE,
|
||||
} from '../../../lib/teamAdmin.js'
|
||||
@@ -14,7 +15,7 @@ import TeamVoice from './TeamVoice.jsx'
|
||||
// Three panels, in the order an operator needs them:
|
||||
//
|
||||
// 1. **Sync state**, verbatim, including the last error. The screen's first job
|
||||
// is to make "the shard has no Teams" and "core has not been able to ask for
|
||||
// is to make "the game server has no Teams" and "core has not been able to ask for
|
||||
// two hours" impossible to confuse — they render almost identically
|
||||
// otherwise, and one is fine while the other is an outage.
|
||||
// 2. **The review queue** — Teams auto-hidden because their name matched the
|
||||
@@ -331,7 +332,7 @@ function ForumLedger({ team, onClose }) {
|
||||
<td className="adm-td dim">{dateTime(r.created_at)}</td>
|
||||
<td className="adm-td" style={{ color: 'var(--head)' }}>{r.action}</td>
|
||||
<td className="adm-td dim">{r.target_type} #{r.target_id}</td>
|
||||
<td className="adm-td">{r.actor_username || '—'}</td>
|
||||
<td className="adm-td">{rowName(r.actor_user_id, r.actor_username)}</td>
|
||||
<td className="adm-td">
|
||||
{/* The distinction the whole ledger exists to preserve. */}
|
||||
<Pill tone={r.actor_role === 'staff' ? 'warn' : 'ok'}>{r.actor_role}</Pill>
|
||||
|
||||
@@ -5,6 +5,9 @@ import RecoveryCodesDisplay from '../../components/security/RecoveryCodesDisplay
|
||||
import TrustedDevicesPanel from '../../components/security/TrustedDevicesPanel.jsx'
|
||||
import RecoveryCodesPanel from '../../components/security/RecoveryCodesPanel.jsx'
|
||||
import EmailAddressPanel from '../../components/security/EmailAddressPanel.jsx'
|
||||
import DeleteAccountPanel from '../../components/security/DeleteAccountPanel.jsx'
|
||||
import BlockedPanel from '../../components/security/BlockedPanel.jsx'
|
||||
import { IdentitySummary, linkBanner, linkableProviders, unlinkPrompt } from '../../components/security/linkedIdentities.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
@@ -224,13 +227,7 @@ function LinkedAccounts() {
|
||||
const [available, setAvailable] = useState([])
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const banner = (() => {
|
||||
const q = new URLSearchParams(window.location.search)
|
||||
if (q.get('linked')) return { ok: true, text: 'Account linked.' }
|
||||
if (q.get('link_error') === 'in_use') return { ok: false, text: 'That external account is already linked to another user.' }
|
||||
if (q.get('link_error')) return { ok: false, text: 'Could not link that account. Please try again.' }
|
||||
return null
|
||||
})()
|
||||
const banner = linkBanner(window.location.search)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -247,12 +244,11 @@ function LinkedAccounts() {
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const nameFor = (id) => available.find((p) => p.id === id)?.name || id.charAt(0).toUpperCase() + id.slice(1)
|
||||
const iconFor = (id) => (id === 'google' || id === 'discord' ? id : 'oidc')
|
||||
|
||||
async function unlink(provider) {
|
||||
if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return
|
||||
async function unlink(identity) {
|
||||
if (!window.confirm(unlinkPrompt(identity, nameFor(identity.provider)))) return
|
||||
try {
|
||||
await api.unlinkIdentity(provider)
|
||||
await api.unlinkIdentity(identity.provider, identity.id)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not unlink.')
|
||||
@@ -262,13 +258,12 @@ function LinkedAccounts() {
|
||||
if (error) return <ErrorState message={error} />
|
||||
if (!linked) return null
|
||||
|
||||
const linkedIds = new Set(linked.map((i) => i.provider))
|
||||
const linkable = available.filter((p) => !linkedIds.has(p.id))
|
||||
const linkable = linkableProviders(available, linked)
|
||||
|
||||
return (
|
||||
<Section title="Linked accounts">
|
||||
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||
Link a Google, Discord, or other provider so you can sign in with it.
|
||||
Link a Google, Discord, Steam, or other provider so you can sign in with it.
|
||||
</p>
|
||||
{banner && (
|
||||
<p className="sans" style={{ color: banner.ok ? '#7fd0a4' : '#d98b84', fontSize: '0.86rem' }}>{banner.text}</p>
|
||||
@@ -276,15 +271,9 @@ function LinkedAccounts() {
|
||||
{linked.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
|
||||
{linked.map((i) => (
|
||||
<div key={i.provider} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||
<span style={{ display: 'inline-flex', width: 20, height: 20 }}>
|
||||
<ProviderIcon icon={iconFor(i.provider)} size={20} />
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>{nameFor(i.provider)}</div>
|
||||
{i.email && <div className="sans dim" style={{ fontSize: '0.78rem' }}>{i.email}</div>}
|
||||
</div>
|
||||
<button onClick={() => unlink(i.provider)} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>Unlink</button>
|
||||
<div key={i.id ?? i.provider} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||
<IdentitySummary identity={i} providerName={nameFor(i.provider)} />
|
||||
<button onClick={() => unlink(i)} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>Unlink</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -435,6 +424,8 @@ export default function PlayerAccount() {
|
||||
)}
|
||||
<LinkedAccounts />
|
||||
<ActiveDevices />
|
||||
<BlockedPanel />
|
||||
<DeleteAccountPanel account={account} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
84
client/src/routes/player/PlayerEvents.jsx
Normal file
84
client/src/routes/player/PlayerEvents.jsx
Normal file
@@ -0,0 +1,84 @@
|
||||
// This account's event participation (EVENTS.md §J, Phase 14a).
|
||||
//
|
||||
// **The screen's one real design decision is what an unranked row says.** A run
|
||||
// whose participants were collected but whose results have not been published
|
||||
// has a score and no rank, and that is a real state rather than an error — it is
|
||||
// the same state the admin run console has shown since Phase 10. Rendering "—"
|
||||
// with nothing explaining it would read as a bug; the row says "not published",
|
||||
// which is a fact about the event rather than about the reader.
|
||||
//
|
||||
// The list is keyset-paged on the participation row's own id, not offset-paged:
|
||||
// it gains a row every time the reader attends something.
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
import { eventDateTime } from '../../lib/eventCalendar.js'
|
||||
|
||||
const PAGE = 25
|
||||
|
||||
export default function PlayerEvents() {
|
||||
const [pages, setPages] = useState([])
|
||||
const [more, setMore] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const result = await api.player.eventHistory({ limit: PAGE })
|
||||
setPages([result.entries || []])
|
||||
setMore((result.entries || []).length === PAGE)
|
||||
return result
|
||||
}, [])
|
||||
const { loading, error } = useAsync(load)
|
||||
|
||||
const entries = pages.flat()
|
||||
|
||||
const loadMore = async () => {
|
||||
const last = entries[entries.length - 1]
|
||||
if (!last) return
|
||||
setLoadingMore(true)
|
||||
try {
|
||||
const result = await api.player.eventHistory({ limit: PAGE, before: last.id })
|
||||
setPages((p) => [...p, result.entries || []])
|
||||
setMore((result.entries || []).length === PAGE)
|
||||
} finally {
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (error) return <ErrorState message="Could not load your event history." />
|
||||
if (loading) return <Loading />
|
||||
if (entries.length === 0) {
|
||||
return <EmptyState>You have not taken part in an event yet.</EmptyState>
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{entries.map((e) => (
|
||||
<div key={e.id} className="panel" style={{ padding: '16px 20px', display: 'flex', gap: 18, flexWrap: 'wrap' }}>
|
||||
<span style={{ flex: 1, minWidth: 240 }}>
|
||||
<Link to={`/site/events/${e.slug}?run=${e.runId}`} style={{ fontSize: '1.05rem' }}>
|
||||
{e.title}
|
||||
</Link>
|
||||
<div className="dim sans" style={{ fontSize: '0.82rem', marginTop: 4 }}>
|
||||
{eventDateTime(e.scheduledFor, e.timezone)}
|
||||
{e.seriesName && ` · ${e.seriesName}`}
|
||||
</div>
|
||||
</span>
|
||||
<span style={{ textAlign: 'right', minWidth: 140 }}>
|
||||
<div className="sans" style={{ color: 'var(--head)' }}>
|
||||
{e.rank != null ? `Rank ${e.rank}` : <span className="dim">Results not published</span>}
|
||||
</div>
|
||||
<div className="dim sans" style={{ fontSize: '0.82rem' }}>Score {e.score}</div>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{more && (
|
||||
<button className="btn" onClick={loadMore} disabled={loadingMore}>
|
||||
{loadingMore ? 'Loading…' : 'Show more'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -39,6 +39,11 @@ const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-1
|
||||
const IconBell = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-9" /><path d="M13.7 21a2 2 0 01-3.4 0" /></Icon>
|
||||
// The settings row's own icon: a bell would make the two rows read as the same
|
||||
// destination twice, which is exactly the confusion the split was meant to end.
|
||||
// Participation history (Phase 14a). A calendar rather than a trophy: the row
|
||||
// is every event this account attended, ranked or not, and most of them will
|
||||
// never have a result published against them at all.
|
||||
const IconCalendar = () => <Icon><rect x="3" y="5" width="18" height="16" rx="2" /><path d="M3 10h18M8 3v4M16 3v4" /></Icon>
|
||||
|
||||
const IconBellGear = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h11" /><circle cx="18" cy="18" r="3" /><path d="M18 14v1M18 21v1M14 18h1M21 18h1" /></Icon>
|
||||
|
||||
// Exported because Admin -> Navigation edits this list. It stays declared here;
|
||||
@@ -51,6 +56,7 @@ const IconBellGear = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h11" />
|
||||
// UO module registers it again at `/player/uo/characters`, in this position,
|
||||
// with `order: 0`.
|
||||
export const NAV = [
|
||||
{ to: '/account/events', label: 'Events', icon: IconCalendar },
|
||||
{ to: '/account/appeals', label: 'Appeals', icon: IconShield },
|
||||
{ to: '/account/notifications', label: 'Notifications', end: true, icon: IconBell },
|
||||
{ to: '/account/notifications/settings', label: 'Notification settings', icon: IconBellGear },
|
||||
|
||||
@@ -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.')
|
||||
|
||||
227
client/src/routes/public/EventPage.jsx
Normal file
227
client/src/routes/public/EventPage.jsx
Normal file
@@ -0,0 +1,227 @@
|
||||
// One event's public page (EVENTS.md § API surface, Phase 14a).
|
||||
//
|
||||
// The storyline, its arc, what is live, what is next, what happened recently,
|
||||
// and a results table once an occurrence has published one.
|
||||
//
|
||||
// **`?run=` is read from the URL and passed straight through**, because that is
|
||||
// what an announcement's link carries. The page lives at the definition's slug —
|
||||
// one stable address, so a link posted in Discord survives a retitle — and the
|
||||
// occurrence has to be in the query string or a mail about last Friday's
|
||||
// invasion would open next Friday's.
|
||||
//
|
||||
// **The error is checked before the form.** Phase 13 found the inverse of this
|
||||
// on the admin editor: `if (loading || !form) return <Loading/>` above the error
|
||||
// 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, 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()
|
||||
const [params] = useSearchParams()
|
||||
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">
|
||||
<div className="shell-mid page-body">
|
||||
<ErrorState message="That event could not be found." />
|
||||
<p style={{ marginTop: 16 }}>
|
||||
<Link to="/site/events">Back to the calendar</Link>
|
||||
</p>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-mid page-body">
|
||||
<Loading />
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const event = data.event
|
||||
const headline = event.current || event.next
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-mid page-body">
|
||||
<PageHeader
|
||||
eyebrow={event.series ? event.series.name : 'Event'}
|
||||
title={event.title}
|
||||
lead={event.summary || ''}
|
||||
/>
|
||||
|
||||
{event.series && (
|
||||
<p className="sans" style={{ marginTop: -12 }}>
|
||||
<Link to={`/site/events/series/${event.series.slug}`}>Part of {event.series.name}</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* The one fact a visitor came for, before the storyline rather than
|
||||
after it: whether it is happening now, and if not, when it next is. */}
|
||||
<div
|
||||
className="panel"
|
||||
style={{
|
||||
padding: '18px 22px',
|
||||
marginBottom: 24,
|
||||
borderColor: event.live ? '#8fc79a' : undefined,
|
||||
}}
|
||||
>
|
||||
{event.live ? (
|
||||
<>
|
||||
<div
|
||||
className="sans"
|
||||
style={{ color: '#8fc79a', fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase', fontSize: '0.74rem' }}
|
||||
>
|
||||
Happening now
|
||||
</div>
|
||||
<div style={{ marginTop: 6, color: 'var(--head)', fontSize: '1.1rem' }}>
|
||||
{/* The phase LABEL, and only while it is live. The plan behind
|
||||
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 ? (
|
||||
<>
|
||||
<div className="sans dim" style={{ letterSpacing: '0.06em', textTransform: 'uppercase', fontSize: '0.74rem' }}>
|
||||
Next
|
||||
</div>
|
||||
<div style={{ marginTop: 6, color: 'var(--head)', fontSize: '1.1rem' }}>
|
||||
{eventDateTime(event.next.scheduledFor, event.next.timezone)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="dim">Nothing scheduled at the moment.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{event.body && (
|
||||
<article
|
||||
className="panel"
|
||||
style={{ padding: 28, marginBottom: 24 }}
|
||||
// Sanitized on write, the treatment a wiki page and a forum post get.
|
||||
dangerouslySetInnerHTML={{ __html: event.body }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{event.results && (
|
||||
<section style={{ marginBottom: 24 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.3rem', color: 'var(--head)' }}>
|
||||
Results
|
||||
</h2>
|
||||
<p className="dim sans" style={{ marginTop: -6, fontSize: '0.85rem' }}>
|
||||
{eventDateTime(event.results.scheduledFor, event.timezone)}
|
||||
</p>
|
||||
{event.results.participants.length === 0 ? (
|
||||
<EmptyState>Results were published with nobody recorded.</EmptyState>
|
||||
) : (
|
||||
<table className="table" style={{ width: '100%' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 60 }}>#</th>
|
||||
<th>Who</th>
|
||||
<th style={{ width: 120, textAlign: 'right' }}>Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{event.results.participants.map((p, i) => (
|
||||
<tr key={`${p.name || 'anon'}-${i}`}>
|
||||
<td>{p.rank ?? '—'}</td>
|
||||
{/* A module supplies a display name in `meta` or it does
|
||||
not; the member key is never published, so there is
|
||||
genuinely nothing else to render. */}
|
||||
<td>{p.name || <span className="dim">Unnamed</span>}</td>
|
||||
<td style={{ textAlign: 'right' }}>{p.score}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<Occurrences title="Coming up" list={event.upcoming} slug={event.slug} timezone={event.timezone} />
|
||||
<Occurrences title="Previously" list={event.past} slug={event.slug} timezone={event.timezone} past />
|
||||
|
||||
{!headline && event.past.length === 0 && (
|
||||
<EmptyState>This event has not been scheduled yet.</EmptyState>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
function Occurrences({ title, list, slug, timezone, past = false }) {
|
||||
if (!list || list.length === 0) return null
|
||||
return (
|
||||
<section style={{ marginBottom: 24 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.3rem', color: 'var(--head)' }}>
|
||||
{title}
|
||||
</h2>
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{list.map((o) => (
|
||||
<li key={o.runId} className="panel" style={{ padding: '12px 18px', display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||
<span style={{ flex: 1, minWidth: 220 }}>{eventDateTime(o.scheduledFor, o.timezone || timezone)}</span>
|
||||
<span className="dim sans" style={{ fontSize: '0.78rem' }}>{statusWord(o.status, o.scheduledFor)}</span>
|
||||
{/* Only a past occurrence gets its own link, and only when it has
|
||||
results: on any other, `?run=` would change nothing a reader
|
||||
could see. */}
|
||||
{past && o.resultsPublishedAt && (
|
||||
<Link className="sans" style={{ fontSize: '0.78rem' }} to={`/site/events/${slug}?run=${o.runId}`}>
|
||||
Results
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
78
client/src/routes/public/EventSeries.jsx
Normal file
78
client/src/routes/public/EventSeries.jsx
Normal file
@@ -0,0 +1,78 @@
|
||||
// One arc (EVENTS.md §I, Phase 14a).
|
||||
//
|
||||
// **The arc is the thing the tooling this replaces could not express at all.**
|
||||
// A WordPress calendar plugin has no series field, so "Royal Spy Mission → Risky
|
||||
// Partner → Message From the Void" existed only in a GM's head and in whatever
|
||||
// the forum post said. This page is that continuity, in the order an editor
|
||||
// arranged it — which is why the events are numbered rather than dated: an arc
|
||||
// has an order, and its parts may be months apart or run out of sequence.
|
||||
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
export default function EventSeries() {
|
||||
const { slug } = useParams()
|
||||
const { loading, error, data } = useAsync(() => api.publicEventSeries(slug), [slug])
|
||||
|
||||
// Error first, then loading — the order Phase 13 had to fix on the admin
|
||||
// editor, where a failed load sat behind a spinner that never stopped.
|
||||
if (error) {
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-mid page-body">
|
||||
<ErrorState message="That series could not be found." />
|
||||
<p style={{ marginTop: 16 }}>
|
||||
<Link to="/site/events">Back to the calendar</Link>
|
||||
</p>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-mid page-body">
|
||||
<Loading />
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const series = data.series
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-mid page-body">
|
||||
<PageHeader eyebrow="Series" title={series.name} lead={series.description || ''} />
|
||||
<ol style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{series.events.map((e, i) => (
|
||||
<li key={e.slug}>
|
||||
<Link to={`/site/events/${e.slug}`} style={{ textDecoration: 'none' }}>
|
||||
<div className="panel" style={{ padding: '18px 22px', display: 'flex', gap: 18 }}>
|
||||
<span
|
||||
className="display"
|
||||
style={{ color: 'var(--accent)', fontSize: '1.4rem', minWidth: 36, textAlign: 'right' }}
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
<span>
|
||||
<span className="display" style={{ fontSize: '1.15rem', color: 'var(--head)' }}>
|
||||
{e.title}
|
||||
</span>
|
||||
{e.summary && <p style={{ margin: '6px 0 0', color: 'var(--text)' }}>{e.summary}</p>}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className="sans" style={{ marginTop: 24 }}>
|
||||
<Link to="/site/events">Back to the calendar</Link>
|
||||
</p>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
131
client/src/routes/public/Events.jsx
Normal file
131
client/src/routes/public/Events.jsx
Normal file
@@ -0,0 +1,131 @@
|
||||
// The public event calendar (EVENTS.md §I, Phase 14a).
|
||||
//
|
||||
// **A list, not a month grid.** The admin calendar draws a grid because an
|
||||
// operator's question is "what does this month look like" — coverage, clashes,
|
||||
// the gap on the third weekend. A visitor's question is "what is on, and when is
|
||||
// the next one", which a chronological list answers in one glance and a grid
|
||||
// answers by making them count squares. Same data, different question.
|
||||
//
|
||||
// **A projection is drawn differently from a run, and the reason is the
|
||||
// operator's reason one tier along.** Past the materialisation horizon there is
|
||||
// no row: nothing is committed to, nothing can be cancelled, and a forecast
|
||||
// rendered identically to a booking would be the page promising something the
|
||||
// server has not. It is dashed and labelled "expected".
|
||||
//
|
||||
// The date heading is the READER's day and the time beside each entry is the
|
||||
// EVENT's own zone. That split is §I's: the shard's evening is what "8pm" means
|
||||
// to everyone reading it, but "this month" is the month the reader is living in.
|
||||
|
||||
import { 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 { eventTime, readerDayLabel, statusWord } from '../../lib/eventCalendar.js'
|
||||
|
||||
export default function Events() {
|
||||
const { loading, error, data } = useAsync(() => api.publicEvents())
|
||||
const entries = data?.entries || []
|
||||
|
||||
// Grouped by the reader's own day, in order. The server already sorted by
|
||||
// instant, so this preserves that order rather than re-sorting.
|
||||
const days = []
|
||||
for (const entry of entries) {
|
||||
const label = readerDayLabel(entry.scheduledFor)
|
||||
const last = days[days.length - 1]
|
||||
if (last && last.label === label) last.entries.push(entry)
|
||||
else days.push({ label, entries: [entry] })
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-mid page-body">
|
||||
<PageHeader
|
||||
eyebrow="What's on"
|
||||
title="Events"
|
||||
lead="Everything scheduled, live and recently finished. Times are shown in the shard's own timezone."
|
||||
/>
|
||||
<section style={{ display: 'flex', flexDirection: 'column', gap: 28 }}>
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load the calendar right now." />}
|
||||
{!loading && !error && entries.length === 0 && (
|
||||
<EmptyState>Nothing on the calendar just yet — check back soon.</EmptyState>
|
||||
)}
|
||||
{days.map((day) => (
|
||||
<div key={day.label}>
|
||||
<h2
|
||||
className="sans"
|
||||
style={{
|
||||
margin: '0 0 12px',
|
||||
fontSize: '0.74rem',
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
{day.label}
|
||||
</h2>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{day.entries.map((entry) => (
|
||||
<EventRow key={`${entry.slug}-${entry.scheduledFor}-${entry.kind}`} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
function EventRow({ entry }) {
|
||||
const projected = entry.kind === 'projected'
|
||||
const body = (
|
||||
<div
|
||||
className="panel"
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: 16,
|
||||
flexWrap: 'wrap',
|
||||
// The whole visual difference between a booking and a forecast, and it
|
||||
// is deliberately not subtle.
|
||||
borderStyle: projected ? 'dashed' : undefined,
|
||||
opacity: projected ? 0.72 : 1,
|
||||
}}
|
||||
>
|
||||
<span className="sans" style={{ fontWeight: 700, color: 'var(--accent)', minWidth: 96 }}>
|
||||
{eventTime(entry.scheduledFor, entry.timezone)}
|
||||
</span>
|
||||
<span style={{ flex: 1, minWidth: 200 }}>
|
||||
<span className="display" style={{ fontSize: '1.15rem', color: 'var(--head)' }}>
|
||||
{entry.title}
|
||||
</span>
|
||||
{entry.seriesName && (
|
||||
<span className="dim" style={{ marginLeft: 10, fontSize: '0.9rem' }}>
|
||||
{entry.seriesName}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.72rem',
|
||||
letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase',
|
||||
color: entry.live ? '#8fc79a' : 'var(--muted)',
|
||||
fontWeight: entry.live ? 700 : 400,
|
||||
}}
|
||||
>
|
||||
{projected ? 'Expected' : statusWord(entry.status, entry.scheduledFor)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
// A projection has no page of its own worth linking to any differently — the
|
||||
// event page IS the definition's — so both link to the same place. It is the
|
||||
// OCCURRENCE that does not exist yet, not the event.
|
||||
return <Link to={`/site/events/${entry.slug}`} style={{ textDecoration: 'none' }}>{body}</Link>
|
||||
}
|
||||
80
client/test/accountDeletion.test.js
Normal file
80
client/test/accountDeletion.test.js
Normal file
@@ -0,0 +1,80 @@
|
||||
// The Account page's "Delete my account" section (client/src/lib/accountDeletion.js)
|
||||
// and how a deleted account's name is marked for staff (lib/deletedAccount.js).
|
||||
// docs/website/ACCOUNT_DELETION.md §4.1, §4.2, AD5, AD6.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
usernameMatches, isSharedDemoLogin, refusalMessage, isStaffRole, WHAT_GOES, WHAT_STAYS,
|
||||
} from '../src/lib/accountDeletion.js'
|
||||
import { DELETED_PLAYER, markDeleted, rowName } from '../src/lib/deletedAccount.js'
|
||||
import { describeRequest, leadershipOf } from '../src/lib/teamAdmin.js'
|
||||
import { threadSummary } from '../src/lib/teamForum.js'
|
||||
|
||||
// ── the confirm step ───────────────────────────────────────────────────────
|
||||
|
||||
test('Delete is live only once the typed username matches, ignoring case and spaces', () => {
|
||||
assert.equal(usernameMatches('Thorne', 'Thorne'), true)
|
||||
assert.equal(usernameMatches(' thorne ', 'Thorne'), true)
|
||||
assert.equal(usernameMatches('Thorn', 'Thorne'), false)
|
||||
assert.equal(usernameMatches('', 'Thorne'), false)
|
||||
assert.equal(usernameMatches(' ', 'Thorne'), false)
|
||||
assert.equal(usernameMatches(undefined, 'Thorne'), false)
|
||||
})
|
||||
|
||||
test('the demo’s shared logins are recognised from the published list, and nothing else is', () => {
|
||||
const settings = { demo: { logins: [{ username: 'demo-player' }, { username: 'play-review' }] } }
|
||||
assert.equal(isSharedDemoLogin(settings, 'Demo-Player'), true)
|
||||
assert.equal(isSharedDemoLogin(settings, 'alice'), false)
|
||||
// Off the demo there is no `demo` block, so nobody is a shared login.
|
||||
assert.equal(isSharedDemoLogin({}, 'demo-player'), false)
|
||||
assert.equal(isSharedDemoLogin(undefined, 'demo-player'), false)
|
||||
})
|
||||
|
||||
test('every refusal code the route answers has its own sentence', () => {
|
||||
for (const code of ['username_mismatch', 'wrong_password', 'code_required', 'wrong_code', 'password_required', 'last_admin', 'DEMO_LOCKED']) {
|
||||
const msg = refusalMessage({ status: 400, body: { code }, message: 'server words' })
|
||||
assert.notEqual(msg, 'server words', code)
|
||||
assert.ok(msg.length > 10, code)
|
||||
}
|
||||
assert.match(refusalMessage({ status: 429, body: null }), /Too many attempts/)
|
||||
assert.equal(refusalMessage({ status: 500, message: 'Internal Server Error' }), 'Internal Server Error')
|
||||
assert.match(refusalMessage(null), /Could not delete/)
|
||||
})
|
||||
|
||||
test('the screen says what goes and what stays, and that staff names are kept forever', () => {
|
||||
assert.ok(WHAT_GOES.some((l) => /game-account links/.test(l)))
|
||||
assert.ok(WHAT_STAYS.some((l) => l.includes('[deleted player]')))
|
||||
assert.equal(isStaffRole('moderator'), true)
|
||||
assert.equal(isStaffRole('player'), false)
|
||||
})
|
||||
|
||||
// ── staff see a deleted account's name, marked ─────────────────────────────
|
||||
|
||||
test('a deleted account’s stored name is marked; the placeholder is left alone', () => {
|
||||
assert.equal(markDeleted('Ravenna', true), 'Ravenna (deleted account)')
|
||||
assert.equal(markDeleted('Ravenna', false), 'Ravenna')
|
||||
assert.equal(markDeleted(DELETED_PLAYER, true), DELETED_PLAYER)
|
||||
assert.equal(markDeleted(null, true), null)
|
||||
})
|
||||
|
||||
test('a raw ledger row is a deleted account only when its id is an explicit null', () => {
|
||||
assert.equal(rowName(null, 'OldMod'), 'OldMod (deleted account)')
|
||||
assert.equal(rowName(4, 'OldMod'), 'OldMod')
|
||||
assert.equal(rowName(undefined, 'OldMod'), 'OldMod')
|
||||
assert.equal(rowName(null, null), '—')
|
||||
})
|
||||
|
||||
test('Admin → Teams marks a deleted requester and a deleted override author', () => {
|
||||
assert.match(
|
||||
describeRequest({ action: 'unhide', requested_by: null, requested_username: 'mod1', team_name: 'X' }),
|
||||
/^mod1 \(deleted account\) asks/,
|
||||
)
|
||||
const l = leadershipOf({ isLeaderSynced: true, leaderOverride: { effect: 'deny', by: 'mod1', byDeleted: true } })
|
||||
assert.match(l.note, /Denied by mod1 \(deleted account\)/)
|
||||
})
|
||||
|
||||
test('a forum thread by a deleted account is marked in the list for staff', () => {
|
||||
assert.match(threadSummary({ type: 'discussion', author: 'Ravenna', authorDeleted: true, postCount: 1 }), /^Ravenna \(deleted account\)/)
|
||||
assert.equal(threadSummary({ type: 'discussion', author: DELETED_PLAYER, authorDeleted: true, postCount: 1 }), DELETED_PLAYER)
|
||||
})
|
||||
@@ -247,8 +247,71 @@ test('the report queue defaults to the open work rather than to everything', asy
|
||||
assert.equal(calls[0].url, '/api/v1/admin/moderation/reports')
|
||||
})
|
||||
|
||||
// USER_SAFETY.md §4.1: a report, a block and a hide name an ITEM, never a
|
||||
// person, and the server reads who wrote it.
|
||||
test('report, block and hide name an item, on the one route for every target', async () => {
|
||||
willReply({ body: { ok: true } })
|
||||
await api.reportItem({ targetType: 'rust.chat', targetId: 4410, reason: 'abuse' })
|
||||
assert.equal(calls[0].url, '/api/v1/player/reports')
|
||||
assert.deepEqual(JSON.parse(calls[0].opts.body), { targetType: 'rust.chat', targetId: 4410, reason: 'abuse' })
|
||||
|
||||
calls = []
|
||||
willReply({ body: { block: {} } })
|
||||
await api.blockAuthor({ targetType: 'team_forum_post', targetId: 80 })
|
||||
assert.equal(calls[0].url, '/api/v1/auth/me/blocks')
|
||||
assert.deepEqual(JSON.parse(calls[0].opts.body), { targetType: 'team_forum_post', targetId: 80 })
|
||||
|
||||
calls = []
|
||||
willReply({ body: { ok: true } })
|
||||
await api.unhideItem({ targetType: 'rust.chat', targetId: 4410 })
|
||||
assert.equal(calls[0].url, '/api/v1/auth/me/hidden/rust.chat/4410')
|
||||
assert.equal(calls[0].opts.method, 'DELETE')
|
||||
|
||||
calls = []
|
||||
willReply({ body: {} })
|
||||
await api.admin.hideReportedItem(41, { note: 'x' })
|
||||
assert.equal(calls[0].url, '/api/v1/admin/moderation/reports/41/hide')
|
||||
})
|
||||
|
||||
test('a Team slug is URL-encoded on every forum path', async () => {
|
||||
willReply({ body: { ok: true } })
|
||||
await api.teamForumReport('a b/c', { targetType: 'team_forum_thread', targetId: 1, reason: 'spam' })
|
||||
assert.equal(calls[0].url, '/api/v1/player/teams/a%20b%2Fc/forum/report')
|
||||
})
|
||||
|
||||
|
||||
// ── Public events (Phase 14a) ───────────────────────────────────────────
|
||||
//
|
||||
// The one shape worth pinning is `?run=`: it is what an announcement's link
|
||||
// carries, and a client that dropped it would make a mail about last Friday's
|
||||
// occurrence open next Friday's.
|
||||
|
||||
test('the public calendar asks for no window at all by default', async () => {
|
||||
willReply({ body: { entries: [] } })
|
||||
await api.publicEvents()
|
||||
// The server defaults to now through a month out, so the first render need
|
||||
// not compute two ISO instants before it can ask for anything.
|
||||
assert.equal(calls[0].url, '/api/v1/public/events')
|
||||
})
|
||||
|
||||
test('an event page carries the run when one was named, and not when it was not', async () => {
|
||||
willReply({ body: { ok: true } })
|
||||
await api.publicEvent('the-yew-invasion')
|
||||
assert.equal(calls[0].url, '/api/v1/public/events/the-yew-invasion')
|
||||
|
||||
willReply({ body: { ok: true } })
|
||||
await api.publicEvent('the-yew-invasion', 3692)
|
||||
assert.equal(calls[1].url, '/api/v1/public/events/the-yew-invasion?run=3692')
|
||||
})
|
||||
|
||||
test('an event slug is URL-encoded on every public path', async () => {
|
||||
willReply({ body: { ok: true } })
|
||||
await api.publicEventSeries('a b/c')
|
||||
assert.equal(calls[0].url, '/api/v1/public/events/series/a%20b%2Fc')
|
||||
})
|
||||
|
||||
test('participation history takes a keyset cursor, never an offset', async () => {
|
||||
willReply({ body: { entries: [] } })
|
||||
await api.player.eventHistory({ limit: 25, before: 900 })
|
||||
assert.equal(calls[0].url, '/api/v1/player/events/history?limit=25&before=900')
|
||||
})
|
||||
|
||||
71
client/test/demoNotice.test.js
Normal file
71
client/test/demoNotice.test.js
Normal 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)
|
||||
})
|
||||
925
client/test/eventAuthoring.test.js
Normal file
925
client/test/eventAuthoring.test.js
Normal file
@@ -0,0 +1,925 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
runControlsFor,
|
||||
stepControlsFor,
|
||||
isParked,
|
||||
lastStartedSeqOf,
|
||||
formFromDefinition,
|
||||
payloadFromForm,
|
||||
parseParams,
|
||||
blankStep,
|
||||
blankPhase,
|
||||
describeLogLine,
|
||||
logKindWord,
|
||||
runStatusWord,
|
||||
describeSchedule,
|
||||
scheduleFormFrom,
|
||||
scheduleFromForm,
|
||||
isProjected,
|
||||
blankAdvance,
|
||||
advanceFormFrom,
|
||||
advancePayload,
|
||||
WEEKDAYS,
|
||||
MONTHLY_NTHS,
|
||||
ADVANCE_KINDS,
|
||||
blankWhere,
|
||||
whereFormFrom,
|
||||
paramsRenderable,
|
||||
paramsMode,
|
||||
paramValue,
|
||||
setParam,
|
||||
datetimeInputValue,
|
||||
priceBodyFrom,
|
||||
worthPricing,
|
||||
PARAM_FORM,
|
||||
PARAM_JSON,
|
||||
} from '../src/lib/eventAuthoring.js'
|
||||
|
||||
// lib/eventAuthoring.js — what the three Events screens say and what they let
|
||||
// staff press (EVENTS.md §I, Phase 3).
|
||||
//
|
||||
// None of this is a boundary: `events/spec.js` decides what may be saved and the
|
||||
// six control statements decide what may happen to a run, each of them a
|
||||
// compare-and-set that re-checks the status this file only predicted.
|
||||
//
|
||||
// **The controls get most of the tests, and the reason is worth stating.** A
|
||||
// button offered that the server refuses is not a wrong write — but it is the
|
||||
// failure an operator meets at 2am, on the screen they opened because something
|
||||
// is already going wrong, about the run they are trying to stop. So the guards
|
||||
// are deliberately written twice and this is where the copy is checked against
|
||||
// the original.
|
||||
|
||||
const run = (over = {}) => ({ id: 1, status: 'running', currentPhase: 'main', ...over })
|
||||
const step = (over = {}) => ({
|
||||
id: 10,
|
||||
phase: 'main',
|
||||
seq: 0,
|
||||
status: 'pending',
|
||||
parked: false,
|
||||
...over,
|
||||
})
|
||||
|
||||
// ── The run controls ───────────────────────────────────────────────────────
|
||||
|
||||
test('pause is offered only for a run in flight', () => {
|
||||
assert.equal(runControlsFor(run({ status: 'running' })).pause, true)
|
||||
assert.equal(runControlsFor(run({ status: 'starting' })).pause, true)
|
||||
// A scheduled occurrence that should not happen is cancelled, not paused:
|
||||
// resuming one after its grace window would produce a `missed` from a button
|
||||
// labelled resume.
|
||||
assert.equal(runControlsFor(run({ status: 'scheduled' })).pause, false)
|
||||
assert.equal(runControlsFor(run({ status: 'paused' })).pause, false)
|
||||
})
|
||||
|
||||
test('cancel is offered right up to the moment a run goes terminal, and never after', () => {
|
||||
for (const status of ['scheduled', 'starting', 'running', 'paused', 'ending']) {
|
||||
assert.equal(runControlsFor(run({ status })).cancel, true, `${status} should be cancellable`)
|
||||
}
|
||||
for (const status of ['completed', 'cancelled', 'failed', 'missed']) {
|
||||
assert.equal(runControlsFor(run({ status })).cancel, false, `${status} should not be`)
|
||||
}
|
||||
})
|
||||
|
||||
test('resume is offered for exactly one status', () => {
|
||||
assert.equal(runControlsFor(run({ status: 'paused' })).resume, true)
|
||||
assert.equal(runControlsFor(run({ status: 'running' })).resume, false)
|
||||
})
|
||||
|
||||
// ── The step controls ──────────────────────────────────────────────────────
|
||||
|
||||
test('a parked step is running with nothing holding it, and only that', () => {
|
||||
assert.equal(isParked(step({ status: 'running', parked: true })), true)
|
||||
assert.equal(isParked(step({ status: 'running', parked: false })), false, 'a live lease is a dispatch')
|
||||
assert.equal(isParked(step({ status: 'pending', parked: true })), false)
|
||||
})
|
||||
|
||||
test('confirm is offered for a parked cue and for nothing else', () => {
|
||||
const r = run()
|
||||
const parked = step({ status: 'running', parked: true })
|
||||
assert.equal(stepControlsFor(r, parked, [parked]).confirm, true)
|
||||
|
||||
const dispatching = step({ status: 'running', parked: false })
|
||||
assert.equal(stepControlsFor(r, dispatching, [dispatching]).confirm, false)
|
||||
|
||||
const pending = step()
|
||||
assert.equal(stepControlsFor(r, pending, [pending]).confirm, false)
|
||||
})
|
||||
|
||||
test('skip is offered for a pending step and a parked cue', () => {
|
||||
const r = run()
|
||||
const pending = step()
|
||||
const parked = step({ id: 11, seq: 1, status: 'running', parked: true })
|
||||
const dispatching = step({ id: 12, seq: 2, status: 'running', parked: false })
|
||||
const failed = step({ id: 13, seq: 3, status: 'failed' })
|
||||
const steps = [pending, parked, dispatching, failed]
|
||||
|
||||
assert.equal(stepControlsFor(r, pending, steps).skip, true)
|
||||
assert.equal(stepControlsFor(r, parked, steps).skip, true)
|
||||
assert.equal(stepControlsFor(r, dispatching, steps).skip, false)
|
||||
// A failed step does not need skipping: the runner already steps over it, so
|
||||
// resuming the run carries the phase past it.
|
||||
assert.equal(stepControlsFor(r, failed, steps).skip, false)
|
||||
})
|
||||
|
||||
test('retry is offered for the failed step a paused run is stopped at', () => {
|
||||
const r = run({ status: 'paused' })
|
||||
const done = step({ id: 1, seq: 0, status: 'done' })
|
||||
const failed = step({ id: 2, seq: 1, status: 'failed' })
|
||||
const pending = step({ id: 3, seq: 2, status: 'pending' })
|
||||
const steps = [done, failed, pending]
|
||||
|
||||
assert.equal(stepControlsFor(r, failed, steps).retry, true)
|
||||
assert.equal(stepControlsFor(r, done, steps).retry, false)
|
||||
assert.equal(stepControlsFor(r, pending, steps).retry, false)
|
||||
})
|
||||
|
||||
test('retry is NOT offered for a failed step the run has moved past', () => {
|
||||
// The case the server guard exists for, and the one this copy of it has to
|
||||
// agree about: a phase that carried on past an `on_failure: skip` failure and
|
||||
// then paused at a later step. Offering retry on the first would re-queue a row
|
||||
// behind the runner's own cursor, where it sits pending for ever.
|
||||
const r = run({ status: 'paused' })
|
||||
const skippedOver = step({ id: 1, seq: 0, status: 'failed' })
|
||||
const carriedOn = step({ id: 2, seq: 1, status: 'done' })
|
||||
const stoppedAt = step({ id: 3, seq: 2, status: 'failed' })
|
||||
const notYet = step({ id: 4, seq: 3, status: 'pending' })
|
||||
const steps = [skippedOver, carriedOn, stoppedAt, notYet]
|
||||
|
||||
assert.equal(stepControlsFor(r, skippedOver, steps).retry, false)
|
||||
assert.equal(stepControlsFor(r, stoppedAt, steps).retry, true)
|
||||
})
|
||||
|
||||
test('retry is not offered while the run is still running, or in a phase it has left', () => {
|
||||
const failed = step({ status: 'failed' })
|
||||
assert.equal(stepControlsFor(run({ status: 'running' }), failed, [failed]).retry, false)
|
||||
|
||||
const old = step({ phase: 'one', status: 'failed' })
|
||||
const r = run({ status: 'paused', currentPhase: 'two' })
|
||||
assert.equal(stepControlsFor(r, old, [old]).retry, false)
|
||||
})
|
||||
|
||||
test('no control is offered on a run that is over', () => {
|
||||
for (const status of ['completed', 'cancelled', 'failed', 'missed']) {
|
||||
const parked = step({ status: 'running', parked: true })
|
||||
assert.deepEqual(stepControlsFor(run({ status }), parked, [parked]), {
|
||||
confirm: false,
|
||||
skip: false,
|
||||
retry: false,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('lastStartedSeqOf is the furthest step of the phase, and null when none has run', () => {
|
||||
const steps = [
|
||||
step({ id: 1, seq: 0, status: 'failed' }),
|
||||
step({ id: 2, seq: 1, status: 'done' }),
|
||||
step({ id: 3, seq: 2, status: 'pending' }),
|
||||
step({ id: 4, seq: 0, phase: 'other', status: 'done' }),
|
||||
]
|
||||
assert.equal(lastStartedSeqOf(steps, 'main'), 1)
|
||||
assert.equal(lastStartedSeqOf([step({ status: 'pending' })], 'main'), null)
|
||||
assert.equal(lastStartedSeqOf(steps, 'nothing-here'), null)
|
||||
})
|
||||
|
||||
// ── The definition form ────────────────────────────────────────────────────
|
||||
|
||||
const ANNOUNCE = {
|
||||
id: 'core.announce',
|
||||
label: 'Announce',
|
||||
risk: 'notify',
|
||||
params: [
|
||||
{ name: 'leg', type: 'string', required: true, example: 'discord' },
|
||||
{ name: 'title', type: 'string', required: false, example: 'The gates open' },
|
||||
{ name: 'body', type: 'string', required: true, example: 'A caravan was sighted.' },
|
||||
],
|
||||
}
|
||||
|
||||
test('a new step arrives prefilled from the action’s declared examples', () => {
|
||||
const fresh = blankStep(ANNOUNCE)
|
||||
assert.equal(fresh.actionId, 'core.announce')
|
||||
assert.deepEqual(JSON.parse(fresh.paramsText), {
|
||||
leg: 'discord',
|
||||
title: 'The gates open',
|
||||
body: 'A caravan was sighted.',
|
||||
})
|
||||
})
|
||||
|
||||
test('a new phase never collides with an existing key', () => {
|
||||
// Two phases sharing a key would silently collapse at materialisation —
|
||||
// `event_run_steps` is UNIQUE on (run_id, phase, seq) — so half the authored
|
||||
// steps would never exist. The server refuses it; the form must not propose it.
|
||||
const first = blankPhase([])
|
||||
const second = blankPhase([first])
|
||||
const third = blankPhase([first, second])
|
||||
assert.equal(new Set([first.key, second.key, third.key]).size, 3)
|
||||
})
|
||||
|
||||
test('the form round-trips a definition without losing a step', () => {
|
||||
const event = {
|
||||
title: 'Invasion',
|
||||
graceSeconds: 600,
|
||||
timezone: 'Europe/Berlin',
|
||||
concurrencyKey: 'invasion:{region}',
|
||||
spec: {
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [
|
||||
{
|
||||
key: 'warn',
|
||||
label: 'Warning',
|
||||
steps: [
|
||||
{ actionId: 'core.announce', label: 'Herald', onFailure: 'skip', params: { leg: 'discord', body: 'hi' } },
|
||||
{ actionId: 'core.wait', params: { seconds: 300 } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const built = payloadFromForm(formFromDefinition(event))
|
||||
assert.equal(built.ok, true)
|
||||
assert.deepEqual(built.payload.spec.phases, [
|
||||
{
|
||||
key: 'warn',
|
||||
label: 'Warning',
|
||||
steps: [
|
||||
{ actionId: 'core.announce', label: 'Herald', onFailure: 'skip', params: { leg: 'discord', body: 'hi' } },
|
||||
{ actionId: 'core.wait', params: { seconds: 300 } },
|
||||
],
|
||||
},
|
||||
])
|
||||
assert.equal(built.payload.graceSeconds, 600)
|
||||
assert.equal(built.payload.concurrencyKey, 'invasion:{region}')
|
||||
})
|
||||
|
||||
test('`listed` round-trips, and an unlisted event is not quietly re-listed', () => {
|
||||
// The trap this guards is `||` where `??` is meant. A definition an operator
|
||||
// deliberately unlisted sends `listed: false`, and `event?.listed || true`
|
||||
// would put it back on the public calendar on the author's next save — a
|
||||
// surprise event announced by a typo fix.
|
||||
const unlisted = payloadFromForm(
|
||||
formFromDefinition({ title: 'Invasion', listed: false, spec: { schedule: { kind: 'manual' }, phases: [] } }),
|
||||
)
|
||||
assert.equal(unlisted.payload.listed, false)
|
||||
|
||||
const listed = payloadFromForm(
|
||||
formFromDefinition({ title: 'Invasion', listed: true, spec: { schedule: { kind: 'manual' }, phases: [] } }),
|
||||
)
|
||||
assert.equal(listed.payload.listed, true)
|
||||
})
|
||||
|
||||
test('a new definition defaults to listed', () => {
|
||||
// The column's own default, and the ordinary case: unlisting is the
|
||||
// deliberate act, not listing.
|
||||
const fresh = payloadFromForm(formFromDefinition({ spec: { schedule: { kind: 'manual' }, phases: [] } }))
|
||||
assert.equal(fresh.payload.listed, true)
|
||||
})
|
||||
|
||||
test('an unchosen onFailure is omitted rather than invented', () => {
|
||||
// The server defaults it from the action's risk class, which is the whole
|
||||
// reason `risk` is required at registration. A form that posted a value would
|
||||
// silently override that — turning a `change` action's `pause` into a `skip`
|
||||
// and advancing a run over a half-changed world.
|
||||
const form = formFromDefinition({
|
||||
spec: { phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'core.announce', params: {} }] }] },
|
||||
})
|
||||
const built = payloadFromForm(form)
|
||||
assert.equal('onFailure' in built.payload.spec.phases[0].steps[0], false)
|
||||
})
|
||||
|
||||
test('a params box that is not JSON is refused with the step named', () => {
|
||||
const form = formFromDefinition({
|
||||
spec: { phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'core.announce', params: {} }] }] },
|
||||
})
|
||||
form.phases[0].steps[0].paramsText = '{ leg: discord }'
|
||||
|
||||
const built = payloadFromForm(form)
|
||||
assert.equal(built.ok, false)
|
||||
assert.match(built.errors[0], /Phase 1 "Main", step 1/)
|
||||
})
|
||||
|
||||
test('an empty params box is an empty object, not an error', () => {
|
||||
assert.deepEqual(parseParams('').params, {})
|
||||
assert.deepEqual(parseParams(' ').params, {})
|
||||
assert.ok(parseParams('[1,2]').error, 'an array is not a params object')
|
||||
assert.ok(parseParams('"leg"').error)
|
||||
})
|
||||
|
||||
// ── Rendering what happened ────────────────────────────────────────────────
|
||||
|
||||
test('a human transition reads differently from the runner’s own', () => {
|
||||
// Both are `run.status` rows. `detail.control` is the only thing that separates
|
||||
// "the runner paused this because a world write failed" from "somebody pressed
|
||||
// pause", and the console has to tell them apart at a glance.
|
||||
const byRunner = describeLogLine({
|
||||
kind: 'run.status',
|
||||
detail: { from: 'running', to: 'paused', because: 'core.spawn' },
|
||||
})
|
||||
const byPerson = describeLogLine({
|
||||
kind: 'run.status',
|
||||
detail: { from: 'running', to: 'paused', control: 'pause', by: 4, reason: 'shard is lagging' },
|
||||
})
|
||||
|
||||
assert.match(byRunner, /Running → Paused/)
|
||||
assert.match(byRunner, /core\.spawn/)
|
||||
assert.match(byPerson, /pause/)
|
||||
assert.match(byPerson, /by staff/)
|
||||
assert.match(byPerson, /shard is lagging/)
|
||||
})
|
||||
|
||||
test('the log lines a run produces all render as something', () => {
|
||||
const lines = [
|
||||
{ kind: 'run.created', detail: { version: 3, rehearsal: true } },
|
||||
{ kind: 'run.blocked', detail: { heldBy: 9, concurrencyKey: 'invasion:Yew' } },
|
||||
{ kind: 'run.health', detail: { to: 'degraded', because: 'core.announce' } },
|
||||
{ kind: 'phase.entered', phase: 'warn', detail: { steps: 2 } },
|
||||
{ kind: 'phase.completed', phase: 'warn', detail: {} },
|
||||
{ kind: 'step.parked', detail: { action: 'core.cue' } },
|
||||
{ kind: 'step.retry', detail: { action: 'core.announce', attempt: 1, of: 3, error: 'timeout' } },
|
||||
{ kind: 'step.status', detail: { action: 'core.wait', to: 'done' } },
|
||||
{ kind: 'note', detail: {} },
|
||||
]
|
||||
for (const line of lines) {
|
||||
const text = describeLogLine(line)
|
||||
assert.equal(typeof text, 'string')
|
||||
assert.ok(text.length > 0, `${line.kind} rendered as nothing`)
|
||||
assert.ok(!text.includes('undefined'), `${line.kind} rendered an undefined: ${text}`)
|
||||
}
|
||||
})
|
||||
|
||||
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', () => {
|
||||
// The failure this guards is subtle and total: `step.detail` falling to the
|
||||
// default renders the literal string "step.detail", which is the reporting
|
||||
// channel existing and showing nothing — exactly what it was built to fix.
|
||||
const text = describeLogLine({
|
||||
kind: 'step.detail',
|
||||
detail: { action: 'uo.item.grant', granted: 8, missed: 4, why: ['bank full', 'offline'] },
|
||||
})
|
||||
|
||||
assert.ok(!text.includes('step.detail'), `the kind id leaked into the sentence: ${text}`)
|
||||
assert.match(text, /uo\.item\.grant/)
|
||||
assert.match(text, /granted: 8/)
|
||||
assert.match(text, /missed: 4/)
|
||||
assert.match(text, /bank full/)
|
||||
})
|
||||
|
||||
test('a module detail is rendered generically, whatever a module puts in it', () => {
|
||||
// Core does not interpret these keys and neither does the renderer — a switch
|
||||
// here would be the browser learning one module vocabulary, which is the thing
|
||||
// the module system exists to prevent. So an unfamiliar shape still reads.
|
||||
const text = describeLogLine({
|
||||
kind: 'step.detail',
|
||||
detail: { action: 'rust.wipe.announce', servers: { eu: 3, us: 1 }, dryRun: false, at: null },
|
||||
})
|
||||
assert.ok(!text.includes('undefined'), text)
|
||||
assert.ok(!text.includes('[object Object]'), `a nested object rendered as a brace: ${text}`)
|
||||
assert.match(text, /eu 3/)
|
||||
assert.match(text, /dryRun: false/, 'false is a value, not an absence')
|
||||
})
|
||||
|
||||
test('a long module detail stays one line', () => {
|
||||
const many = Array.from({ length: 40 }, (_, i) => `player-${i}`)
|
||||
const text = describeLogLine({
|
||||
kind: 'step.detail',
|
||||
detail: { action: 'uo.item.grant', missed: many, note: 'x'.repeat(500) },
|
||||
})
|
||||
assert.match(text, /and 35 more/)
|
||||
assert.ok(text.length < 300, `one row should not wrap eight times: ${text.length} chars`)
|
||||
})
|
||||
|
||||
test('a module detail with nothing in it still reads as a sentence', () => {
|
||||
const text = describeLogLine({ kind: 'step.detail', detail: { action: 'uo.world.save' } })
|
||||
assert.ok(text.length > 0)
|
||||
assert.ok(!text.includes('undefined'), text)
|
||||
})
|
||||
|
||||
test('every run status has a word, and an unknown one falls through rather than blanking', () => {
|
||||
for (const s of ['scheduled', 'starting', 'running', 'paused', 'ending', 'completed', 'cancelled', 'failed', 'missed']) {
|
||||
assert.ok(runStatusWord(s).length > 0)
|
||||
}
|
||||
assert.equal(runStatusWord('something-new'), 'something-new')
|
||||
})
|
||||
|
||||
|
||||
// ── The schedule form (Phase 4) ─────────────────────────────────────
|
||||
//
|
||||
// The form is the whole argument against cron: a closed set of four shapes has a
|
||||
// dropdown, and a dropdown can be proofread. What is checked here is that the
|
||||
// round trip through the form does not quietly change what the author wrote —
|
||||
// the server would refuse a malformed schedule, but it cannot refuse a
|
||||
// well-formed one that says something the author did not mean.
|
||||
|
||||
test('a schedule survives the round trip through the form unchanged', () => {
|
||||
for (const schedule of [
|
||||
{ kind: 'manual' },
|
||||
{ kind: 'once', at: '2026-10-31T20:00' },
|
||||
{ kind: 'weekly', days: ['monday', 'friday'], time: '20:00' },
|
||||
{ kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' },
|
||||
]) {
|
||||
const form = scheduleFormFrom(schedule)
|
||||
assert.deepEqual(scheduleFromForm(form), schedule, JSON.stringify(schedule))
|
||||
}
|
||||
})
|
||||
|
||||
test('switching kind keeps the other shapes fields, and sends only the chosen one', () => {
|
||||
// An author who clicks Weekly, then Monthly, then back must not find the days
|
||||
// they picked gone — but the request body must still be a single clean shape,
|
||||
// not a union of everything they touched.
|
||||
const form = { ...scheduleFormFrom({ kind: 'weekly', days: ['friday'], time: '20:00' }), scheduleKind: 'monthly' }
|
||||
const sent = scheduleFromForm(form)
|
||||
assert.deepEqual(Object.keys(sent).sort(), ['kind', 'nth', 'time', 'weekday'])
|
||||
assert.equal(form.scheduleDays.includes('friday'), true)
|
||||
})
|
||||
|
||||
test('formFromDefinition carries the whole schedule, not only its kind', () => {
|
||||
const form = formFromDefinition({
|
||||
title: 'Fishing contest',
|
||||
timezone: 'Europe/Berlin',
|
||||
spec: {
|
||||
schedule: { kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' },
|
||||
phases: [{ key: 'main', label: 'Main', steps: [] }],
|
||||
},
|
||||
})
|
||||
assert.equal(form.scheduleKind, 'monthly')
|
||||
assert.equal(form.scheduleNth, '-1')
|
||||
assert.equal(form.scheduleWeekday, 'friday')
|
||||
assert.equal(form.scheduleTime, '19:30')
|
||||
|
||||
const built = payloadFromForm(form)
|
||||
assert.equal(built.ok, true)
|
||||
assert.deepEqual(built.payload.spec.schedule, {
|
||||
kind: 'monthly',
|
||||
nth: -1,
|
||||
weekday: 'friday',
|
||||
time: '19:30',
|
||||
})
|
||||
})
|
||||
|
||||
test('a definition with no schedule at all reads as manual rather than as broken', () => {
|
||||
const form = formFromDefinition({ title: 'x', spec: { phases: [] } })
|
||||
assert.equal(form.scheduleKind, 'manual')
|
||||
assert.deepEqual(scheduleFromForm(form), { kind: 'manual' })
|
||||
})
|
||||
|
||||
test('every schedule describes as a sentence, and a half-built one says what is missing', () => {
|
||||
assert.match(describeSchedule({ kind: 'manual' }), /by hand/)
|
||||
assert.equal(
|
||||
describeSchedule({ kind: 'weekly', days: ['friday', 'saturday'], time: '20:00' }, 'Europe/Berlin'),
|
||||
'Every Friday and Saturday at 20:00 (Europe/Berlin)',
|
||||
)
|
||||
assert.equal(
|
||||
describeSchedule({ kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' }, 'Asia/Kolkata'),
|
||||
'The last Friday of every month at 19:30 (Asia/Kolkata)',
|
||||
)
|
||||
// Half-built is the state the preview spends most of its life in — an author
|
||||
// is typing. It must prompt, never render "undefined".
|
||||
for (const partial of [
|
||||
{ kind: 'weekly', days: [], time: '20:00' },
|
||||
{ kind: 'weekly', days: ['friday'], time: '' },
|
||||
{ kind: 'monthly', nth: 1, weekday: '', time: '19:00' },
|
||||
{ kind: 'once', at: '' },
|
||||
]) {
|
||||
const text = describeSchedule(partial, 'UTC')
|
||||
assert.ok(text.length > 0)
|
||||
assert.ok(!text.includes('undefined'), `${JSON.stringify(partial)} rendered: ${text}`)
|
||||
assert.match(text, /choose|no date/i)
|
||||
}
|
||||
})
|
||||
|
||||
test('the weekday and nth vocabularies match the server', () => {
|
||||
// Verbatim `events/recurrence.js`. A client list that drifted would offer a
|
||||
// value the server refuses, which is exactly the class of failure this file
|
||||
// exists to catch.
|
||||
assert.deepEqual(WEEKDAYS, [
|
||||
'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday',
|
||||
])
|
||||
assert.deepEqual(MONTHLY_NTHS.map((n) => n.value), [1, 2, 3, 4, -1])
|
||||
})
|
||||
|
||||
test('a projection is told apart from a run, because only one of them can be acted on', () => {
|
||||
assert.equal(isProjected({ kind: 'projected', runId: null }), true)
|
||||
assert.equal(isProjected({ kind: 'run', runId: 12 }), false)
|
||||
assert.equal(isProjected(null), false)
|
||||
})
|
||||
|
||||
|
||||
// -- The advance gate (Phase 5) ---------------------------------------------
|
||||
//
|
||||
// What this screen must get right is what it OFFERS. `advance` is the one
|
||||
// control in this feature whose whole point is that it overrides the engine, so
|
||||
// a button offered in a state the server refuses would be the "control that
|
||||
// answers 409 and does nothing" this feature has refused twice.
|
||||
|
||||
test('advance is offered only when the phase is waiting on its gate', () => {
|
||||
const gate = (over = {}) => [{ phase: 'boss', satisfied: false, ...over }]
|
||||
const done = [{ phase: 'boss', status: 'done' }]
|
||||
|
||||
assert.equal(runControlsFor({ status: 'running', currentPhase: 'boss' }, gate(), done).advance, true)
|
||||
|
||||
// A phase with an open step is held by the STEP, and skip is its control.
|
||||
assert.equal(
|
||||
runControlsFor({ status: 'running', currentPhase: 'boss' }, gate(), [...done, { phase: 'boss', status: 'pending' }]).advance,
|
||||
false,
|
||||
)
|
||||
assert.equal(
|
||||
runControlsFor({ status: 'running', currentPhase: 'boss' }, gate(), [{ phase: 'boss', status: 'running' }]).advance,
|
||||
false,
|
||||
)
|
||||
|
||||
// A phase with no gate advances on its steps and always has.
|
||||
assert.equal(runControlsFor({ status: 'running', currentPhase: 'boss' }, [], done).advance, false)
|
||||
// A gate already satisfied is not waiting.
|
||||
assert.equal(runControlsFor({ status: 'running', currentPhase: 'boss' }, gate({ satisfied: true }), done).advance, false)
|
||||
// And a run that is not running is waiting on nothing.
|
||||
for (const status of ['scheduled', 'starting', 'paused', 'ending', 'completed', 'cancelled', 'failed', 'missed']) {
|
||||
assert.equal(runControlsFor({ status, currentPhase: 'boss' }, gate(), done).advance, false, status)
|
||||
}
|
||||
})
|
||||
|
||||
test('runControlsFor still answers with no gates or steps at all', () => {
|
||||
// The three Phase 3 controls were called with one argument for two phases, and
|
||||
// the calendar still calls it that way.
|
||||
const controls = runControlsFor({ status: 'running', currentPhase: 'boss' })
|
||||
assert.equal(controls.pause, true)
|
||||
assert.equal(controls.advance, false)
|
||||
})
|
||||
|
||||
test('a gate round-trips through the form without losing the other shape', () => {
|
||||
assert.deepEqual(advanceFormFrom(null), blankAdvance())
|
||||
assert.equal(advanceFormFrom({ after: '2h' }).kind, 'after')
|
||||
assert.equal(advanceFormFrom({ after: '2h' }).after, '2h')
|
||||
|
||||
const on = advanceFormFrom({ on: 'uo.champ.boss_up', where: { variable: 'region', cmp: 'eq', value: 'Yew' }, count: 3 })
|
||||
assert.equal(on.kind, 'on')
|
||||
assert.equal(on.count, 3)
|
||||
assert.deepEqual(JSON.parse(on.whereText), { variable: 'region', cmp: 'eq', value: 'Yew' })
|
||||
|
||||
// The dropdown's three options, and the empty one is what nearly every phase
|
||||
// is — so it is first and it is not called "none".
|
||||
assert.equal(ADVANCE_KINDS[0].value, '')
|
||||
})
|
||||
|
||||
test('advancePayload sends one shape, built from the builder\u2019s rows', () => {
|
||||
const errors = []
|
||||
assert.equal(advancePayload({ kind: '' }, 'Phase 1', errors), null, 'no gate sends no key at all')
|
||||
assert.deepEqual(advancePayload({ kind: 'after', after: '30m' }, 'Phase 1', errors), { after: '30m' })
|
||||
assert.deepEqual(
|
||||
advancePayload({ kind: 'on', on: 'uo.champ.boss_up', count: '2', ...blankWhere() }, 'Phase 1', errors),
|
||||
{ on: 'uo.champ.boss_up', count: 2 },
|
||||
'an empty predicate is omitted, not sent as an empty object',
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
|
||||
// Whether the predicate is VALID is still the server's answer, named variable
|
||||
// and all \u2014 the builder only offers what the trigger declares, and a variable
|
||||
// that has gone away comes back named from the save.
|
||||
assert.deepEqual(
|
||||
advancePayload(
|
||||
{
|
||||
kind: 'on',
|
||||
on: 'x',
|
||||
count: 1,
|
||||
...blankWhere(),
|
||||
whereRows: [{ variable: 'nope', cmp: 'eq', value: '1' }],
|
||||
},
|
||||
'Phase 1',
|
||||
errors,
|
||||
[{ name: 'nope', type: 'int' }],
|
||||
),
|
||||
{ on: 'x', count: 1, where: { variable: 'nope', cmp: 'eq', value: 1 } },
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
test('the builder coerces each literal to the type the trigger declared', () => {
|
||||
// The trap this closes: every value in an HTML input is a string, and
|
||||
// `{ cmp: 'gt', value: "5" }` against an int variable is refused by
|
||||
// engagement/conditions.js. Without this the author reads an error about JSON
|
||||
// rather than about what they typed.
|
||||
const built = advancePayload(
|
||||
{
|
||||
kind: 'on',
|
||||
on: 'x',
|
||||
count: 1,
|
||||
...blankWhere(),
|
||||
whereOp: 'or',
|
||||
whereRows: [
|
||||
{ variable: 'tier', cmp: 'gte', value: '3' },
|
||||
{ variable: 'region', cmp: 'in', value: 'Yew, Britain' },
|
||||
],
|
||||
},
|
||||
'Phase 1',
|
||||
[],
|
||||
[{ name: 'tier', type: 'int' }, { name: 'region', type: 'string' }],
|
||||
)
|
||||
assert.deepEqual(built.where, {
|
||||
op: 'or',
|
||||
nodes: [
|
||||
{ variable: 'tier', cmp: 'gte', value: 3 },
|
||||
{ variable: 'region', cmp: 'in', value: ['Yew', 'Britain'] },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test('a predicate the builder cannot render is posted back unchanged, not flattened', () => {
|
||||
// `A and (B or C)` is not `A and B and C` \u2014 they fire on different events \u2014
|
||||
// and an author would have no way to know the save had done it. The condition
|
||||
// builder's own rule, and this is the same function.
|
||||
const nested = {
|
||||
op: 'and',
|
||||
nodes: [
|
||||
{ variable: 'region', cmp: 'eq', value: 'Yew' },
|
||||
{ op: 'or', nodes: [{ variable: 'tier', cmp: 'eq', value: 1 }, { variable: 'tier', cmp: 'eq', value: 2 }] },
|
||||
],
|
||||
}
|
||||
const form = whereFormFrom(nested)
|
||||
assert.equal(form.whereEditable, false)
|
||||
assert.deepEqual(form.whereRows, [])
|
||||
|
||||
const errors = []
|
||||
const built = advancePayload({ kind: 'on', on: 'x', count: 1, ...form }, 'Phase 1', errors)
|
||||
assert.deepEqual(built.where, nested, 'the tree survives a screen that cannot draw it')
|
||||
assert.equal(errors.length, 0)
|
||||
|
||||
// And the text is still the thing that can fail to parse, which is the only
|
||||
// reason this path keeps an error channel at all.
|
||||
advancePayload(
|
||||
{ kind: 'on', on: 'x', count: 1, whereEditable: false, whereText: '{ not json' },
|
||||
'Phase 2 "Boss"',
|
||||
errors,
|
||||
)
|
||||
assert.equal(errors.length, 1)
|
||||
assert.match(errors[0], /Phase 2 "Boss", advance condition:/)
|
||||
})
|
||||
|
||||
test('a phase with no gate sends no `advance` key', () => {
|
||||
const form = formFromDefinition({
|
||||
title: 'x',
|
||||
spec: { schedule: { kind: 'manual' }, phases: [{ key: 'main', label: 'Main', steps: [] }] },
|
||||
})
|
||||
const built = payloadFromForm(form)
|
||||
assert.equal(built.ok, true)
|
||||
assert.equal('advance' in built.payload.spec.phases[0], false)
|
||||
})
|
||||
|
||||
test('an authored gate survives the round trip through the form', () => {
|
||||
const form = formFromDefinition({
|
||||
title: 'x',
|
||||
spec: {
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [
|
||||
{ key: 'boss', label: 'Boss', steps: [], advance: { on: 'uo.champ.boss_up', where: { variable: 'region', cmp: 'eq', value: 'Yew' }, count: 2 } },
|
||||
{ key: 'loot', label: 'Loot', steps: [], advance: { after: '10m' } },
|
||||
],
|
||||
},
|
||||
})
|
||||
const built = payloadFromForm(form)
|
||||
assert.equal(built.ok, true)
|
||||
assert.deepEqual(built.payload.spec.phases[0].advance, {
|
||||
on: 'uo.champ.boss_up',
|
||||
where: { variable: 'region', cmp: 'eq', value: 'Yew' },
|
||||
count: 2,
|
||||
})
|
||||
assert.deepEqual(built.payload.spec.phases[1].advance, { after: '10m' })
|
||||
})
|
||||
|
||||
test('the log renders Phase 5\'s three kinds, including the near miss', () => {
|
||||
assert.match(
|
||||
describeLogLine({ kind: 'phase.gate', phase: 'boss', detail: { kind: 'on', trigger: 'uo.champ.boss_up', needed: 2, where: 'region is "Yew"' } }),
|
||||
/boss advances on 2 × uo\.champ\.boss_up where region is "Yew"/,
|
||||
)
|
||||
assert.match(describeLogLine({ kind: 'phase.gate', phase: 'loot', detail: { kind: 'after', after: '10m' } }), /loot advances 10m after it started/)
|
||||
assert.match(
|
||||
describeLogLine({ kind: 'condition.evaluated', detail: { trigger: 'uo.champ.boss_up', matched: false, seen: 0, needed: 2 } }),
|
||||
/did not count — 0 of 2/,
|
||||
)
|
||||
assert.match(
|
||||
describeLogLine({ kind: 'condition.evaluated', detail: { trigger: 'uo.champ.boss_up', matched: true, seen: 2, needed: 2, satisfied: true } }),
|
||||
/counted — 2 of 2, condition met/,
|
||||
)
|
||||
assert.match(
|
||||
describeLogLine({ kind: 'phase.advanced', phase: 'boss', detail: { because: 'forced', waitedSeconds: 4080, reason: 'never spawned' } }),
|
||||
/boss advanced by hand after 4080s: never spawned/,
|
||||
)
|
||||
assert.match(
|
||||
describeLogLine({ kind: 'phase.advanced', phase: 'loot', detail: { because: 'elapsed', waitedSeconds: 600 } }),
|
||||
/loot advanced on its deadline after 600s/,
|
||||
)
|
||||
})
|
||||
|
||||
test("the log renders Phase 6's three kinds, and a refusal does not read as a failure", () => {
|
||||
// The distinction the whole kind exists for. An operator scanning a stopped run
|
||||
// has to be able to see that nothing is broken — the deployment simply does not
|
||||
// permit what the author asked for — and the answer differs by cause: a switch
|
||||
// for "not enabled", a number for "over the cap".
|
||||
assert.match(
|
||||
describeLogLine({
|
||||
kind: 'step.refused',
|
||||
detail: { action: 'uo.creature.spawn', error: 'asks for 12 of "uo.creatures"; 28 of 30 is already spent this run' },
|
||||
}),
|
||||
/uo\.creature\.spawn refused: asks for 12 of "uo\.creatures"; 28 of 30 is already spent this run/,
|
||||
)
|
||||
assert.match(
|
||||
describeLogLine({
|
||||
kind: 'step.refused',
|
||||
detail: { action: 'uo.creature.spawn', error: '"Spawn creatures" is not enabled on this deployment' },
|
||||
}),
|
||||
/refused: "Spawn creatures" is not enabled/,
|
||||
)
|
||||
assert.equal(logKindWord('step.refused'), 'Refused')
|
||||
|
||||
// The caps a run was seeded with, and which switch set each — so a number on
|
||||
// the meter can be traced back to something an operator can change.
|
||||
assert.match(
|
||||
describeLogLine({
|
||||
kind: 'run.budget',
|
||||
detail: { dimensions: [{ dimension: 'uo.creatures', cap: 30, from: 'uo.creature.spawn' }] },
|
||||
}),
|
||||
/uo\.creatures capped at 30 \(uo\.creature\.spawn\)/,
|
||||
)
|
||||
assert.match(
|
||||
describeLogLine({ kind: 'run.budget', detail: { dimensions: [{ dimension: 'uo.gate.minutes', cap: null, from: null }] } }),
|
||||
/uo\.gate\.minutes capped at nothing/,
|
||||
)
|
||||
// A run with no capped dimension at all still gets a sentence rather than an
|
||||
// empty line, because an empty log entry reads as a bug.
|
||||
assert.match(describeLogLine({ kind: 'run.budget', detail: { dimensions: [] } }), /no caps apply to this run/)
|
||||
|
||||
assert.match(
|
||||
describeLogLine({ kind: 'version.verified', detail: { versionId: 4, version: 2, by: 1 } }),
|
||||
/Version 2 passed its dry run — scheduled occurrences may start/,
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
// ── Step params as a form (Phase 13) ──────────────────────────────
|
||||
//
|
||||
// The form is not a boundary either — `events/spec.js` still decides what may be
|
||||
// saved. What is tested here is the thing that would be wrong SILENTLY: a form
|
||||
// that drops a param it cannot draw, or writes a value the author never typed.
|
||||
|
||||
const spawn = {
|
||||
id: 'test.spawn',
|
||||
label: 'Spawn',
|
||||
params: [
|
||||
{ name: 'creature', type: 'string', required: true, example: 'orc', source: 'test.creatures' },
|
||||
{ name: 'count', type: 'int', required: true, example: 8 },
|
||||
{ name: 'tame', type: 'boolean', required: false, example: false },
|
||||
{ name: 'at', type: 'datetime', required: false, example: '2026-09-07T20:00:00.000Z' },
|
||||
],
|
||||
}
|
||||
|
||||
const stepWith = (params, over = {}) => ({
|
||||
actionId: 'test.spawn',
|
||||
paramsText: JSON.stringify(params, null, 2),
|
||||
...over,
|
||||
})
|
||||
|
||||
test('a step whose params the form can hold opens as a form', () => {
|
||||
const mode = paramsMode(stepWith({ creature: 'orc', count: 8 }), spawn)
|
||||
assert.deepEqual(mode, { mode: PARAM_FORM, forced: false, reason: null })
|
||||
})
|
||||
|
||||
test('an author who chose JSON stays in JSON', () => {
|
||||
const mode = paramsMode(stepWith({ creature: 'orc' }, { paramsMode: PARAM_JSON }), spawn)
|
||||
assert.equal(mode.mode, PARAM_JSON)
|
||||
assert.equal(mode.forced, false, 'their choice, so no reason is shown')
|
||||
})
|
||||
|
||||
test('a param the action does not declare FORCES the JSON box and says which', () => {
|
||||
// The form would render four fields and post four values, having deleted
|
||||
// `radius` — a save that looks clean and means something else. The save path
|
||||
// refuses it by name, which is what the author needs to see.
|
||||
const mode = paramsMode(stepWith({ creature: 'orc', count: 8, radius: 12 }), spawn)
|
||||
assert.equal(mode.mode, PARAM_JSON)
|
||||
assert.equal(mode.forced, true)
|
||||
assert.match(mode.reason, /carries "radius", which test\.spawn does not declare/)
|
||||
})
|
||||
|
||||
test('a value no single control can hold forces the JSON box', () => {
|
||||
assert.match(paramsMode(stepWith({ creature: ['orc', 'troll'] }), spawn).reason, /holds a list/)
|
||||
assert.match(paramsMode(stepWith({ creature: { id: 'orc' } }), spawn).reason, /holds a structure/)
|
||||
})
|
||||
|
||||
test('a dormant step is edited as JSON, because there is no declaration to draw', () => {
|
||||
const mode = paramsMode(stepWith({ creature: 'orc' }), undefined)
|
||||
assert.equal(mode.mode, PARAM_JSON)
|
||||
assert.equal(mode.forced, true)
|
||||
assert.match(mode.reason, /not installed/)
|
||||
})
|
||||
|
||||
test('a params box that is not JSON opens as JSON with the parse error', () => {
|
||||
const mode = paramsMode({ actionId: 'test.spawn', paramsText: '{ not json' }, spawn)
|
||||
assert.equal(mode.mode, PARAM_JSON)
|
||||
assert.equal(mode.forced, true)
|
||||
assert.match(mode.reason, /not valid JSON/)
|
||||
})
|
||||
|
||||
test('paramsRenderable accepts a step with nothing in it', () => {
|
||||
// A brand-new step with an optional-only action, and the empty case a form
|
||||
// needs to survive before anybody has typed.
|
||||
assert.deepEqual(paramsRenderable(spawn, {}), { ok: true })
|
||||
})
|
||||
|
||||
test('setParam writes the type the param declared, not the string the input held', () => {
|
||||
const step = stepWith({ creature: 'orc', count: 8 })
|
||||
assert.deepEqual(JSON.parse(setParam(step, 'count', '12', 'int')), { creature: 'orc', count: 12 })
|
||||
assert.deepEqual(JSON.parse(setParam(step, 'tame', 'true', 'boolean')), {
|
||||
creature: 'orc',
|
||||
count: 8,
|
||||
tame: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('a half-typed number is kept as typed rather than turned into NaN', () => {
|
||||
// `coerceLiteral`'s rule, and the reason it is borrowed rather than rewritten:
|
||||
// turning `-` into NaN while somebody types would either post a value they
|
||||
// never wrote or make a negative impossible to enter. The server's type check
|
||||
// then names the param.
|
||||
const step = stepWith({ count: 8 })
|
||||
assert.deepEqual(JSON.parse(setParam(step, 'count', '-', 'int')), { count: '-' })
|
||||
})
|
||||
|
||||
test('clearing a field REMOVES the key rather than posting an empty string', () => {
|
||||
// `checkParams` treats undefined, null and '' alike — absent — so a required
|
||||
// param left blank comes back as "is required", which is the error the author
|
||||
// needs, instead of a type complaint about "".
|
||||
const step = stepWith({ creature: 'orc', count: 8 })
|
||||
assert.deepEqual(JSON.parse(setParam(step, 'creature', '', 'string')), { count: 8 })
|
||||
})
|
||||
|
||||
test('setParam leaves an unparseable box alone rather than overwriting it', () => {
|
||||
// The only way to reach this is a race between the mode switch and a
|
||||
// keystroke; silently replacing the text with `{ "count": 1 }` would destroy
|
||||
// whatever the author was midway through writing.
|
||||
const step = { actionId: 'test.spawn', paramsText: '{ not json' }
|
||||
assert.equal(setParam(step, 'count', '1', 'int'), '{ not json')
|
||||
})
|
||||
|
||||
test('paramValue reads one param, and answers nothing for a box that does not parse', () => {
|
||||
assert.equal(paramValue(stepWith({ count: 8 }), 'count'), 8)
|
||||
assert.equal(paramValue(stepWith({ count: 8 }), 'creature'), undefined)
|
||||
assert.equal(paramValue({ paramsText: '{ not json' }, 'count'), undefined)
|
||||
})
|
||||
|
||||
test('a datetime is sliced to what the input wants, and anything else is empty', () => {
|
||||
assert.equal(datetimeInputValue('2026-09-07T20:00:00.000Z'), '2026-09-07T20:00')
|
||||
assert.equal(datetimeInputValue(undefined), '')
|
||||
assert.equal(datetimeInputValue(12), '')
|
||||
})
|
||||
|
||||
// ── The meter's request (Phase 13) ────────────────────────────
|
||||
|
||||
test('the price body carries the plan and nothing else', () => {
|
||||
const form = formFromDefinition({
|
||||
title: 'Invasion',
|
||||
spec: {
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [
|
||||
{ key: 'warn', label: 'Warn', steps: [{ actionId: 'core.announce', params: { trigger: 'x' } }] },
|
||||
{ key: 'assault', label: 'Assault', steps: [{ actionId: 'test.spawn', params: { count: 8 } }] },
|
||||
],
|
||||
},
|
||||
})
|
||||
assert.deepEqual(priceBodyFrom(form), {
|
||||
phases: [
|
||||
{ key: 'warn', steps: [{ actionId: 'core.announce', params: { trigger: 'x' } }] },
|
||||
{ key: 'assault', steps: [{ actionId: 'test.spawn', params: { count: 8 } }] },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test('a step whose params do not parse is priced with none rather than dropped', () => {
|
||||
// Dropping it would move every step after it up an ordinal, so the meter's
|
||||
// "phase 2 step 3" would name a different step from the one on the screen.
|
||||
const form = {
|
||||
phases: [{ key: 'p', steps: [{ actionId: 'test.spawn', paramsText: '{ not json' }] }] ,
|
||||
}
|
||||
assert.deepEqual(priceBodyFrom(form).phases[0].steps, [{ actionId: 'test.spawn', params: {} }])
|
||||
})
|
||||
|
||||
test('an empty plan is not worth pricing', () => {
|
||||
// Otherwise the meter asks the server what nothing costs on every keystroke of
|
||||
// the title field.
|
||||
assert.equal(worthPricing({ phases: [] }), false)
|
||||
assert.equal(worthPricing({ phases: [{ steps: [] }] }), false)
|
||||
assert.equal(worthPricing({ phases: [{ steps: [{ actionId: '' }] }] }), false)
|
||||
assert.equal(worthPricing({ phases: [{ steps: [{ actionId: 'test.spawn' }] }] }), true)
|
||||
})
|
||||
97
client/test/eventCalendar.test.js
Normal file
97
client/test/eventCalendar.test.js
Normal file
@@ -0,0 +1,97 @@
|
||||
// The public event screens' time rendering (EVENTS_PLAN.md Phase 14a).
|
||||
//
|
||||
// One property matters here and it is EVENTS.md §I's: **the time beside an
|
||||
// entry is the EVENT's zone, the day it is filed under is the READER's.** A
|
||||
// helper that quietly rendered both in the reader's zone would pass any test
|
||||
// that only ever looked at one of them, and would put an American shard's 8pm
|
||||
// event at "02:00" for a player in Berlin — true, useless, and looking like the
|
||||
// shard's own announcement was wrong.
|
||||
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
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
|
||||
// observable at all.
|
||||
const INSTANT = '2026-09-12T00:00:00.000Z'
|
||||
|
||||
test('the time is rendered in the EVENT’s zone, not the reader’s', () => {
|
||||
assert.equal(eventTime(INSTANT, 'America/New_York'), '20:00 New York')
|
||||
assert.equal(eventTime(INSTANT, 'UTC'), '00:00 UTC')
|
||||
assert.equal(eventTime(INSTANT, 'Europe/Berlin'), '02:00 Berlin')
|
||||
})
|
||||
|
||||
test('the zone is named in a form a reader recognises', () => {
|
||||
// `America/New_York` is a database identifier, not something to show a player.
|
||||
assert.match(eventTime(INSTANT, 'America/Los_Angeles'), /Los Angeles$/)
|
||||
})
|
||||
|
||||
test('an unknown zone falls back to UTC rather than throwing', () => {
|
||||
// `Intl` rejects an unknown identifier, and an event whose timezone column
|
||||
// holds a typo must still render.
|
||||
assert.equal(eventTime(INSTANT, 'Not/AZone'), '00:00 UTC')
|
||||
assert.equal(eventDateTime(INSTANT, 'Not/AZone'), '2026-09-12 00:00 UTC')
|
||||
})
|
||||
|
||||
test('a bad instant renders as nothing rather than as "Invalid Date"', () => {
|
||||
assert.equal(readerDayLabel('not a date'), '')
|
||||
assert.equal(eventDateTime('not a date', 'UTC'), '')
|
||||
})
|
||||
|
||||
test('the day label is the reader’s own day, whatever the event’s zone', () => {
|
||||
// Two entries at the same instant in different event zones are filed under one
|
||||
// heading, which is what makes a chronological list group correctly.
|
||||
assert.equal(readerDayLabel(INSTANT), readerDayLabel(INSTANT))
|
||||
const label = readerDayLabel(INSTANT)
|
||||
assert.ok(label.length > 0)
|
||||
// The instant's UTC date is the 12th and New York's is the 11th; the label
|
||||
// must not carry a zone at all, because it is neither of theirs.
|
||||
assert.equal(/UTC|New York/.test(label), false)
|
||||
})
|
||||
|
||||
test('eventDateTime carries the day and the zone together', () => {
|
||||
const text = eventDateTime(INSTANT, 'America/New_York')
|
||||
assert.match(text, /New York$/)
|
||||
assert.match(text, /20:00/)
|
||||
})
|
||||
|
||||
// ── The status word ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Found by the browser walk: the calendar was saying "DID NOT HAPPEN" about a
|
||||
// run four days out that an operator had cancelled. The server publishes
|
||||
// `failed` and `missed` as `cancelled` too — to a visitor the three are one
|
||||
// event — but they do not share one English sentence, so the tense follows the
|
||||
// clock rather than the status.
|
||||
|
||||
const NOW = Date.parse('2026-09-08T12:00:00Z')
|
||||
|
||||
test('a cancelled occurrence in the future reads "Cancelled"', () => {
|
||||
assert.equal(statusWord('cancelled', '2026-09-12T18:00:00Z', NOW), 'Cancelled')
|
||||
})
|
||||
|
||||
test('a cancelled occurrence in the past reads "Did not happen"', () => {
|
||||
// Which is also the honest word for the failed and missed runs folded into
|
||||
// `cancelled` on the way out.
|
||||
assert.equal(statusWord('cancelled', '2026-09-04T18:00:00Z', NOW), 'Did not happen')
|
||||
})
|
||||
|
||||
test('the other three words do not depend on the clock at all', () => {
|
||||
for (const at of ['2026-09-04T18:00:00Z', '2026-09-12T18:00:00Z']) {
|
||||
assert.equal(statusWord('live', at, NOW), 'Happening now')
|
||||
assert.equal(statusWord('scheduled', at, NOW), 'Scheduled')
|
||||
assert.equal(statusWord('completed', at, NOW), 'Finished')
|
||||
}
|
||||
})
|
||||
|
||||
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), '')
|
||||
})
|
||||
94
client/test/eventParamMultiple.test.js
Normal file
94
client/test/eventParamMultiple.test.js
Normal 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), '')
|
||||
})
|
||||
110
client/test/eventStarts.test.js
Normal file
110
client/test/eventStarts.test.js
Normal 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')
|
||||
})
|
||||
144
client/test/eventStepEditor.test.js
Normal file
144
client/test/eventStepEditor.test.js
Normal 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')
|
||||
})
|
||||
@@ -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/)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
24
client/test/userSafety.test.js
Normal file
24
client/test/userSafety.test.js
Normal file
@@ -0,0 +1,24 @@
|
||||
// Account → Blocked and the Reports queue's "Hide for everyone"
|
||||
// (client/src/lib/userSafety.js; docs/website/USER_SAFETY.md §1, §4.1).
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { blockSubtitle, canHideForEveryone } from '../src/lib/userSafety.js'
|
||||
|
||||
test('a game block says it is a Steam id with no account yet; an account block says Account', () => {
|
||||
const game = blockSubtitle({
|
||||
kind: 'game',
|
||||
identity: { module: 'rust', kind: 'steam', label: 'Steam account', id: '76561198000000123' },
|
||||
})
|
||||
assert.equal(game, 'Steam account 76561198000000123, not linked to an account')
|
||||
assert.equal(blockSubtitle({ kind: 'account', identity: null }), 'Account')
|
||||
assert.match(blockSubtitle({ kind: 'account', createdAt: '2026-10-30T10:00:00Z' }), /^Account · blocked /)
|
||||
})
|
||||
|
||||
test('hide for everyone is offered on a post, a thread and a chat line, never on an upload or twice', () => {
|
||||
assert.equal(canHideForEveryone({ targetType: 'team_forum_post', actionedWith: null }), true)
|
||||
assert.equal(canHideForEveryone({ targetType: 'team_forum_thread', actionedWith: null }), true)
|
||||
assert.equal(canHideForEveryone({ targetType: 'rust.chat', actionedWith: null }), true)
|
||||
assert.equal(canHideForEveryone({ targetType: 'team_forum_upload', actionedWith: null }), false)
|
||||
assert.equal(canHideForEveryone({ targetType: 'rust.chat', actionedWith: 'hide' }), false)
|
||||
})
|
||||
61
deploy/demo/.env.example
Normal file
61
deploy/demo/.env.example
Normal 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
53
deploy/demo/README.md
Normal 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.
|
||||
70
deploy/demo/docker-compose.yml
Normal file
70
deploy/demo/docker-compose.yml
Normal 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
372
deploy/demo/rig.mjs
Normal 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
158
deploy/demo/seed.json
Normal 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" } }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,22 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
UNIQUE KEY uq_users_email_norm (email_norm)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Usernames that can never be taken again (ACCOUNT_DELETION.md AD9). A deleted
|
||||
-- STAFF account's name lands here in the same transaction that deletes the row,
|
||||
-- so nobody can register "Ravenna" the day after the moderator Ravenna left and
|
||||
-- speak with her name. A player's name is never added and is free at once.
|
||||
--
|
||||
-- There is no release path, by decision ("completely unavailable"). The column
|
||||
-- carries the users.username collation so "reserved" means exactly what "taken"
|
||||
-- means: the same case folding the UNIQUE index above applies. Registration, an
|
||||
-- invite's acceptance, an admin creating or renaming an account and a username
|
||||
-- change all refuse a reserved name with the answer they give a taken one.
|
||||
CREATE TABLE IF NOT EXISTS reserved_usernames (
|
||||
username_norm VARCHAR(32) NOT NULL COLLATE utf8mb4_general_ci PRIMARY KEY,
|
||||
reason ENUM('deleted_staff') NOT NULL,
|
||||
reserved_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
category ENUM('news','five_on_friday','newsletter','screenshot') NOT NULL,
|
||||
@@ -179,7 +195,7 @@ CREATE TABLE IF NOT EXISTS activity_log (
|
||||
-- custom (oidc/oauth2) providers.
|
||||
CREATE TABLE IF NOT EXISTS auth_providers (
|
||||
id VARCHAR(64) PRIMARY KEY, -- 'google' | 'discord' | custom slug
|
||||
kind ENUM('google','discord','oidc','oauth2') NOT NULL,
|
||||
kind ENUM('google','discord','oidc','oauth2','steam') NOT NULL,
|
||||
name VARCHAR(80) NOT NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
client_id VARCHAR(255) NULL,
|
||||
@@ -203,6 +219,8 @@ CREATE TABLE IF NOT EXISTS user_identities (
|
||||
provider VARCHAR(64) NOT NULL, -- matches auth_providers.id
|
||||
subject VARCHAR(191) NOT NULL, -- external stable user id (sub / discord id)
|
||||
email VARCHAR(255) NULL,
|
||||
display_name VARCHAR(191) NULL, -- Steam persona name (D394); null for other providers
|
||||
avatar_url VARCHAR(500) NULL, -- Steam avatar, a Steam CDN https URL; null for others
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_identity_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY uq_identity_provider_subject (provider, subject),
|
||||
@@ -1151,7 +1169,7 @@ CREATE TABLE IF NOT EXISTS team_forum_posts (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
thread_id INT NOT NULL,
|
||||
author_user_id INT NULL,
|
||||
author_username VARCHAR(32) NULL, -- snapshot; renders as "[deleted account]" when both are gone
|
||||
author_username VARCHAR(32) NULL, -- snapshot; with the id NULL it is a deleted account: "[deleted player]" to members, the name to staff (utils/displayAuthor.js)
|
||||
body_html MEDIUMTEXT NOT NULL, -- sanitised on write via utils/forumHtml.js
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
edited_at DATETIME NULL,
|
||||
@@ -1283,6 +1301,72 @@ CREATE TABLE IF NOT EXISTS content_reports (
|
||||
INDEX idx_cr_team (team_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- What the reported item said when it was reported (USER_SAFETY.md §4.1). A chat
|
||||
-- line is pruned with the rest of a module's event log, and a queue row must still
|
||||
-- show what was said after that; a forum author editing a post after the report
|
||||
-- must not change what staff read. NULL on reports filed before 1.21.0.
|
||||
--
|
||||
-- `actioned_with` names what the queue DID when a staffer actioned a report
|
||||
-- through it ('hide' — hide for everyone). NULL for an "actioned" that only
|
||||
-- records a decision, which is every report before 1.21.0.
|
||||
ALTER TABLE content_reports ADD COLUMN IF NOT EXISTS snapshot JSON NULL;
|
||||
ALTER TABLE content_reports ADD COLUMN IF NOT EXISTS actioned_with VARCHAR(16) NULL;
|
||||
|
||||
-- A member's blocks (USER_SAFETY.md §4.1, S2, S12, S14). Saved on the account,
|
||||
-- so a block applies on the website and in the app, on every device.
|
||||
--
|
||||
-- Two kinds of row, never both at once:
|
||||
-- • an ACCOUNT block names `blocked_user_id`. This is the normal block (S12):
|
||||
-- a forum author, and a chat speaker whose game identity is linked to an
|
||||
-- account, are blocked as the account.
|
||||
-- • a GAME block names (`module_id`, `identity_kind`, `external_id`) — a
|
||||
-- Steam id in a chat feed that no account has linked. When that identity is
|
||||
-- later linked, the row is rewritten as an account block the next time it is
|
||||
-- read (S14).
|
||||
--
|
||||
-- Staff cannot be blocked (S8). The refusal happens when the block is made; a
|
||||
-- row whose target LATER became staff is kept and simply stops applying, so the
|
||||
-- Blocked page can say why rather than the block vanishing.
|
||||
--
|
||||
-- Each kind has its own unique key, and MySQL's NULL-distinct rule keeps them
|
||||
-- out of each other's way: an account row's game columns are NULL, and a game
|
||||
-- row's `blocked_user_id` is.
|
||||
--
|
||||
-- `display_name` is a snapshot of what the member saw when they blocked: the
|
||||
-- username, or the in-game name of an unlinked speaker.
|
||||
CREATE TABLE IF NOT EXISTS user_blocks (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
blocker_user_id INT NOT NULL,
|
||||
blocked_user_id INT NULL,
|
||||
module_id VARCHAR(32) NULL,
|
||||
identity_kind VARCHAR(32) NULL,
|
||||
external_id VARCHAR(64) NULL,
|
||||
display_name VARCHAR(64) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_ub_blocker FOREIGN KEY (blocker_user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_ub_blocked FOREIGN KEY (blocked_user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY uq_ub_account (blocker_user_id, blocked_user_id),
|
||||
UNIQUE KEY uq_ub_game (blocker_user_id, module_id, identity_kind, external_id),
|
||||
INDEX idx_ub_external (module_id, identity_kind, external_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- A member's "Hide for me" (USER_SAFETY.md §1, S3): one item gone from their own
|
||||
-- view on every device. No report is filed and nothing about the item changes.
|
||||
--
|
||||
-- `target_type` is a report target type (`team_forum_thread`, `team_forum_post`,
|
||||
-- or a module's, e.g. `rust.chat`) and `target_id` its id. Nothing checks the
|
||||
-- item exists: answering "no such item" would tell a member whether a thread id
|
||||
-- exists in a forum they cannot see, and a row for an item that is not there
|
||||
-- hides nothing.
|
||||
CREATE TABLE IF NOT EXISTS user_hidden_items (
|
||||
user_id INT NOT NULL,
|
||||
target_type VARCHAR(32) NOT NULL,
|
||||
target_id BIGINT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, target_type, target_id),
|
||||
CONSTRAINT fk_uhi_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- The §2.9 approval queue. A MODERATOR performing one of the three actions that
|
||||
-- publish untrusted game-sourced strings creates a pending row here; an ADMIN
|
||||
-- performing one applies it immediately. Rows are kept after a decision — "a
|
||||
@@ -1774,9 +1858,18 @@ CREATE TABLE IF NOT EXISTS engagement_cooldowns (
|
||||
-- MariaDB would coerce a NULL one anyway. '' is "this rule cools per user, not
|
||||
-- per subject".
|
||||
subject_key VARCHAR(190) NOT NULL DEFAULT '',
|
||||
-- The CHANNEL the cooldown is about, added in Phase 11b after the live walk.
|
||||
-- Without it a rule naming two channels delivers on exactly ONE of them: the
|
||||
-- claim runs inside the engine's per-channel loop, `inapp` is ranked first on
|
||||
-- purpose (so push can reference its inbox row), and every later channel is
|
||||
-- then reported as cooled. Phase 11b's decision 8 requires the letter and the
|
||||
-- inbox item to fire together, so the cooldown is per delivery, not per
|
||||
-- occasion. VARCHAR like `engagement_outbox.channel`, and for the same reason:
|
||||
-- the channel set is data a module can extend.
|
||||
channel VARCHAR(32) NOT NULL DEFAULT '',
|
||||
last_fired_at DATETIME NOT NULL,
|
||||
fire_count INT NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (rule_id, user_id, subject_key),
|
||||
PRIMARY KEY (rule_id, user_id, subject_key, channel),
|
||||
CONSTRAINT fk_engc_rule FOREIGN KEY (rule_id) REFERENCES engagement_rules(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_engc_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
-- So a prune worker can drop rows older than the longest configured cooldown.
|
||||
@@ -1785,6 +1878,29 @@ CREATE TABLE IF NOT EXISTS engagement_cooldowns (
|
||||
INDEX idx_engc_sweep (last_fired_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Widen the key on a deployment that already has the table. Two statements, and
|
||||
-- the second is guarded because MariaDB has no conditional form of a PRIMARY KEY
|
||||
-- change: re-running `DROP PRIMARY KEY, ADD PRIMARY KEY` on a table that already
|
||||
-- carries the new one is an error, not a no-op, so replaying this file on every
|
||||
-- boot would fail the whole schema after the first run. The guard reads the key
|
||||
-- itself out of information_schema rather than the column's existence, because
|
||||
-- `ADD COLUMN IF NOT EXISTS` above can succeed while the key change does not.
|
||||
--
|
||||
-- Existing rows keep `channel = ''`, which is one stale cooldown per (rule, user,
|
||||
-- subject) that expires on its own interval. That is the right trade against
|
||||
-- deleting them: a cooldown that outlives its rewrite costs at most one delayed
|
||||
-- notification, and dropping the table would let a bounce storm through.
|
||||
ALTER TABLE engagement_cooldowns ADD COLUMN IF NOT EXISTS channel VARCHAR(32) NOT NULL DEFAULT '';
|
||||
SET @engc_key_has_channel := (
|
||||
SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'engagement_cooldowns'
|
||||
AND INDEX_NAME = 'PRIMARY' AND COLUMN_NAME = 'channel'
|
||||
);
|
||||
SET @sql := IF(@engc_key_has_channel = 0,
|
||||
'ALTER TABLE engagement_cooldowns DROP PRIMARY KEY, ADD PRIMARY KEY (rule_id, user_id, subject_key, channel)',
|
||||
'DO 0');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- §4.2a. Modelled on announce_jobs / announce_job_legs. One row per
|
||||
-- (rule, user, channel) occurrence of an event.
|
||||
CREATE TABLE IF NOT EXISTS engagement_outbox (
|
||||
@@ -1818,7 +1934,10 @@ CREATE TABLE IF NOT EXISTS engagement_outbox (
|
||||
INDEX idx_engo_due (status, due_at),
|
||||
-- What a RESOLVING event queries: a house repaired back to LikeNew cancels
|
||||
-- every scheduled row for that (rule, user, house).
|
||||
INDEX idx_engo_cancel (rule_id, user_id, subject_key, status)
|
||||
INDEX idx_engo_cancel (rule_id, user_id, subject_key, status),
|
||||
-- Phase 14. The sweep is `status IN (terminal) AND created_at < ?`, and
|
||||
-- `idx_engo_due` cannot serve it: its second column is `due_at`.
|
||||
INDEX idx_engo_sweep (status, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- G15: the per-message record. Today "did user X get the mail?" is unanswerable.
|
||||
@@ -1845,7 +1964,11 @@ CREATE TABLE IF NOT EXISTS engagement_sends (
|
||||
INDEX idx_engs_user (user_id, created_at),
|
||||
-- The per-rule hourly ceiling (§7.1 Q3) is counted here, so the count has to be
|
||||
-- an index range scan rather than a table scan: it runs once per rule per event.
|
||||
INDEX idx_engs_rule_window (rule_id, created_at)
|
||||
INDEX idx_engs_rule_window (rule_id, created_at),
|
||||
-- Phase 14's retention sweep deletes by age alone, so it needs `created_at`
|
||||
-- LEADING. Every index above has it in second position, which serves a
|
||||
-- per-rule or per-user window and is useless to a whole-table horizon.
|
||||
INDEX idx_engs_sweep (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- §4.4. The mail (and, from Phase 7, in-app) bodies an operator can edit, stored
|
||||
@@ -1905,6 +2028,14 @@ CREATE TABLE IF NOT EXISTS engagement_templates (
|
||||
-- link in it. Same vocabulary as engagement_digest_state.scope_key below.
|
||||
ALTER TABLE engagement_outbox ADD COLUMN IF NOT EXISTS scope_key VARCHAR(190) NULL;
|
||||
|
||||
-- Phase 14 (retention). The two sweep indexes, for deployments whose tables
|
||||
-- predate them. `IF NOT EXISTS` on an index is MariaDB-only and already used
|
||||
-- above (`idx_wiki_search`), so this needs no INFORMATION_SCHEMA guard like the
|
||||
-- cooldown primary-key change did -- that one needed one because MariaDB has no
|
||||
-- conditional form of a PRIMARY KEY change, not because indexes lack one.
|
||||
ALTER TABLE engagement_outbox ADD INDEX IF NOT EXISTS idx_engo_sweep (status, created_at);
|
||||
ALTER TABLE engagement_sends ADD INDEX IF NOT EXISTS idx_engs_sweep (created_at);
|
||||
|
||||
-- §4.2b: digest state, and DELIBERATELY not a digest queue.
|
||||
--
|
||||
-- The generic engine enqueues an outbox row per (rule, user, channel) at emit
|
||||
@@ -2036,3 +2167,685 @@ CREATE TABLE IF NOT EXISTS engagement_suppressions (
|
||||
INDEX idx_engsup_created (created_at),
|
||||
INDEX idx_engsup_reason (reason, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ── The Event System (EVENTS.md §D — Phase 1) ──────────────────────────────
|
||||
|
||||
-- Six of the eleven core tables land here: the ones that do not depend on the
|
||||
-- module contract. The rest arrive with the phases that give them a writer
|
||||
-- rather than as empty tables nothing reads -- `event_run_phase_gates` in P5,
|
||||
-- `event_action_settings` and `event_run_budget` in P6, `event_run_resources` in
|
||||
-- P8 (below) and `event_run_participants` in P10.
|
||||
--
|
||||
-- Core tables, so no module prefix, and no game vocabulary anywhere below: an
|
||||
-- action id, a scope, a resource kind and a budget dimension are all opaque
|
||||
-- strings core stores and never interprets (§C).
|
||||
|
||||
-- The arc. Definitions optionally belong to one, and the series is what carries
|
||||
-- continuity across them — "Royal Spy Mission -> Risky Partner -> Message From
|
||||
-- the Void" is a thing the tooling this replaces cannot express at all.
|
||||
--
|
||||
-- The table lands in Phase 1 because `event_definitions.series_id` points at it;
|
||||
-- the routes that create and order one are Phase 4's, where the calendar makes
|
||||
-- an arc visible.
|
||||
CREATE TABLE IF NOT EXISTS event_series (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
slug VARCHAR(160) NOT NULL,
|
||||
description TEXT NULL,
|
||||
-- Where this series sits among the others on the calendar. Not a position
|
||||
-- WITHIN the series: a definition's place in its arc is `event_definitions`'
|
||||
-- own `series_order` below, because that is the column an editor drags.
|
||||
ordering INT NOT NULL DEFAULT 0,
|
||||
created_by INT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_evser_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
UNIQUE KEY uq_evser_slug (slug)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- The thing that is listed, searched, scheduled and audited.
|
||||
--
|
||||
-- **Three states, not five** (§E). There is no `submitted` and no `approved`: an
|
||||
-- admin publishes their own work, so there is nobody to submit it to, and a
|
||||
-- review state nobody uses is a state every query has to remember anyway.
|
||||
--
|
||||
-- `owner_module` is NULLable and is the module that SHIPPED this definition as
|
||||
-- content, not the module whose actions its steps call — a definition may call
|
||||
-- three modules' verbs and belong to none of them. NULL means an operator
|
||||
-- authored it here, which is the ordinary case.
|
||||
--
|
||||
-- `concurrency_key` is stored as the TEMPLATE, not as the rendered value
|
||||
-- (`invasion:{region}`), because it is rendered from a run's own params at
|
||||
-- materialisation (§E). A flat definition-id key would wrongly stop one
|
||||
-- definition running in two regions at once.
|
||||
--
|
||||
-- `current_version_id` carries NO foreign key, deliberately, and it is the one
|
||||
-- column in this group without one: `event_versions.definition_id` already
|
||||
-- points back here, and a second FK in the other direction makes the pair a
|
||||
-- chicken and an egg on insert.
|
||||
CREATE TABLE IF NOT EXISTS event_definitions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
slug VARCHAR(200) NOT NULL,
|
||||
summary VARCHAR(500) NULL,
|
||||
-- The storyline. Sanitized HTML, the same treatment a wiki page gets.
|
||||
body MEDIUMTEXT NULL,
|
||||
image_url VARCHAR(500) NULL,
|
||||
owner_module VARCHAR(64) NULL,
|
||||
state ENUM('draft','ready','archived') NOT NULL DEFAULT 'draft',
|
||||
current_version_id INT NULL,
|
||||
-- The WORKING COPY of the spec - phases and their steps - as the author last
|
||||
-- saved it. §D's column list does not name it because §D describes what a
|
||||
-- PUBLISHED event is made of, and a version row is where a spec ends up. But
|
||||
-- "editing a draft is free; no version exists yet" (EVENTS.md "Versioning")
|
||||
-- has to mean the draft lives somewhere, and it cannot be an `event_versions`
|
||||
-- row: that table is immutable and a run pins one, so a mutable unpublished
|
||||
-- row in it would be the exact thing versioning exists to prevent. Publishing
|
||||
-- copies this column into a version and leaves it here as the next draft.
|
||||
spec JSON NOT NULL,
|
||||
series_id INT NULL,
|
||||
series_order INT NOT NULL DEFAULT 0,
|
||||
concurrency_key VARCHAR(190) NULL,
|
||||
-- The grace window (§E). A schedule that passed this many seconds ago while the
|
||||
-- process was down is `missed`, never a late silent start.
|
||||
grace_seconds INT NOT NULL DEFAULT 900,
|
||||
-- IANA, and it belongs to the EVENT rather than to the viewer: every listing
|
||||
-- this replaces is written in the shard's local zone, and a recurrence computed
|
||||
-- in UTC puts a Friday-8pm event at 7pm for half the year.
|
||||
timezone VARCHAR(64) NOT NULL DEFAULT 'UTC',
|
||||
created_by INT NULL,
|
||||
updated_by INT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_evdef_series FOREIGN KEY (series_id) REFERENCES event_series(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_evdef_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_evdef_updater FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
UNIQUE KEY uq_evdef_slug (slug),
|
||||
-- The admin list's default ordering, and the public calendar's filter.
|
||||
INDEX idx_evdef_state (state, updated_at),
|
||||
INDEX idx_evdef_series (series_id, series_order)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- An immutable snapshot of a whole definition spec: phases, steps, schedule,
|
||||
-- conditions, announcements. A run pins one, and that pin is the entire reason
|
||||
-- this table exists — it is what makes a run reproducible, and an audit
|
||||
-- answerable, after the definition has been edited underneath it.
|
||||
--
|
||||
-- Nothing updates a row here. Editing a `ready` definition creates the NEXT
|
||||
-- version on publish; a live run keeps the version it pinned and is unaffected
|
||||
-- (EVENTS.md "Versioning, and editing a live event").
|
||||
CREATE TABLE IF NOT EXISTS event_versions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
definition_id INT NOT NULL,
|
||||
version INT NOT NULL,
|
||||
spec JSON NOT NULL,
|
||||
published_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
published_by INT NULL,
|
||||
CONSTRAINT fk_evver_def FOREIGN KEY (definition_id) REFERENCES event_definitions(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_evver_user FOREIGN KEY (published_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
-- Two publishes racing for version 4 is one 1062, not two rows called 4.
|
||||
UNIQUE KEY uq_evver_def_version (definition_id, version)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- One occurrence of one definition, in one scope.
|
||||
--
|
||||
-- The UNIQUE key below — not the claim — is what makes "one run per occurrence
|
||||
-- per scope" true (§E). The claim decides WHO advances an occurrence; this index
|
||||
-- is what stops two of them existing. `scope` is inside the key so a worldwide
|
||||
-- event fans out to many servers without colliding with itself, and it is
|
||||
-- module-opaque: core stores the string and never parses it.
|
||||
--
|
||||
-- `scheduled_for` is UTC. The definition's IANA zone is what the occurrence was
|
||||
-- COMPUTED in (Phase 4); what is stored is the instant.
|
||||
--
|
||||
-- `health` is a separate column from `status` because a run can be genuinely
|
||||
-- running and degraded at once — announcements landing, world writes parked —
|
||||
-- and one column cannot say both. `cleanup_status` is separate for the mirror
|
||||
-- reason: a run reaches `completed` with `cleanup_status = 'incomplete'` rather
|
||||
-- than being held open, and stays on the admin screen until a human resolves it.
|
||||
--
|
||||
-- `version_id`'s foreign key has no ON DELETE clause, so it RESTRICTs: a run
|
||||
-- whose pinned spec had been deleted could not be explained afterwards, which is
|
||||
-- the one thing this table is for.
|
||||
CREATE TABLE IF NOT EXISTS event_runs (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
definition_id INT NOT NULL,
|
||||
version_id INT NOT NULL,
|
||||
-- Module-opaque, and '' rather than NULL for the single-scope case: it is part
|
||||
-- of a UNIQUE key, and multiple NULLs do not collide in MariaDB, so a NULL
|
||||
-- scope would silently permit two runs of one occurrence.
|
||||
scope VARCHAR(190) NOT NULL DEFAULT '',
|
||||
status ENUM('scheduled','starting','running','paused','ending',
|
||||
'completed','cancelled','failed','missed')
|
||||
NOT NULL DEFAULT 'scheduled',
|
||||
health ENUM('ok','degraded','stalled') NOT NULL DEFAULT 'ok',
|
||||
cleanup_status ENUM('not_required','pending','complete','incomplete')
|
||||
NOT NULL DEFAULT 'not_required',
|
||||
current_phase VARCHAR(64) NULL,
|
||||
scheduled_for DATETIME NOT NULL,
|
||||
timezone VARCHAR(64) NOT NULL DEFAULT 'UTC',
|
||||
concurrency_key VARCHAR(190) NULL, -- rendered from this run's params
|
||||
params JSON NULL,
|
||||
-- A rehearsal dispatches for real but is excluded from the public calendar and
|
||||
-- from participation history. Declared here in Phase 1 so the column exists
|
||||
-- before anything can create a run without it.
|
||||
rehearsal TINYINT(1) NOT NULL DEFAULT 0,
|
||||
started_at DATETIME NULL,
|
||||
ended_at DATETIME NULL,
|
||||
claimed_by VARCHAR(64) NULL,
|
||||
claim_expires_at DATETIME NULL,
|
||||
started_by INT NULL,
|
||||
last_error VARCHAR(500) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_evrun_def FOREIGN KEY (definition_id) REFERENCES event_definitions(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_evrun_version FOREIGN KEY (version_id) REFERENCES event_versions(id),
|
||||
CONSTRAINT fk_evrun_user FOREIGN KEY (started_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
UNIQUE KEY uq_evrun_occurrence (definition_id, scope, scheduled_for),
|
||||
-- The runner's materialise/advance scan: due runs by status.
|
||||
INDEX idx_evrun_due (status, scheduled_for),
|
||||
-- The admin run list, newest first, and the per-definition history.
|
||||
INDEX idx_evrun_def (definition_id, scheduled_for),
|
||||
-- Phase 2's overlap check. NULL keys are skipped by the index, which is right:
|
||||
-- a definition with no key never contends.
|
||||
INDEX idx_evrun_concurrency (concurrency_key, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- The work queue: one action invocation, claimed with the outbox's
|
||||
-- compare-and-set. This is the shape `engagement_outbox` already proved, and
|
||||
-- retries, timeouts, duplicate execution and resumption are all properties of
|
||||
-- this row rather than of a scheduler's memory.
|
||||
--
|
||||
-- `idempotency_key` is minted ONCE at materialisation and does not vary by
|
||||
-- attempt (§E) — a retry re-sends the same key so the game side can recognise
|
||||
-- the repeat. It is generated by core rather than by the module because core is
|
||||
-- what guarantees its stability.
|
||||
--
|
||||
-- `action_version` records what the step was AUTHORED against. A module that
|
||||
-- bumps its action makes the step render a warning in the editor rather than
|
||||
-- dispatch a mistyped parameter.
|
||||
--
|
||||
-- `refused` is in the status set and is deliberately not `failed`: a cap breach
|
||||
-- means nothing is wrong with the system, and an author asked for more than this
|
||||
-- deployment allows.
|
||||
CREATE TABLE IF NOT EXISTS event_run_steps (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
run_id BIGINT NOT NULL,
|
||||
phase VARCHAR(64) NOT NULL,
|
||||
seq INT NOT NULL,
|
||||
action_id VARCHAR(96) NOT NULL,
|
||||
params JSON NULL,
|
||||
action_version INT NOT NULL DEFAULT 1,
|
||||
status ENUM('pending','running','done','failed','skipped','refused','cancelled')
|
||||
NOT NULL DEFAULT 'pending',
|
||||
due_at DATETIME NULL,
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
-- Defaulted from the action's risk class at materialisation (§L): retry->skip
|
||||
-- for notify, retry->pause for change, retry->abort_run for irreversible.
|
||||
on_failure VARCHAR(32) NOT NULL DEFAULT 'skip',
|
||||
idempotency_key CHAR(40) NOT NULL,
|
||||
claimed_by VARCHAR(64) NULL,
|
||||
claim_expires_at DATETIME NULL,
|
||||
last_error VARCHAR(500) NULL,
|
||||
started_at DATETIME NULL,
|
||||
finished_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_evstep_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
|
||||
-- The drain scan, exactly as written: due, pending, oldest first.
|
||||
INDEX idx_evstep_due (status, due_at),
|
||||
-- The run console: every step of one run in authored order.
|
||||
INDEX idx_evstep_run (run_id, phase, seq),
|
||||
-- Materialisation is INSERT IGNORE against this, so a tick that overruns into
|
||||
-- the next one cannot double-materialise a phase.
|
||||
UNIQUE KEY uq_evstep_slot (run_id, phase, seq)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- "Why didn't phase 3 start?" must be a query.
|
||||
--
|
||||
-- `activity_log.detail` is TEXT and unqueryable, which is the whole reason this
|
||||
-- table exists rather than the audit log being reused: an operator diagnosing a
|
||||
-- stalled phase needs to filter by kind and read structured detail, and an
|
||||
-- administrative audit of WHO published WHAT is a different question with a
|
||||
-- different retention. Both are written — the audit to `activity_log`, the
|
||||
-- diagnosis here.
|
||||
--
|
||||
-- `kind` is a closed set enforced in `eventRunLog.db.js` rather than an ENUM,
|
||||
-- because the set grows with almost every later phase and an ENUM change is a
|
||||
-- table alter this project has no migration system for.
|
||||
--
|
||||
-- The log is high-cardinality and grows per event, so it needs a retention sweep
|
||||
-- from the start — `engagementRetentionPrune` is the pattern, and the rule that
|
||||
-- work learned is that only TERMINAL rows are eligible. The sweep itself lands
|
||||
-- with the runner in Phase 2; the index it needs is here from the beginning.
|
||||
CREATE TABLE IF NOT EXISTS event_run_log (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
run_id BIGINT NOT NULL,
|
||||
step_id BIGINT NULL,
|
||||
kind VARCHAR(48) NOT NULL,
|
||||
phase VARCHAR(64) NULL,
|
||||
detail JSON NULL,
|
||||
at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_evlog_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_evlog_step FOREIGN KEY (step_id) REFERENCES event_run_steps(id) ON DELETE SET NULL,
|
||||
-- The run console reads this whole index and nothing else.
|
||||
INDEX idx_evlog_run (run_id, at),
|
||||
-- What the Phase 2 retention sweep queries. Without it the sweep is a table
|
||||
-- scan of every line this deployment has ever logged.
|
||||
INDEX idx_evlog_at (at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- What a phase is waiting for, and how far it has got (§E, Phase 5).
|
||||
--
|
||||
-- A phase used to advance on one fact — every step terminal — and that fact
|
||||
-- lives in `event_run_steps`. An advance CONDITION is a second fact, and it is
|
||||
-- not derivable from any row that already exists: `{ on: 'uo.champ.boss_up',
|
||||
-- count: 3 }` is a tally of things that happened between one tick and the next,
|
||||
-- and the runner is not running when they happen. This table is where a firing
|
||||
-- is counted at the moment it fires.
|
||||
--
|
||||
-- **One row per (run, phase), created at phase entry by INSERT IGNORE**, the
|
||||
-- same idempotence `materialisePhase` has and for the same reason: a process
|
||||
-- that died between entering a phase and writing this must not open a second
|
||||
-- gate on the next tick.
|
||||
--
|
||||
-- **The tally is incremented by one statement with the threshold in it**, never
|
||||
-- read-then-written — the argument `event_run_budget`'s conditional increment
|
||||
-- makes, one phase early. Two emits arriving together each add one, and exactly
|
||||
-- one of them crosses `needed`.
|
||||
--
|
||||
-- `last_event` holds ONLY the variables the condition names, not the payload.
|
||||
-- It exists to answer "what did the last one look like, and why did it not
|
||||
-- count", and a copy of a whole game event's data is a second copy of exactly
|
||||
-- the content `engagement_sends` is careful not to keep.
|
||||
--
|
||||
-- `satisfied_by` is a VARCHAR rather than an ENUM for `event_run_log.kind`'s
|
||||
-- reason: the set can grow (an authored timeout was considered and declined for
|
||||
-- Phase 5) and this project has no migration system for a column alter. `kind`
|
||||
-- IS an ENUM, because §E closes it at two shapes.
|
||||
CREATE TABLE IF NOT EXISTS event_run_phase_gates (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
run_id BIGINT NOT NULL,
|
||||
phase VARCHAR(64) NOT NULL,
|
||||
kind ENUM('after','on') NOT NULL,
|
||||
-- kind='after': normalised to seconds at save, so the runner never parses a
|
||||
-- duration string. `due_at` is entered_at + this, computed once at entry.
|
||||
after_seconds INT NULL,
|
||||
-- kind='on': the trigger being waited on and the predicate over its declared
|
||||
-- variables. `conditions` is NULL for "any firing of this trigger".
|
||||
trigger_id VARCHAR(96) NULL,
|
||||
conditions JSON NULL,
|
||||
needed INT NOT NULL DEFAULT 1,
|
||||
tally INT NOT NULL DEFAULT 0,
|
||||
entered_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
due_at DATETIME NULL,
|
||||
last_event JSON NULL,
|
||||
last_event_at DATETIME NULL,
|
||||
satisfied_at DATETIME NULL,
|
||||
satisfied_by VARCHAR(16) NULL, -- 'condition' | 'elapsed' | 'forced'
|
||||
forced_by INT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_evgate_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_evgate_user FOREIGN KEY (forced_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
-- Entry is INSERT IGNORE against this.
|
||||
UNIQUE KEY uq_evgate_phase (run_id, phase),
|
||||
-- The emit path's only query: every open gate waiting on this trigger. It runs
|
||||
-- on every game event of every trigger anything waits on, so it is the one
|
||||
-- index in this feature that is on a hot path rather than an admin screen.
|
||||
INDEX idx_evgate_open (trigger_id, satisfied_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ── Enablement, caps and the verify gate (EVENTS.md §D/§K — Phase 6) ───────
|
||||
|
||||
-- The deployment's switchboard, and the whole of the permission model beyond the
|
||||
-- role (§K layer 2).
|
||||
--
|
||||
-- **Not a grant table.** Nobody is named in it, because the role check already
|
||||
-- answered who; this table answers *what this deployment permits at all*, and
|
||||
-- how much of it per run. That is the distinction §K draws between a capability
|
||||
-- and permission to invoke it: a module declaring `uo.creature.spawn` is code the
|
||||
-- operator installed, not a permission they granted.
|
||||
--
|
||||
-- **A missing row is not "disabled" — it is "the default for its risk class".**
|
||||
-- Rows are written when an admin changes something, never seeded at boot, for a
|
||||
-- reason that is structural rather than tidy: the registry is assembled in
|
||||
-- `registerCore()` and by module `register()`, both of which run under
|
||||
-- `routeManifest.js` and `swagger.js` against a DEAD POOL (MODULE_API.md §2.2).
|
||||
-- A boot-time seed from the registry would be exactly the database write those
|
||||
-- two forbid. Reading a default from the risk class costs one branch and means a
|
||||
-- deployment that never opens this screen behaves correctly.
|
||||
--
|
||||
-- The row survives its action: uninstalling a module leaves the settings behind,
|
||||
-- so re-installing it restores the caps the operator chose rather than silently
|
||||
-- resetting them. The switchboard only lists what is registered *now*, so a
|
||||
-- stranded row is invisible until its action comes back.
|
||||
--
|
||||
-- `action_id` is the primary key rather than an id column: there is exactly one
|
||||
-- row per action and every read is by that id.
|
||||
CREATE TABLE IF NOT EXISTS event_action_settings (
|
||||
action_id VARCHAR(96) NOT NULL PRIMARY KEY,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
-- `{dimension: perRunCap}`. A dimension absent from this object is uncapped by
|
||||
-- this action; an empty object is an action that declares no cost at all.
|
||||
caps JSON NULL,
|
||||
updated_by INT NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_evset_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- What one run has spent, and the most it may.
|
||||
--
|
||||
-- **The cap is COPIED here at run start, not read live.** A run is already
|
||||
-- reproducible in every other respect — it pins a version, and the version is
|
||||
-- immutable — and a cap read live would be the one input to a run's behaviour
|
||||
-- that an admin could change underneath it at three in the morning. Copying also
|
||||
-- makes the console's meter answer the right question afterwards: "what was this
|
||||
-- run allowed", not "what is allowed now".
|
||||
--
|
||||
-- **One row per dimension, so the cap is the tightest of the actions the run's
|
||||
-- version names** (org lead, 2026-09-03). Two actions that both spend
|
||||
-- `uo.creatures` share this row, which is what makes a dimension a bound on the
|
||||
-- run's total effect rather than a per-verb allowance. `effective_from` records
|
||||
-- which action's cap won, so the console can say so.
|
||||
--
|
||||
-- `consumed + ? <= cap` in the WHERE is the whole concurrency story (§E): two
|
||||
-- steps drawing on one dimension in the same tick cannot both see 28/30 and both
|
||||
-- spend, and no transaction is needed to say so. Same shape as the outbox claim
|
||||
-- and the gate's conditional increment, and the same reason.
|
||||
CREATE TABLE IF NOT EXISTS event_run_budget (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
run_id BIGINT NOT NULL,
|
||||
dimension VARCHAR(96) NOT NULL,
|
||||
consumed INT NOT NULL DEFAULT 0,
|
||||
-- **NULL is uncapped, and it is a row rather than an absent one.** A dimension
|
||||
-- every action naming it left uncapped still accumulates here, so the console's
|
||||
-- meter can say "14 spawned, no cap" -- and so that a MISSING row keeps its one
|
||||
-- unambiguous meaning: a step spending a dimension its own run's version never
|
||||
-- priced, which `spend()` refuses.
|
||||
cap INT NULL,
|
||||
-- The action whose cap was the minimum. Documentary: it is what lets the run
|
||||
-- console say "30, from uo.creature.spawn" rather than showing a number the
|
||||
-- operator cannot trace back to a switch they set.
|
||||
effective_from VARCHAR(96) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_evbud_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
|
||||
-- Seeding is INSERT IGNORE against this, so a tick that overruns into the next
|
||||
-- one cannot double-seed a run's budget.
|
||||
UNIQUE KEY uq_evbud_dim (run_id, dimension)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- The cleanup ledger: everything one run created or leased, and what became of
|
||||
-- it (EVENTS.md §D, §L "The ledger's two rules"; Phase 8).
|
||||
--
|
||||
-- **It holds both kinds of thing an event owns.** An OBJECT it created is
|
||||
-- `kind: 'creature'` with `ref` a serial, reverted by its own action's
|
||||
-- `revert()`. A VALUE it leased is `kind: 'override'` with `ref` the lease id and
|
||||
-- `payload` carrying the baseline and what was applied, restored by the lease's
|
||||
-- own `restore()`. One table, because cleanup asks both the same question --
|
||||
-- what is still out there, and did putting it back work.
|
||||
--
|
||||
-- **Rule 1: a resource is recorded BEFORE it is confirmed.** A spawn's serial
|
||||
-- does not exist until the module answers, so what is written before the dispatch
|
||||
-- is a PLACEHOLDER keyed by the step's idempotency key (`kind` = the reserved
|
||||
-- '@step', `ref` = that key). On the answer the reported resources are inserted
|
||||
-- `confirmed` and the placeholder is resolved. If the acknowledgement is lost the
|
||||
-- placeholder survives, and cleanup calls `revert()` with the idempotency key and
|
||||
-- no resources -- which is why §F's `revert({ runId, resources, idempotencyKey })`
|
||||
-- takes the key at all. Recording afterwards instead would make every object
|
||||
-- whose ack was lost invisible to cleanup for ever.
|
||||
--
|
||||
-- **Rule 2: revert is idempotent, and its failure is loud and sticky.** A row
|
||||
-- that never reverts stays visible -- the run reaches `completed` with
|
||||
-- `cleanup_status = 'incomplete'` rather than being held `running`, because a
|
||||
-- tidy `completed` over a shard full of orphaned monsters is the failure that
|
||||
-- would end this feature's credibility on its first bad night.
|
||||
--
|
||||
-- **The unique key is what stops two events leasing one target**, and it must
|
||||
-- hold among LIVE rows only: last week's finished event must not keep this
|
||||
-- week's from leasing the same rate. MariaDB has no partial index, so the
|
||||
-- encoding is a STORED generated column that is NULL once the row is no longer
|
||||
-- ours -- and multiple NULLs do not collide in a unique index. It is derived from
|
||||
-- `status` ALONE and the opaque columns stay in the KEY, which is the shape
|
||||
-- TEAMS.md §2.5 had to be corrected into: MariaDB refuses ON DELETE SET NULL on a
|
||||
-- foreign key whose column is a base column of a stored generated column
|
||||
-- (error 1901), so `step_id` must not appear in the expression.
|
||||
--
|
||||
-- **The key is held by the three statuses that mean "core still believes this is
|
||||
-- ours"** -- `pending`, `confirmed`, `reverting` -- and released by the three that
|
||||
-- mean it is not. §D says "among non-reverted rows", which was written before the
|
||||
-- six statuses had their meanings; taken literally it makes `drifted` and
|
||||
-- `orphaned` hold a target for ever, so one bad night would disable a lease
|
||||
-- permanently with no control able to clear it. `drifted` means somebody else has
|
||||
-- hold of the value and this run has deliberately let go of it; `orphaned` means
|
||||
-- it vanished. Neither is a claim on the target, and both stay LOUD by another
|
||||
-- mechanism -- `cleanup_status = 'incomplete'` and a row on the run console --
|
||||
-- which is what §L's rule 2 actually asks for. Amended 2026-09-03.
|
||||
CREATE TABLE IF NOT EXISTS event_run_resources (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
run_id BIGINT NOT NULL,
|
||||
-- Which step made it. It is how cleanup finds the ACTION to call `revert()` on:
|
||||
-- the row records the module and the opaque names, and the step records the
|
||||
-- verb. SET NULL rather than CASCADE, for `engagement_sends`' reason -- a record
|
||||
-- of what was changed in the world must outlive the row that scheduled it.
|
||||
step_id BIGINT NULL,
|
||||
-- The registering module, copied at record time rather than derived from the
|
||||
-- action id, so an uninstalled module still names itself on the console.
|
||||
owner_module VARCHAR(64) NOT NULL,
|
||||
-- Both module-opaque, stored verbatim, never interpreted -- `ctx.teams.activity.push`'s
|
||||
-- treatment. '@step' is the one reserved `kind` and core owns it.
|
||||
kind VARCHAR(64) NOT NULL,
|
||||
ref VARCHAR(190) NOT NULL,
|
||||
payload JSON NULL,
|
||||
-- A lease's deadline, and NULL for an owned object. It goes DOWN THE WIRE as
|
||||
-- well: the game side restores baseline when it passes, without being asked
|
||||
-- again, which is the fail-safe that makes an unattended world change
|
||||
-- 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','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
|
||||
-- Phase 14's rule -- only a terminal row is ever retention-eligible -- is what
|
||||
-- makes an unbounded counter a row nothing can ever sweep.
|
||||
revert_attempts INT NOT NULL DEFAULT 0,
|
||||
last_error VARCHAR(500) NULL,
|
||||
-- Optional, and module-opaque like the rest: who received it, for a granted
|
||||
-- reward that results should be able to name. `event_run_participants` joins on
|
||||
-- the same key in Phase 10.
|
||||
member_key VARCHAR(190) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
-- 1 while core still believes this resource is this run's, NULL once it is not.
|
||||
-- See the unique key below; derived from `status` alone, deliberately.
|
||||
live_marker TINYINT AS (IF(status IN ('pending','confirmed','reverting'), 1, NULL)) STORED,
|
||||
CONSTRAINT fk_evres_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_evres_step FOREIGN KEY (step_id) REFERENCES event_run_steps(id) ON DELETE SET NULL,
|
||||
-- "Two events cannot hold a lease on one target", among non-reverted rows.
|
||||
UNIQUE KEY uq_evres_target (owner_module, kind, ref, live_marker),
|
||||
-- The run console, and the cleanup sweep's read: one run's ledger in order.
|
||||
INDEX idx_evres_run (run_id, status),
|
||||
-- The cleanup leg's scan across runs, and the boot-time lease self-check.
|
||||
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
|
||||
-- rather than of the definition, and what makes recording it two columns rather
|
||||
-- than a table.
|
||||
--
|
||||
-- Enforced for SCHEDULED starts only (org lead, 2026-09-03): a human pressing
|
||||
-- start is watching, and that human is the review the gate exists to require.
|
||||
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
|
||||
-- `findSchedulable` comment in eventDefinitions.db.js warns about: `state` says
|
||||
-- whether an event is SCHEDULABLE, and this says whether it is ANNOUNCED. The
|
||||
-- two came apart the moment there was a public surface at all, because
|
||||
-- publishing is what makes a definition runnable -- so without this column a
|
||||
-- surprise invasion would have to be advertised a fortnight in advance in order
|
||||
-- to be allowed to happen.
|
||||
--
|
||||
-- Default 1, so every definition that exists keeps the behaviour it had while
|
||||
-- the only reader was staff, and unlisting is the deliberate act.
|
||||
--
|
||||
-- It hides the definition, its runs and its projections from the public
|
||||
-- surfaces and from a participant's own history. It hides nothing from staff:
|
||||
-- the admin calendar is the operational view, and an event nobody outside can
|
||||
-- see is still an event the team is running.
|
||||
ALTER TABLE event_definitions ADD COLUMN IF NOT EXISTS listed TINYINT(1) NOT NULL DEFAULT 1;
|
||||
|
||||
-- ── Integrations: participants, results and the run's announcements
|
||||
-- (EVENTS.md §D/§J — Phase 10) ─────────────────────────────────────────────
|
||||
|
||||
-- Who took part, and how well. The eleventh and last of §D's core tables.
|
||||
--
|
||||
-- **Core writes this table and never sources it.** A `member_key` is
|
||||
-- module-opaque, exactly like a resource's `ref`: core cannot map "Darrow of
|
||||
-- Britain" onto a user row and must not try, because the mapping is one game's
|
||||
-- (`shard_links`, for module-uo) and would be compiled into core the moment it
|
||||
-- guessed. A module that knows both halves supplies both — `memberKey` always,
|
||||
-- `userId` when its own link table has one — and core stores what it is told.
|
||||
--
|
||||
-- `SET NULL` rather than `CASCADE`, matching `engagement_sends`: a record of what
|
||||
-- happened at an event has to survive the deletion of an account that attended
|
||||
-- it, or the results of last year's invasion silently rewrite themselves.
|
||||
--
|
||||
-- **`rank` is NULL until results are published** and is computed then, by
|
||||
-- `core.results.publish`, over `score DESC`. It is a stored column rather than a
|
||||
-- window function in the read because a published result is a fact about a
|
||||
-- moment: a participant added afterwards (a late correction, a module's second
|
||||
-- collect step) must not silently renumber a table people have already read.
|
||||
CREATE TABLE IF NOT EXISTS event_run_participants (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
run_id BIGINT NOT NULL,
|
||||
-- Module-opaque and NOT NULL: it is the identity the module knows, and the
|
||||
-- half of the unique key that makes a repeated collect idempotent. A run whose
|
||||
-- module cannot name its participants has no rows here at all.
|
||||
member_key VARCHAR(190) NOT NULL,
|
||||
user_id INT NULL,
|
||||
-- Signed, because a game may score downward as readily as upward, and DECIMAL
|
||||
-- rather than a float so two equal scores compare equal and a rank is stable.
|
||||
score DECIMAL(18,4) NOT NULL DEFAULT 0,
|
||||
rank_at INT NULL,
|
||||
joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
-- Module-opaque. Whatever the module wants results to be able to display
|
||||
-- beside a name -- a class, a city, a kill count -- with no core vocabulary in
|
||||
-- it and nothing core ever reads.
|
||||
meta JSON NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_evpart_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_evpart_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
-- One row per participant per run. What makes a module reporting the same set
|
||||
-- twice -- a retried collect step, a second attempt after a timeout -- an
|
||||
-- upsert rather than a duplicated leaderboard.
|
||||
UNIQUE KEY uq_evpart_member (run_id, member_key),
|
||||
-- The results table: one run, best first.
|
||||
INDEX idx_evpart_score (run_id, score),
|
||||
-- Profile history (`GET /player/events/history`, Phase 14), newest first.
|
||||
INDEX idx_evpart_user (user_id, joined_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- When the results table was published, and by which run of the publish action.
|
||||
--
|
||||
-- A stamp rather than a status: a run either has published results or it has
|
||||
-- not, and the two questions a surface asks -- "may I show this table" and "when
|
||||
-- was it settled" -- are the same column. `core.results.publish` is idempotent
|
||||
-- against it (a re-run re-ranks and re-stamps), which is what makes it safe as an
|
||||
-- ordinary retried step.
|
||||
ALTER TABLE event_runs ADD COLUMN IF NOT EXISTS results_published_at DATETIME NULL;
|
||||
|
||||
-- The run an announce job belongs to, when it belongs to one.
|
||||
--
|
||||
-- **Nullable, and every existing row keeps NULL**: the news pipeline's jobs are
|
||||
-- not an event's, and nothing about how they are enqueued, retried or rolled up
|
||||
-- changes. What this buys is that `core.announce.post` may enqueue a SECOND job
|
||||
-- for a post that has already been announced -- the common case, since the post
|
||||
-- an event announces is very often the news post that announced it -- without
|
||||
-- either colliding with the first or overwriting `posts.announce_job_id`, which
|
||||
-- is the back-pointer the post admin panel's retry button reads.
|
||||
--
|
||||
-- **No foreign key, exactly like `posts.announce_job_id` beside it.** An announce
|
||||
-- job that went out is a delivery record and must outlive whatever asked for it,
|
||||
-- and `ADD CONSTRAINT ... FOREIGN KEY` has no `IF NOT EXISTS` in MariaDB -- so a
|
||||
-- constraint here would be the one statement in this file that cannot replay.
|
||||
-- The column is read only to answer "which run announced this", and a run id
|
||||
-- that no longer resolves answers that honestly.
|
||||
ALTER TABLE announce_jobs ADD COLUMN IF NOT EXISTS run_id BIGINT NULL;
|
||||
ALTER TABLE announce_jobs ADD INDEX IF NOT EXISTS idx_announce_run (run_id);
|
||||
|
||||
-- ── A trigger can start a run (Rust PLAN_REDESIGNS §14.3.12, D370) ─────────
|
||||
|
||||
-- Why a run exists, when a game firing started it: the trigger, when it fired,
|
||||
-- its subject and its variables, so the console can say "started because a
|
||||
-- champion boss is up at Abyss" and the audit can say what. NULL for a run an
|
||||
-- admin or the schedule started, which `run.created`'s `source` already says.
|
||||
--
|
||||
-- A column rather than only a log line because the run log is pruned and the
|
||||
-- run is not, and "why did this run happen" outlives "what did each step do".
|
||||
ALTER TABLE event_runs ADD COLUMN IF NOT EXISTS origin JSON NULL;
|
||||
|
||||
-- One row per definition that a trigger has tried to start: the cooldown's
|
||||
-- clock, the claim that makes "one run at a time" true, and the count of
|
||||
-- firings it ignored, which the editor shows ("2 firings ignored while
|
||||
-- running").
|
||||
--
|
||||
-- **The claim is a conditional UPDATE on this row**, the event_run_budget
|
||||
-- argument once more: two firings arriving together each try it, and the
|
||||
-- cooldown, the one-at-a-time check and a stale-claim guard are all in its
|
||||
-- WHERE, so exactly one of them owns the start. `claimed_at` is held only
|
||||
-- between the claim and the run row existing — a minute is a crashed process,
|
||||
-- not a slow insert.
|
||||
--
|
||||
-- The counts are since the LAST trigger-started run, and a new start zeroes
|
||||
-- them: what an operator asks is "is it ignoring things right now", and a
|
||||
-- lifetime total cannot answer that.
|
||||
CREATE TABLE IF NOT EXISTS event_trigger_starts (
|
||||
definition_id INT NOT NULL PRIMARY KEY,
|
||||
claimed_at DATETIME NULL,
|
||||
last_started_at DATETIME NULL, -- the firing's time, which is the run's scheduled_for
|
||||
last_run_id BIGINT NULL,
|
||||
ignored_running INT NOT NULL DEFAULT 0,
|
||||
ignored_cooldown INT NOT NULL DEFAULT 0,
|
||||
ignored_unverified INT NOT NULL DEFAULT 0,
|
||||
last_ignored_at DATETIME NULL,
|
||||
last_ignored_reason VARCHAR(32) NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_evtrig_def FOREIGN KEY (definition_id) REFERENCES event_definitions(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Sign in with Steam (STEAM_SIGNIN.md §4). Steam is a built-in provider beside
|
||||
-- Google and Discord, so its row needs the kind; and a linked Steam account
|
||||
-- shows its persona name and avatar (D394), kept on the identity and refreshed
|
||||
-- at each Steam sign-in. Both additive. MODIFY has no IF NOT EXISTS form, but
|
||||
-- re-declaring the same ENUM is an idempotent no-op.
|
||||
ALTER TABLE auth_providers MODIFY COLUMN kind ENUM('google','discord','oidc','oauth2','steam') NOT NULL;
|
||||
ALTER TABLE user_identities ADD COLUMN IF NOT EXISTS display_name VARCHAR(191) NULL;
|
||||
ALTER TABLE user_identities ADD COLUMN IF NOT EXISTS avatar_url VARCHAR(500) NULL;
|
||||
|
||||
284
server/db/seedDemo.js
Normal file
284
server/db/seedDemo.js
Normal file
@@ -0,0 +1,284 @@
|
||||
// ── `npm run seed:demo` — the public demo's base (DEMO_MODE.md §7.3) ────────
|
||||
//
|
||||
// Run by the wipe workflow inside the app container, after `compose down -v` and
|
||||
// a fresh `up` have left an empty database with the schema in place:
|
||||
//
|
||||
// docker compose exec app npm run seed:demo -- /app/deploy/demo/seed.json
|
||||
//
|
||||
// It writes, from one fixture file:
|
||||
// 1. the four static logins (§4) — upserted, so a password a visitor somehow
|
||||
// changed is back within one cycle and a re-run is harmless;
|
||||
// 2. the site settings the demo starts from (live, registration disabled, and
|
||||
// whatever the fixture adds);
|
||||
// 3. the seeded content — posts, wiki pages, pages, events — on the first run
|
||||
// against a database only, so a re-run never duplicates it;
|
||||
// 4. each installed module's own rows, through the module's `onDemoSeed` hook
|
||||
// (API 1.18.0, DM15): core never writes a module's tables;
|
||||
// 5. `demo_state` — when the next wipe is due and the published logins, for
|
||||
// the banner and the sign-in page (§6).
|
||||
//
|
||||
// With the events it also switches on, in the events switchboard, every action
|
||||
// those events' steps use — otherwise a seeded event cannot run (§7.3).
|
||||
//
|
||||
// A string `"$env:NAME"` anywhere in the fixture is replaced with that variable,
|
||||
// so the rig's sidecar token lives in the host's .env and never in the repo. An
|
||||
// unset variable fails the run rather than seeding an empty credential.
|
||||
//
|
||||
// Refuses to run without DEMO_MODE=true: pointed at a real shard by mistake, it
|
||||
// would reset four accounts' passwords and flip the site live.
|
||||
|
||||
require('dotenv').config()
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const { isDemo, DEMO_ACCOUNTS } = require('../src/demo/mode')
|
||||
|
||||
const ROLES = ['admin', 'editor', 'moderator', 'player']
|
||||
|
||||
const log = require('../src/utils/logger')('seed-demo')
|
||||
|
||||
// ── Pure helpers (tested in test/seedDemo.test.js) ─────────────────────────
|
||||
|
||||
/** Replace every `"$env:NAME"` string, at any depth. Throws on an unset one. */
|
||||
function resolveEnv(value, env = process.env, where = 'fixture') {
|
||||
if (typeof value === 'string') {
|
||||
const m = /^\$env:([A-Z_][A-Z0-9_]*)$/.exec(value)
|
||||
if (!m) return value
|
||||
const got = env[m[1]]
|
||||
if (got === undefined || got === '') throw new Error(`${where}: environment variable ${m[1]} is not set`)
|
||||
return got
|
||||
}
|
||||
if (Array.isArray(value)) return value.map((v, i) => resolveEnv(v, env, `${where}[${i}]`))
|
||||
if (value && typeof value === 'object') {
|
||||
const out = {}
|
||||
for (const [k, v] of Object.entries(value)) out[k] = resolveEnv(v, env, `${where}.${k}`)
|
||||
return out
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* When the next wipe is due: the next whole multiple of `everyHours` past
|
||||
* midnight UTC, strictly after `now` — the schedule the wipe workflow's cron
|
||||
* runs on (DM7: every 2 hours on the hour). The seed runs a minute or two after
|
||||
* a wipe starts, so "strictly after" lands on the following slot.
|
||||
*/
|
||||
function nextResetAt(now = new Date(), everyHours = 2) {
|
||||
const step = everyHours * 60 * 60 * 1000
|
||||
const midnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
|
||||
const slots = Math.floor((now.getTime() - midnight) / step) + 1
|
||||
return new Date(midnight + slots * step)
|
||||
}
|
||||
|
||||
/** Check the fixture's shape before anything is written. Returns a list of problems. */
|
||||
function checkFixture(fixture) {
|
||||
const problems = []
|
||||
if (!fixture || typeof fixture !== 'object' || Array.isArray(fixture)) return ['the fixture must be a JSON object']
|
||||
const accounts = fixture.accounts || {}
|
||||
for (const name of DEMO_ACCOUNTS) {
|
||||
const a = accounts[name]
|
||||
if (!a) {
|
||||
problems.push(`accounts.${name} is missing`)
|
||||
continue
|
||||
}
|
||||
if (typeof a.password !== 'string' || a.password.length < 8) {
|
||||
problems.push(`accounts.${name}.password must be a string of at least 8 characters`)
|
||||
}
|
||||
if (!ROLES.includes(a.role)) problems.push(`accounts.${name}.role must be one of ${ROLES.join(', ')}`)
|
||||
}
|
||||
for (const name of Object.keys(accounts)) {
|
||||
if (!DEMO_ACCOUNTS.includes(name)) {
|
||||
problems.push(`accounts.${name} is not a demo account (the lock only knows ${DEMO_ACCOUNTS.join(', ')})`)
|
||||
}
|
||||
}
|
||||
if (fixture.settings !== undefined) {
|
||||
if (!fixture.settings || typeof fixture.settings !== 'object' || Array.isArray(fixture.settings)) {
|
||||
problems.push('settings must be an object')
|
||||
} else if ('demo_state' in fixture.settings) {
|
||||
problems.push('settings.demo_state is written by the seed itself')
|
||||
}
|
||||
}
|
||||
for (const key of ['posts', 'wiki', 'pages', 'events']) {
|
||||
const list = fixture.content?.[key]
|
||||
if (list !== undefined && !Array.isArray(list)) problems.push(`content.${key} must be an array`)
|
||||
}
|
||||
if (fixture.modules !== undefined && (typeof fixture.modules !== 'object' || Array.isArray(fixture.modules))) {
|
||||
problems.push('modules must be an object keyed by module id')
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
/**
|
||||
* Every action a seeded event's steps use, once each, in first-use order.
|
||||
*
|
||||
* A fresh database has no switchboard rows, so every `change` and `irreversible`
|
||||
* action starts disabled (EVENTS.md §K) — and a seeded event that answers "not
|
||||
* enabled on this deployment" when a visitor runs it is a showcase that does not
|
||||
* show (step-5 walk, W1). The seed switches on exactly these and nothing else.
|
||||
*/
|
||||
function eventActionIds(events = []) {
|
||||
const ids = []
|
||||
for (const e of events) {
|
||||
for (const phase of e?.spec?.phases || []) {
|
||||
for (const step of phase?.steps || []) {
|
||||
if (step?.actionId && !ids.includes(step.actionId)) ids.push(step.actionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// ── The run ────────────────────────────────────────────────────────────────
|
||||
|
||||
/* eslint-disable global-require */
|
||||
async function upsertAccounts(fixture) {
|
||||
const users = require('../src/model/users/users.model')
|
||||
const ids = {}
|
||||
for (const username of DEMO_ACCOUNTS) {
|
||||
const { password, role } = fixture.accounts[username]
|
||||
const existing = await users.getRawByUsername(username)
|
||||
let id
|
||||
if (existing) {
|
||||
id = existing.id
|
||||
// A password change bumps the session cutoff, which signs every visitor out
|
||||
// of this account — the right outcome at a wipe, and on a fresh database
|
||||
// (the normal case) there is nobody to sign out.
|
||||
await users.update(id, { password, role, status: 'active', emailVerified: true })
|
||||
await users.disableTotp(id)
|
||||
} else {
|
||||
const created = await users.createUser({ username, password, role, status: 'active', emailVerified: true })
|
||||
id = created.id
|
||||
}
|
||||
ids[username] = id
|
||||
log.info(`account ${username} (${role}) ready`, { id })
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
async function writeSettings(fixture) {
|
||||
const settings = require('../src/model/settings/settings.model')
|
||||
await settings.setMany({
|
||||
site_mode: 'live',
|
||||
site_mode_changed_at: new Date().toISOString(),
|
||||
site_mode_changed_by: 'seed:demo',
|
||||
// §4: registration is closed, and the route is locked too (DM16).
|
||||
[settings.REGISTRATION_KEY]: 'disabled',
|
||||
...(fixture.settings || {}),
|
||||
})
|
||||
}
|
||||
|
||||
async function writeContent(fixture, authorId) {
|
||||
const content = fixture.content || {}
|
||||
const posts = require('../src/model/posts/posts.model')
|
||||
const wiki = require('../src/model/wiki/wiki.model')
|
||||
const pages = require('../src/model/pages/pages.model')
|
||||
const events = require('../src/model/events/eventDefinitions.model')
|
||||
|
||||
for (const p of content.posts || []) {
|
||||
const category = posts.toDbCategory(p.category || 'news')
|
||||
if (!category) throw new Error(`post "${p.title}": unknown category "${p.category}"`)
|
||||
await posts.create({ ...p, category, published: p.published !== false, author_id: authorId })
|
||||
}
|
||||
for (const w of content.wiki || []) {
|
||||
const category = w.category ? await wiki.getCategoryBySlug(w.category) : null
|
||||
if (w.category && !category) throw new Error(`wiki "${w.slug}": unknown category "${w.category}"`)
|
||||
await wiki.create({ ...w, categoryId: category ? category.id : null, updatedBy: authorId })
|
||||
}
|
||||
for (const pg of content.pages || []) {
|
||||
await pages.create(pg, authorId)
|
||||
}
|
||||
for (const e of content.events || []) {
|
||||
const { publish, ...input } = e
|
||||
const made = await events.create(input, authorId, { role: 'admin' })
|
||||
if (!made.ok) throw new Error(`event "${input.name || input.title}": ${(made.errors || []).join('; ')}`)
|
||||
if (publish) {
|
||||
const pub = await events.publish(made.id, authorId)
|
||||
if (!pub.ok) throw new Error(`event "${input.name || input.title}" did not publish: ${(pub.errors || []).join('; ')}`)
|
||||
}
|
||||
}
|
||||
// Uncapped, like a switch an admin flips without filling the cap boxes: the
|
||||
// deny list (DM11) is what keeps a visitor's own command step harmless, and the
|
||||
// wipe takes the row away with the rest of the database.
|
||||
const actionSettings = require('../src/model/events/eventActionSettings.db')
|
||||
const enabled = eventActionIds(content.events)
|
||||
for (const actionId of enabled) await actionSettings.put(actionId, { enabled: true, caps: {} }, authorId)
|
||||
log.info('content written', {
|
||||
posts: (content.posts || []).length,
|
||||
wiki: (content.wiki || []).length,
|
||||
pages: (content.pages || []).length,
|
||||
events: (content.events || []).length,
|
||||
actionsEnabled: enabled,
|
||||
})
|
||||
}
|
||||
|
||||
async function seedModules(fixture, accounts) {
|
||||
const modules = require('../src/modules/loader')
|
||||
const hooks = modules.demoSeedHooks()
|
||||
const wanted = fixture.modules || {}
|
||||
for (const id of Object.keys(wanted)) {
|
||||
if (!hooks.some((h) => h.id === id)) {
|
||||
throw new Error(`modules.${id}: no installed module "${id}" takes a demo seed (onDemoSeed)`)
|
||||
}
|
||||
}
|
||||
for (const { id, hook } of hooks) {
|
||||
if (wanted[id] === undefined) continue
|
||||
// The module gets its part of the fixture and the demo accounts' ids, so it
|
||||
// can link one of them to a game identity without reading core's users table.
|
||||
await hook(wanted[id], { accounts: { ...accounts } })
|
||||
log.info(`module ${id} seeded`)
|
||||
}
|
||||
}
|
||||
|
||||
async function writeState(fixture, now = new Date()) {
|
||||
const settings = require('../src/model/settings/settings.model')
|
||||
const everyHours = Number(process.env.DEMO_RESET_EVERY_HOURS) || 2
|
||||
const logins = DEMO_ACCOUNTS
|
||||
.filter((name) => fixture.accounts[name].published !== false)
|
||||
.map((name) => ({ username: name, password: fixture.accounts[name].password, role: fixture.accounts[name].role }))
|
||||
const state = { resetsAt: nextResetAt(now, everyHours).toISOString(), logins }
|
||||
await settings.set(settings.DEMO_STATE_KEY, JSON.stringify(state))
|
||||
log.info('demo state stamped', { resetsAt: state.resetsAt, logins: logins.length })
|
||||
}
|
||||
|
||||
async function run(file) {
|
||||
if (!isDemo()) throw new Error('seed:demo runs only with DEMO_MODE=true')
|
||||
if (!file) throw new Error('usage: npm run seed:demo -- <fixture.json> (or set DEMO_SEED_FILE)')
|
||||
|
||||
const fixture = resolveEnv(JSON.parse(fs.readFileSync(path.resolve(file), 'utf8')))
|
||||
const problems = checkFixture(fixture)
|
||||
if (problems.length) throw new Error(`the fixture has problems:\n - ${problems.join('\n - ')}`)
|
||||
|
||||
// The app itself, without listening: this is what loads the installed modules
|
||||
// (and core's registries, which an event definition is validated against)
|
||||
// exactly as the server does. Nothing here calls onBoot, so no module starts
|
||||
// its workers in this process.
|
||||
require('../src/app')
|
||||
const settings = require('../src/model/settings/settings.model')
|
||||
|
||||
const firstRun = !(await settings.get(settings.DEMO_STATE_KEY))
|
||||
const accounts = await upsertAccounts(fixture)
|
||||
await writeSettings(fixture)
|
||||
if (firstRun) await writeContent(fixture, accounts['demo-admin'])
|
||||
else log.info('content already seeded on this database; left as is')
|
||||
await seedModules(fixture, accounts)
|
||||
await writeState(fixture)
|
||||
}
|
||||
/* eslint-enable global-require */
|
||||
|
||||
if (require.main === module) {
|
||||
;(async () => {
|
||||
const { close } = require('../src/utils/db') // eslint-disable-line global-require
|
||||
try {
|
||||
await run(process.argv[2] || process.env.DEMO_SEED_FILE)
|
||||
log.info('demo seeded')
|
||||
} catch (err) {
|
||||
log.error(`seed:demo failed: ${err.message}`)
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
module.exports = { resolveEnv, nextResetAt, checkFixture, eventActionIds, run }
|
||||
@@ -1,7 +1,483 @@
|
||||
{
|
||||
"_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.8.0",
|
||||
"moduleApiVersion": "1.21.0",
|
||||
"triggers": [
|
||||
{
|
||||
"id": "event.phase.changed",
|
||||
"owner": "core",
|
||||
"label": "Event — a new phase",
|
||||
"description": "An event that is under way has moved on to its next stage.",
|
||||
"kind": "event",
|
||||
"subjectKey": "runId",
|
||||
"audience": "subscribers",
|
||||
"ceiling": "authenticated",
|
||||
"version": 2,
|
||||
"variables": [
|
||||
{
|
||||
"name": "runId",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "3692",
|
||||
"description": "The run this is about. Also the cooldown subject."
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "The Yew Invasion",
|
||||
"description": "The event title."
|
||||
},
|
||||
{
|
||||
"name": "summary",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Orcish warbands are massing north of Yew.",
|
||||
"description": "The event’s one-line summary, when it has one."
|
||||
},
|
||||
{
|
||||
"name": "seriesName",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "The Yew Campaign",
|
||||
"description": "The arc this event belongs to, when it belongs to one."
|
||||
},
|
||||
{
|
||||
"name": "timezone",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "America/New_York",
|
||||
"description": "The zone the run was computed in — what a time in the body should be read as."
|
||||
},
|
||||
{
|
||||
"name": "phase",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "assault",
|
||||
"description": "The phase key just entered, as authored in the spec."
|
||||
},
|
||||
{
|
||||
"name": "phaseLabel",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "The assault",
|
||||
"description": "The phase label, when the spec gave it one. Falls back to the key."
|
||||
},
|
||||
{
|
||||
"name": "phaseIndex",
|
||||
"type": "int",
|
||||
"required": true,
|
||||
"example": 2,
|
||||
"description": "Which phase this is, counting from 1."
|
||||
},
|
||||
{
|
||||
"name": "phaseCount",
|
||||
"type": "int",
|
||||
"required": true,
|
||||
"example": 4,
|
||||
"description": "How many phases the pinned version has in total."
|
||||
},
|
||||
{
|
||||
"name": "eventUrl",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"example": "/site/events/the-yew-invasion?run=3692",
|
||||
"description": "The public page for this occurrence."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "event.run.cancelled",
|
||||
"owner": "core",
|
||||
"label": "Event — cancelled",
|
||||
"description": "A scheduled event was cancelled by a member of staff.",
|
||||
"kind": "event",
|
||||
"subjectKey": "runId",
|
||||
"audience": "subscribers",
|
||||
"ceiling": "authenticated",
|
||||
"version": 2,
|
||||
"variables": [
|
||||
{
|
||||
"name": "runId",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "3692",
|
||||
"description": "The run this is about. Also the cooldown subject."
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "The Yew Invasion",
|
||||
"description": "The event title."
|
||||
},
|
||||
{
|
||||
"name": "summary",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Orcish warbands are massing north of Yew.",
|
||||
"description": "The event’s one-line summary, when it has one."
|
||||
},
|
||||
{
|
||||
"name": "seriesName",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "The Yew Campaign",
|
||||
"description": "The arc this event belongs to, when it belongs to one."
|
||||
},
|
||||
{
|
||||
"name": "timezone",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "America/New_York",
|
||||
"description": "The zone the run was computed in — what a time in the body should be read as."
|
||||
},
|
||||
{
|
||||
"name": "reason",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "The shard is down for an emergency patch.",
|
||||
"description": "What the staff member gave as the reason, when they gave one."
|
||||
},
|
||||
{
|
||||
"name": "eventUrl",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"example": "/site/events/the-yew-invasion?run=3692",
|
||||
"description": "The public page for this occurrence."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "event.run.completed",
|
||||
"owner": "core",
|
||||
"label": "Event — finished",
|
||||
"description": "An event has finished.",
|
||||
"kind": "event",
|
||||
"subjectKey": "runId",
|
||||
"audience": "subscribers",
|
||||
"ceiling": "authenticated",
|
||||
"version": 2,
|
||||
"variables": [
|
||||
{
|
||||
"name": "runId",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "3692",
|
||||
"description": "The run this is about. Also the cooldown subject."
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "The Yew Invasion",
|
||||
"description": "The event title."
|
||||
},
|
||||
{
|
||||
"name": "summary",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Orcish warbands are massing north of Yew.",
|
||||
"description": "The event’s one-line summary, when it has one."
|
||||
},
|
||||
{
|
||||
"name": "seriesName",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "The Yew Campaign",
|
||||
"description": "The arc this event belongs to, when it belongs to one."
|
||||
},
|
||||
{
|
||||
"name": "timezone",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "America/New_York",
|
||||
"description": "The zone the run was computed in — what a time in the body should be read as."
|
||||
},
|
||||
{
|
||||
"name": "participantCount",
|
||||
"type": "int",
|
||||
"required": true,
|
||||
"example": 47,
|
||||
"description": "How many participants the run recorded. Zero when nothing collected any."
|
||||
},
|
||||
{
|
||||
"name": "durationMinutes",
|
||||
"type": "int",
|
||||
"required": true,
|
||||
"example": 95,
|
||||
"description": "How long the run took, start to end, in whole minutes."
|
||||
},
|
||||
{
|
||||
"name": "eventUrl",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"example": "/site/events/the-yew-invasion?run=3692",
|
||||
"description": "The public page for this occurrence."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "event.run.ending",
|
||||
"owner": "core",
|
||||
"label": "Event — winding down",
|
||||
"description": "An event is drawing to a close.",
|
||||
"kind": "event",
|
||||
"subjectKey": "runId",
|
||||
"audience": "subscribers",
|
||||
"ceiling": "authenticated",
|
||||
"version": 2,
|
||||
"variables": [
|
||||
{
|
||||
"name": "runId",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "3692",
|
||||
"description": "The run this is about. Also the cooldown subject."
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "The Yew Invasion",
|
||||
"description": "The event title."
|
||||
},
|
||||
{
|
||||
"name": "summary",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Orcish warbands are massing north of Yew.",
|
||||
"description": "The event’s one-line summary, when it has one."
|
||||
},
|
||||
{
|
||||
"name": "seriesName",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "The Yew Campaign",
|
||||
"description": "The arc this event belongs to, when it belongs to one."
|
||||
},
|
||||
{
|
||||
"name": "timezone",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "America/New_York",
|
||||
"description": "The zone the run was computed in — what a time in the body should be read as."
|
||||
},
|
||||
{
|
||||
"name": "eventUrl",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"example": "/site/events/the-yew-invasion?run=3692",
|
||||
"description": "The public page for this occurrence."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "event.run.failed",
|
||||
"owner": "core",
|
||||
"label": "Event — run failed",
|
||||
"description": "An event stopped before it finished.",
|
||||
"kind": "event",
|
||||
"subjectKey": "runId",
|
||||
"audience": "admin",
|
||||
"ceiling": "admin",
|
||||
"version": 1,
|
||||
"variables": [
|
||||
{
|
||||
"name": "runId",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "3692",
|
||||
"description": "The run this is about. Also the cooldown subject."
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "The Yew Invasion",
|
||||
"description": "The event title."
|
||||
},
|
||||
{
|
||||
"name": "summary",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Orcish warbands are massing north of Yew.",
|
||||
"description": "The event’s one-line summary, when it has one."
|
||||
},
|
||||
{
|
||||
"name": "seriesName",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "The Yew Campaign",
|
||||
"description": "The arc this event belongs to, when it belongs to one."
|
||||
},
|
||||
{
|
||||
"name": "timezone",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "America/New_York",
|
||||
"description": "The zone the run was computed in — what a time in the body should be read as."
|
||||
},
|
||||
{
|
||||
"name": "phase",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "assault",
|
||||
"description": "The phase it failed in, when it had entered one."
|
||||
},
|
||||
{
|
||||
"name": "error",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "sidecar responded 503",
|
||||
"description": "The run’s last error, verbatim from the run row."
|
||||
},
|
||||
{
|
||||
"name": "runUrl",
|
||||
"type": "url",
|
||||
"required": true,
|
||||
"example": "/admin/events/runs/3692",
|
||||
"description": "Site-relative path to the run console."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "event.run.scheduled",
|
||||
"owner": "core",
|
||||
"label": "Event — scheduled",
|
||||
"description": "A new event has been added to the calendar.",
|
||||
"kind": "event",
|
||||
"subjectKey": "runId",
|
||||
"audience": "subscribers",
|
||||
"ceiling": "authenticated",
|
||||
"version": 2,
|
||||
"variables": [
|
||||
{
|
||||
"name": "runId",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "3692",
|
||||
"description": "The run this is about. Also the cooldown subject."
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "The Yew Invasion",
|
||||
"description": "The event title."
|
||||
},
|
||||
{
|
||||
"name": "summary",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Orcish warbands are massing north of Yew.",
|
||||
"description": "The event’s one-line summary, when it has one."
|
||||
},
|
||||
{
|
||||
"name": "seriesName",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "The Yew Campaign",
|
||||
"description": "The arc this event belongs to, when it belongs to one."
|
||||
},
|
||||
{
|
||||
"name": "timezone",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "America/New_York",
|
||||
"description": "The zone the run was computed in — what a time in the body should be read as."
|
||||
},
|
||||
{
|
||||
"name": "startsAt",
|
||||
"type": "datetime",
|
||||
"required": true,
|
||||
"example": "2026-09-12T20:00:00.000Z",
|
||||
"description": "When the occurrence is due to start, UTC."
|
||||
},
|
||||
{
|
||||
"name": "startsAtLabel",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Saturday 12 September at 8:00 pm (America/New_York)",
|
||||
"description": "The start time written out in the shard-local zone, for a mail to read."
|
||||
},
|
||||
{
|
||||
"name": "eventUrl",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"example": "/site/events/the-yew-invasion?run=3692",
|
||||
"description": "The public page for this occurrence."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "event.run.started",
|
||||
"owner": "core",
|
||||
"label": "Event — starting now",
|
||||
"description": "A scheduled event has begun.",
|
||||
"kind": "event",
|
||||
"subjectKey": "runId",
|
||||
"audience": "subscribers",
|
||||
"ceiling": "authenticated",
|
||||
"version": 2,
|
||||
"variables": [
|
||||
{
|
||||
"name": "runId",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "3692",
|
||||
"description": "The run this is about. Also the cooldown subject."
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "The Yew Invasion",
|
||||
"description": "The event title."
|
||||
},
|
||||
{
|
||||
"name": "summary",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Orcish warbands are massing north of Yew.",
|
||||
"description": "The event’s one-line summary, when it has one."
|
||||
},
|
||||
{
|
||||
"name": "seriesName",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "The Yew Campaign",
|
||||
"description": "The arc this event belongs to, when it belongs to one."
|
||||
},
|
||||
{
|
||||
"name": "timezone",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "America/New_York",
|
||||
"description": "The zone the run was computed in — what a time in the body should be read as."
|
||||
},
|
||||
{
|
||||
"name": "startsAt",
|
||||
"type": "datetime",
|
||||
"required": true,
|
||||
"example": "2026-09-12T20:00:00.000Z",
|
||||
"description": "When it actually started, UTC."
|
||||
},
|
||||
{
|
||||
"name": "startsAtLabel",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Saturday 12 September at 8:00 pm (America/New_York)",
|
||||
"description": "The start time written out in the shard-local zone, for a mail to read."
|
||||
},
|
||||
{
|
||||
"name": "eventUrl",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"example": "/site/events/the-yew-invasion?run=3692",
|
||||
"description": "The public page for this occurrence."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "news.post",
|
||||
"owner": "core",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -194,6 +194,24 @@
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/retention",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/engagement/retention",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/rules",
|
||||
@@ -320,6 +338,15 @@
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/engagement/suppressions/by-hash/:hash",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/templates",
|
||||
@@ -392,6 +419,285 @@
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/events/:id",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/:id",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/events/:id",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/:id/publish",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/:id/runs",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/:id/verify",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/:id/versions",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/actions",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/events/actions",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/calendar",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/catalog",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/catalog/options/:sourceId",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/catalog/templates/:templateId",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/price",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/runs",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/runs/:runId",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/advance",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/cancel",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/cleanup",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/runs/:runId/log",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/pause",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/resume",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/steps/:stepId/confirm",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/steps/:stepId/retry",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/steps/:stepId/skip",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/series",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/series",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/events/series/:seriesId",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/events/series/:seriesId",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/invites",
|
||||
@@ -512,6 +818,17 @@
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/moderation/reports/:id/hide",
|
||||
"handlers": 4,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/moderation/search",
|
||||
@@ -1478,6 +1795,18 @@
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/auth/me/account",
|
||||
"handlers": 9,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"backoffGuard",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/account",
|
||||
@@ -1528,7 +1857,7 @@
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/auth/me/account/identities/:provider",
|
||||
"handlers": 3,
|
||||
"handlers": 4,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
@@ -1610,6 +1939,37 @@
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/blocks",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/me/blocks",
|
||||
"handlers": 5,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/auth/me/blocks/:id",
|
||||
"handlers": 4,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/devices",
|
||||
@@ -1641,6 +2001,46 @@
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/auth/me/hidden",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/hidden",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/me/hidden",
|
||||
"handlers": 5,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/auth/me/hidden/:targetType/:targetId",
|
||||
"handlers": 5,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/notifications",
|
||||
@@ -1970,6 +2370,26 @@
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/events/history",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/reports",
|
||||
"handlers": 7,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/teams",
|
||||
@@ -2147,6 +2567,31 @@
|
||||
"handlers": 1,
|
||||
"gates": []
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/events",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"siteMode"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/events/:slug",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"siteMode",
|
||||
"optionalAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/events/series/:slug",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"siteMode"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/modules",
|
||||
|
||||
@@ -85,6 +85,14 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/channels"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/retention"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/engagement/retention"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/rules"
|
||||
@@ -141,6 +149,10 @@
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/engagement/suppressions"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/engagement/suppressions/by-hash/:hash"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/templates"
|
||||
@@ -173,6 +185,130 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/triggers"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/events/:id"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/:id"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/events/:id"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/:id/publish"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/:id/runs"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/:id/verify"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/:id/versions"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/actions"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/events/actions"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/calendar"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/catalog"
|
||||
},
|
||||
{
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/runs"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/runs/:runId"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/advance"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/cancel"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/cleanup"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/runs/:runId/log"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/pause"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/resume"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/steps/:stepId/confirm"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/steps/:stepId/retry"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/steps/:stepId/skip"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/series"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/series"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/events/series/:seriesId"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/events/series/:seriesId"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/invites"
|
||||
@@ -221,6 +357,10 @@
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/moderation/reports/:id/handle"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/moderation/reports/:id/hide"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/moderation/search"
|
||||
@@ -597,6 +737,10 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/auth/me/account"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/account"
|
||||
@@ -649,6 +793,18 @@
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/auth/me/account/username"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/blocks"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/me/blocks"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/auth/me/blocks/:id"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/devices"
|
||||
@@ -661,6 +817,22 @@
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/auth/me/devices/:id"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/auth/me/hidden"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/hidden"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/me/hidden"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/auth/me/hidden/:targetType/:targetId"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/notifications"
|
||||
@@ -801,6 +973,14 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/appeals/eligible"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/events/history"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/reports"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/teams"
|
||||
@@ -873,6 +1053,18 @@
|
||||
"method": "POST",
|
||||
"path": "/api/v1/public/engagement/unsubscribe/:token"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/events"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/events/:slug"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/events/series/:slug"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/modules"
|
||||
|
||||
@@ -108,7 +108,21 @@ function main() {
|
||||
return
|
||||
}
|
||||
|
||||
const current = fs.existsSync(MANIFEST_PATH) ? fs.readFileSync(MANIFEST_PATH, 'utf8') : ''
|
||||
// **Line endings are normalised before the comparison**, exactly as
|
||||
// `routeManifest.js` does one file along, and for a reason that is not
|
||||
// cosmetic: this repo is developed on Windows under `core.autocrlf=true`, so
|
||||
// git checks a committed LF blob out as CRLF and a byte comparison then calls
|
||||
// an unchanged manifest stale. That failure is worse than useless — it fires on
|
||||
// every Windows checkout, says "a trigger declaration changed", and is fixed by
|
||||
// regenerating a file whose CONTENT was already correct, which teaches a
|
||||
// developer to ignore the one check that exists to be believed.
|
||||
//
|
||||
// What is being asserted is that the committed manifest describes the same
|
||||
// declarations, and a line ending is not a declaration. Policing the encoding
|
||||
// is `.gitattributes`' job, not this check's.
|
||||
const current = fs.existsSync(MANIFEST_PATH)
|
||||
? fs.readFileSync(MANIFEST_PATH, 'utf8').replace(/\r\n/g, '\n')
|
||||
: ''
|
||||
if (current === next) {
|
||||
process.stdout.write('engagement-triggers.json is current\n')
|
||||
return
|
||||
|
||||
@@ -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, '..')
|
||||
|
||||
174
server/src/auth/accountDeletion.js
Normal file
174
server/src/auth/accountDeletion.js
Normal file
@@ -0,0 +1,174 @@
|
||||
// ── Deleting an account (docs/website/ACCOUNT_DELETION.md §4.1) ────────────
|
||||
//
|
||||
// One procedure for both ways an account goes: a member deleting their own
|
||||
// (`DELETE /auth/me/account`, by: 'self') and staff deleting it from the admin
|
||||
// panel (`DELETE /admin/users/:id`, by: 'staff'). The two routes check different
|
||||
// things before they get here — the member proves who they are, the admin is not
|
||||
// allowed to delete themself — and from here on they do the same thing.
|
||||
//
|
||||
// In order:
|
||||
//
|
||||
// 1. **Before the delete.** Read what is needed afterwards (the address for the
|
||||
// notice, the username, the role), then ask every started module's
|
||||
// `onUserDeleting` hook in turn (MODULE_API 1.20.0). That is where game links
|
||||
// are unlinked through each module's own unlink (AD10): a cascade is not an
|
||||
// unlink, and for UO the link lives on the shard.
|
||||
// 2. **One transaction.** A staff name is added to `reserved_usernames` (AD9)
|
||||
// and the users row is deleted. The foreign keys do the rest: credentials,
|
||||
// sessions and settings cascade, authored content is detached. Snapshot
|
||||
// columns, the content and the activity log are NOT touched (AD5, AD6) —
|
||||
// who sees which name is utils/displayAuthor's rule, applied when read.
|
||||
// 3. **After commit**, fire-and-forget, each step on its own: a Teams
|
||||
// reconcile, the activity-log entry, and the notice to the old address.
|
||||
//
|
||||
// **Nothing stops a deletion once it starts** (AD11). A hook that throws, or runs
|
||||
// past HOOK_TIMEOUT_MS, is logged and the deletion goes ahead: Play requires the
|
||||
// request to be honoured, and a game server that is down must not hold a
|
||||
// member's request open. Whatever a module could not finish is its own pending
|
||||
// work. The refusals below all come BEFORE step 1.
|
||||
|
||||
const users = require('../model/users/users.model')
|
||||
const activity = require('../model/activity/activity.model')
|
||||
const teamSync = require('../model/teams/teamSync.model')
|
||||
const loader = require('../modules/loader')
|
||||
const mailer = require('../utils/mailer')
|
||||
const { isDemo, isDemoUsername } = require('../demo/mode')
|
||||
const log = require('../utils/logger')('account-deletion')
|
||||
|
||||
const HOOK_TIMEOUT_MS = 10_000
|
||||
const BY = ['self', 'staff']
|
||||
|
||||
function withTimeout(fn, ms) {
|
||||
let timer
|
||||
const deadline = new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(`no answer within ${ms} ms`)), ms)
|
||||
})
|
||||
return Promise.race([Promise.resolve().then(fn), deadline]).finally(() => clearTimeout(timer))
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask every started module's `onUserDeleting`, one at a time.
|
||||
*
|
||||
* In turn rather than at once so a module's log lines read in the order they ran,
|
||||
* and because a site runs one module: there is nothing to parallelise. Never
|
||||
* throws; returns what each module did, for the log.
|
||||
*
|
||||
* @returns {Promise<{module: string, ok: boolean, error?: string}[]>}
|
||||
*/
|
||||
async function runHooks(payload, timeoutMs) {
|
||||
const frozen = Object.freeze({ userId: payload.userId, deletedBy: payload.deletedBy })
|
||||
const outcomes = []
|
||||
for (const { id, hook } of loader.userDeletingHooks()) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await withTimeout(() => hook(frozen), timeoutMs)
|
||||
outcomes.push({ module: id, ok: true })
|
||||
} catch (err) {
|
||||
log.error('a module could not finish before an account was deleted; deleting anyway', {
|
||||
module: id,
|
||||
userId: frozen.userId,
|
||||
message: err.message,
|
||||
})
|
||||
outcomes.push({ module: id, ok: false, error: err.message })
|
||||
}
|
||||
}
|
||||
return outcomes
|
||||
}
|
||||
|
||||
// "29 October 2026", in UTC so the date in the mail does not depend on the
|
||||
// host's zone.
|
||||
function formatDate(when) {
|
||||
return when.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3. Each step in its own try, and the whole thing never rejects, so the
|
||||
* caller may leave it un-awaited. Returned anyway, so a test has a moment at
|
||||
* which the after-commit work has happened.
|
||||
*/
|
||||
async function afterCommit({ userId, by, req, email, username, reserved, when }) {
|
||||
try {
|
||||
teamSync.request({ reason: 'account deleted' })
|
||||
} catch (err) {
|
||||
log.warn('teams reconcile request failed after a deletion', { message: err.message })
|
||||
}
|
||||
|
||||
// Never the username: the entry outlives the account, and the activity log is
|
||||
// a record of what was done, not a second place the name is kept. A member
|
||||
// deleting themself is no longer a user the row could name, so the entry
|
||||
// carries no user id, only the IP the request came from (AD6).
|
||||
try {
|
||||
const action = by === 'self' ? 'user.self_delete' : 'user.delete'
|
||||
const detail = { id: userId, ...(reserved ? { usernameReserved: true } : {}) }
|
||||
if (by === 'self') {
|
||||
await activity.log({ req: { ip: req ? req.ip : null }, action, detail })
|
||||
} else {
|
||||
await activity.log({ req, action, detail })
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('activity entry failed after a deletion', { message: err.message })
|
||||
}
|
||||
|
||||
if (email) {
|
||||
try {
|
||||
const sent = await mailer.sendAccountDeleted({ to: email, username, deletedOn: formatDate(when), by })
|
||||
if (!sent.sent) log.info('deletion notice not sent (mail not configured)', { userId })
|
||||
} catch (err) {
|
||||
log.warn('deletion notice failed', { userId, message: err.message })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete one account.
|
||||
*
|
||||
* @param {number} userId
|
||||
* @param {{by: 'self'|'staff', req?: object, hookTimeoutMs?: number}} opts
|
||||
* @returns {Promise<
|
||||
* | {ok: true, id: number, reserved: boolean, modules: object[], settled: Promise<void>}
|
||||
* | {ok: false, reason: 'not_found' | 'demo' | 'last_admin'}
|
||||
* >}
|
||||
*/
|
||||
async function deleteAccount(userId, { by, req = null, hookTimeoutMs = HOOK_TIMEOUT_MS } = {}) {
|
||||
if (!BY.includes(by)) throw new Error(`deleteAccount: by must be one of ${BY.join(', ')}`)
|
||||
|
||||
const target = await users.getById(userId)
|
||||
if (!target) return { ok: false, reason: 'not_found' }
|
||||
// The demo's shared logins. The demo lock refuses both routes before they get
|
||||
// here (demo/locks.js); this is the line behind it, so no future caller of the
|
||||
// service can delete the account every visitor signs in as.
|
||||
if (isDemo() && isDemoUsername(target.username)) return { ok: false, reason: 'demo' }
|
||||
// A deployment with no admin left has nobody who can run it.
|
||||
if (target.role === 'admin' && (await users.countAdmins()) <= 1) return { ok: false, reason: 'last_admin' }
|
||||
|
||||
// 1. Before the delete.
|
||||
const modules = await runHooks({ userId, deletedBy: by }, hookTimeoutMs)
|
||||
|
||||
// 2. One transaction: reserve a staff name, delete the row.
|
||||
const reserveName = target.role !== 'player' ? target.username : null
|
||||
const removed = await users.removeReserving(userId, reserveName)
|
||||
// Gone between the read above and now: somebody else's deletion won. Nothing
|
||||
// of ours happened, so nothing after commit runs either.
|
||||
if (!removed) return { ok: false, reason: 'not_found' }
|
||||
|
||||
log.info('account deleted', {
|
||||
userId,
|
||||
by,
|
||||
reserved: Boolean(reserveName),
|
||||
modules: modules.map((m) => `${m.module}:${m.ok ? 'ok' : 'failed'}`),
|
||||
})
|
||||
|
||||
// 3. After commit.
|
||||
const settled = afterCommit({
|
||||
userId,
|
||||
by,
|
||||
req,
|
||||
email: target.email || null,
|
||||
username: target.username,
|
||||
reserved: Boolean(reserveName),
|
||||
when: new Date(),
|
||||
})
|
||||
return { ok: true, id: userId, reserved: Boolean(reserveName), modules, settled }
|
||||
}
|
||||
|
||||
module.exports = { deleteAccount, HOOK_TIMEOUT_MS }
|
||||
97
server/src/auth/identityHooks.js
Normal file
97
server/src/auth/identityHooks.js
Normal 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 }
|
||||
@@ -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,
|
||||
|
||||
172
server/src/auth/providers/steam.provider.js
Normal file
172
server/src/auth/providers/steam.provider.js
Normal 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
|
||||
770
server/src/config/coreEventActions.js
Normal file
770
server/src/config/coreEventActions.js
Normal file
@@ -0,0 +1,770 @@
|
||||
// ── Core's own event actions ───────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §F, and Phase 1 of EVENTS_PLAN.md. The twin of config/coreTriggers.js
|
||||
// and registered through the same staging area a module will use in Phase 7 —
|
||||
// which is the entire reason these three exist this early. A registry whose first
|
||||
// real registrant is a module is a registry that has already drifted, and §F's
|
||||
// claim that core is "an event engine that can announce, wait, cue a human and
|
||||
// publish results" with NO module installed is only true if core declares the
|
||||
// verbs that do it.
|
||||
//
|
||||
// **Three actions, and between them they cover the three things an event can do
|
||||
// that name no game noun at all**: tell people something, let time pass, and ask
|
||||
// a human to go and do something. A deployment with no game module installed has
|
||||
// a working event system made of exactly these.
|
||||
//
|
||||
// **Phase 8 added a fourth, and it is the odd one out on purpose.** `core.lease`
|
||||
// names no game noun either — it borrows a value some module declared — but
|
||||
// unlike the other three it genuinely changes the world, so it is `risk: 'change'`
|
||||
// and therefore default-off, admin-only and cap-checked like any module verb.
|
||||
// It is CORE's rather than each module's because §F puts the duration bound and
|
||||
// the two-events-one-target conflict check on core's side of the seam: a lease
|
||||
// verb per module would be that bound re-implemented once per module, advisory
|
||||
// everywhere, and wrong in the first one that forgot it.
|
||||
//
|
||||
// **Phase 10 added the last two, and they are the integrations** (EVENTS.md
|
||||
// §J). `core.announce.post` sends an ARTICLE rather than a line — it links a
|
||||
// post an editor already wrote and queues it through `announce_jobs`, so the
|
||||
// town crier and Discord arrive as already-registered legs with their retry and
|
||||
// their classification rather than as a second delivery pipeline. And
|
||||
// `core.results.publish` is what makes §F's "publish results" literal: it ranks
|
||||
// the run's participants and stamps the table published. Both name a game noun
|
||||
// nowhere, which is why they are core's; six actions is now the whole of what an
|
||||
// event can do on a deployment with no game module installed at all.
|
||||
//
|
||||
// **Phase 2 gave all three real bodies**, and between them they exercise every
|
||||
// shape §F's envelope can take: `core.announce` does work and finishes,
|
||||
// `core.wait` finishes while deferring what follows it, and `core.cue` succeeds
|
||||
// without finishing at all. The runner learns nothing about any of them by id —
|
||||
// each says what it needs in the envelope, through the same two members Phase 7
|
||||
// hands to a module.
|
||||
//
|
||||
// **This file must not touch the database.** It is required from `registerCore()`,
|
||||
// which runs under `routeManifest.js` and `swagger.js` against a dead pool
|
||||
// (MODULE_API.md §2.2). Nothing below runs at require time; the announce leg is
|
||||
// looked up inside `perform()`, per call, which is also what makes a leg
|
||||
// registered by a module that booted later reachable at all. `core.lease` is the
|
||||
// one action here that reaches a table, and it requires the model INSIDE
|
||||
// `perform()` for the same reason — a top-level require would make this file
|
||||
// build a pool during route-manifest generation.
|
||||
|
||||
const registries = require('../modules/registries')
|
||||
|
||||
// `event_run_resources.ref` is VARCHAR(190). A targeted lease composes its ref
|
||||
// from the lease id and the target, so this is the one place a caller can push a
|
||||
// ref past the column — and the ledger's own rule applies: refuse, never
|
||||
// truncate, because a truncated ref is a restore pointed at another object.
|
||||
const MAX_LEASE_REF = 190
|
||||
|
||||
|
||||
/**
|
||||
* Turn the `value` param's text into whatever the named lease says it holds.
|
||||
*
|
||||
* The range check is here too, and it is REQUIRED on the numeric types for the
|
||||
* reason §F gives: unlike a cap, a bad lease value is in force the moment it is
|
||||
* applied, so "0.5 to 5" is not advice.
|
||||
*/
|
||||
function coerceLeaseValue(lease, raw) {
|
||||
const text = String(raw === undefined || raw === null ? '' : raw).trim()
|
||||
if (lease.type === 'string') {
|
||||
// A string lease with a declared value set is bounded here, at authoring
|
||||
// time, exactly as a numeric one is by its range (Phase 12b). Without it the
|
||||
// only check on the value is the game side's, and that refusal arrives
|
||||
// unattended, mid-run, from a step nobody is watching.
|
||||
if (lease.values && !lease.values.includes(text)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `${lease.label} accepts ${lease.values.join(', ')}, and "${raw}" is none of them`,
|
||||
}
|
||||
}
|
||||
return { ok: true, value: text }
|
||||
}
|
||||
if (lease.type === 'bool') {
|
||||
if (['true', '1', 'yes', 'on'].includes(text.toLowerCase())) return { ok: true, value: true }
|
||||
if (['false', '0', 'no', 'off'].includes(text.toLowerCase())) return { ok: true, value: false }
|
||||
return { ok: false, error: `"${raw}" is not a yes or no value for ${lease.label}` }
|
||||
}
|
||||
const n = Number(text)
|
||||
if (text === '' || !Number.isFinite(n)) {
|
||||
return { ok: false, error: `"${raw}" is not a number, and ${lease.label} holds one` }
|
||||
}
|
||||
if (lease.type === 'int' && !Number.isInteger(n)) {
|
||||
return { ok: false, error: `${lease.label} holds a whole number, and "${raw}" is not one` }
|
||||
}
|
||||
if (n < lease.min || n > lease.max) {
|
||||
return { ok: false, error: `${lease.label} accepts ${lease.min} to ${lease.max}, and "${raw}" is outside that` }
|
||||
}
|
||||
return { ok: true, value: n }
|
||||
}
|
||||
|
||||
const ACTIONS = [
|
||||
{
|
||||
id: 'core.announce',
|
||||
label: 'Announce',
|
||||
description:
|
||||
'Publish a line of text to an announce leg — Discord, the in-game town crier, or any leg a module has registered.',
|
||||
|
||||
// Nothing in the world changes and nothing is created: a message goes out.
|
||||
// That is what makes the default `on_failure` for this step `retry -> skip`
|
||||
// (§L) rather than `pause`, and it is the honest class even though the
|
||||
// message itself cannot be unsent.
|
||||
risk: 'notify',
|
||||
// A sent announcement is gone. `none` rather than `ledger` is not an
|
||||
// omission — there is no undo to write, and declaring `ledger` would put a
|
||||
// row in the cleanup ledger that teardown could never resolve.
|
||||
reversible: 'none',
|
||||
version: 1,
|
||||
|
||||
params: [
|
||||
{
|
||||
// A leg id, checked against the announce-leg registry at dispatch rather
|
||||
// than here: legs are registered by modules, and this file is evaluated
|
||||
// before any module has registered anything.
|
||||
//
|
||||
// **`source` is what moves that check earlier** (Phase 7). The dispatch
|
||||
// check stays — a module can boot between authoring and the run — but
|
||||
// until now a typo here was caught mid-run and nowhere else, which is the
|
||||
// defect Phase 6's walk hit: an announce leg "site" no module registers,
|
||||
// found by a dry run rather than by the form that accepted it.
|
||||
name: 'leg',
|
||||
type: 'string',
|
||||
required: true,
|
||||
example: 'discord',
|
||||
source: 'core.options.legs',
|
||||
description: 'The announce leg to publish on. Registered legs only.',
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
type: 'string',
|
||||
required: false,
|
||||
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 east road.',
|
||||
description: 'The announcement itself. Plain text.',
|
||||
},
|
||||
],
|
||||
|
||||
/**
|
||||
* Publish through the announce leg the step names.
|
||||
*
|
||||
* **The legs are reused rather than reimplemented** (§J, "reuse the legs"):
|
||||
* `discord` is core's and `towncrier` is module-uo's, both already registered,
|
||||
* both already carrying a `classify()` that knows what their transport's
|
||||
* failures mean. An event announcement that went out by some other path would
|
||||
* be a second delivery mechanism with its own bugs.
|
||||
*
|
||||
* A leg's `dispatch()` takes a POST — that is the shape the news path gave it
|
||||
* — so an event announcement is presented as one. `excerpt` is the body
|
||||
* because it is the field every leg renders as prose, and `image_url` is null
|
||||
* because an event announcement has no article behind it to illustrate.
|
||||
* Widening the leg contract to carry a second payload shape is a
|
||||
* MODULE_API change, and Phase 7 is where those are made.
|
||||
*
|
||||
* The leg id is checked HERE rather than at authoring time, and that is not
|
||||
* laxness: legs are registered by modules, and a spec is validated in a
|
||||
* process that may have booted before the module that owns the leg.
|
||||
*/
|
||||
async perform({ params, verify }) {
|
||||
const registered = registries.announceLeg(params.leg)
|
||||
if (!registered) {
|
||||
// Terminal, not transient. A leg nobody registers will not appear
|
||||
// between two attempts sixty seconds apart, and the honest cause — a
|
||||
// module removed, or a typo the authoring form could not catch — is a
|
||||
// thing a human fixes.
|
||||
return { ok: false, retry: false, error: `no module registers the announce leg "${params.leg}"` }
|
||||
}
|
||||
// A dry run reports what it WOULD do and sends nothing (§I). Answering
|
||||
// before the dispatch rather than inside the leg is what keeps that true
|
||||
// for legs written by people who never read this file.
|
||||
if (verify) return { ok: true }
|
||||
|
||||
const result = await registered.dispatch({
|
||||
title: params.title || null,
|
||||
excerpt: params.body,
|
||||
image_url: null,
|
||||
})
|
||||
// The leg's own classification, not a second opinion. `retry` vs
|
||||
// `terminal` for a Discord webhook is a judgement `discordAnnounce.classify`
|
||||
// already makes, and making it twice is how the two drift.
|
||||
const { outcome, error } = registered.classify(result)
|
||||
if (outcome === 'done') return { ok: true }
|
||||
return { ok: false, retry: outcome === 'retry', error: error || `announce leg "${params.leg}" refused` }
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'core.wait',
|
||||
label: 'Wait',
|
||||
description: 'Let a fixed amount of time pass before the next step of this phase runs.',
|
||||
|
||||
// `inspect` rather than `notify`: nothing is sent and nobody is told. It is
|
||||
// the weakest class the closed set has for an action that is not a broadcast.
|
||||
risk: 'inspect',
|
||||
reversible: 'none',
|
||||
version: 1,
|
||||
|
||||
params: [
|
||||
{
|
||||
name: 'seconds',
|
||||
type: 'int',
|
||||
required: true,
|
||||
example: 300,
|
||||
description: 'How long to wait. The runner sets the next step due_at from this.',
|
||||
},
|
||||
],
|
||||
|
||||
// A wait is a genuine no-op at dispatch, and it stayed one: the delay is the
|
||||
// NEXT step's `due_at`, which the runner owns, not something this function
|
||||
// sleeps through. A `perform` that slept would hold a step's claim for the
|
||||
// duration and turn a five-minute pause into a five-minute lease — and the
|
||||
// reclaim would then re-dispatch it, so a long enough wait would never end.
|
||||
//
|
||||
// `holdFor` is an ordinary envelope member (org lead, 2026-09-02), which is
|
||||
// why the runner can honour this without knowing what `core.wait` is.
|
||||
async perform({ params, verify }) {
|
||||
if (verify) return { ok: true }
|
||||
return { ok: true, holdFor: params.seconds }
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'core.cue',
|
||||
label: 'Cue a human',
|
||||
description:
|
||||
'Post an instruction for staff and wait for someone to confirm it was done before the run advances.',
|
||||
|
||||
// The action itself only posts an instruction. Whatever the human then does
|
||||
// is outside this system entirely, which is precisely why the cue exists:
|
||||
// it is how an event uses a capability no module has automated.
|
||||
risk: 'notify',
|
||||
reversible: 'none',
|
||||
version: 1,
|
||||
|
||||
params: [
|
||||
{
|
||||
name: 'instruction',
|
||||
type: 'string',
|
||||
required: true,
|
||||
example: 'Open the north gate and read the herald script in the town square.',
|
||||
description: 'What the staff member is being asked to do.',
|
||||
},
|
||||
{
|
||||
name: 'assignee',
|
||||
type: 'string',
|
||||
required: false,
|
||||
example: 'Event Team',
|
||||
description: 'Who the cue is addressed to. A label, not an account.',
|
||||
},
|
||||
],
|
||||
|
||||
/**
|
||||
* Post the instruction and PARK. The step does not complete here.
|
||||
*
|
||||
* `await: 'human'` is the envelope member that says so (org lead,
|
||||
* 2026-09-02), and the runner's answer to it is to leave the step `running`
|
||||
* with a NULL lease — genuinely in flight, nothing holding it, so the stale
|
||||
* reclaim passes it by and a cue posted on Friday is still waiting on Monday.
|
||||
* The step ends when someone presses confirm, which is Phase 3's control.
|
||||
*
|
||||
* **Nothing is delivered from here in Phase 2, and that is visible rather
|
||||
* than pretended.** The instruction is carried by the step's own params and
|
||||
* shown on the run console; routing it to Discord or to a staff inbox is
|
||||
* Phase 10's integration work, through the engagement triggers that own every
|
||||
* other notification on this platform. An action that grew its own delivery
|
||||
* path would be the second one.
|
||||
*/
|
||||
async perform({ verify }) {
|
||||
if (verify) return { ok: true }
|
||||
return { ok: true, await: 'human' }
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'core.lease',
|
||||
label: 'Borrow a value',
|
||||
description:
|
||||
'Hold a module-declared value at a new setting for a bounded time, and put the old one back at teardown.',
|
||||
|
||||
// The world changes and it changes back, so `change` rather than
|
||||
// `irreversible` — and `change`'s default `on_failure` is `pause`, which is
|
||||
// the right stop for a run that failed halfway through altering the world.
|
||||
risk: 'change',
|
||||
// The one action core ships in this class. `override` is what tells the
|
||||
// cleanup sweep to restore through the LEASE registry rather than through an
|
||||
// action's `revert()`, which is why this action needs no `revert()` of its own
|
||||
// and why the registry refuses one on it.
|
||||
reversible: 'override',
|
||||
version: 1,
|
||||
|
||||
params: [
|
||||
{
|
||||
name: 'lease',
|
||||
type: 'string',
|
||||
required: true,
|
||||
// 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.',
|
||||
},
|
||||
{
|
||||
// **A string, and the coercion is here rather than in the type system.**
|
||||
// A param declares ONE type; a lease declares its own, and they are four
|
||||
// different ones. Typing this `float` would make a boolean lease
|
||||
// unauthorable and a string lease nonsense, so the field takes text and
|
||||
// this action turns it into whatever the named lease said it holds — the
|
||||
// one place that knows both halves.
|
||||
name: 'value',
|
||||
type: 'string',
|
||||
required: true,
|
||||
example: '3.0',
|
||||
description: 'What to hold it at, in whatever type the lease declares.',
|
||||
},
|
||||
{
|
||||
// **Optional here, required by the LEASE** (Phase 12b), and the two are
|
||||
// not the same statement. A param's `required` is a property of the
|
||||
// action, and this action serves both a config key (which has no target)
|
||||
// and an object property (which cannot be named without one) — so the
|
||||
// field is declared optional and `perform` refuses a targeted lease with
|
||||
// nothing in it, in the lease's own words.
|
||||
//
|
||||
// It carries no `source` for the same reason: the values behind it are
|
||||
// the chosen LEASE's, and a param declares one source for all time. The
|
||||
// lease's own `target.source` is what the authoring form reads once the
|
||||
// author has picked a lease, which is the only moment the right list is
|
||||
// knowable.
|
||||
name: 'target',
|
||||
type: 'string',
|
||||
required: false,
|
||||
example: '003f11b8-9bfa-4587-991e-ca263004efe6',
|
||||
description: 'Which one, for a value that exists on many things. Leave empty otherwise.',
|
||||
},
|
||||
{
|
||||
name: 'minutes',
|
||||
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.',
|
||||
},
|
||||
],
|
||||
|
||||
// What a lease costs is the LEASE's business to bound, not a budget's:
|
||||
// `maxDurationMs` and the numeric range are declared beside the callables and
|
||||
// enforced below. A cap dimension here would be core inventing an accounting
|
||||
// unit for something a module already bounds — and `registerEventBudgets`
|
||||
// refuses a dimension nobody declared, which is exactly the rule that would
|
||||
// then bite core's own action.
|
||||
|
||||
/**
|
||||
* Read the baseline, reserve the target, apply the value.
|
||||
*
|
||||
* **This is rule 1 in its strongest form.** Unlike a spawn, a lease's target
|
||||
* is knowable before the dispatch — it is the lease id the step names — so
|
||||
* the ledger row is written with its real `kind` and `ref` BEFORE anything
|
||||
* touches the world, and the two-events-one-target refusal comes from the
|
||||
* unique index at that moment rather than from a check that read and then
|
||||
* wrote. A second run asking for a lease another run holds comes back
|
||||
* `refused`, in the same words a cap breach uses and for the same reason:
|
||||
* nothing is broken, the deployment already has that value spoken for.
|
||||
*
|
||||
* The order is read then reserve then apply, and a failure at each stage
|
||||
* undoes the one before it: a reservation whose `apply` refuses is released
|
||||
* here rather than left for the sweep, because there is nothing out there to
|
||||
* give back and a shard that is merely down must not lock a lease out for the
|
||||
* length of a retry cycle.
|
||||
*/
|
||||
async perform({ runId, stepId, params, verify }) {
|
||||
// eslint-disable-next-line global-require
|
||||
const resourcesDb = require('../model/events/eventRunResources.db')
|
||||
const lease = registries.eventLease(params.lease)
|
||||
if (!lease) {
|
||||
return { ok: false, retry: false, error: `no module registers the lease "${params.lease}"` }
|
||||
}
|
||||
|
||||
const coerced = coerceLeaseValue(lease, params.value)
|
||||
if (!coerced.ok) return { ok: false, retry: false, error: coerced.error }
|
||||
|
||||
// **The target is checked before anything else about the world is read**
|
||||
// (Phase 12b), because both of its failures are authoring mistakes rather
|
||||
// than outages: a targeted lease with no target names nothing, and a target
|
||||
// on a lease that has none is an author who has confused two fields. Both
|
||||
// are `retry: false` — the second attempt has the same params.
|
||||
const targetRaw = params.target === undefined || params.target === null ? '' : String(params.target).trim()
|
||||
if (lease.target && !targetRaw) {
|
||||
return { ok: false, retry: false, error: `${lease.label} needs a ${lease.target.label.toLowerCase()}` }
|
||||
}
|
||||
if (!lease.target && targetRaw) {
|
||||
return { ok: false, retry: false, error: `${lease.label} is a single value and takes no target` }
|
||||
}
|
||||
const target = lease.target ? targetRaw : null
|
||||
const ref = registries.leaseRef(lease.id, target)
|
||||
// Refused rather than truncated, on the ledger's own rule for a resource
|
||||
// ref: a truncated ref is a restore pointed at the wrong object.
|
||||
if (ref.length > MAX_LEASE_REF) {
|
||||
return { ok: false, retry: false, error: `that target is too long to record (${ref.length} of ${MAX_LEASE_REF})` }
|
||||
}
|
||||
|
||||
const minutes = Number(params.minutes)
|
||||
if (!Number.isFinite(minutes) || minutes <= 0) {
|
||||
return { ok: false, retry: false, error: `"${params.minutes}" is not a number of minutes` }
|
||||
}
|
||||
const ms = Math.round(minutes * 60_000)
|
||||
if (ms > lease.maxDurationMs) {
|
||||
return {
|
||||
ok: false,
|
||||
retry: false,
|
||||
error: `${lease.label} may be held for at most ${Math.floor(lease.maxDurationMs / 60_000)} minutes, not ${minutes}`,
|
||||
}
|
||||
}
|
||||
|
||||
// **The dry run stops here, and it has still checked everything worth
|
||||
// checking**: the lease exists, the value is in range and the duration is
|
||||
// allowed. What it deliberately does not do is reserve the target — a
|
||||
// verify that took a lease would be a dry run that changed something, and
|
||||
// it would then refuse the real run that followed it.
|
||||
//
|
||||
// It also does not check that the TARGET exists, and that is the same
|
||||
// rule rather than an exception: asking the game side whether a spawner is
|
||||
// there is a live read the shard may be down for, and a dry run that fails
|
||||
// because a shard is restarting would make `verified_at` a property of the
|
||||
// moment rather than of the version (§K).
|
||||
if (verify) return { ok: true }
|
||||
|
||||
const baseline = await lease.read({ target })
|
||||
if (!baseline || baseline.ok !== true) {
|
||||
// A module that says `retry: false` has said a retry cannot help, so that
|
||||
// verdict is passed through; anything else stays undefined so the
|
||||
// dispatcher's own default (retry) still applies.
|
||||
return {
|
||||
ok: false,
|
||||
retry: baseline && baseline.retry === false ? false : undefined,
|
||||
error: baseline && baseline.error
|
||||
? String(baseline.error)
|
||||
: `could not read the current value of ${lease.label}`,
|
||||
}
|
||||
}
|
||||
|
||||
const until = new Date(Date.now() + ms)
|
||||
const reserved = await resourcesDb.reserve({
|
||||
runId,
|
||||
stepId,
|
||||
owner: lease.owner || 'core',
|
||||
kind: 'override',
|
||||
// **The ref carries the target, and that is what makes the unique index
|
||||
// right rather than merely present.** Reserved under the lease id alone,
|
||||
// an event turning up one spawner would lock every other run out of every
|
||||
// other spawner — a conflict check that refuses correct work is as wrong
|
||||
// as one that permits a collision, and on a shard with 6,707 spawners it
|
||||
// is the failure an operator would actually meet.
|
||||
ref,
|
||||
payload: {
|
||||
target: ref,
|
||||
leaseTarget: target,
|
||||
baseline: baseline.value,
|
||||
applied: coerced.value,
|
||||
until: until.toISOString(),
|
||||
},
|
||||
leaseUntil: until,
|
||||
})
|
||||
if (!reserved.ok) {
|
||||
const heldBy = reserved.holder ? ` (run ${reserved.holder.run_id})` : ''
|
||||
return {
|
||||
ok: false,
|
||||
retry: false,
|
||||
error: `${lease.label} is already leased by another run${heldBy}`,
|
||||
}
|
||||
}
|
||||
|
||||
// **`until` goes down the wire** (§F). The module passes it to its sidecar
|
||||
// and the game side restores baseline when it passes, without being asked
|
||||
// again — the fail-safe that makes an unattended, scheduled world change
|
||||
// defensible, because the worst case is a world back at baseline early
|
||||
// rather than one stuck changed indefinitely.
|
||||
let applied
|
||||
try {
|
||||
applied = await lease.apply(coerced.value, until, { target })
|
||||
} catch (err) {
|
||||
applied = { ok: false, error: err.message }
|
||||
}
|
||||
if (!applied || applied.ok !== true) {
|
||||
await resourcesDb.markReverted(reserved.id)
|
||||
return {
|
||||
ok: false,
|
||||
retry: applied && applied.retry === false ? false : undefined,
|
||||
error: applied && applied.error ? String(applied.error) : `${lease.label} refused the new value`,
|
||||
}
|
||||
}
|
||||
|
||||
await resourcesDb.confirm(reserved.id)
|
||||
// **The run now owes the world something, and something has to say so.**
|
||||
// The generic path marks a run dirty when it records a module's reported
|
||||
// resources; this action reserves its own row and never goes through it, so
|
||||
// a run whose only resource was a lease would have kept `cleanup_status =
|
||||
// 'not_required'` and never been swept. Found by the live walk, and the
|
||||
// cleanup leg's own scan was widened to make the class impossible rather
|
||||
// than only this instance.
|
||||
// eslint-disable-next-line global-require
|
||||
await require('../events/ledger').markRunDirty(runId)
|
||||
return { ok: true }
|
||||
},
|
||||
|
||||
/**
|
||||
* Which of this run's leases the game side still has a record of (Phase 11b).
|
||||
*
|
||||
* **A lease row had no reconcile path at all until this existed**, and nothing
|
||||
* failed to say so. `cleanup.js` resolves a resource to the action of the step
|
||||
* that made it, and for a lease that action is `core.lease` — a CORE action, on
|
||||
* a path a module cannot register anything on. So every `override` row came
|
||||
* back `unanswered` for the life of the run, and a lease the shard had quietly
|
||||
* dropped (a restart reverts every config lease, by design) stayed in the
|
||||
* ledger as live until teardown went looking for a baseline nobody was holding.
|
||||
*
|
||||
* The question asked is deliberately NOT "is the value still what we applied".
|
||||
* That is drift, and drift is teardown's verdict to deliver through `restore`
|
||||
* so the row lands as `drifted` with the current value beside it. A reconcile
|
||||
* that inferred absence from a changed value would orphan the row first and
|
||||
* throw that away — the operator would be told the lease vanished rather than
|
||||
* that somebody moved it.
|
||||
*
|
||||
* A lease with no `inForce()` is reported in force, which is core's posture
|
||||
* everywhere else in this file: "I could not ask" must never be recorded as
|
||||
* "it is gone".
|
||||
*/
|
||||
async reconcile({ resources }) {
|
||||
const inForce = []
|
||||
|
||||
for (const row of resources || []) {
|
||||
if (row.kind !== 'override') continue
|
||||
|
||||
// Resolved through the ref parser rather than by a bare map lookup: a
|
||||
// targeted row's ref is `<id>#<target>` and `eventLease` would miss it,
|
||||
// which would silently report every property lease still in force.
|
||||
const found = registries.eventLeaseForRef(row.ref)
|
||||
const lease = found && found.lease
|
||||
|
||||
if (!lease || typeof lease.inForce !== 'function') {
|
||||
inForce.push(row.ref)
|
||||
continue
|
||||
}
|
||||
|
||||
let answer
|
||||
try {
|
||||
answer = await lease.inForce({ ref: row.ref, target: found.target, payload: row.payload || null })
|
||||
} catch (err) {
|
||||
answer = null
|
||||
}
|
||||
|
||||
// Only an explicit `held: false` takes a row out. A module that threw, timed
|
||||
// out, or answered something unrecognisable has not said the lease is gone.
|
||||
if (answer && answer.ok === true && answer.held === false) continue
|
||||
|
||||
inForce.push(row.ref)
|
||||
}
|
||||
|
||||
return { ok: true, inForce }
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'core.announce.post',
|
||||
label: 'Announce a post',
|
||||
description:
|
||||
'Send an existing news post out on every registered announce leg — Discord, the in-game town crier — as this run\'s announcement.',
|
||||
|
||||
// Nothing in the world changes and nothing is created; a message goes out.
|
||||
// Same class as `core.announce` and for the same reason.
|
||||
risk: 'notify',
|
||||
// The job is queued, the legs deliver, and none of it can be unsent. A
|
||||
// `ledger` here would put a row in the cleanup ledger that teardown could
|
||||
// never resolve.
|
||||
reversible: 'none',
|
||||
version: 1,
|
||||
|
||||
// **`core.announce` sends a line; this sends an ARTICLE**, and that is the
|
||||
// whole difference between them (EVENTS.md §J, "News"). Events does not
|
||||
// write posts — `ctx.posts` is read-only to modules and the CMS is core's —
|
||||
// so an event that wants prose, an image and a permanent page links a post
|
||||
// an editor already wrote. What this action adds over `core.announce` is
|
||||
// therefore not a second transport but a second SHAPE: every leg's
|
||||
// `dispatch()` takes a post, and this is the one that hands it a real one.
|
||||
params: [
|
||||
{
|
||||
name: 'postId',
|
||||
type: 'int',
|
||||
required: true,
|
||||
example: 412,
|
||||
source: 'core.options.posts',
|
||||
description: 'The published post to announce. Any category.',
|
||||
},
|
||||
],
|
||||
|
||||
/**
|
||||
* Queue the post on every registered leg, as this run's announcement.
|
||||
*
|
||||
* **The refusals are all `retry: false`**, and each is a thing a human has to
|
||||
* fix: a post id that names nothing, or a draft. Neither will have changed
|
||||
* sixty seconds later, and retrying would spend two more attempts before
|
||||
* saying the same thing.
|
||||
*
|
||||
* **What it does NOT wait for is delivery.** `enqueueForRun` writes the job
|
||||
* and the legs and returns; `announceWorker` drains them on its own tick with
|
||||
* its own backoff. So this step is `done` when the announcement is queued,
|
||||
* not when Discord has it — which is honest, because a leg that fails after
|
||||
* six attempts over two hours is not something a step could usefully have
|
||||
* stayed open for, and the post admin panel is where that failure is already
|
||||
* surfaced.
|
||||
*/
|
||||
async perform({ runId, params, verify }) {
|
||||
/* eslint-disable global-require */
|
||||
const posts = require('../model/posts/posts.model')
|
||||
const announceJobs = require('../model/announceJobs/announceJobs.model')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
const postId = Number(params.postId)
|
||||
if (!Number.isInteger(postId) || postId < 1) {
|
||||
return { ok: false, retry: false, error: `"${params.postId}" is not a post id` }
|
||||
}
|
||||
|
||||
const post = await posts.getById(postId)
|
||||
if (!post) return { ok: false, retry: false, error: `no post with id ${postId}` }
|
||||
if (!post.published) {
|
||||
// A draft has no public page for a town-crier line to point at, and
|
||||
// announcing one would publish its title to a shard before an editor
|
||||
// meant to. Refused rather than published on the author's behalf:
|
||||
// publishing is the CMS's decision and this action is not it.
|
||||
return { ok: false, retry: false, error: `"${post.title}" is not published` }
|
||||
}
|
||||
|
||||
// The dry run has now checked everything worth checking — the post exists
|
||||
// and is published — and queues nothing. Checked BEFORE the legs are read,
|
||||
// because a deployment with no leg registered is a real state and a verify
|
||||
// that reported it as a failure would refuse a plan that is fine.
|
||||
if (verify) return { ok: true }
|
||||
|
||||
await announceJobs.enqueueForRun(postId, runId)
|
||||
return { ok: true }
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'core.results.publish',
|
||||
label: 'Publish the results',
|
||||
description:
|
||||
'Rank this run\'s participants by score and publish the results table.',
|
||||
|
||||
// Nothing in the game world changes and nobody is messaged: a table core
|
||||
// already holds becomes readable. `inspect` is the weakest class the closed
|
||||
// set has and it is the honest one — which also means this action is
|
||||
// default-ON like `core.wait`, and an author can place it without an admin
|
||||
// first visiting the switchboard.
|
||||
risk: 'inspect',
|
||||
// **`none`, and it is worth saying why a publication is not reversible.**
|
||||
// Nothing is created that core would have to come back for; un-publishing is
|
||||
// an admin decision about a table, not a teardown obligation, and a `ledger`
|
||||
// row here would make every completed event carry an outstanding resource
|
||||
// for ever.
|
||||
reversible: 'none',
|
||||
version: 1,
|
||||
|
||||
// No params. What is published is this run's participants, which is the only
|
||||
// set there is — a param naming which run would be a way to publish someone
|
||||
// else's results from inside your own event.
|
||||
params: [],
|
||||
|
||||
/**
|
||||
* Rank, stamp, and say how many.
|
||||
*
|
||||
* **Idempotent by construction**, which is what makes it safe as an ordinary
|
||||
* retried step: ranking is a total order over `(score, joined_at, id)`, so
|
||||
* running it twice over an unchanged table writes the same numbers, and the
|
||||
* stamp simply moves. A late participant added by a second collect step and
|
||||
* a re-publish afterwards renumbers deliberately — that is the operator
|
||||
* asking for exactly that.
|
||||
*
|
||||
* **A run with no participants publishes an empty table rather than
|
||||
* failing.** "Nobody was recorded" is a true and renderable result, and it is
|
||||
* the state of every run until a module can source attendance at all (Phase
|
||||
* 12). Failing here would make an event whose module reports nothing look
|
||||
* broken on the console for a reason that has nothing to do with the event.
|
||||
*/
|
||||
async perform({ runId, verify }) {
|
||||
/* eslint-disable global-require */
|
||||
const participantsDb = require('../model/events/eventRunParticipants.db')
|
||||
const runsDb = require('../model/events/eventRuns.db')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
if (verify) return { ok: true }
|
||||
|
||||
await participantsDb.rankRun(runId)
|
||||
await runsDb.markResultsPublished(runId)
|
||||
return { ok: true }
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// ── Core's own param option sources (§F, Phase 7) ──────────────────
|
||||
//
|
||||
// One, and it is core's half of the seam it hands a module on the same boot: a
|
||||
// param's `source` names a registered option source, core asks it for values, and
|
||||
// the authoring form renders a dropdown instead of a text box.
|
||||
//
|
||||
// **The legs are already a registry with labels in it**, so this costs nothing
|
||||
// new — which is what makes it the right first exercise. `resolve()` is called
|
||||
// per request rather than read once, for the same reason `core.announce` looks a
|
||||
// leg up inside `perform()`: a leg registered by a module that booted after this
|
||||
// file was evaluated must still appear, and a module uninstalled since must stop
|
||||
// appearing.
|
||||
const OPTION_SOURCES = [
|
||||
{
|
||||
id: 'core.options.legs',
|
||||
label: 'Announce legs',
|
||||
description: 'Every delivery leg registered on this deployment right now.',
|
||||
async resolve() {
|
||||
return registries.announceLegs().map((l) => ({ value: l.leg, label: l.label || l.leg }))
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'core.options.leases',
|
||||
label: 'Borrowable values',
|
||||
description: 'Every value a module has declared this deployment may lease.',
|
||||
async resolve() {
|
||||
return registries
|
||||
.allEventLeases()
|
||||
.map((l) => ({ value: l.id, label: l.label, group: l.id.split('.')[0] }))
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'core.options.posts',
|
||||
label: 'Published posts',
|
||||
description: 'Every published post an event may announce, newest first.',
|
||||
/**
|
||||
* **The one option source in core that reaches a table**, and the reason it
|
||||
* is allowed to is the rule §F draws about WHEN: a source resolves on its own
|
||||
* request (`GET /admin/events/catalog/options/:sourceId`), which is a live
|
||||
* request on a booted server, not at `register()` time under a dead pool.
|
||||
*
|
||||
* Grouped by category so the dropdown separates news from the newsletter
|
||||
* rather than presenting one long list in which the two are indistinguishable
|
||||
* — a `group` is what the form renders as an optgroup, and it costs a column
|
||||
* that is already selected.
|
||||
*/
|
||||
async resolve() {
|
||||
// eslint-disable-next-line global-require
|
||||
const postsDb = require('../model/posts/posts.db')
|
||||
const rows = await postsDb.listPublishedForOptions(200)
|
||||
return rows.map((p) => ({ value: p.id, label: p.title, group: p.category }))
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
module.exports = { ACTIONS, OPTION_SOURCES }
|
||||
@@ -75,6 +75,38 @@ const STREAMS = [
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
|
||||
// ── The event system (EVENTS.md §J — Phase 10) ──────────────────────────
|
||||
//
|
||||
// **One of the seven `event.` triggers is also a stream, and that is a
|
||||
// decision rather than an oversight** (org lead, 2026-09-04). A stream is a
|
||||
// PUSH toggle: `notificationChannelPrefs.catalog` offers the push channel only
|
||||
// for ids registered here, `publishToUsers` joins `notification_subscriptions`,
|
||||
// and that table is only ever written for a channel a user could switch on. So
|
||||
// a trigger that is not also a stream can be mailed and put in the inbox, and
|
||||
// its push is dead — a tickle published to nobody, which the send log
|
||||
// nonetheless records as sent. Found on the live rig; the seeded rule named
|
||||
// `push` before this line existed.
|
||||
//
|
||||
// **`run.started` alone, because push is the channel that says "now".** It is
|
||||
// the one lifecycle moment worth waking a phone for — ENGAGEMENT.md §8.5's
|
||||
// *"come back for X"* — and the other six are things a player reads when they
|
||||
// next look. Six more toggles would put a wall of switches on the preferences
|
||||
// screen for one feature, and `event.phase.changed` is the one most likely to
|
||||
// buzz a phone four times in an evening.
|
||||
//
|
||||
// Same id as the trigger, which is §7.2's one namespace and the same-owner
|
||||
// upgrade `news.post` already is: one id, one owner, two facets.
|
||||
{
|
||||
id: 'event.run.started',
|
||||
label: 'Events — starting now',
|
||||
description: 'A scheduled event is beginning.',
|
||||
// Not owner-keyed: this is a public event happening in public, not a fact
|
||||
// about one account's own property. Same as `news.post`.
|
||||
personal: false,
|
||||
// A player with no linked game account can still want to know an event is on.
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
]
|
||||
|
||||
module.exports = { STREAMS }
|
||||
|
||||
@@ -29,6 +29,29 @@
|
||||
// test-send without a live game event, which is the reason template systems go
|
||||
// untested.
|
||||
|
||||
/**
|
||||
* The three facts `events/announce.js` puts on EVERY `event.*` payload, declared
|
||||
* once because they are spread into all seven.
|
||||
*
|
||||
* `baseFor()` has always computed them and nothing declared them, so
|
||||
* `engagementEmit.validatePayload` dropped all three before a template could see
|
||||
* one — they were absent from the variable list an author picks from, and every
|
||||
* single event emit logged `emit carried undeclared variables`. Found by the
|
||||
* Phase 16 acceptance walk, in the DEBUG line it had been writing all along.
|
||||
*
|
||||
* All three are optional, and each for its own reason rather than by default: an
|
||||
* event need not carry a summary, most events belong to no series, and a run
|
||||
* whose definition has been deleted resolves no zone.
|
||||
*/
|
||||
const EVENT_AMBIENT = [
|
||||
{ name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.',
|
||||
description: 'The event’s one-line summary, when it has one.' },
|
||||
{ name: 'seriesName', type: 'string', required: false, example: 'The Yew Campaign',
|
||||
description: 'The arc this event belongs to, when it belongs to one.' },
|
||||
{ name: 'timezone', type: 'string', required: false, example: 'America/New_York',
|
||||
description: 'The zone the run was computed in — what a time in the body should be read as.' },
|
||||
]
|
||||
|
||||
const TRIGGERS = [
|
||||
{
|
||||
id: 'news.post',
|
||||
@@ -149,6 +172,269 @@ const TRIGGERS = [
|
||||
description: 'Site-relative path to the announcement.' },
|
||||
],
|
||||
},
|
||||
|
||||
// ── The event system (EVENTS.md §J — Phase 10) ──────────────────────────
|
||||
//
|
||||
// **Seven triggers, one per moment a run passes through that somebody outside
|
||||
// the run console might want to hear about — and Events owns none of the
|
||||
// delivery.** A run emits; an operator's rule decides who is told, on what,
|
||||
// and how often. That is the whole of §J's "clean fit" row, and it is why
|
||||
// there is no announcement machinery anywhere in `utils/eventRunner.js`
|
||||
// beyond a call to `emit`.
|
||||
//
|
||||
// **Six are ceilinged `authenticated` and one at `admin`** (§J, and the org
|
||||
// lead 2026-09-04). `run.failed` is an operational fact — a step ran out of
|
||||
// attempts, the world may be half-changed — and a rule that mailed it to
|
||||
// every subscriber would publish the deployment's incidents. The other six
|
||||
// describe a public event happening in public, so they sit exactly where
|
||||
// `news.post` sits: ceiling `authenticated`, default audience `subscribers`,
|
||||
// which is "people who asked to be told" rather than the whole user table.
|
||||
//
|
||||
// **Every `description` here is read by two audiences**, and the second one is
|
||||
// easy to forget: the rule editor's catalog, and — through
|
||||
// `projection.project`'s `intro` fallback — every recipient of an unauthored
|
||||
// render through `notify.event` or `inapp.event`. So each is prose a player
|
||||
// can read rather than a note to the operator. The live rig caught the
|
||||
// original `run.failed` line, which ended "Staff-facing." and put those words
|
||||
// in an administrator's own inbox item. Who a trigger is for is said by its
|
||||
// CEILING, which is the only place that can enforce it anyway.
|
||||
//
|
||||
// **`subjectKey: 'runId'` on every one of them**, and it is the one place
|
||||
// these differ from `news.post`. A cooldown keyed on the user would make
|
||||
// `phase.changed` mean "at most one phase of at most one event an hour",
|
||||
// silently swallowing the second wave of an invasion because the first wave's
|
||||
// mail went out forty minutes ago. Keyed on the run it means "at most one
|
||||
// line an hour ABOUT THIS RUN", which is the useful sentence — and across
|
||||
// runs of the same definition the ids differ, so a weekly event is not
|
||||
// throttled by last week's.
|
||||
//
|
||||
// **All six public ones now declare `eventUrl`, and Phase 14a is what made
|
||||
// that legal.** Until it there was no public event page at all — `App.jsx`
|
||||
// mounted nothing under `/site/events` — and `news.post` had already paid for
|
||||
// that mistake once: its `postUrl` example named `/news/<slug>`, a path that
|
||||
// did not exist, so the template editor previewed a link that was dead in
|
||||
// every mail it sent. The variable arrived with the page it points at, which
|
||||
// is what makes this a version bump (1 -> 2) rather than a correction.
|
||||
//
|
||||
// **It carries `?run=`, and the query string is the whole reason it is a run
|
||||
// url and not an event url.** The page lives at the DEFINITION's slug, so a
|
||||
// weekly event has one stable address — but every one of these triggers is
|
||||
// about one OCCURRENCE, and a mail about last Friday's invasion whose link
|
||||
// opened next Friday's would answer a different question from the one the
|
||||
// reader clicked. `run.failed` keeps its own `runUrl` into the admin console
|
||||
// and gains nothing here: an admin reading about broken machinery wants the
|
||||
// console, not the storyline.
|
||||
{
|
||||
id: 'event.run.scheduled',
|
||||
label: 'Event — scheduled',
|
||||
description: 'A new event has been added to the calendar.',
|
||||
kind: 'event',
|
||||
subjectKey: 'runId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 2,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
|
||||
description: 'The event title.' },
|
||||
...EVENT_AMBIENT,
|
||||
{ name: 'startsAt', type: 'datetime', required: true, example: '2026-09-12T20:00:00.000Z',
|
||||
description: 'When the occurrence is due to start, UTC.' },
|
||||
// **A presentational fragment, and §4.6.1 convention 1 is what sanctions
|
||||
// one.** `startsAt` is a `datetime`, which the seam normalises to an ISO
|
||||
// string — correct as data and unreadable in a mail, and a template has no
|
||||
// logic with which to format it. So the formatting happens at the emitter,
|
||||
// in the shard-local zone, and arrives as a variable whose `example` shows
|
||||
// exactly what it produces. Same trade `forWhom` makes in the auth bodies.
|
||||
{ name: 'startsAtLabel', type: 'string', required: false,
|
||||
example: 'Saturday 12 September at 8:00 pm (America/New_York)',
|
||||
description: 'The start time written out in the shard-local zone, for a mail to read.' },
|
||||
// The public page for THIS occurrence (Phase 14a). Relative, like
|
||||
// `postUrl` and `runUrl`: the seam resolves it against the site's own
|
||||
// base, and an absolute one baked in here would be wrong on every
|
||||
// deployment but the first.
|
||||
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
|
||||
description: 'The public page for this occurrence.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'event.run.started',
|
||||
label: 'Event — starting now',
|
||||
description: 'A scheduled event has begun.',
|
||||
kind: 'event',
|
||||
subjectKey: 'runId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 2,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
|
||||
description: 'The event title.' },
|
||||
...EVENT_AMBIENT,
|
||||
{ name: 'startsAt', type: 'datetime', required: true, example: '2026-09-12T20:00:00.000Z',
|
||||
description: 'When it actually started, UTC.' },
|
||||
// **A presentational fragment, and §4.6.1 convention 1 is what sanctions
|
||||
// one.** `startsAt` is a `datetime`, which the seam normalises to an ISO
|
||||
// string — correct as data and unreadable in a mail, and a template has no
|
||||
// logic with which to format it. So the formatting happens at the emitter,
|
||||
// in the shard-local zone, and arrives as a variable whose `example` shows
|
||||
// exactly what it produces. Same trade `forWhom` makes in the auth bodies.
|
||||
{ name: 'startsAtLabel', type: 'string', required: false,
|
||||
example: 'Saturday 12 September at 8:00 pm (America/New_York)',
|
||||
description: 'The start time written out in the shard-local zone, for a mail to read.' },
|
||||
// The public page for THIS occurrence (Phase 14a). Relative, like
|
||||
// `postUrl` and `runUrl`: the seam resolves it against the site's own
|
||||
// base, and an absolute one baked in here would be wrong on every
|
||||
// deployment but the first.
|
||||
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
|
||||
description: 'The public page for this occurrence.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'event.phase.changed',
|
||||
label: 'Event — a new phase',
|
||||
description: 'An event that is under way has moved on to its next stage.',
|
||||
kind: 'event',
|
||||
subjectKey: 'runId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 2,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
|
||||
description: 'The event title.' },
|
||||
...EVENT_AMBIENT,
|
||||
{ name: 'phase', type: 'string', required: true, example: 'assault',
|
||||
description: 'The phase key just entered, as authored in the spec.' },
|
||||
{ name: 'phaseLabel', type: 'string', required: false, example: 'The assault',
|
||||
description: 'The phase label, when the spec gave it one. Falls back to the key.' },
|
||||
{ name: 'phaseIndex', type: 'int', required: true, example: 2,
|
||||
description: 'Which phase this is, counting from 1.' },
|
||||
{ name: 'phaseCount', type: 'int', required: true, example: 4,
|
||||
description: 'How many phases the pinned version has in total.' },
|
||||
// The public page for THIS occurrence (Phase 14a). Relative, like
|
||||
// `postUrl` and `runUrl`: the seam resolves it against the site's own
|
||||
// base, and an absolute one baked in here would be wrong on every
|
||||
// deployment but the first.
|
||||
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
|
||||
description: 'The public page for this occurrence.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'event.run.ending',
|
||||
label: 'Event — winding down',
|
||||
description: 'An event is drawing to a close.',
|
||||
kind: 'event',
|
||||
subjectKey: 'runId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 2,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
|
||||
description: 'The event title.' },
|
||||
...EVENT_AMBIENT,
|
||||
// The public page for THIS occurrence (Phase 14a). Relative, like
|
||||
// `postUrl` and `runUrl`: the seam resolves it against the site's own
|
||||
// base, and an absolute one baked in here would be wrong on every
|
||||
// deployment but the first.
|
||||
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
|
||||
description: 'The public page for this occurrence.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'event.run.completed',
|
||||
label: 'Event — finished',
|
||||
description: 'An event has finished.',
|
||||
kind: 'event',
|
||||
subjectKey: 'runId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 2,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
|
||||
description: 'The event title.' },
|
||||
...EVENT_AMBIENT,
|
||||
// Counted from `event_run_participants` at emit. Zero on a run whose
|
||||
// module reported nobody, which is every run until a module collects —
|
||||
// a template that says "47 took part" needs a number that is never
|
||||
// missing, and "0" is the honest one.
|
||||
{ name: 'participantCount', type: 'int', required: true, example: 47,
|
||||
description: 'How many participants the run recorded. Zero when nothing collected any.' },
|
||||
{ name: 'durationMinutes', type: 'int', required: true, example: 95,
|
||||
description: 'How long the run took, start to end, in whole minutes.' },
|
||||
// The public page for THIS occurrence (Phase 14a). Relative, like
|
||||
// `postUrl` and `runUrl`: the seam resolves it against the site's own
|
||||
// base, and an absolute one baked in here would be wrong on every
|
||||
// deployment but the first.
|
||||
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
|
||||
description: 'The public page for this occurrence.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'event.run.cancelled',
|
||||
label: 'Event — cancelled',
|
||||
description: 'A scheduled event was cancelled by a member of staff.',
|
||||
kind: 'event',
|
||||
subjectKey: 'runId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 2,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
|
||||
description: 'The event title.' },
|
||||
...EVENT_AMBIENT,
|
||||
// **The operator's reason, and not the run's `last_error`.** `cancel`
|
||||
// takes a `{ reason }` a human typed for other humans; a diagnostic
|
||||
// string is for the run console and would read as gibberish in a mail.
|
||||
{ name: 'reason', type: 'string', required: false, example: 'The shard is down for an emergency patch.',
|
||||
description: 'What the staff member gave as the reason, when they gave one.' },
|
||||
// The public page for THIS occurrence (Phase 14a). Relative, like
|
||||
// `postUrl` and `runUrl`: the seam resolves it against the site's own
|
||||
// base, and an absolute one baked in here would be wrong on every
|
||||
// deployment but the first.
|
||||
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
|
||||
description: 'The public page for this occurrence.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'event.run.failed',
|
||||
label: 'Event — run failed',
|
||||
description: 'An event stopped before it finished.',
|
||||
kind: 'event',
|
||||
subjectKey: 'runId',
|
||||
// **`admin`, and both halves of that.** The ceiling is the security
|
||||
// boundary (§J, G24): no rule may ever widen this past admins, because a
|
||||
// failure names the deployment's own broken machinery. The default audience
|
||||
// matches, so a rule created from this trigger starts where it must end.
|
||||
audience: 'admin',
|
||||
ceiling: 'admin',
|
||||
version: 1,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
|
||||
description: 'The event title.' },
|
||||
...EVENT_AMBIENT,
|
||||
{ name: 'phase', type: 'string', required: false, example: 'assault',
|
||||
description: 'The phase it failed in, when it had entered one.' },
|
||||
{ name: 'error', type: 'string', required: false, example: 'sidecar responded 503',
|
||||
description: 'The run’s last error, verbatim from the run row.' },
|
||||
// The admin console, not the public page — and this trigger gains no
|
||||
// `eventUrl` at all. An admin reading that the machinery broke wants the
|
||||
// steps and the errors, not the storyline. See the note above the six.
|
||||
{ name: 'runUrl', type: 'url', required: true, example: '/admin/events/runs/3692',
|
||||
description: 'Site-relative path to the run console.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
module.exports = { TRIGGERS }
|
||||
|
||||
@@ -11,6 +11,25 @@
|
||||
//
|
||||
// `api` is the coarse contract version (bumped only on a breaking re-shape, which
|
||||
// would be a v2 mount); `server` is the informational package version.
|
||||
//
|
||||
// ── `capabilities` (Phase 14a) ──
|
||||
//
|
||||
// Opaque strings naming what CORE serves beyond the surface every backend has —
|
||||
// the same idea as a module's `capabilities` on GET /public/modules, and
|
||||
// deliberately the same word, so a client feature-detects one way rather than
|
||||
// two. They are a different LIST because core is not a module: publishing core
|
||||
// as a pseudo-module would leave a client unable to tell "this backend has
|
||||
// events" from "a module called core happens to be installed", which is exactly
|
||||
// the distinction the loader exists to make.
|
||||
//
|
||||
// The value is in what is ABSENT. A backend released before Events answers this
|
||||
// object with no `capabilities` key at all, so a client can tell an older site
|
||||
// from one that simply has nothing on its calendar — which it could not do by
|
||||
// probing /public/events, where "not built" and "temporarily down" look alike.
|
||||
//
|
||||
// Static, because these are compiled-in features rather than installed ones:
|
||||
// a core that has these routes always has them. An unknown string is to be
|
||||
// treated as absent, exactly as MODULE_API.md §2.1 says of a module's.
|
||||
|
||||
const pkg = require('../../package.json')
|
||||
|
||||
@@ -18,4 +37,6 @@ module.exports = {
|
||||
service: 'runic-gateway', // stable backend identifier for first-run detection
|
||||
api: 'v1', // API contract version (matches the /api/v1 mount)
|
||||
server: pkg.version || '0.0.0', // server package version (informational)
|
||||
// What core serves beyond the baseline. See the note above.
|
||||
capabilities: ['events'],
|
||||
}
|
||||
|
||||
250
server/src/demo/locks.js
Normal file
250
server/src/demo/locks.js
Normal file
@@ -0,0 +1,250 @@
|
||||
// ── The demo lock (docs/website/DEMO_MODE.md §3) ───────────────────────────
|
||||
//
|
||||
// With DEMO_MODE on, app.js mounts four middlewares from this file, and with it
|
||||
// off none of them is mounted at all, so a real shard runs none of this code.
|
||||
//
|
||||
// • `refuseMultipart` — any request with a multipart body, anywhere, before a
|
||||
// router sees it (§3.3 "Uploads — locked hard"). An upload route added later
|
||||
// is locked without anyone remembering to list it, and a module cannot open one.
|
||||
// • `lock` — each write request against core's list below plus every module's
|
||||
// declared `demoLocks`, at `/api/v1`, ahead of every router.
|
||||
// • `refuseDataUri` — any write whose JSON body carries a data: URI in any
|
||||
// string (DM25): a pasted image is an upload that did not use multipart.
|
||||
// • `noindex` + `robots` — nothing a visitor writes reaches a search engine.
|
||||
//
|
||||
// **The server is the lock (DM10).** Everything here is decided from the request
|
||||
// alone, so `curl`, an old app build and a hand-edited page are refused exactly
|
||||
// like a click. The client explains a refusal; it never enforces one.
|
||||
//
|
||||
// A refusal is always the same answer, so the web client and the app can each
|
||||
// recognise it by `code` and show one sentence for it.
|
||||
|
||||
const { compile, match } = require('./paths')
|
||||
const { isDemoUsername } = require('./mode')
|
||||
|
||||
const log = require('../utils/logger')('demo')
|
||||
|
||||
const CODE = 'DEMO_LOCKED'
|
||||
const MESSAGE = 'This is turned off on the demo site.'
|
||||
const WRITES = ['POST', 'PUT', 'PATCH', 'DELETE']
|
||||
|
||||
function refuse(res, extra = {}) {
|
||||
return res.status(403).json({ message: MESSAGE, code: CODE, ...extra })
|
||||
}
|
||||
|
||||
// ── Conditions ─────────────────────────────────────────────────────────────
|
||||
// A few rows lock a route only for some requests. Each condition is asked only
|
||||
// after the method and path have matched, so the DB is touched on a handful of
|
||||
// rare admin and self-service writes and on nothing else.
|
||||
|
||||
// Required lazily: this file is required by app.js, and the users model would
|
||||
// otherwise make the DB pool a startup dependency of the lock.
|
||||
/* eslint-disable global-require */
|
||||
const users = () => require('../model/users/users.model')
|
||||
const sessions = () => require('../auth/session.service')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
async function isDemoUserId(id) {
|
||||
const n = Number(id)
|
||||
if (!Number.isInteger(n) || n < 1) return false
|
||||
const user = await users().getById(n)
|
||||
return Boolean(user && isDemoUsername(user.username))
|
||||
}
|
||||
|
||||
// `PUT /admin/users/:id` and its kin: an admin acting on one of the shared logins.
|
||||
const targetIsDemo = (req, params) => isDemoUserId(params.id)
|
||||
|
||||
// `/auth/me/*`: the caller signed in as one of the shared logins. The identity is
|
||||
// read the way requireAuth reads it, and the account row decides — not the
|
||||
// username claim in the token. No session is not a refusal: the route's own
|
||||
// requireAuth answers that with its usual 401.
|
||||
async function callerIsDemo(req) {
|
||||
const session = sessions().validateSession(req)
|
||||
return Boolean(session && (await isDemoUserId(session.userId)))
|
||||
}
|
||||
|
||||
// `PUT /admin/settings` stays open (DM2), but that one route writes arbitrary
|
||||
// keys, and a few keys are another door into something Tier A locks: `site_mode`
|
||||
// is what `PUT /admin/site-mode` writes, `module_source_hosts` what the Modules
|
||||
// screen's sources do, `teams_voice_*` the voice settings. `demo_*` is the
|
||||
// banner and the published logins, which the seed owns. `hero_layout` is the
|
||||
// live homepage hero (DM21): the editor's draft (`hero_layout_draft`) and its
|
||||
// Preview stay open, its Publish is refused.
|
||||
const PROTECTED_SETTINGS = [/^site_mode/, /^module_source_hosts$/, /^teams_voice_/, /^demo_/, /^hero_layout$/]
|
||||
|
||||
function protectedKeys(body) {
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) return []
|
||||
return Object.keys(body).filter((key) => PROTECTED_SETTINGS.some((re) => re.test(key)))
|
||||
}
|
||||
|
||||
// ── Core's list (§3.3) ─────────────────────────────────────────────────────
|
||||
// Paths are relative to /api/v1 and written as the routers declare them;
|
||||
// test/demoLocks.test.js holds every row against routes.manifest.json, so a
|
||||
// row that stops naming a real route fails the build rather than reading as a lock.
|
||||
|
||||
const CORE_LOCKS = [
|
||||
// Tier A — site configuration.
|
||||
{ methods: ['PUT'], path: '/admin/site-mode' },
|
||||
{ methods: WRITES, path: '/admin/modules/*' },
|
||||
{ methods: WRITES, path: '/admin/auth/providers/*' },
|
||||
{ methods: ['PUT'], path: '/admin/email/config' },
|
||||
{ methods: ['POST'], path: '/admin/email/test' },
|
||||
{ methods: ['POST'], path: '/admin/email/disconnect' },
|
||||
{ methods: ['PUT'], path: '/admin/discord-bot/config' },
|
||||
{ methods: WRITES, path: '/admin/teams/voice/*' },
|
||||
{ methods: ['PUT'], path: '/admin/settings', when: (req) => protectedKeys(req.body).length > 0 },
|
||||
|
||||
// Tier A — the shared logins, by an admin.
|
||||
{ methods: ['PUT', 'DELETE'], path: '/admin/users/:id', when: targetIsDemo },
|
||||
{ methods: ['DELETE'], path: '/admin/users/:id/trusted-devices', when: targetIsDemo },
|
||||
{ methods: ['DELETE'], path: '/admin/users/:id/trusted-devices/:deviceId', when: targetIsDemo },
|
||||
{ methods: ['POST'], path: '/admin/users/:id/mfa/reset', when: targetIsDemo },
|
||||
|
||||
// Tier A — the shared logins, by whoever is signed in as one.
|
||||
{ methods: ['PATCH'], path: '/auth/me/account/username', when: callerIsDemo },
|
||||
{ methods: ['PATCH'], path: '/auth/me/account/password', when: callerIsDemo },
|
||||
{ methods: ['PATCH'], path: '/auth/me/account/email', when: callerIsDemo },
|
||||
{ methods: ['POST'], path: '/auth/me/account/totp/setup', when: callerIsDemo },
|
||||
{ methods: ['POST'], path: '/auth/me/account/totp/enable', when: callerIsDemo },
|
||||
{ methods: ['POST'], path: '/auth/me/account/totp/disable', when: callerIsDemo },
|
||||
{ methods: ['POST'], path: '/auth/me/account/recovery-codes/generate', when: callerIsDemo },
|
||||
{ methods: ['DELETE'], path: '/auth/me/account/identities/:provider', when: callerIsDemo },
|
||||
// ACCOUNT_DELETION.md §4.3: the screen stays visible on the demo, because
|
||||
// Play's reviewer must be able to find it, and the shared logins cannot be
|
||||
// deleted. An account an admin created on the demo can be.
|
||||
{ methods: ['DELETE'], path: '/auth/me/account', when: callerIsDemo },
|
||||
// DM17: the whole route, because a mobile access token carries no refresh-row
|
||||
// id and "a session that is not the caller's" cannot be decided. Signing out
|
||||
// (POST /auth/mobile/logout) revokes the caller's own row and stays open.
|
||||
{ methods: ['DELETE'], path: '/auth/me/sessions/:id', when: callerIsDemo },
|
||||
|
||||
// Tier A — credentials for everyone.
|
||||
{ methods: ['POST'], path: '/auth/password/forgot' },
|
||||
{ methods: ['POST'], path: '/auth/password/reset/:token' },
|
||||
// DM16: settings are open, so `player_registration` can be switched on; the
|
||||
// route is what keeps registration closed (§4).
|
||||
{ methods: ['POST'], path: '/auth/register' },
|
||||
|
||||
// Tier C's one exception: the contact form mails a person.
|
||||
{ methods: ['POST'], path: '/public/contact' },
|
||||
|
||||
// Uploads. `refuseMultipart` already stops every one of these; they are
|
||||
// listed too so the table in §3.3 and this file say the same thing, and so a
|
||||
// non-multipart body sent to an upload route is refused the same way.
|
||||
{ methods: ['POST'], path: '/admin/uploads' },
|
||||
{ methods: ['POST'], path: '/admin/posts/upload' },
|
||||
{ methods: ['POST'], path: '/admin/settings/brand-asset/:slot' },
|
||||
{ methods: ['POST'], path: '/player/teams/:slug/forum/uploads' },
|
||||
].map((rule) => ({ ...rule, source: 'core', compiled: compile(rule.path) }))
|
||||
|
||||
/**
|
||||
* Build the lock middleware.
|
||||
*
|
||||
* @param {() => object[]} moduleLocks returns the installed modules' compiled
|
||||
* locks — asked per request, because the loader runs after app.js mounts this.
|
||||
*/
|
||||
function createLock(moduleLocks = () => []) {
|
||||
return async function lock(req, res, next) {
|
||||
if (!WRITES.includes(req.method)) return next()
|
||||
const rules = CORE_LOCKS.concat(moduleLocks())
|
||||
for (const rule of rules) {
|
||||
if (!rule.methods.includes(req.method)) continue
|
||||
const params = match(rule.compiled, req.path)
|
||||
if (!params) continue
|
||||
if (rule.when) {
|
||||
let applies
|
||||
try {
|
||||
applies = await rule.when(req, params)
|
||||
} catch (err) {
|
||||
// Fail closed: a lock that cannot decide is a lock.
|
||||
log.warn('lock condition failed; refusing', { path: rule.path, message: err.message })
|
||||
applies = true
|
||||
}
|
||||
if (!applies) continue
|
||||
}
|
||||
log.info('refused', { method: req.method, path: req.originalUrl, rule: rule.path, source: rule.source })
|
||||
const keys = rule.path === '/admin/settings' ? { keys: protectedKeys(req.body) } : {}
|
||||
return refuse(res, keys)
|
||||
}
|
||||
return next()
|
||||
}
|
||||
}
|
||||
|
||||
// Any multipart content type, not only form-data: nothing on the demo takes one,
|
||||
// and a narrower test is one more spelling to get wrong. The header alone
|
||||
// decides, so a multipart request with no body is refused too.
|
||||
function refuseMultipart(req, res, next) {
|
||||
const type = String(req.headers['content-type'] || '').trim().toLowerCase()
|
||||
if (!type.startsWith('multipart/')) return next()
|
||||
log.info('refused upload', { method: req.method, path: req.originalUrl })
|
||||
return refuse(res)
|
||||
}
|
||||
|
||||
// DM25: an image pasted into rich text travels inside the JSON body as a data:
|
||||
// URI, which is an upload by another name. Any string anywhere in a write's
|
||||
// body is checked, so a field added later, a setting or a module's route is
|
||||
// covered without being listed, as with multipart.
|
||||
//
|
||||
// A data: URI is `data:` (not the tail of a word like "metadata:") followed by
|
||||
// a comma, a `;`, or a type/subtype and then a comma. Prose such as
|
||||
// "Data: none, yet" does not match. Before testing, HTML character references
|
||||
// are decoded and tabs and newlines removed, because a browser does both when
|
||||
// it reads an attribute: `data:` 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(/:/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
26
server/src/demo/mode.js
Normal 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
70
server/src/demo/paths.js
Normal 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 }
|
||||
@@ -156,11 +156,27 @@ async function resolveForRule(rule, event) {
|
||||
* same id more tightly. That is precisely the case where a stale rule would
|
||||
* otherwise mail a population the current declaration forbids, which is what
|
||||
* makes this the security boundary rather than a duplicate check.
|
||||
*
|
||||
* **`emitted` is the second thing this gate now weighs** (Phase 10). A firing may
|
||||
* carry a ceiling of its own — a rehearsal's `staff` (EVENTS.md §I) — and the
|
||||
* effective bound is the MEET of the two, so a firing can only ever narrow what
|
||||
* the declaration allows. Two incomparable ceilings meet to null and the gate
|
||||
* refuses: `owner` and `staff` have no common descendant, and picking one would
|
||||
* be the guess §5.1a rule 3 exists to refuse. That is also why an unknown value
|
||||
* cannot get here — `emit` validates it against the same lattice — but the null
|
||||
* is handled anyway, because this is the boundary and a boundary that trusts its
|
||||
* caller is not one.
|
||||
*
|
||||
* @param {string} triggerId
|
||||
* @param {string} ceiling the audience the rule resolved to
|
||||
* @param {string|null} [emitted] a narrowing ceiling this firing carries
|
||||
*/
|
||||
function permitted(triggerId, ceiling) {
|
||||
function permitted(triggerId, ceiling, emitted = null) {
|
||||
const declaration = registries.eventTrigger(triggerId)
|
||||
if (!declaration) return false
|
||||
return ceilings.permits(declaration.ceiling, ceiling)
|
||||
const bound = emitted ? ceilings.meet(declaration.ceiling, emitted) : declaration.ceiling
|
||||
if (!bound) return false
|
||||
return ceilings.permits(bound, ceiling)
|
||||
}
|
||||
|
||||
module.exports = { resolveForRule, permitted, defaultOnChannels }
|
||||
|
||||
@@ -248,4 +248,18 @@ const vocabulary = () =>
|
||||
/** Convenience for a caller holding only a trigger id. */
|
||||
const validateFor = (triggerId, raw) => validate(registries.eventTrigger(triggerId), raw)
|
||||
|
||||
module.exports = { validate, validateFor, evaluate, vocabulary, OPERATORS, MAX_LIST, MAX_DEPTH }
|
||||
// `checkLiteral` is exported for the event system's step-param validator
|
||||
// (EVENTS.md §F, Phase 1), which checks an authored param value against an
|
||||
// action's declared param type — the same six types over the same coercion. A
|
||||
// second copy of this switch would be a second answer to "is this a datetime",
|
||||
// and the two would drift on the first zone-suffixed string somebody typed.
|
||||
module.exports = {
|
||||
validate,
|
||||
validateFor,
|
||||
evaluate,
|
||||
vocabulary,
|
||||
checkLiteral,
|
||||
OPERATORS,
|
||||
MAX_LIST,
|
||||
MAX_DEPTH,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// ── The five rules core ships, all of them OFF ─────────────────────────────
|
||||
// ── The seven rules core ships, all of them OFF ────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 6, decision 3. Before this phase the Team pipeline mailed
|
||||
// people with no operator configuration at all: the code decided who was mailed
|
||||
@@ -34,6 +34,11 @@
|
||||
// on and no way to tell why. One key per seed GROUP is the rule this establishes;
|
||||
// a sixth rule for a new trigger takes a sixth key, and a rule added to an
|
||||
// existing group is a rule that only fresh installs will ever see.
|
||||
//
|
||||
// **EVENTS.md Phase 10 added two more, and a third key**, for the event
|
||||
// lifecycle: `event.run.started` and `event.run.failed`. Same argument, third
|
||||
// application — a deployment that has already stamped the news key must still
|
||||
// see these.
|
||||
|
||||
const rulesDb = require('../model/engagement/engagementRules.db')
|
||||
const settingsDb = require('../model/settings/settings.db')
|
||||
@@ -46,6 +51,9 @@ const SEEDED_KEY = 'engagement_team_rules_seeded'
|
||||
// Phase 11's, and separate for the reason above. Same shape, same semantics.
|
||||
const NEWS_SEEDED_KEY = 'engagement_news_rule_seeded'
|
||||
|
||||
// EVENTS.md Phase 10's, third group, third key.
|
||||
const EVENT_SEEDED_KEY = 'engagement_event_rules_seeded'
|
||||
|
||||
const RULES = [
|
||||
{
|
||||
trigger_id: 'team.forum.post',
|
||||
@@ -145,6 +153,73 @@ const NEWS_RULES = [
|
||||
},
|
||||
]
|
||||
|
||||
// Phase 10's two, in their own list under their own one-shot key — the rule
|
||||
// Phase 11 established, applied for the second time. Appending to `NEWS_RULES`
|
||||
// would seed these on fresh installs only and on exactly the upgrades that want
|
||||
// them, never.
|
||||
//
|
||||
// **Two rules for seven triggers, and that is the whole decision** (org lead,
|
||||
// 2026-09-04). Every one of the seven is declared, so an operator can write a
|
||||
// rule against any of them from the rules screen; what is SEEDED is the pair
|
||||
// somebody would otherwise have to build from scratch on the first day — the
|
||||
// player-facing "it is starting" and the staff-facing "it broke". Seeding all
|
||||
// seven would grow Admin → Engagement → Rules by seven disabled rows nobody
|
||||
// asked for, and `event.phase.changed` is the one most likely to be switched on
|
||||
// by accident and then mail a player four times in one evening.
|
||||
const EVENT_RULES = [
|
||||
{
|
||||
trigger_id: 'event.run.started',
|
||||
name: 'Events — starting now',
|
||||
// `subscribers`, the trigger's own default: people who opted into this id on
|
||||
// at least one channel. Not `authenticated`, even though the ceiling permits
|
||||
// it — an event is worth telling people who asked to be told about events,
|
||||
// and mailing the whole user table every Saturday night is how a feature
|
||||
// earns a spam complaint. An operator who wants the whole site can widen it;
|
||||
// the ceiling is what stops them widening it past that.
|
||||
audience: 'subscribers',
|
||||
// All three, like the news rule and for the same reason: push is the channel
|
||||
// that gets somebody to log in *now*, which is the entire point of a
|
||||
// "come back for this" notice (ENGAGEMENT.md §8.5), and the in-app inbox is
|
||||
// the surface a content-free tickle deep-links into.
|
||||
channels: ['email', 'inapp', 'push'],
|
||||
// The one bespoke body this phase seeds; see `templateSeeds.js` for why it
|
||||
// is one and not seven. `inapp.event` is the in-app renderer's generic, and
|
||||
// push carries no content by construction and needs no template.
|
||||
template_keys: { email: 'notify.event-started', inapp: 'inapp.event', digest: 'notify.digest' },
|
||||
// An hour, per user PER RUN — `event.run.started` declares `subjectKey:
|
||||
// 'runId'`, so the cooldown subject is the run and not the recipient. It is
|
||||
// near-redundant on a trigger that fires once per run, which is the point:
|
||||
// it costs nothing and it is the guard if a run is ever restarted.
|
||||
cooldown_seconds: 3600,
|
||||
max_sends_per_hour: 1000,
|
||||
},
|
||||
{
|
||||
trigger_id: 'event.run.failed',
|
||||
name: 'Events — a run failed',
|
||||
// `admin`, which is both the trigger's default and its ceiling. A failed run
|
||||
// names the deployment's own broken machinery — a sidecar that did not
|
||||
// answer, a step that ran out of attempts — and there is no widening of this
|
||||
// that is not a disclosure.
|
||||
audience: 'admin',
|
||||
// No push. An admin's phone buzzing at four in the morning for a step that
|
||||
// will still be failed at breakfast is a notification people switch off
|
||||
// wholesale, and switching it off wholesale is how the one that mattered is
|
||||
// missed. Mail and the inbox both wait.
|
||||
channels: ['email', 'inapp'],
|
||||
// The generic body plus the structural projection: `event.run.failed`
|
||||
// declares its own `title` and a `runUrl`, so an unauthored mail is already
|
||||
// headed with the event's name and buttoned through to the run console —
|
||||
// §4.6.1 property 1, working exactly as it promises.
|
||||
template_keys: { email: 'notify.event', inapp: 'inapp.event' },
|
||||
// **No cooldown, and this is the one rule in the file that must not have
|
||||
// one.** The subject is the run, so a cooldown would only ever suppress a
|
||||
// second failure of the SAME run — which is precisely the run an
|
||||
// administrator most needs the second line about.
|
||||
cooldown_seconds: 0,
|
||||
max_sends_per_hour: 200,
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Seed one group of rules, once, under its own guard key.
|
||||
*
|
||||
@@ -159,8 +234,15 @@ const NEWS_RULES = [
|
||||
async function seedGroup(key, rules, note) {
|
||||
const summary = { inserted: 0, skipped: 0 }
|
||||
try {
|
||||
const seen = await settingsDb.get(key)
|
||||
if (seen) return { ...summary, skipped: rules.length }
|
||||
// **Claimed BEFORE the loop, atomically**, and the stamp is the claim. A
|
||||
// `get()` here with a `set()` after the inserts is not a guard when two
|
||||
// instances boot together — both read "absent", both seed — and a duplicate
|
||||
// rule is two mails per event. `claim()` is an `INSERT IGNORE` reporting its
|
||||
// own `affectedRows`, so exactly one caller proceeds. See the note below on
|
||||
// what a partial run costs: that trade is unchanged, only its ordering.
|
||||
if (!(await settingsDb.claim(key, new Date().toISOString()))) {
|
||||
return { ...summary, skipped: rules.length }
|
||||
}
|
||||
|
||||
for (const rule of rules) {
|
||||
try {
|
||||
@@ -178,13 +260,13 @@ async function seedGroup(key, rules, note) {
|
||||
})
|
||||
summary.inserted += 1
|
||||
} catch (err) {
|
||||
log.error('team rule seed failed', { trigger: rule.trigger_id, message: err.message })
|
||||
log.error('rule seed failed', { key, trigger: rule.trigger_id, message: err.message })
|
||||
}
|
||||
}
|
||||
// Stamped even on a partial run. Re-running would duplicate the rules that
|
||||
// did insert, and a duplicate rule is two mails per event — a worse outcome
|
||||
// than the one missing rule an operator can add from the screen.
|
||||
await settingsDb.set(key, new Date().toISOString())
|
||||
// Stamped even on a partial run — the claim above is the stamp. Re-running
|
||||
// would duplicate the rules that did insert, and a duplicate rule is two
|
||||
// mails per event, a worse outcome than the one missing rule an operator can
|
||||
// add from the screen.
|
||||
if (summary.inserted) {
|
||||
log.info('seeded engagement rules, all disabled', { rules: summary.inserted, note })
|
||||
}
|
||||
@@ -202,6 +284,10 @@ const seedTeamRules = () =>
|
||||
const seedNewsRule = () =>
|
||||
seedGroup(NEWS_SEEDED_KEY, NEWS_RULES, 'News notifications stay off until an operator enables this rule')
|
||||
|
||||
/** The two event-lifecycle rules (EVENTS.md Phase 10). */
|
||||
const seedEventRules = () =>
|
||||
seedGroup(EVENT_SEEDED_KEY, EVENT_RULES, 'Event notifications stay off until an operator enables one')
|
||||
|
||||
/**
|
||||
* Both groups, which is what the boot path calls.
|
||||
*
|
||||
@@ -212,9 +298,10 @@ const seedNewsRule = () =>
|
||||
async function seedCoreRules() {
|
||||
const team = await seedTeamRules()
|
||||
const news = await seedNewsRule()
|
||||
const events = await seedEventRules()
|
||||
return {
|
||||
inserted: team.inserted + news.inserted,
|
||||
skipped: team.skipped + news.skipped,
|
||||
inserted: team.inserted + news.inserted + events.inserted,
|
||||
skipped: team.skipped + news.skipped + events.skipped,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,8 +309,11 @@ module.exports = {
|
||||
seedCoreRules,
|
||||
seedTeamRules,
|
||||
seedNewsRule,
|
||||
seedEventRules,
|
||||
RULES,
|
||||
NEWS_RULES,
|
||||
EVENT_RULES,
|
||||
SEEDED_KEY,
|
||||
NEWS_SEEDED_KEY,
|
||||
EVENT_SEEDED_KEY,
|
||||
}
|
||||
|
||||
@@ -150,13 +150,21 @@ async function applyRule(rule, event, now) {
|
||||
|
||||
// G24, re-run at send time. A rule saved when its trigger permitted a wider
|
||||
// audience must not keep reaching it after a module upgrade narrowed the
|
||||
// declaration - and that is the only way this can fail, since the save path
|
||||
// declaration - and that was the only way this could fail, since the save path
|
||||
// ran the same check.
|
||||
if (!audiences.permitted(event.triggerId, resolved.ceiling)) {
|
||||
//
|
||||
// **Phase 10 gave it a second way, and it is the one that fires in practice:**
|
||||
// the event may carry a narrowing ceiling of its own. A rehearsal emits
|
||||
// `event.run.started` with `ceiling: 'staff'`, and every rule an operator wrote
|
||||
// for the real thing is then refused here rather than mailing subscribers about
|
||||
// an event that is not happening. Nothing about the rule changed; the occasion
|
||||
// did. See `audiences.permitted`.
|
||||
if (!audiences.permitted(event.triggerId, resolved.ceiling, event.ceiling)) {
|
||||
log.warn('rule audience exceeds its trigger ceiling - refusing', {
|
||||
rule: rule.id,
|
||||
trigger: event.triggerId,
|
||||
audience: resolved.ceiling,
|
||||
emitted: event.ceiling || null,
|
||||
})
|
||||
summary.skipped = 'ceiling'
|
||||
return summary
|
||||
@@ -203,9 +211,18 @@ async function applyRule(rule, event, now) {
|
||||
summary.capped += 1
|
||||
continue
|
||||
}
|
||||
// One statement, guarded on the interval, so two concurrent emits cannot
|
||||
// both pass a read-then-write check (§4.1).
|
||||
const allowed = await cooldownsDb.claim(rule.id, userId, subjectKey, rule.cooldown_seconds, now)
|
||||
// Guarded on the interval, so two concurrent emits cannot both pass a
|
||||
// read-then-write check (§4.1).
|
||||
//
|
||||
// **Keyed on the CHANNEL as well**, which is what makes this loop correct
|
||||
// rather than what makes it work. Without the channel, the first channel of
|
||||
// a rule claims the cooldown and every later one is refused as cooling —
|
||||
// and `inapp` is ranked first above, so a rule naming email + in-app would
|
||||
// deliver the in-app item and silently never the mail. Found on Phase 11b's
|
||||
// live rig; a cooldown is per delivery, not per occasion.
|
||||
const allowed = await cooldownsDb.claim(
|
||||
rule.id, userId, subjectKey, channel, rule.cooldown_seconds, now,
|
||||
)
|
||||
if (!allowed) {
|
||||
summary.cooled += 1
|
||||
continue
|
||||
|
||||
194
server/src/engagement/moduleSeeds.js
Normal file
194
server/src/engagement/moduleSeeds.js
Normal file
@@ -0,0 +1,194 @@
|
||||
// ── Seeding what a module ships (ENGAGEMENT.md Phase 11b, decision 7) ──────
|
||||
//
|
||||
// Core's own bodies and rules are seeded from `seedDefaults()`, and a module's
|
||||
// cannot be: `server.js` calls `seedDefaults()` BEFORE it requires `app.js`, and
|
||||
// requiring `app.js` is what scans the volume and runs the loader. At the moment
|
||||
// core seeds, no module has registered anything at all.
|
||||
//
|
||||
// So this runs from `modules/lifecycle.js` `boot()` instead — after the
|
||||
// `installed_modules` reconcile, so a module the operator disabled or one that
|
||||
// failed to load is skipped rather than seeded, and BEFORE the `onBoot`
|
||||
// dispatch, so a module that warms a cache in `onBoot` may assume its rules
|
||||
// exist.
|
||||
//
|
||||
// **It reuses core's two seeders rather than reimplementing them**, which is the
|
||||
// whole argument for the registry existing (decision 7): `seedOne` owns the
|
||||
// `customized` skip and the `seed_version` comparison, `validateEmailBlocks`
|
||||
// owns what a renderable body is, and a module supplies data. A copy of either
|
||||
// living outside this directory would drift the first time core improved the
|
||||
// original — and the drift would surface as a mail somebody already received.
|
||||
//
|
||||
// ── The asymmetry, once more, because it is the thing to get right ─────────
|
||||
//
|
||||
// **Templates are re-ensured every boot.** A row carries `seed_key`,
|
||||
// `seed_version` and `customized`, so re-ensuring is how a better default
|
||||
// reaches a deployment without stealing an operator's edit (§4.6.1 property 3),
|
||||
// and a template added in a later module version reaches every deployment rather
|
||||
// than only fresh ones.
|
||||
//
|
||||
// **Rule groups are one-shot, each under its own settings guard.** Re-ensuring a
|
||||
// rule would resurrect one an operator deleted and reset one they enabled. This
|
||||
// is 11a's seed-key finding as a mechanism: a rule appended to an existing group
|
||||
// reaches fresh installs only, and a rule that must reach already-stamped
|
||||
// deployments takes a new group key. The module chooses; this file honours it.
|
||||
//
|
||||
// **Never throws.** It is on the boot path beside every other `safe()`-wrapped
|
||||
// step in `lifecycle.boot()`, and a body that would not seed costs the shipped
|
||||
// default — `renderByKey`'s fallback stays in charge — not the deployment.
|
||||
|
||||
const templatesDb = require('../model/engagement/engagementTemplates.db')
|
||||
const rulesDb = require('../model/engagement/engagementRules.db')
|
||||
const settingsDb = require('../model/settings/settings.db')
|
||||
const emailBlocks = require('../emailBlocks')
|
||||
const log = require('../utils/logger')('engagement')
|
||||
|
||||
/**
|
||||
* The one-shot guard for one module's rule group.
|
||||
*
|
||||
* Namespaced by owner AND by group so two modules may use the same group name,
|
||||
* and so a module can add a second group later without touching the first. Its
|
||||
* VALUE is the timestamp — purely so an operator reading the settings table can
|
||||
* tell when it ran; only its presence is read.
|
||||
*/
|
||||
const guardKey = (owner, group) => `engagement_module_rules_seeded:${owner}:${group}`
|
||||
|
||||
/**
|
||||
* Ensure one module's templates, and bring un-customized rows up to the current
|
||||
* seed. Idempotent.
|
||||
*/
|
||||
async function seedModuleTemplates(owner, templates, deps = {}) {
|
||||
const templates_ = deps.templatesDb || templatesDb
|
||||
const counts = { inserted: 0, updated: 0, skipped: 0, invalid: 0 }
|
||||
for (const seed of templates) {
|
||||
// Validated against the block registry before it is stored, exactly as core's
|
||||
// own seeds are and for the same reason: a shipped block array no renderer
|
||||
// understands sitting in the table reads to an operator as their deployment
|
||||
// being broken. Refusing to write it leaves the fallback in charge and puts
|
||||
// the reason in the boot log, with the module named.
|
||||
const { valid, errors } = emailBlocks.validateEmailBlocks(seed.blocks)
|
||||
if (!valid) {
|
||||
log.error('a module template is invalid and was not seeded', { owner, key: seed.key, errors })
|
||||
counts.invalid += 1
|
||||
continue
|
||||
}
|
||||
try {
|
||||
counts[await templates_.seedOne(seed)] += 1
|
||||
} catch (err) {
|
||||
log.error('module template seed failed', { owner, key: seed.key, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
// The third arm of §4.6.1 property 3: a customized row is never touched, and
|
||||
// the fact that a better default now exists is surfaced instead of applied.
|
||||
let stale = []
|
||||
try {
|
||||
stale = await templates_.staleCustomized(
|
||||
templates.map((t) => ({ key: t.key, seedVersion: t.seedVersion })),
|
||||
)
|
||||
} catch {
|
||||
stale = []
|
||||
}
|
||||
if (stale.length) {
|
||||
log.info('customized module templates have a newer shipped default', {
|
||||
owner,
|
||||
keys: stale.map((t) => t.key),
|
||||
})
|
||||
}
|
||||
return { ...counts, stale: stale.map((t) => t.key) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed one named rule group, once, under its own guard.
|
||||
*
|
||||
* Mirrors `coreRules.seedGroup` deliberately, including the claim-before-insert
|
||||
* ordering and what it costs: re-running would duplicate the rules that DID
|
||||
* insert, and a duplicate rule is two mails per event — worse than the one
|
||||
* missing rule an operator can add from the Rules screen. The guard is taken
|
||||
* atomically for the same reason; see the comment at the claim.
|
||||
*/
|
||||
async function seedRuleGroup(owner, group, deps = {}) {
|
||||
const rules_ = deps.rulesDb || rulesDb
|
||||
const settings_ = deps.settingsDb || settingsDb
|
||||
const summary = { inserted: 0, skipped: 0 }
|
||||
const key = guardKey(owner, group.key)
|
||||
try {
|
||||
// **Claim BEFORE inserting, not after.** The guard used to be a `get()` here
|
||||
// and a `set()` after the loop, which is not a guard under concurrency: two
|
||||
// processes starting in the same moment both read "absent" and both insert
|
||||
// the whole group. That is not hypothetical — `docker compose up
|
||||
// --scale app=2` and a rolling restart both boot two instances deliberately,
|
||||
// and Phase 13's acceptance walk hit it with two, ending up with 52 module
|
||||
// rules where the module ships 26. `claim()` is an `INSERT IGNORE` reporting
|
||||
// its own `affectedRows`, so exactly one caller wins.
|
||||
//
|
||||
// The cost is the one this function already accepted below: a process that
|
||||
// dies mid-loop leaves the group stamped and partly seeded, and the missing
|
||||
// rules are an operator's visit to the "new rule" form. Duplicates are the
|
||||
// worse failure — two mails per event, for every rule in the group — which
|
||||
// is why the order is this way round rather than the other.
|
||||
if (!(await settings_.claim(key, new Date().toISOString()))) {
|
||||
return { ...summary, skipped: group.rules.length }
|
||||
}
|
||||
|
||||
for (const rule of group.rules) {
|
||||
try {
|
||||
await rules_.insert(rule)
|
||||
summary.inserted += 1
|
||||
} catch (err) {
|
||||
log.error('module rule seed failed', {
|
||||
owner,
|
||||
group: group.key,
|
||||
trigger: rule.trigger_id,
|
||||
message: err.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (summary.inserted) {
|
||||
log.info('seeded module engagement rules, all disabled', {
|
||||
owner,
|
||||
group: group.key,
|
||||
rules: summary.inserted,
|
||||
note: group.note || undefined,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('module rule group seeding failed', { owner, group: group.key, message: err.message })
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed every registered module's engagement content.
|
||||
*
|
||||
* @param {object} [deps]
|
||||
* @param {Function} [deps.seeds] () => [{ owner, templates, ruleGroups }]
|
||||
* @param {Set} [deps.skip] owners not to seed (disabled or failed)
|
||||
* @param {object} [deps.templatesDb] / [deps.rulesDb] / [deps.settingsDb] — test seams
|
||||
*/
|
||||
async function seedModuleEngagement({ seeds, skip = new Set(), ...dbs } = {}) {
|
||||
// eslint-disable-next-line global-require
|
||||
const read = seeds || require('../modules/registries').allEngagementSeeds
|
||||
const totals = { templates: 0, rules: 0 }
|
||||
|
||||
for (const entry of read()) {
|
||||
if (skip.has(entry.owner)) {
|
||||
log.info('skipping engagement seeds for a module that is not booting', { owner: entry.owner })
|
||||
continue
|
||||
}
|
||||
const t = await seedModuleTemplates(entry.owner, entry.templates || [], dbs)
|
||||
totals.templates += t.inserted + t.updated
|
||||
for (const group of entry.ruleGroups || []) {
|
||||
const r = await seedRuleGroup(entry.owner, group, dbs)
|
||||
totals.rules += r.inserted
|
||||
}
|
||||
log.info('module engagement seeds ensured', { owner: entry.owner, ...t })
|
||||
}
|
||||
return totals
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
seedModuleEngagement,
|
||||
seedModuleTemplates,
|
||||
seedRuleGroup,
|
||||
guardKey,
|
||||
}
|
||||
@@ -44,6 +44,27 @@ const AMBIENT_VARIABLES = Object.freeze([
|
||||
{ name: 'year', type: 'string', required: true, example: '2026' },
|
||||
])
|
||||
|
||||
|
||||
// The per-DELIVERY additions, which are a different thing from the ambient set
|
||||
// above and are declared separately because they apply to a different set of
|
||||
// templates.
|
||||
//
|
||||
// `emailChannel.deliver` computes an unsubscribe token per recipient and merges
|
||||
// it LAST over the projection, so a body may always reference it — but a template
|
||||
// bound to a TRIGGER takes its variable list from that trigger's declaration
|
||||
// (`variablesFor`), and a trigger has no business declaring a fact about how the
|
||||
// mail was delivered. Without these, `{{unsubscribeUrl}}` renders correctly and
|
||||
// then the save-time undeclared-variable check refuses the first operator who
|
||||
// tries to EDIT the body around it.
|
||||
//
|
||||
// Found in Phase 11b, where module-uo's sixteen in-universe bodies are the first
|
||||
// trigger-bound templates in the system to carry an unsubscribe line of their
|
||||
// own: core's generic `notify.event` declares it in its own seed and is bound to
|
||||
// no trigger, so nothing had ever taken this path.
|
||||
const DELIVERY_VARIABLES = Object.freeze([
|
||||
{ name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' },
|
||||
])
|
||||
|
||||
// A tiny helper so the block arrays below read as content rather than as JSON.
|
||||
const text = (id, body, opts = {}) => ({
|
||||
id,
|
||||
@@ -136,6 +157,36 @@ const SEEDS = [
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
// ACCOUNT_DELETION.md §4.1 step 3: the one notice a person gets if somebody
|
||||
// else used their unlocked phone. It goes to the address the account HAD,
|
||||
// read before the row went, and there is no link in it because there is no
|
||||
// account left for a link to reach.
|
||||
key: 'auth.account-deleted',
|
||||
name: 'Account deleted',
|
||||
channel: 'email',
|
||||
protected: true,
|
||||
seedVersion: 1,
|
||||
subject: 'Your {{siteName}} account was deleted',
|
||||
variables: [
|
||||
{ name: 'username', type: 'string', required: true, example: 'Darrow' },
|
||||
{ name: 'deletedOn', type: 'string', required: true, example: '29 October 2026' },
|
||||
{ name: 'byWhom', type: 'string', required: false, example: ' at your request' },
|
||||
],
|
||||
blocks: [
|
||||
text('p1', 'The {{siteName}} account “{{username}}” was deleted on {{deletedOn}}{{byWhom}}.'),
|
||||
text(
|
||||
'p2',
|
||||
'Its sign-in, email address, two-factor settings, linked sign-ins and game links are gone. ' +
|
||||
'What it wrote where others read it stays, and other members see it as written by “[deleted player]”.',
|
||||
),
|
||||
text(
|
||||
'p3',
|
||||
'If you did not ask for this, contact the {{siteName}} staff at {{siteUrl}}. ' +
|
||||
'A deleted account cannot be restored, but they can find out what happened.',
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'admin.contact-message',
|
||||
name: 'Contact form message',
|
||||
@@ -289,6 +340,63 @@ const SEEDS = [
|
||||
button('cta', 'Open', '{{actionUrl}}'),
|
||||
],
|
||||
},
|
||||
// ── The event system (EVENTS.md §J — Phase 10) ─────────────────────────
|
||||
//
|
||||
// **One body, not seven.** Six of the seven `event.` triggers render through
|
||||
// `notify.event` and the structural projection with no authoring at all
|
||||
// (§4.6.1 property 1) — they declare their own `title`, so an unauthored mail
|
||||
// is already headed with the event's name — and seeding a bespoke body per
|
||||
// trigger would be seven templates an operator has to maintain to change one
|
||||
// sentence.
|
||||
//
|
||||
// `event.run.started` gets one because it is the flagship: the mail that
|
||||
// answers §8.5's *"Come back for X — a scheduled event is starting"*, the one
|
||||
// an operator will actually enable, and the one where the generic body reads
|
||||
// visibly worse — `notify.event` renders the title over the TRIGGER's
|
||||
// description, while this reads the payload's own names and says what is
|
||||
// starting, when, and what arc it belongs to. Same argument `notify.team-post`
|
||||
// makes beside the generic body, one feature along.
|
||||
//
|
||||
// **Every optional line is one token on its own**, which is this template
|
||||
// language's whole conditional (see `email.text`: a block whose content is a
|
||||
// single absent variable renders nothing, in both parts). A standalone event
|
||||
// has no `seriesName` and its line disappears rather than reading "Part of .".
|
||||
//
|
||||
// **The button arrived with the page it points at** (Phase 14a). Until then
|
||||
// there was no public event page, the six public triggers declared no url
|
||||
// variable, and a button here would have rendered as an inert grey label in
|
||||
// every mail — worse than no button, because it advertises a link the reader
|
||||
// cannot follow. `eventUrl` is optional and `email.button` drops itself when
|
||||
// its url interpolates to nothing, so an event that is not public still mails
|
||||
// correctly: the block disappears rather than degrading.
|
||||
{
|
||||
key: 'notify.event-started',
|
||||
name: 'Event starting',
|
||||
channel: 'email',
|
||||
protected: false,
|
||||
// Bumped with the button. A deployment whose operator has not customized
|
||||
// this template gets the new one; one that has is left alone and reported as
|
||||
// stale, which is the whole mechanism.
|
||||
seedVersion: 2,
|
||||
subject: '{{title}} is starting',
|
||||
variables: [
|
||||
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion' },
|
||||
{ name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.' },
|
||||
{ name: 'seriesName', type: 'string', required: false, example: 'The Yew Campaign' },
|
||||
{ name: 'startsAtLabel', type: 'string', required: false, example: 'Saturday 12 September at 8:00 pm (America/New_York)' },
|
||||
{ name: 'eventUrl', type: 'string', required: false, example: '/site/events/the-yew-invasion?run=3692' },
|
||||
{ name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' },
|
||||
],
|
||||
blocks: [
|
||||
heading('h', '{{title}}'),
|
||||
text('summary', '{{summary}}'),
|
||||
text('when', '{{startsAtLabel}}', { muted: true }),
|
||||
text('series', '{{seriesName}}', { muted: true }),
|
||||
button('open', 'Read more', '{{eventUrl}}', 'Read more about it here:'),
|
||||
divider('rule'),
|
||||
button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these emails, use this link:'),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** @returns {object|null} the seed definition for `key`. */
|
||||
@@ -296,4 +404,4 @@ function seedByKey(key) {
|
||||
return SEEDS.find((s) => s.key === key) || null
|
||||
}
|
||||
|
||||
module.exports = { SEEDS, AMBIENT_VARIABLES, seedByKey }
|
||||
module.exports = { SEEDS, AMBIENT_VARIABLES, DELIVERY_VARIABLES, seedByKey }
|
||||
|
||||
@@ -18,7 +18,7 @@ const templatesDb = require('../model/engagement/engagementTemplates.db')
|
||||
const settings = require('../model/settings/settings.model')
|
||||
const brand = require('../config/brand')
|
||||
const emailBlocks = require('../emailBlocks')
|
||||
const { SEEDS, AMBIENT_VARIABLES, seedByKey } = require('./templateSeeds')
|
||||
const { SEEDS, AMBIENT_VARIABLES, DELIVERY_VARIABLES, seedByKey } = require('./templateSeeds')
|
||||
// The trigger registry lives with the module registries, not here — a trigger is
|
||||
// something a MODULE declares (see engagement/index.js's header).
|
||||
const { eventTrigger } = require('../modules/registries')
|
||||
@@ -84,6 +84,11 @@ function variablesFor(template) {
|
||||
if (template && template.trigger_id) {
|
||||
const declared = eventTrigger(template.trigger_id)
|
||||
if (declared && Array.isArray(declared.variables)) own.push(...declared.variables)
|
||||
// A trigger-bound body is engagement mail, and engagement mail always carries
|
||||
// an unsubscribe the channel computes per recipient. A trigger declares what
|
||||
// HAPPENED and has no business declaring how the mail was sent, so the
|
||||
// delivery facts are added here rather than to every declaration.
|
||||
own.push(...DELIVERY_VARIABLES)
|
||||
} else if (template && template.seed_key) {
|
||||
const seed = seedByKey(template.seed_key)
|
||||
if (seed) own.push(...seed.variables)
|
||||
|
||||
258
server/src/events/announce.js
Normal file
258
server/src/events/announce.js
Normal file
@@ -0,0 +1,258 @@
|
||||
// ── A run's lifecycle, told to the engagement engine ───────────────────────
|
||||
//
|
||||
// EVENTS.md §J, and Phase 10 of EVENTS_PLAN.md. Seven moments in a run's life
|
||||
// become seven `event.` triggers, and **Events owns none of the delivery**.
|
||||
//
|
||||
// That sentence is the whole design and it is worth being exact about what it
|
||||
// buys. Nothing in this file knows what email is, whether anyone is subscribed,
|
||||
// what a template says, or how often somebody may be mailed. It says a thing
|
||||
// happened, with the facts the declaration asked for; an operator's rule decides
|
||||
// the rest. Every announcement channel the platform has — email, the in-app
|
||||
// inbox, content-free push tickles, Discord and the town crier through the
|
||||
// announce legs — arrives for free the day a rule points at one, and none of
|
||||
// them arrives by anything in `events/` growing a second delivery path.
|
||||
//
|
||||
// **Nothing here throws and nothing here is awaited for its answer.** `emit`
|
||||
// itself is fire-and-forget by construction (see `engagementEmit`'s header) —
|
||||
// the whole point of the seam is that the emitter does not wait on rule lookups
|
||||
// and a dozen inserts. What IS awaited here is the read that assembles the
|
||||
// payload, and it is wrapped: a run must not fail to start because the row that
|
||||
// says what it is called could not be read.
|
||||
//
|
||||
// **A rehearsal narrows the ceiling rather than staying silent.** §I: "run for
|
||||
// real with announcements ceilinged to `staff`". Every emit below carries
|
||||
// `ceiling: 'staff'` when the run is a rehearsal, so the same triggers fire, the
|
||||
// same rules are evaluated, the same log lines are written — and the only rules
|
||||
// that survive the G24 gate are ones whose audience a staff member is in. A
|
||||
// rehearsal that emitted nothing would be a rehearsal of everything except the
|
||||
// announcements, which are the part most worth rehearsing.
|
||||
|
||||
const definitionsDb = require('../model/events/eventDefinitions.db')
|
||||
const participantsDb = require('../model/events/eventRunParticipants.db')
|
||||
const logDb = require('../model/events/eventRunLog.db')
|
||||
const engagementEmit = require('../utils/engagementEmit')
|
||||
const log = require('../utils/logger')('events')
|
||||
|
||||
// §I, and the one place a rehearsal differs from the real thing on the announce
|
||||
// path. `staff` rather than `admin` because a rehearsal is the event team's
|
||||
// dress run and a moderator on it should see what an attendee would.
|
||||
const REHEARSAL_CEILING = 'staff'
|
||||
|
||||
/**
|
||||
* The start time written out in the shard-local zone, for a mail to read.
|
||||
*
|
||||
* **A presentational fragment computed at the emitter** (ENGAGEMENT.md §4.6.1
|
||||
* convention 1). `startsAt` also goes down the wire as a `datetime`, which the
|
||||
* seam normalises to an ISO string — right as data and wrong in a sentence — and
|
||||
* a template has no logic with which to format one. The zone is the shard's own,
|
||||
* because "8pm" means the shard's evening to everyone reading it and the
|
||||
* recipient's browser is not in the room when a mail is rendered.
|
||||
*
|
||||
* A bad zone answers null rather than throwing: `Intl` rejects an unknown
|
||||
* identifier, and an event whose timezone column holds a typo must still
|
||||
* announce. The variable is optional and its block is one token, so an absent
|
||||
* label renders as nothing at all rather than as a broken line.
|
||||
*/
|
||||
function startsAtLabel(at, zone) {
|
||||
const when = at instanceof Date ? at : new Date(at)
|
||||
if (Number.isNaN(when.getTime())) return undefined
|
||||
try {
|
||||
const text = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: zone || 'UTC',
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
// Explicit rather than left to the locale, because `en-GB` would otherwise
|
||||
// render midnight as "00:00" while the schedule editor beside it writes
|
||||
// "12:00 AM" — one event, two spellings of the same instant.
|
||||
//
|
||||
// `hourCycle: 'h12'` and NOT `hour12: true`, which is not the same request
|
||||
// and does not survive a Node upgrade. For a locale whose default cycle is
|
||||
// h23 — `en-GB` is one — Node 20 resolves `hour12: true` to **h11**, whose
|
||||
// hours run 0–11, so midnight comes out "0:00 am"; Node 22 and later
|
||||
// resolve it to h12 and it comes out "12:00 am". Same ICU on both, so this
|
||||
// is V8's ECMA-402 behaviour and not locale data, and the image ships
|
||||
// node:20-alpine while a dev machine is newer — which is how this rendered
|
||||
// correctly in front of everyone who wrote it and wrongly for every real
|
||||
// recipient. `recurrence.js` states the mirror-image rule for `h23`; there
|
||||
// is no `hour12` left in this repo and it should stay that way.
|
||||
hourCycle: 'h12',
|
||||
}).format(when)
|
||||
return `${text} (${zone || 'UTC'})`
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The facts every `event.` trigger shares, read once per emit.
|
||||
*
|
||||
* A run row from `findDue` is `SELECT *` over `event_runs` alone — no title, no
|
||||
* series, no summary — so the definition is fetched here rather than threaded
|
||||
* through every call site in the runner. It is one indexed read per lifecycle
|
||||
* transition, which is a handful per run.
|
||||
*/
|
||||
async function baseFor(run) {
|
||||
const definition = await definitionsDb.getById(run.definition_id)
|
||||
if (!definition) return null
|
||||
return {
|
||||
runId: String(run.id),
|
||||
title: definition.title,
|
||||
summary: definition.summary || undefined,
|
||||
seriesName: definition.series_name || undefined,
|
||||
timezone: run.timezone || definition.timezone || undefined,
|
||||
eventUrl: eventUrl(definition, run),
|
||||
definition,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The public page for one occurrence (Phase 14a).
|
||||
*
|
||||
* **Site-relative, and it carries the run.** The page lives at the definition's
|
||||
* slug — one stable address for a weekly event, which is what makes a link in
|
||||
* Discord survive a retitle — so the occurrence has to be in the query string or
|
||||
* a mail about last Friday's invasion would open next Friday's.
|
||||
*
|
||||
* **`undefined` when the event is not public**, rather than a path that answers
|
||||
* 404. An unlisted or not-yet-`ready` definition has no page, and `eventUrl` is
|
||||
* declared optional precisely so its block can disappear from a template instead
|
||||
* of rendering a dead button. That is `news.post`'s lesson applied before it
|
||||
* costs anything: a link nobody can follow is worse than no link, because it
|
||||
* advertises one.
|
||||
*/
|
||||
function eventUrl(definition, run) {
|
||||
if (!definition.slug) return undefined
|
||||
if (definition.state !== 'ready' || !definition.listed) return undefined
|
||||
return `/site/events/${encodeURIComponent(definition.slug)}?run=${run.id}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire one lifecycle trigger.
|
||||
*
|
||||
* `extra` is merged over the shared facts and may drop any of them — a payload
|
||||
* key set to `undefined` is simply absent, and `validatePayload` treats absent
|
||||
* and null alike, so a trigger that declares fewer variables than this assembles
|
||||
* is not a problem: undeclared keys are dropped at the seam and logged as a
|
||||
* debug line rather than refused.
|
||||
*
|
||||
* Answers nothing. Every caller is a transition in the runner and none of them
|
||||
* has anything it could correctly do with a failure of core's own notification
|
||||
* bookkeeping.
|
||||
*/
|
||||
async function fire(run, triggerId, extra = {}) {
|
||||
try {
|
||||
const base = await baseFor(run)
|
||||
if (!base) {
|
||||
// The definition is gone. `event_runs.definition_id` cascades on delete, so
|
||||
// this is a race with an archive rather than an ordinary state — nothing to
|
||||
// announce and nothing broken.
|
||||
return
|
||||
}
|
||||
const { definition, ...facts } = base
|
||||
const ceiling = run.rehearsal ? REHEARSAL_CEILING : undefined
|
||||
|
||||
engagementEmit.emit('core', triggerId, {
|
||||
// The run, not the definition. Two occurrences of a weekly event are two
|
||||
// subjects, so last week's mail does not throttle this week's — and within
|
||||
// one run a cooldown means "at most one line an hour about THIS", which is
|
||||
// the sentence an operator writing `phase.changed` actually wants.
|
||||
subject: String(run.id),
|
||||
// Bounded and stable, because it ends up in a signed unsubscribe token that
|
||||
// will sit in a mailbox for months. A run id is both.
|
||||
scopeKey: `event:${run.id}`,
|
||||
ceiling,
|
||||
data: { ...facts, ...extra },
|
||||
})
|
||||
|
||||
// Written here rather than at each call site: what an operator wants in the
|
||||
// run log is that the run SAID something happened, and with what bound. How
|
||||
// many people were told is the engagement engine's own log line and its own
|
||||
// decision — a run log that claimed to know the number would be reporting a
|
||||
// decision it does not make.
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
kind: 'announcement.emitted',
|
||||
phase: run.current_phase || null,
|
||||
detail: { trigger: triggerId, ...(ceiling ? { ceiling, because: 'rehearsal' } : {}) },
|
||||
})
|
||||
} catch (err) {
|
||||
log.error('lifecycle announcement failed', { run: run.id, trigger: triggerId, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
// ── One function per moment, so the runner names a moment and not a payload ──
|
||||
//
|
||||
// The alternative — `fire(run, 'event.run.started', { … })` at each call site —
|
||||
// would put the payload assembly in `eventRunner.js`, where a change to a
|
||||
// declaration becomes a change to the runner. These are the seam.
|
||||
|
||||
const runScheduled = (run) =>
|
||||
fire(run, 'event.run.scheduled', {
|
||||
startsAt: run.scheduled_for,
|
||||
startsAtLabel: startsAtLabel(run.scheduled_for, run.timezone),
|
||||
})
|
||||
|
||||
const runStarted = (run, startedAt) => {
|
||||
const at = startedAt || run.started_at || new Date()
|
||||
return fire(run, 'event.run.started', { startsAt: at, startsAtLabel: startsAtLabel(at, run.timezone) })
|
||||
}
|
||||
|
||||
const phaseChanged = (run, { phase, label, index, count }) =>
|
||||
fire(run, 'event.phase.changed', {
|
||||
phase,
|
||||
phaseLabel: label || phase,
|
||||
// One-based, because it is read by a human in a sentence. Every caller
|
||||
// passes the zero-based index it already has and the conversion is here, in
|
||||
// one place, rather than at three call sites where two of them would drift.
|
||||
phaseIndex: index + 1,
|
||||
phaseCount: count,
|
||||
})
|
||||
|
||||
const runEnding = (run) => fire(run, 'event.run.ending')
|
||||
|
||||
async function runCompleted(run, endedAt) {
|
||||
// Counted at emit rather than carried by the caller: the last thing a run does
|
||||
// before completing is its teardown, and a module's collect step may have
|
||||
// written rows within the same tick.
|
||||
let participantCount = 0
|
||||
try {
|
||||
participantCount = await participantsDb.countForRun(run.id)
|
||||
} catch (err) {
|
||||
// Declared `required`, so it has to be a number. Zero is the honest answer
|
||||
// for a count that could not be read, and it is also the answer for the far
|
||||
// more common case of a run nothing collected for.
|
||||
log.warn('participant count unavailable for announcement', { run: run.id, message: err.message })
|
||||
}
|
||||
const started = run.started_at ? new Date(run.started_at) : null
|
||||
const ended = endedAt ? new Date(endedAt) : new Date()
|
||||
const durationMinutes = started ? Math.max(0, Math.round((ended - started) / 60_000)) : 0
|
||||
return fire(run, 'event.run.completed', { participantCount, durationMinutes })
|
||||
}
|
||||
|
||||
const runCancelled = (run, reason) =>
|
||||
fire(run, 'event.run.cancelled', { reason: reason || undefined })
|
||||
|
||||
const runFailed = (run, error) =>
|
||||
fire(run, 'event.run.failed', {
|
||||
phase: run.current_phase || undefined,
|
||||
error: error || run.last_error || undefined,
|
||||
// The one destination that exists today. See `coreTriggers.js`'s note above
|
||||
// the six public declarations for why none of them has one.
|
||||
runUrl: `/admin/events/runs/${run.id}`,
|
||||
})
|
||||
|
||||
module.exports = {
|
||||
fire,
|
||||
startsAtLabel,
|
||||
runScheduled,
|
||||
runStarted,
|
||||
phaseChanged,
|
||||
runEnding,
|
||||
runCompleted,
|
||||
runCancelled,
|
||||
runFailed,
|
||||
REHEARSAL_CEILING,
|
||||
}
|
||||
402
server/src/events/authorize.js
Normal file
402
server/src/events/authorize.js
Normal file
@@ -0,0 +1,402 @@
|
||||
// ── The whole authorisation decision, behind one function ──────────────────
|
||||
//
|
||||
// EVENTS.md §K, and Phase 6 of EVENTS_PLAN.md. Four layers stand between an
|
||||
// action being *declared* and an action being *carried out* — role, enablement,
|
||||
// cap, and the shard's own switch — and §K asks for them in one place rather
|
||||
// than spread across route middleware:
|
||||
//
|
||||
// > **Keep the check in one function.** Not for tidiness: it is what makes an
|
||||
// > EM-style delegation model a *later* option rather than a redesign. If a
|
||||
// > deployment ever wants named coordinators with their own budgets, that is
|
||||
// > one function learning to consult a second table, and nothing else in this
|
||||
// > document changes.
|
||||
//
|
||||
// So `mayInvoke()` below is the only thing in this codebase that answers "may
|
||||
// this happen". The router still calls `requireRole` — that gate is about
|
||||
// reaching the ROUTE — but whether a particular verb may be aimed at the world is
|
||||
// decided here, once, on every path that can cause it: authoring a step,
|
||||
// publishing, the dry run, starting a run, and the runner's own unattended
|
||||
// dispatch.
|
||||
//
|
||||
// ## Four layers, and where each one is actually enforced
|
||||
//
|
||||
// 1. **Declaration** — a module says a verb exists. Not a permission, and not
|
||||
// checked here: `dispatch.js` already answers a step whose action nobody
|
||||
// registers, and it answers it `dormant` rather than `refused`, because an
|
||||
// uninstalled module is a different fact from a forbidden one.
|
||||
// 2. **Role** — `change` and `irreversible` are `admin` only. See below.
|
||||
// 3. **Enablement and caps** — this file, against `event_action_settings` and
|
||||
// `event_run_budget`.
|
||||
// 4. **The shard's own switches** — `AdminWriteEnabled` and `AdminAccessFloor`
|
||||
// live on the shard host, outside the website's reach entirely, and core
|
||||
// deliberately does not duplicate them. A module honours them when it
|
||||
// translates an action into a sidecar command (P9); a second copy of that
|
||||
// decision in core would be a copy that could disagree with the shard about
|
||||
// whether the shard is accepting writes. It is named as a layer because
|
||||
// leaving it unnamed is how it comes to be re-implemented.
|
||||
//
|
||||
// ## The role line, and why it is drawn at `change`
|
||||
//
|
||||
// §K's table says "any step whose action is above `notify`, and the action
|
||||
// switchboard — `admin` only". Read literally that is the same sentence that made
|
||||
// `core.wait` — `risk: 'inspect'` — ship disabled by default, and the org lead
|
||||
// settled that on 2026-09-03: the line falls between `inspect` and `change`, not
|
||||
// between `notify` and `inspect`. An `inspect` action reads state and writes
|
||||
// nothing, so neither the default nor the role floor gains a deployment anything
|
||||
// by excluding it, and an editor who cannot author a step that WAITS has an
|
||||
// authoring role that cannot author.
|
||||
//
|
||||
// ## Why `user` may be null, and what that means
|
||||
//
|
||||
// The runner dispatches with nobody logged in. It is not "the system escalating":
|
||||
// the role was checked when a human published the version and again when a human
|
||||
// or the scheduler started the run, and **a run already in flight is not re-gated
|
||||
// against its starter's current role**. Re-checking would mean that demoting an
|
||||
// admin at midnight silently strands every event they started — an event stopping
|
||||
// halfway through because of an unrelated personnel change. §K's "a demoted user
|
||||
// loses access at once" is about reaching a route, and it still holds exactly
|
||||
// there. Cancel is the control for a run that should stop.
|
||||
//
|
||||
// ## Why the cap check can spend
|
||||
//
|
||||
// `mayInvoke` reads on every path but ONE, and on that one it must also write.
|
||||
// The cap is held by a conditional `UPDATE` whose WHERE carries the guard (§E), so
|
||||
// checking and then spending would be two statements with a race between them —
|
||||
// the exact race the conditional increment exists to remove. `spend: true` is
|
||||
// therefore a parameter rather than a separate function: one decision procedure,
|
||||
// one set of layers, and the authoritative check is the one that also commits.
|
||||
|
||||
const settingsDb = require('../model/events/eventActionSettings.db')
|
||||
const budgetDb = require('../model/events/eventRunBudget.db')
|
||||
const registries = require('../modules/registries')
|
||||
const log = require('../utils/logger')('events')
|
||||
|
||||
// The risk classes that change the world, and the two things that follow from
|
||||
// being on this list: the action arrives DISABLED on a fresh deployment, and only
|
||||
// an admin may author a step that names it. Both were one sentence in §K and both
|
||||
// were settled together (org lead, 2026-09-03).
|
||||
const WORLD_CHANGING = ['change', 'irreversible']
|
||||
|
||||
/** Does this action alter the world, in the sense the switchboard and the role floor mean? */
|
||||
const changesWorld = (action) => WORLD_CHANGING.includes(action?.risk)
|
||||
|
||||
/**
|
||||
* Whether an action is enabled, given the deployment's stored opinion — or, when
|
||||
* it has none, its risk class.
|
||||
*
|
||||
* Exported because the switchboard renders the same answer, and a screen that
|
||||
* computed the default itself would be a second copy of the posture.
|
||||
*/
|
||||
function isEnabled(action, settingsRow) {
|
||||
if (settingsRow) return Boolean(settingsRow.enabled)
|
||||
return !changesWorld(action)
|
||||
}
|
||||
|
||||
/**
|
||||
* What one invocation of `action` costs, as `{dimension: amount}`.
|
||||
*
|
||||
* **A module's `cost()` is called here and nowhere else.** It is declared as a
|
||||
* function of params (§F) and it is called with the params a step actually
|
||||
* carries, so the number core enforces is the number the module said. A `cost`
|
||||
* that throws, or that answers something other than a flat object of
|
||||
* non-negative finite numbers, is treated as an unpriceable action rather than a
|
||||
* free one: `null` comes back, and every caller reads `null` as a refusal. That
|
||||
* is the fail-closed direction, and it is the only honest one — an action whose
|
||||
* own accounting is broken is not an action whose consumption is zero.
|
||||
*/
|
||||
function priceOf(action, params) {
|
||||
if (typeof action?.cost !== 'function') return {}
|
||||
let raw
|
||||
try {
|
||||
raw = action.cost(params || {})
|
||||
} catch (err) {
|
||||
log.warn('event action cost() threw', { action: action.id, message: err.message })
|
||||
return null
|
||||
}
|
||||
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null
|
||||
const out = {}
|
||||
for (const [dimension, amount] of Object.entries(raw)) {
|
||||
const n = Number(amount)
|
||||
if (!Number.isFinite(n) || n < 0) return null
|
||||
if (n > 0) out[dimension] = n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The dimensions an action can spend, discovered by pricing its declared
|
||||
* examples.
|
||||
*
|
||||
* **Phase 7 replaced half of this and deliberately kept the other half.** §F's
|
||||
* `registerEventBudgets` now declares a dimension's id, label and unit, so the
|
||||
* switchboard no longer has to invent a name for a box — see `budgetsOf` below,
|
||||
* which is what the screen reads. What a registry cannot answer is *which*
|
||||
* dimensions THIS action spends, because `cost` is a function of params (§F) and
|
||||
* the only honest way to ask it is to call it. So the discovery stays: core
|
||||
* prices each action's own `example` values, which is a use every param already
|
||||
* has a required `example` for.
|
||||
*
|
||||
* It is honest about its limits: a `cost()` that returns different dimension KEYS
|
||||
* for different params under-reports here. That costs an operator a cap box on
|
||||
* the switchboard, and it costs a run nothing at all — a run's budget is seeded
|
||||
* from the params its steps were actually authored with, never from examples.
|
||||
*/
|
||||
function dimensionsOf(action) {
|
||||
const params = {}
|
||||
for (const p of action?.params || []) {
|
||||
// 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() : []
|
||||
}
|
||||
|
||||
/**
|
||||
* The same dimensions, dressed with what the registry says they are called.
|
||||
*
|
||||
* The switchboard's read (Phase 7). `registered: false` is the case worth having
|
||||
* a field for: an action that prices a dimension no module declares is a
|
||||
* DECLARATION ERROR — `mayInvoke` refuses it, the dry run fails on it, and the
|
||||
* save refuses it — so the screen has to be able to show the operator the reason
|
||||
* their action will not run, rather than silently listing one fewer cap box than
|
||||
* the action has dimensions. Hiding it would make a broken module look like a
|
||||
* cheap one.
|
||||
*/
|
||||
function budgetsOf(action) {
|
||||
return dimensionsOf(action).map((id) => {
|
||||
const declared = registries.eventBudget(id)
|
||||
return declared
|
||||
? { id, label: declared.label, unit: declared.unit, registered: true }
|
||||
: { id, label: id, unit: '', registered: false }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of these dimensions does nobody declare? (§F, org lead 2026-09-03.)
|
||||
*
|
||||
* Fail closed. §F's *"a module cannot spend a budget it did not declare"* is a
|
||||
* rule only if something asks, and this is what asks — from `mayInvoke` at
|
||||
* dispatch and at the dry run, and from the spec validator at save. Three places
|
||||
* because they answer at three different moments and only the first of them is
|
||||
* cheap: catching it at save costs an editor a red line, catching it at dispatch
|
||||
* costs a run a refused step at two in the morning.
|
||||
*
|
||||
* Not folded into `priceOf`, which answers *what does this cost* and should keep
|
||||
* answering only that: a cost of 12 creatures is a true statement about the
|
||||
* action whether or not anyone declared the dimension, and the two facts have
|
||||
* different fixes.
|
||||
*/
|
||||
function undeclaredDimensions(cost) {
|
||||
return Object.keys(cost || {}).filter((d) => !registries.isEventBudget(d))
|
||||
}
|
||||
|
||||
/**
|
||||
* The effective per-run cap for each dimension a set of steps will spend.
|
||||
*
|
||||
* **The tightest cap wins** (org lead, 2026-09-03). `event_action_settings.caps`
|
||||
* is per action while `event_run_budget` is one row per dimension, so two actions
|
||||
* both spending `uo.creatures` have to agree on one number, and the number a
|
||||
* safety limit should settle on is the smaller. It is what keeps a dimension a
|
||||
* bound on the RUN's total effect rather than a per-verb allowance that two verbs
|
||||
* can each draw in full.
|
||||
*
|
||||
* A dimension no action caps comes back `{ cap: null }` — uncapped, and still
|
||||
* seeded, so the meter counts it and a missing row keeps its one meaning.
|
||||
*
|
||||
* `steps` are `{ actionId, params }`; the answer is `{dimension: {cap, from}}`.
|
||||
*/
|
||||
function effectiveCaps(steps, settingsByAction) {
|
||||
const out = {}
|
||||
for (const step of steps || []) {
|
||||
const action = registries.eventAction(step.actionId)
|
||||
if (!action) continue
|
||||
const priced = priceOf(action, step.params)
|
||||
if (!priced) continue
|
||||
const declared = (settingsByAction.get(action.id) || {}).caps || {}
|
||||
for (const dimension of Object.keys(priced)) {
|
||||
const raw = declared[dimension]
|
||||
const cap = Number.isFinite(Number(raw)) && Number(raw) >= 0 ? Number(raw) : null
|
||||
if (!(dimension in out)) {
|
||||
out[dimension] = { cap, from: cap === null ? null : action.id }
|
||||
continue
|
||||
}
|
||||
const held = out[dimension]
|
||||
// `null` is uncapped, so it never wins a minimum — an action that declines
|
||||
// to cap a dimension must not raise the ceiling another action set.
|
||||
if (cap !== null && (held.cap === null || cap < held.cap)) {
|
||||
out[dimension] = { cap, from: action.id }
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* May this action be carried out, and — when asked — spend its cost.
|
||||
*
|
||||
* Answers an envelope, never throws, and never answers a bare boolean: every
|
||||
* refusal carries a `code` a caller can branch on and a `reason` a human reads.
|
||||
* The reason is written here rather than at the four call sites for the same
|
||||
* argument Phase 5 made about the diagnosis panel — one place the words are
|
||||
* written, so the dry run, the editor, the run console and the log all say the
|
||||
* same sentence about the same fact.
|
||||
*
|
||||
* `{ user }` null means the unattended runner; see the header. `{ run }` null
|
||||
* means there is no budget to draw on yet — authoring and the dry run — and the
|
||||
* cap layer then compares the cost against the effective cap instead of against
|
||||
* what is left of it.
|
||||
*/
|
||||
async function mayInvoke({
|
||||
user = null,
|
||||
action,
|
||||
params = {},
|
||||
run = null,
|
||||
settings = undefined,
|
||||
spend = false,
|
||||
} = {}) {
|
||||
if (!action) return { ok: false, code: 'unregistered', reason: 'no module registers this action' }
|
||||
|
||||
// ── Layer 2: the role ──
|
||||
if (user && changesWorld(action) && user.role !== 'admin') {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'role',
|
||||
reason: `"${action.label}" changes the world, so only an administrator may use it`,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Layer 3a: enablement ──
|
||||
const row = settings === undefined ? await settingsDb.get(action.id) : settings
|
||||
if (!isEnabled(action, row)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'disabled',
|
||||
reason: `"${action.label}" is not enabled on this deployment`,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Layer 3b: the cap ──
|
||||
const cost = priceOf(action, params)
|
||||
if (cost === null) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'unpriceable',
|
||||
reason: `"${action.label}" could not report what it costs`,
|
||||
}
|
||||
}
|
||||
const dimensions = Object.keys(cost)
|
||||
if (!dimensions.length) return { ok: true, cost }
|
||||
|
||||
// Before any cap arithmetic, because a dimension nobody declared has no cap to
|
||||
// be under and no meter to draw on — asking "is 12 within the limit" about a
|
||||
// resource core has never been told the name of would be answering a question
|
||||
// that has not been asked yet. It is also the honest reading of the refusal:
|
||||
// this is a module whose declaration is incomplete, not a deployment whose
|
||||
// allowance is spent, and an operator who is told the second will go and raise
|
||||
// a cap that changes nothing.
|
||||
const undeclared = undeclaredDimensions(cost)
|
||||
if (undeclared.length) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'undeclared',
|
||||
reason: `spends "${undeclared[0]}", which no module declares as a budget`,
|
||||
dimension: undeclared[0],
|
||||
requested: cost[undeclared[0]],
|
||||
}
|
||||
}
|
||||
|
||||
if (!run) {
|
||||
// No run, so nothing to draw on: the question is whether the cost could EVER
|
||||
// fit, which is what the dry run and the editor are asking. A cost larger
|
||||
// than the cap is an authoring error and it is answerable before anything is
|
||||
// scheduled — which is the entire value of catching it here.
|
||||
const caps = effectiveCaps([{ actionId: action.id, params }], new Map([[action.id, row || {}]]))
|
||||
for (const dimension of dimensions) {
|
||||
const { cap } = caps[dimension] || { cap: null }
|
||||
if (cap !== null && cost[dimension] > cap) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'cap',
|
||||
reason: `asks for ${cost[dimension]} of "${dimension}" and this deployment allows ${cap} per run`,
|
||||
dimension,
|
||||
requested: cost[dimension],
|
||||
cap,
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ok: true, cost }
|
||||
}
|
||||
|
||||
if (!spend) {
|
||||
// A read of the meter rather than a draw on it. Deliberately advisory: this
|
||||
// answer is stale the moment another step in the same tick spends, which is
|
||||
// exactly why the authoritative check is the one that commits.
|
||||
const rows = await budgetDb.forRun(run.id)
|
||||
const byDimension = new Map(rows.map((r) => [r.dimension, r]))
|
||||
for (const dimension of dimensions) {
|
||||
const held = byDimension.get(dimension)
|
||||
if (!held) return refusal(dimension, cost[dimension], null, 0, 'unbudgeted')
|
||||
if (held.cap !== null && held.consumed + cost[dimension] > held.cap) {
|
||||
return refusal(dimension, cost[dimension], held.cap, held.consumed, 'cap')
|
||||
}
|
||||
}
|
||||
return { ok: true, cost }
|
||||
}
|
||||
|
||||
// ── The committing path ──
|
||||
//
|
||||
// One statement per dimension, because the atomicity that matters is per
|
||||
// dimension: a cap is a bound on one thing, and a transaction spanning three of
|
||||
// them would serialise three unrelated counters to buy nothing. What it does
|
||||
// create is a partial spend — creatures taken, bosses refused — and a step that
|
||||
// did not run must not have spent anything, so the taken ones are given back.
|
||||
const taken = []
|
||||
for (const dimension of dimensions) {
|
||||
if (await budgetDb.spend(run.id, dimension, cost[dimension])) {
|
||||
taken.push(dimension)
|
||||
continue
|
||||
}
|
||||
for (const back of taken) await budgetDb.refund(run.id, back, cost[back])
|
||||
const rows = await budgetDb.forRun(run.id)
|
||||
const held = rows.find((r) => r.dimension === dimension)
|
||||
return held
|
||||
? refusal(dimension, cost[dimension], held.cap, held.consumed, 'cap')
|
||||
: refusal(dimension, cost[dimension], null, 0, 'unbudgeted')
|
||||
}
|
||||
return { ok: true, cost, spent: true }
|
||||
}
|
||||
|
||||
/** The two cap refusals, written once so they cannot drift apart. */
|
||||
function refusal(dimension, requested, cap, consumed, code) {
|
||||
if (code === 'unbudgeted') {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'unbudgeted',
|
||||
reason: `spends "${dimension}", which this run has no budget for`,
|
||||
dimension,
|
||||
requested,
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
code: 'cap',
|
||||
reason: `asks for ${requested} of "${dimension}"; ${consumed} of ${cap} is already spent this run`,
|
||||
dimension,
|
||||
requested,
|
||||
cap,
|
||||
consumed,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
mayInvoke,
|
||||
isEnabled,
|
||||
priceOf,
|
||||
dimensionsOf,
|
||||
budgetsOf,
|
||||
undeclaredDimensions,
|
||||
effectiveCaps,
|
||||
changesWorld,
|
||||
WORLD_CHANGING,
|
||||
}
|
||||
538
server/src/events/cleanup.js
Normal file
538
server/src/events/cleanup.js
Normal file
@@ -0,0 +1,538 @@
|
||||
// ── Giving back what a run took ────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §C ("Cleanup is generated, never authored"), §L and its two ledger
|
||||
// rules, and Phase 8 of EVENTS_PLAN.md. `events/ledger.js` is the write half;
|
||||
// this is the undo half, plus the reconcile that answers "is any of it still
|
||||
// there?" after something outside core restarted.
|
||||
//
|
||||
// **Cleanup is derived from the ledger, never authored.** An operator cannot be
|
||||
// relied on to write the undo, and an aborted run never reaches the phase they
|
||||
// wrote it in — so there is no cleanup phase in a spec and no `on_teardown` on an
|
||||
// action. There is one function, it reads rows, and it runs on EVERY terminal
|
||||
// path: completion, cancellation and abort alike.
|
||||
//
|
||||
// **It is not built out of `event_run_steps` rows** (org lead, 2026-09-03). The
|
||||
// plan's phrase is "cleanup steps are generated from the ledger", and the
|
||||
// tempting reading is a synthetic phase of real step rows so the console's
|
||||
// per-step retry comes free. It is the wrong shape here for one concrete reason:
|
||||
// `event_run_resources` already carries `revert_attempts` and `last_error`, so
|
||||
// synthetic steps would put a second retry counter beside the first and the two
|
||||
// would disagree the first time a step reverted three of its four resources.
|
||||
// The manual retry the API surface promises is a route over the ledger —
|
||||
// `POST /admin/events/runs/:runId/cleanup` — rather than a step control.
|
||||
//
|
||||
// **Where it runs from.** One place: the runner's cleanup leg, which finds
|
||||
// terminal runs that still owe the world something and works their rows. Hooking
|
||||
// each terminal path instead would be four call sites, three of which are inside
|
||||
// a request, and none of which would survive the process dying mid-cleanup. The
|
||||
// leg is ordered AFTER advance in the tick, so a run that completes in one tick
|
||||
// is cleaned in the same one.
|
||||
//
|
||||
// **The scan's WHERE clause cost two live-walk findings, in opposite
|
||||
// directions.** A run whose only resource was a LEASE never went through
|
||||
// `ledger.markRunDirty` — `core.lease` reserves its own row — so its
|
||||
// `cleanup_status` stayed `not_required` and the lease was never given back at
|
||||
// all. And a run whose first sweep failed was moved to `incomplete` by that very
|
||||
// sweep, so it was never picked up again: `MAX_REVERT_ATTEMPTS` meant ONE attempt
|
||||
// rather than three. The first is why `not_required` is in the scan; the second
|
||||
// is why `incomplete` is written HERE only once nothing retryable is left.
|
||||
//
|
||||
// **Rule 2 is what the bounds are for.** A revert that never succeeds must stay
|
||||
// visible rather than cycle: `MAX_REVERT_ATTEMPTS` stops the automatic retry, the
|
||||
// run reaches `completed` with `cleanup_status = 'incomplete'`, and the rows stay
|
||||
// on the console with their last error. Only a human's cleanup clears the
|
||||
// counter — Engagement Phase 14's rule, whose defect was a sweep that reset every
|
||||
// stale row and made the ceiling unreachable for ever.
|
||||
|
||||
const resourcesDb = require('../model/events/eventRunResources.db')
|
||||
const runsDb = require('../model/events/eventRuns.db')
|
||||
const logDb = require('../model/events/eventRunLog.db')
|
||||
const stepsDb = require('../model/events/eventRunSteps.db')
|
||||
const registries = require('../modules/registries')
|
||||
const { withDeadline } = require('./dispatch')
|
||||
const log = require('../utils/logger')('events')
|
||||
|
||||
// How many times the automatic sweep will ask before leaving a resource for a
|
||||
// human. Three, like a step's, and for the same reason: a fourth attempt against
|
||||
// a shard that has answered the same way three times is not new information.
|
||||
const MAX_REVERT_ATTEMPTS = Number(process.env.EVENT_REVERT_MAX_ATTEMPTS) || 3
|
||||
|
||||
// The bound on one revert call, when the action that made the resource is gone
|
||||
// and there is no `budgetMs` to read. A restore is a round trip like any other.
|
||||
const DEFAULT_REVERT_BUDGET_MS = 10_000
|
||||
|
||||
// How many runs one cleanup leg looks at, and how many resource groups it works
|
||||
// per run. Bounds rather than targets, exactly like `RUN_BATCH`: the tick runs
|
||||
// again, and an unbounded teardown is how one run's bad night stalls every other.
|
||||
const CLEANUP_RUN_BATCH = Number(process.env.EVENT_CLEANUP_RUN_BATCH) || 10
|
||||
const CLEANUP_GROUPS_PER_RUN = Number(process.env.EVENT_CLEANUP_GROUPS_PER_RUN) || 25
|
||||
|
||||
/**
|
||||
* Classify one revert answer, with `dispatch.classify`'s posture: no shape a
|
||||
* failure can take may read as success.
|
||||
*
|
||||
* The extra value here is `drifted`. It is NOT an error — the module did exactly
|
||||
* what it was asked and found somebody else's value in place — so it is a third
|
||||
* outcome rather than a failure with a flag, and the row it produces is the one
|
||||
* §L wants surfaced beside the unreverted ones.
|
||||
*/
|
||||
function classifyRevert(raw, what) {
|
||||
if (raw && raw.__timedOut) return { outcome: 'retry', error: raw.error }
|
||||
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
return { outcome: 'retry', error: `${what} answered with no envelope` }
|
||||
}
|
||||
if (raw.ok === true) {
|
||||
// §L, and the Rust wipe: "gone, and that is fine" is a successful revert. A
|
||||
// module never has to distinguish "I deleted it" from "it was not there".
|
||||
return { outcome: 'done', failed: Array.isArray(raw.failed) ? raw.failed.map(String) : [] }
|
||||
}
|
||||
if (raw.drifted === true) {
|
||||
return {
|
||||
outcome: 'drifted',
|
||||
error: `the value is now ${JSON.stringify(raw.current)} rather than what this run applied, so it was left alone`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
outcome: raw.retry === false ? 'terminal' : 'retry',
|
||||
error: raw.error ? String(raw.error) : `${what} refused`,
|
||||
}
|
||||
}
|
||||
|
||||
/** Call a lease's `restore`, under a deadline, never throwing. */
|
||||
async function restoreLease(row) {
|
||||
// Through the ref parser, because a targeted lease's ref is `<id>#<target>`
|
||||
// (Phase 12b). A bare map lookup would miss every property lease and report
|
||||
// "no module registers" for one that is registered — leaving a spawner turned
|
||||
// up for good and blaming an uninstalled module for it.
|
||||
const found = registries.eventLeaseForRef(row.ref)
|
||||
const lease = found && found.lease
|
||||
if (!lease) {
|
||||
// The module that owned it is uninstalled or failed to boot. Not a failure to
|
||||
// retry away — nothing will change until an operator reinstalls it — and not
|
||||
// an orphan either, because core has no idea whether the value is still
|
||||
// applied. It stays unresolved with the reason on it, which is exactly what
|
||||
// `cleanup_status = 'incomplete'` is for.
|
||||
return { outcome: 'terminal', error: `no module registers the lease "${row.ref}"` }
|
||||
}
|
||||
const payload = row.payload || {}
|
||||
let raw
|
||||
try {
|
||||
raw = await withDeadline(
|
||||
() =>
|
||||
lease.restore(payload.baseline, {
|
||||
expected: payload.applied,
|
||||
runId: row.run_id,
|
||||
target: found.target,
|
||||
}),
|
||||
DEFAULT_REVERT_BUDGET_MS,
|
||||
row.ref,
|
||||
)
|
||||
} catch (err) {
|
||||
return { outcome: 'retry', error: err.message }
|
||||
}
|
||||
return classifyRevert(raw, row.ref)
|
||||
}
|
||||
|
||||
/** Call an action's `revert` over a group of its rows, under a deadline, never throwing. */
|
||||
async function revertGroup(actionId, rows, idempotencyKey) {
|
||||
const action = registries.eventAction(actionId)
|
||||
if (!action || typeof action.revert !== 'function') {
|
||||
return {
|
||||
outcome: 'terminal',
|
||||
error: action
|
||||
? `${actionId} declares no revert()`
|
||||
: `no module registers "${actionId}", so its resources cannot be given back`,
|
||||
}
|
||||
}
|
||||
const payload = rows
|
||||
.filter((r) => r.kind !== resourcesDb.STEP_KIND)
|
||||
.map((r) => ({ kind: r.kind, ref: r.ref, payload: r.payload || null, memberKey: r.member_key || null }))
|
||||
|
||||
let raw
|
||||
try {
|
||||
raw = await withDeadline(
|
||||
() => action.revert({ runId: rows[0].run_id, resources: payload, idempotencyKey }),
|
||||
action.budgetMs || DEFAULT_REVERT_BUDGET_MS,
|
||||
actionId,
|
||||
)
|
||||
} catch (err) {
|
||||
// A module should not throw from `revert` any more than from `perform`, and
|
||||
// one that does has produced a transient failure rather than a crashed sweep.
|
||||
log.warn('event revert threw', { action: actionId, message: err.message })
|
||||
return { outcome: 'retry', error: err.message }
|
||||
}
|
||||
return classifyRevert(raw, actionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Work one run's ledger once.
|
||||
*
|
||||
* Answers what it found and what it managed, and sets `cleanup_status` from the
|
||||
* rows that are left rather than from what it did — the two differ whenever
|
||||
* another writer touched the run, and the rows are the truth.
|
||||
*
|
||||
* `resetAttempts` is the human's flag. It is never set by the automatic leg.
|
||||
*/
|
||||
async function cleanupRun(run, { resetAttempts = false, actor = null } = {}) {
|
||||
const summary = { attempted: 0, reverted: 0, drifted: 0, failed: 0, remaining: 0 }
|
||||
|
||||
if (resetAttempts) {
|
||||
const cleared = await resourcesDb.resetAttempts(run.id)
|
||||
await runsDb.setCleanupStatus(run.id, 'pending')
|
||||
if (cleared) {
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
kind: 'cleanup.retry',
|
||||
detail: { resources: cleared, by: actor },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const rows = await resourcesDb.unresolvedForRun(run.id, {
|
||||
maxAttempts: resetAttempts ? null : MAX_REVERT_ATTEMPTS,
|
||||
})
|
||||
|
||||
// **Grouped by the step that made them**, because that is what names the verb:
|
||||
// the resource row records the module and the opaque names, the step records
|
||||
// the action, and `revert()` takes a LIST so one round trip can give back
|
||||
// twelve creatures. A lease is its own group of one — core restores it through
|
||||
// the lease registry rather than through any action, which is the split §F
|
||||
// draws and the reason `core.lease` needs no `revert()` of its own.
|
||||
const leases = rows.filter((r) => r.kind === 'override')
|
||||
const byStep = new Map()
|
||||
for (const row of rows) {
|
||||
if (row.kind === 'override') continue
|
||||
const key = row.step_id === null ? `orphan:${row.id}` : `step:${row.step_id}`
|
||||
if (!byStep.has(key)) byStep.set(key, [])
|
||||
byStep.get(key).push(row)
|
||||
}
|
||||
|
||||
const groups = [...leases.map((r) => ({ lease: r })), ...[...byStep.values()].map((rs) => ({ rows: rs }))]
|
||||
|
||||
for (const group of groups.slice(0, CLEANUP_GROUPS_PER_RUN)) {
|
||||
if (group.lease) {
|
||||
const row = group.lease
|
||||
if (!(await resourcesDb.claimRevert(row.id))) continue
|
||||
summary.attempted += 1
|
||||
const verdict = await restoreLease(row)
|
||||
await applyVerdict(run, [row], verdict, summary, row.ref)
|
||||
continue
|
||||
}
|
||||
|
||||
const rs = group.rows
|
||||
// The step is what names the action and carries the idempotency key. A row
|
||||
// whose step was deleted keeps the action id in its own payload, which is why
|
||||
// the placeholder writes one.
|
||||
const step = rs[0].step_id === null ? null : await stepsDb.getById(rs[0].step_id)
|
||||
const actionId = step?.action_id || rs[0].payload?.action || null
|
||||
if (!actionId) {
|
||||
await noteUnrevertable(run, rs, 'nothing records which action created this', summary)
|
||||
continue
|
||||
}
|
||||
const claimed = []
|
||||
for (const row of rs) if (await resourcesDb.claimRevert(row.id)) claimed.push(row)
|
||||
if (!claimed.length) continue
|
||||
summary.attempted += claimed.length
|
||||
const verdict = await revertGroup(actionId, claimed, step?.idempotency_key || rs[0].ref)
|
||||
await applyVerdict(run, claimed, verdict, summary, actionId)
|
||||
}
|
||||
|
||||
summary.remaining = await resourcesDb.unresolvedCount(run.id)
|
||||
// **`incomplete` means "finished with, and not finished"**, so it is written
|
||||
// only once there is nothing left this sweep will try. Writing it after the
|
||||
// FIRST failure — which is what the first draft did — took the run straight out
|
||||
// of the leg's own scan, and `MAX_REVERT_ATTEMPTS` quietly meant one attempt
|
||||
// rather than three. Found by the live walk, watching `revert_attempts` sit at
|
||||
// 1 through half a minute of ticks.
|
||||
const retryable = await resourcesDb.unresolvedForRun(run.id, { maxAttempts: MAX_REVERT_ATTEMPTS })
|
||||
const status = summary.remaining === 0 ? 'complete' : retryable.length ? 'pending' : 'incomplete'
|
||||
await runsDb.setCleanupStatus(run.id, status)
|
||||
|
||||
if (summary.attempted > 0) {
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
kind: 'cleanup.swept',
|
||||
detail: { ...summary, by: actor },
|
||||
})
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
/** Write one verdict across the rows it covers, and count it. */
|
||||
async function applyVerdict(run, rows, verdict, summary, what) {
|
||||
for (const row of rows) {
|
||||
if (verdict.outcome === 'done' && !(verdict.failed || []).includes(row.ref)) {
|
||||
await resourcesDb.markReverted(row.id)
|
||||
summary.reverted += 1
|
||||
continue
|
||||
}
|
||||
if (verdict.outcome === 'drifted') {
|
||||
await resourcesDb.failRevert(row.id, verdict.error, 'drifted')
|
||||
summary.drifted += 1
|
||||
continue
|
||||
}
|
||||
const error =
|
||||
verdict.outcome === 'done'
|
||||
? `${what} could not give "${row.ref}" back`
|
||||
: verdict.error
|
||||
await resourcesDb.failRevert(row.id, error, 'confirmed')
|
||||
summary.failed += 1
|
||||
}
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
kind: verdict.outcome === 'done' ? 'cleanup.reverted' : 'cleanup.failed',
|
||||
detail: {
|
||||
what,
|
||||
outcome: verdict.outcome,
|
||||
resources: rows.map((r) => `${r.kind}:${r.ref}`),
|
||||
...(verdict.error ? { error: verdict.error } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** A group core cannot even name a verb for. Counted as failed, and said once. */
|
||||
async function noteUnrevertable(run, rows, reason, summary) {
|
||||
for (const row of rows) {
|
||||
if (!(await resourcesDb.claimRevert(row.id))) continue
|
||||
await resourcesDb.failRevert(row.id, reason, 'confirmed')
|
||||
summary.attempted += 1
|
||||
summary.failed += 1
|
||||
}
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
kind: 'cleanup.failed',
|
||||
detail: { what: null, outcome: 'terminal', resources: rows.map((r) => `${r.kind}:${r.ref}`), error: reason },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The cleanup leg of the tick: every TERMINAL run with something left to give
|
||||
* back.
|
||||
*
|
||||
* Terminal only. A run still in flight has a ledger that is still growing, and
|
||||
* reverting a resource the next step is about to use would be core undoing an
|
||||
* event while it is happening.
|
||||
*/
|
||||
async function sweep() {
|
||||
// The ceiling goes INTO the query, so a run whose rows are all spent is not
|
||||
// selected, worked over and found to have nothing to do on every tick for the
|
||||
// rest of its life. It is also what excludes a run an admin cancelled without
|
||||
// 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 {
|
||||
await cleanupRun(candidate)
|
||||
swept += 1
|
||||
} catch (err) {
|
||||
log.error('event cleanup failed', { run: candidate.id, message: err.message })
|
||||
}
|
||||
}
|
||||
return swept
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask one module which of its ledgered resources the game still has (§L, and
|
||||
* §N7's "the shard stays stateless about events").
|
||||
*
|
||||
* **Core cannot know when to ask**, and that is not an omission: §F says core has
|
||||
* no concept of the game being up, because a module with six sidecars cannot
|
||||
* answer that question in the singular. So the module triggers this, through
|
||||
* `ctx.events.reconcile()`, when it sees its own reconnect — module-uo already
|
||||
* watches `bootId` for exactly that. Core also asks once at boot, for its own
|
||||
* restart.
|
||||
*
|
||||
* **A resource the module no longer has becomes `orphaned`, never `reverted`.**
|
||||
* 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, now = new Date()) {
|
||||
const rows = await resourcesDb.liveForModule(owner)
|
||||
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()
|
||||
for (const row of rows) {
|
||||
if (row.kind === resourcesDb.STEP_KIND) continue // nothing to ask about yet
|
||||
const key = row.step_id === null ? `orphan:${row.id}` : `step:${row.step_id}`
|
||||
if (!byStep.has(key)) byStep.set(key, [])
|
||||
byStep.get(key).push(row)
|
||||
}
|
||||
|
||||
for (const group of byStep.values()) {
|
||||
const step = group[0].step_id === null ? null : await stepsDb.getById(group[0].step_id)
|
||||
const actionId = step?.action_id || group[0].payload?.action || null
|
||||
const action = actionId ? registries.eventAction(actionId) : null
|
||||
if (!action || typeof action.reconcile !== 'function') {
|
||||
summary.unanswered += group.length
|
||||
continue
|
||||
}
|
||||
summary.asked += group.length
|
||||
let raw
|
||||
try {
|
||||
raw = await withDeadline(
|
||||
() =>
|
||||
action.reconcile({
|
||||
runId: group[0].run_id,
|
||||
resources: group.map((r) => ({ kind: r.kind, ref: r.ref, payload: r.payload || null })),
|
||||
}),
|
||||
action.budgetMs || DEFAULT_REVERT_BUDGET_MS,
|
||||
actionId,
|
||||
)
|
||||
} catch (err) {
|
||||
log.warn('event reconcile threw', { action: actionId, message: err.message })
|
||||
raw = null
|
||||
}
|
||||
// Same posture as everywhere else: nothing that is not an explicit answer
|
||||
// counts as one. A module that could not answer leaves the ledger alone,
|
||||
// because "I do not know" must never be read as "it is gone".
|
||||
if (!raw || raw.__timedOut || raw.ok !== true || !Array.isArray(raw.inForce)) {
|
||||
summary.unanswered += group.length
|
||||
continue
|
||||
}
|
||||
const held = new Set(raw.inForce.map(String))
|
||||
for (const row of group) {
|
||||
if (held.has(row.ref)) {
|
||||
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({
|
||||
runId: row.run_id,
|
||||
kind: 'resource.orphaned',
|
||||
detail: { module: owner, resource: `${row.kind}:${row.ref}`, action: actionId },
|
||||
})
|
||||
}
|
||||
}
|
||||
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()
|
||||
const out = {}
|
||||
for (const owner of owners) {
|
||||
try {
|
||||
out[owner] = await reconcileModule(owner)
|
||||
} catch (err) {
|
||||
log.error('event reconcile failed', { module: owner, message: err.message })
|
||||
}
|
||||
}
|
||||
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,
|
||||
cleanupRun,
|
||||
sweep,
|
||||
reconcileModule,
|
||||
reconcileAll,
|
||||
expireResource,
|
||||
}
|
||||
299
server/src/events/dispatch.js
Normal file
299
server/src/events/dispatch.js
Normal file
@@ -0,0 +1,299 @@
|
||||
// ── Dispatching one step to one action ─────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §F. This is the boundary between the runner and code core did not
|
||||
// write, and it exists as its own file because it has exactly one job: call
|
||||
// `perform()` and turn whatever comes back — an envelope, a lie, a throw, a
|
||||
// promise that never settles — into one of four classifications the runner knows
|
||||
// how to act on.
|
||||
//
|
||||
// **§F's load-bearing rule, and the reason none of this is inlined into the
|
||||
// runner: no shape a failure can take may read as success.** A rejected promise,
|
||||
// a throw, a timeout, a non-object and a missing `ok` are all
|
||||
// `{ ok: false, retry: true }`. That is the inverse of `registerTeamProvider`'s
|
||||
// default, deliberately — a team provider that refuses leaves core showing what
|
||||
// it already had, because staleness is cheap, whereas an action that half-ran and
|
||||
// was recorded as `done` is a world change nothing will ever come back for.
|
||||
//
|
||||
// **The timeout is the module contract's, not this file's opinion.** Every action
|
||||
// declares `budgetMs` at registration and the registry bounds it there; here it
|
||||
// is enforced. Without it a module whose `perform()` awaits a socket that never
|
||||
// answers holds a step's claim until the lease expires, and the reclaim then
|
||||
// re-dispatches it — which is how one wedged sidecar becomes an infinite loop
|
||||
// rather than a failed step.
|
||||
|
||||
const registries = require('../modules/registries')
|
||||
const log = require('../utils/logger')('events')
|
||||
|
||||
// What a classification can be. `parked` is Phase 2's addition and it is the one
|
||||
// outcome that is neither terminal nor a retry: the action succeeded, and the
|
||||
// step is not finished, because something outside this system has to happen next.
|
||||
const OUTCOMES = ['done', 'parked', 'retry', 'terminal']
|
||||
|
||||
// The upper bound on `holdFor`, in seconds. A wait is a scheduling instruction,
|
||||
// not a lease, so this is generous — but it is bounded, because an action that
|
||||
// answers `holdFor: 1e9` would park the phase past the heat death of the shard
|
||||
// and the step that did it would look, in the console, exactly like one that
|
||||
// worked.
|
||||
const MAX_HOLD_SECONDS = 7 * 24 * 60 * 60
|
||||
|
||||
// The upper bound on a module's `detail`, in bytes of serialised JSON. It lands
|
||||
// in `event_run_log.detail` and is read back by the run console, so it is a
|
||||
// diagnostic line rather than a data channel — a module with more to say than
|
||||
// this has a table of its own to say it in. Dropped rather than truncated when it
|
||||
// is over: a truncated JSON object is not a JSON object, and a console that
|
||||
// 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.
|
||||
*
|
||||
* Optional, module-opaque, and **never interpreted by core** — it is carried to
|
||||
* the run log and rendered, and nothing here or in the runner reads a key out of
|
||||
* it. That is the whole contract: a module knows things about its own verb that
|
||||
* core cannot compute and has no other way to say. `uo.item.grant` is the case
|
||||
* that forced it — a grant reaches the players a run's ledger holds, and *which
|
||||
* of them missed out* is knowable only to the module and reported nowhere else,
|
||||
* so an operator saw a step marked `done` and never learned four of twelve got
|
||||
* nothing.
|
||||
*
|
||||
* **Anything wrong with it is dropped and logged, never a failure.** A step that
|
||||
* did what it was asked must not be re-run because its module's commentary was
|
||||
* malformed — that would be a world write repeated for a log line. Same posture
|
||||
* `participants` takes, and for the same reason.
|
||||
*/
|
||||
function safeDetail(detail, actionId) {
|
||||
if (detail === undefined || detail === null) return null
|
||||
|
||||
// Objects only. The column is JSON and the console renders keys, so a bare
|
||||
// string or a number has nothing to render under — and core inventing a key to
|
||||
// put it beneath would be core interpreting it after all.
|
||||
if (typeof detail !== 'object' || Array.isArray(detail)) {
|
||||
log.warn('action detail is not an object', { action: actionId, type: typeof detail })
|
||||
return null
|
||||
}
|
||||
|
||||
let encoded
|
||||
try {
|
||||
encoded = JSON.stringify(detail)
|
||||
} catch (err) {
|
||||
// A circular reference, or a `toJSON` that throws. Reaching the runner would
|
||||
// make the INSERT throw instead, inside the one write that is documented
|
||||
// never to.
|
||||
log.warn('action detail could not be serialised', { action: actionId, message: err.message })
|
||||
return null
|
||||
}
|
||||
if (encoded === undefined) {
|
||||
log.warn('action detail serialised to nothing', { action: actionId })
|
||||
return null
|
||||
}
|
||||
if (Buffer.byteLength(encoded, 'utf8') > MAX_DETAIL_BYTES) {
|
||||
log.warn('action detail is too large', {
|
||||
action: actionId,
|
||||
bytes: Buffer.byteLength(encoded, 'utf8'),
|
||||
max: MAX_DETAIL_BYTES,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
// Re-parsed rather than passed through, so what the runner writes is a plain
|
||||
// JSON value with no getters, no prototype and no live reference into whatever
|
||||
// the module still holds.
|
||||
return JSON.parse(encoded)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn()` under a deadline.
|
||||
*
|
||||
* The loser of the race is not cancelled — JavaScript has no such thing, and a
|
||||
* `perform()` still awaiting a socket keeps awaiting it. What the deadline buys
|
||||
* is that the RUNNER stops waiting, which is the half that matters: the step is
|
||||
* classified, the claim is released, and the tick moves on. A late answer from
|
||||
* the abandoned call lands on a step that has already been written, and the
|
||||
* idempotency key is what makes the retry that follows safe on the game side.
|
||||
*/
|
||||
function withDeadline(fn, ms, actionId) {
|
||||
let timer = null
|
||||
const deadline = new Promise((resolve) => {
|
||||
timer = setTimeout(
|
||||
() => resolve({ __timedOut: true, error: `${actionId} exceeded its ${ms}ms budget` }),
|
||||
ms,
|
||||
)
|
||||
if (timer.unref) timer.unref()
|
||||
})
|
||||
return Promise.race([Promise.resolve().then(fn), deadline]).finally(() => {
|
||||
if (timer) clearTimeout(timer)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a raw `perform()` answer into
|
||||
* `{ 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
|
||||
* whether a world change is recorded as having happened.
|
||||
*/
|
||||
function classify(result, actionId) {
|
||||
if (result && result.__timedOut) {
|
||||
// Transient by default: a timeout says nothing about whether the action ran.
|
||||
// That ambiguity is exactly what the idempotency key exists to resolve, and
|
||||
// resolving it on the game side is Phase 11's protocol work — until then a
|
||||
// retry is the honest choice and the risk class decides what happens when the
|
||||
// retries run out.
|
||||
return { outcome: 'retry', error: result.error }
|
||||
}
|
||||
if (result === null || typeof result !== 'object' || Array.isArray(result)) {
|
||||
return { outcome: 'retry', error: `${actionId} answered with no envelope` }
|
||||
}
|
||||
if (result.ok !== true) {
|
||||
// `retry` must be opted into. An action that means "this will never work"
|
||||
// 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
|
||||
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" ──
|
||||
//
|
||||
// Both were settled by the org lead on 2026-09-02, and both are envelope
|
||||
// members rather than special cases keyed on an action id, so that the runner
|
||||
// never names a verb. `core.cue` and `core.wait` reach them through the same
|
||||
// door Phase 7 opens to a module's own long-running action.
|
||||
if (result.await === 'human') {
|
||||
return {
|
||||
outcome: 'parked',
|
||||
error: null,
|
||||
resources: result.resources || [],
|
||||
participants: result.participants || [],
|
||||
detail: safeDetail(result.detail, actionId),
|
||||
}
|
||||
}
|
||||
|
||||
let holdSeconds = 0
|
||||
if (result.holdFor !== undefined && result.holdFor !== null) {
|
||||
const n = Number(result.holdFor)
|
||||
if (!Number.isFinite(n) || n < 0) {
|
||||
return { outcome: 'terminal', error: `${actionId} answered a bad holdFor "${result.holdFor}"` }
|
||||
}
|
||||
holdSeconds = Math.min(Math.floor(n), MAX_HOLD_SECONDS)
|
||||
}
|
||||
|
||||
// `participants` rides beside `resources` and on the same two success shapes
|
||||
// (Phase 10). It is carried rather than interpreted here: what a member key
|
||||
// means is the module's business, and this file's whole job is to know
|
||||
// nothing about the verb it just called.
|
||||
return {
|
||||
outcome: 'done',
|
||||
error: null,
|
||||
holdSeconds,
|
||||
resources: result.resources || [],
|
||||
participants: result.participants || [],
|
||||
// On the same two success shapes as `resources` and `participants`, and for
|
||||
// the same reason: `await: 'human'` is a success, and a cue's confirm
|
||||
// finishes the step without a second dispatch, so this is the only moment
|
||||
// its module could ever have said anything.
|
||||
detail: safeDetail(result.detail, actionId),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch one step. Never throws.
|
||||
*
|
||||
* `verify` rides through to `perform()` unchanged (§I's dry run, Phase 6's
|
||||
* route): `verify === true` means validate and report, change nothing. It is
|
||||
* passed from here rather than being a separate code path so that the dry run
|
||||
* exercises the real dispatcher — a dry run down a second path is a dry run of
|
||||
* the second path.
|
||||
*/
|
||||
async function dispatchStep(step, { run, actor = null, verify = false } = {}) {
|
||||
const action = registries.eventAction(step.action_id)
|
||||
if (!action) {
|
||||
// §L, verbatim: "a step naming one fails terminal with the module named, and
|
||||
// the run degrades rather than claiming success. Never a silent skip." The
|
||||
// module was uninstalled or failed to boot between publish and now — publish
|
||||
// refuses a dormant step, so this cannot be an authoring mistake.
|
||||
return { outcome: 'terminal', error: `no module registers "${step.action_id}"`, dormant: true }
|
||||
}
|
||||
|
||||
const envelope = {
|
||||
runId: run.id,
|
||||
stepId: step.id,
|
||||
idempotencyKey: step.idempotency_key,
|
||||
scope: run.scope || '',
|
||||
params: step.params || {},
|
||||
actor,
|
||||
verify: Boolean(verify),
|
||||
}
|
||||
|
||||
let raw
|
||||
try {
|
||||
raw = await withDeadline(() => action.perform(envelope), action.budgetMs, action.id)
|
||||
} catch (err) {
|
||||
// A module should not throw, and if one does it is a transient failure rather
|
||||
// than a crashed tick — announceWorker's posture with its legs, and the
|
||||
// reason one bad module cannot stop every other run on the deployment.
|
||||
log.warn('event action threw', { action: action.id, run: run.id, step: step.id, message: err.message })
|
||||
return { outcome: 'retry', error: err.message }
|
||||
}
|
||||
|
||||
const classification = classify(raw, action.id)
|
||||
if (step.action_version && action.version !== step.action_version) {
|
||||
// Not a refusal: the step was authored against an older declaration and the
|
||||
// module has moved on. The editor is where that becomes a warning (§F); here
|
||||
// it is recorded, so a run that behaved oddly can be explained afterwards by
|
||||
// reading the log rather than by guessing.
|
||||
classification.actionVersionDrift = { authored: step.action_version, registered: action.version }
|
||||
}
|
||||
return classification
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
dispatchStep,
|
||||
classify,
|
||||
withDeadline,
|
||||
safeDetail,
|
||||
safeRetryAfter,
|
||||
OUTCOMES,
|
||||
MAX_HOLD_SECONDS,
|
||||
MAX_DETAIL_BYTES,
|
||||
MIN_RETRY_AFTER_MS,
|
||||
MAX_RETRY_AFTER_MS,
|
||||
}
|
||||
244
server/src/events/gates.js
Normal file
244
server/src/events/gates.js
Normal file
@@ -0,0 +1,244 @@
|
||||
// ── Phase advance gates: the emit-path observer, and the words for the panel ─
|
||||
//
|
||||
// EVENTS.md §E and § Observability, and Phase 5 of EVENTS_PLAN.md. Two things
|
||||
// live here because they are two halves of one claim — that an operator can
|
||||
// answer *"why didn't phase 3 start?"* without reading a server log:
|
||||
//
|
||||
// `observe(event)` — the trigger stream's other subscriber. Beside
|
||||
// `engine.dispatch`, on the same seam, with the same
|
||||
// fire-and-forget posture.
|
||||
// `describe(gate)` — the same gate rendered in the condition builder's own
|
||||
// words, which is what the diagnosis panel shows.
|
||||
//
|
||||
// **Why the counting happens here and not on the runner's tick.** A gate that
|
||||
// waits for three boss spawns is counting things that happen *between* ticks. A
|
||||
// poller cannot count them: fifteen seconds after the third spawn there is
|
||||
// nothing left to observe, and a tally kept in a process's memory is a tally a
|
||||
// restart silently sets back to zero — with the phase then waiting for three
|
||||
// more of something that already happened. So the emit path writes, and the tick
|
||||
// reads. That division is the whole design of this file.
|
||||
//
|
||||
// **The cost of that, and its bound.** Every game event of every trigger some
|
||||
// run is waiting on costs one indexed lookup, and the common answer is zero
|
||||
// rows. Only when a gate is open does anything else happen, and then it is one
|
||||
// UPDATE per open gate — bounded by how many runs can be waiting on one trigger
|
||||
// at once, which is bounded by how many runs exist.
|
||||
//
|
||||
// **The words are rendered here, on the server, not in the client.** The panel's
|
||||
// entire value is that it reads the way the condition builder reads — `gte` as
|
||||
// *"is at least"*, `present` as *"is present"* — and those labels are defined in
|
||||
// `engagement/conditions.js`. A renderer in the browser would be a second
|
||||
// implementation of a grammar the server owns, and the first operator to meet a
|
||||
// clause it spelled differently would be the operator diagnosing a stalled run
|
||||
// at two in the morning.
|
||||
|
||||
const gatesDb = require('../model/events/eventPhaseGates.db')
|
||||
const runsDb = require('../model/events/eventRuns.db')
|
||||
const logDb = require('../model/events/eventRunLog.db')
|
||||
const conditions = require('../engagement/conditions')
|
||||
const log = require('../utils/logger')('event-gates')
|
||||
|
||||
// How long an `on` gate may wait before the run is called `stalled` (§E's third
|
||||
// health value, which nothing had ever written before this phase). It is a
|
||||
// VISIBILITY threshold and not a timeout: nothing advances, nothing fails, and
|
||||
// the operator decides. An hour is long enough that a champion spawn nobody has
|
||||
// killed yet is not an alarm, and short enough that a run which will wait for
|
||||
// ever is on the screen inside one shift.
|
||||
//
|
||||
// It does not apply to an `after` gate. A phase waiting out six hours it was
|
||||
// authored to wait is not stalled, it is working, and health that said otherwise
|
||||
// would train an operator to ignore it.
|
||||
const STALL_MS = Number(process.env.EVENT_PHASE_STALL_MS) || 60 * 60 * 1000
|
||||
|
||||
/** Every variable a condition tree names, in the order it names them. */
|
||||
function variablesIn(node, out = []) {
|
||||
if (!node || typeof node !== 'object') return out
|
||||
if (Array.isArray(node.nodes)) {
|
||||
node.nodes.forEach((child) => variablesIn(child, out))
|
||||
return out
|
||||
}
|
||||
if (node.variable && !out.includes(node.variable)) out.push(node.variable)
|
||||
return out
|
||||
}
|
||||
|
||||
const quote = (v) => (typeof v === 'string' ? `"${v}"` : String(v))
|
||||
|
||||
/**
|
||||
* One condition tree as a sentence, using the grammar's own operator labels.
|
||||
*
|
||||
* `null` answers null rather than "always": the caller renders *"on any
|
||||
* `uo.champ.boss_up`"* for a gate with no predicate, and a phrase saying
|
||||
* "everything is true" would be a clause an operator has to read past.
|
||||
*/
|
||||
function phrase(node) {
|
||||
if (!node || typeof node !== 'object') return null
|
||||
if (node.op === 'not') {
|
||||
const inner = phrase((node.nodes || [])[0])
|
||||
return inner ? `not (${inner})` : null
|
||||
}
|
||||
if (node.op === 'and' || node.op === 'or') {
|
||||
const parts = (node.nodes || []).map(phrase).filter(Boolean)
|
||||
if (!parts.length) return null
|
||||
// Parenthesised only where it changes the reading. A flat `and` of three
|
||||
// comparisons is a sentence; the same three wrapped in brackets is a
|
||||
// diagnosis an operator has to parse rather than read.
|
||||
const joined = parts.map((p) => (p.includes(' or ') || p.includes(' and ') ? `(${p})` : p))
|
||||
return joined.join(node.op === 'and' ? ' and ' : ' or ')
|
||||
}
|
||||
const operator = conditions.OPERATORS[node.cmp]
|
||||
if (!operator) return null
|
||||
if (operator.arity === 0) return `${node.variable} ${operator.label}`
|
||||
if (operator.arity === 'list') return `${node.variable} ${operator.label} ${node.value.map(quote).join(', ')}`
|
||||
return `${node.variable} ${operator.label} ${quote(node.value)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The shape the run console renders — a gate as an operator reads it.
|
||||
*
|
||||
* Derived rather than stored, every field of it. `stalled` in particular is a
|
||||
* comparison against the clock and not a column: a threshold that had been
|
||||
* written into a row at entry could not be changed by an operator raising
|
||||
* `EVENT_PHASE_STALL_MS`, and one written at the moment of stalling would be a
|
||||
* fourth writer on a row two already share.
|
||||
*/
|
||||
function describe(gate, now = new Date()) {
|
||||
if (!gate) return null
|
||||
const since = new Date(gate.entered_at)
|
||||
const satisfied = Boolean(gate.satisfied_at)
|
||||
// **A satisfied gate's clock stops when it was satisfied**, not at read time.
|
||||
// Live it answers "how long has this phase been waiting"; afterwards it
|
||||
// answers "how long did it wait", and those are the same number only while it
|
||||
// is still waiting. The walk caught it disagreeing with `phase.advanced`'s
|
||||
// own `waitedSeconds` by the age of the screen — 139s beside a logged 121.
|
||||
const until = satisfied ? new Date(gate.satisfied_at) : now
|
||||
const elapsedSeconds = Math.max(0, Math.round((until.getTime() - since.getTime()) / 1000))
|
||||
|
||||
return {
|
||||
phase: gate.phase,
|
||||
kind: gate.kind,
|
||||
satisfied,
|
||||
satisfiedAt: gate.satisfied_at || null,
|
||||
satisfiedBy: gate.satisfied_by || null,
|
||||
since: gate.entered_at,
|
||||
elapsedSeconds,
|
||||
// An `after` gate is never stalled; an `on` gate is stalled once it has
|
||||
// waited past the threshold and not before.
|
||||
stalled: !satisfied && gate.kind === 'on' && now.getTime() - since.getTime() >= STALL_MS,
|
||||
...(gate.kind === 'after'
|
||||
? { after: gate.after_seconds, dueAt: gate.due_at }
|
||||
: {
|
||||
waitingOn: gate.trigger_id,
|
||||
where: phrase(gate.conditions),
|
||||
seen: gate.tally,
|
||||
needed: gate.needed,
|
||||
lastEvent: gate.last_event,
|
||||
lastEventAt: gate.last_event_at || null,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The trigger stream's second subscriber.
|
||||
*
|
||||
* Called from `engagementEmit.emit` beside `engine.dispatch` and, like it, never
|
||||
* awaited and never allowed to reject. An emit is a module saying something
|
||||
* happened in the game; whether some event run cared is core's business, and a
|
||||
* failure of core's must not become the module's control flow.
|
||||
*
|
||||
* **Every firing is logged, matched or not** (§ Observability: "trigger
|
||||
* evaluations that did and did not satisfy a condition"). The near miss is the
|
||||
* more valuable of the two on the night: *"the boss did spawn, in Britain"* and
|
||||
* *"no boss has spawned"* are different answers, and without this line they look
|
||||
* identical on the screen.
|
||||
*/
|
||||
async function observe(event) {
|
||||
const summary = { gates: 0, counted: 0, satisfied: 0 }
|
||||
try {
|
||||
const open = await gatesDb.openForTrigger(event.triggerId)
|
||||
summary.gates = open.length
|
||||
if (!open.length) return summary
|
||||
|
||||
const now = new Date()
|
||||
for (const gate of open) {
|
||||
const matched = conditions.evaluate(gate.conditions, event.data || {})
|
||||
|
||||
// ONLY the variables the condition names, never the payload. This row is
|
||||
// read back onto an admin screen, and a copy of a whole game event's data
|
||||
// is a second copy of exactly the content `engagement_sends` is careful
|
||||
// not to keep. The named variables are also the useful ones: they are the
|
||||
// reason it did or did not count.
|
||||
const named = variablesIn(gate.conditions)
|
||||
const lastEvent = {
|
||||
trigger: event.triggerId,
|
||||
at: event.occurredAt,
|
||||
subject: event.subject ?? null,
|
||||
matched,
|
||||
variables: Object.fromEntries(
|
||||
named.filter((n) => event.data && n in event.data).map((n) => [n, event.data[n]]),
|
||||
),
|
||||
}
|
||||
|
||||
const result = matched
|
||||
? await gatesDb.count(gate.id, { lastEvent, now })
|
||||
: { counted: false, satisfied: false, tally: gate.tally, near: await gatesDb.noteNearMiss(gate.id, { lastEvent, now }) }
|
||||
|
||||
if (matched && result.counted) summary.counted += 1
|
||||
if (result.satisfied) summary.satisfied += 1
|
||||
|
||||
await logDb.write({
|
||||
runId: gate.run_id,
|
||||
kind: 'condition.evaluated',
|
||||
phase: gate.phase,
|
||||
detail: {
|
||||
trigger: event.triggerId,
|
||||
matched,
|
||||
// The tally the DATABASE holds after the write, not the one this
|
||||
// process predicted — two emits arriving together each read the same
|
||||
// stale number, and only one of them is right about what it became.
|
||||
seen: result.tally ?? gate.tally,
|
||||
needed: gate.needed,
|
||||
satisfied: result.satisfied,
|
||||
variables: lastEvent.variables,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (summary.counted || summary.satisfied) {
|
||||
log.info('phase gate advanced', { trigger: event.triggerId, ...summary })
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('gate observation failed', { trigger: event.triggerId, message: err.message })
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this phase's gate open — and if it is not, why not?
|
||||
*
|
||||
* The runner's question, and the one place an `after` gate is closed: its
|
||||
* deadline passing is not an event anything emits, so the tick that finds it
|
||||
* past `due_at` is what records it. Doing that here rather than in the runner
|
||||
* keeps `satisfied_by` a fact one file writes.
|
||||
*
|
||||
* Answers `{ open, gate }`. `open: true` with a null gate is a phase with no
|
||||
* advance condition at all — every phase before this one, and most after it.
|
||||
*/
|
||||
async function check(runId, phase, now = new Date()) {
|
||||
const gate = await gatesDb.forPhase(runId, phase)
|
||||
if (!gate) return { open: true, gate: null }
|
||||
if (gate.satisfied_at) return { open: true, gate }
|
||||
|
||||
if (gate.kind === 'after' && gate.due_at && new Date(gate.due_at) <= now) {
|
||||
if (await gatesDb.satisfy(gate.id, 'elapsed', { now })) {
|
||||
return { open: true, gate: await gatesDb.byId(gate.id) }
|
||||
}
|
||||
// Somebody else closed it between the read and the write — a force, or the
|
||||
// tick that overran into this one. Either way it is open, and whichever
|
||||
// reason won is the one in the row.
|
||||
return { open: true, gate: await gatesDb.byId(gate.id) }
|
||||
}
|
||||
|
||||
return { open: false, gate }
|
||||
}
|
||||
|
||||
module.exports = { observe, check, describe, phrase, variablesIn, STALL_MS }
|
||||
224
server/src/events/ledger.js
Normal file
224
server/src/events/ledger.js
Normal file
@@ -0,0 +1,224 @@
|
||||
// ── Recording what a run changed in the world ──────────────────────────────
|
||||
//
|
||||
// EVENTS.md §D and §L, and Phase 8 of EVENTS_PLAN.md. The write half of the
|
||||
// resource ledger; `events/cleanup.js` is the read-and-undo half.
|
||||
//
|
||||
// **Rule 1 is the whole reason this file is not two lines inside `drainStep`.**
|
||||
// A resource is recorded BEFORE it is confirmed. The obstacle is that a spawn's
|
||||
// serial does not exist until the module answers, so there is nothing to write a
|
||||
// row about yet — which is why what goes in before the dispatch is a PLACEHOLDER
|
||||
// keyed by the step's idempotency key rather than by the object:
|
||||
//
|
||||
// pre-dispatch INSERT pending { kind: '@step', ref: <idempotency key> }
|
||||
// answer INSERT confirmed { kind: 'creature', ref: '0x40001234' } × n
|
||||
// resolve the placeholder
|
||||
// ack lost the placeholder is still `pending`
|
||||
// cleanup revert({ idempotencyKey, resources: [] })
|
||||
//
|
||||
// That last line is why §F's `revert({ runId, resources, idempotencyKey })` takes
|
||||
// the key at all. A module that half-ran and never answered is reachable by its
|
||||
// key and by nothing else, and Phase 11's plugin-side key ledger is what makes
|
||||
// answering it exact. Until then the contract is still honest, because §L
|
||||
// requires reverting something that does not exist to be a SUCCESS.
|
||||
//
|
||||
// **Recording is idempotent, and the database is what makes it so.** A retry
|
||||
// re-dispatches the same idempotency key, and a module that answers with the same
|
||||
// resources twice must not produce two rows. `uq_evres_target` refuses the second
|
||||
// insert, and this file reads that refusal as "already recorded" rather than as an
|
||||
// error — the same posture `materialisePhase`'s INSERT IGNORE takes.
|
||||
//
|
||||
// **A lease does not use the placeholder.** Its target is knowable before the
|
||||
// dispatch — it is the lease id the step names — so `core.lease` reserves the
|
||||
// real row first, which is both a stronger form of rule 1 and the only place the
|
||||
// two-events-one-target refusal can happen before the world has been written to.
|
||||
|
||||
const resourcesDb = require('../model/events/eventRunResources.db')
|
||||
const runsDb = require('../model/events/eventRuns.db')
|
||||
const registries = require('../modules/registries')
|
||||
const log = require('../utils/logger')('events')
|
||||
|
||||
// Which reversible classes get a pre-dispatch placeholder. `none` is gone once
|
||||
// done and `self` undoes itself, so neither has anything core could come back
|
||||
// for; `override` reserves its own target instead (see the header). That leaves
|
||||
// `ledger` — the class that declares `revert()`, which is exactly the class whose
|
||||
// refs core cannot know until the module speaks.
|
||||
const PLACEHOLDER_CLASSES = ['ledger']
|
||||
|
||||
// A resource `kind` may be anything a module likes except core's own reserved
|
||||
// one. Bounded to the column, and refused rather than truncated: a truncated ref
|
||||
// is a cleanup call naming the wrong object.
|
||||
const MAX_KIND = 64
|
||||
const MAX_REF = 190
|
||||
const MAX_MEMBER_KEY = 190
|
||||
|
||||
/** Does this action produce anything core will have to come back for? */
|
||||
const ledgers = (action) => action && (action.reversible === 'ledger' || action.reversible === 'override')
|
||||
|
||||
/**
|
||||
* Turn one entry of a module's `resources` array into a row, or say why not.
|
||||
*
|
||||
* Every failure here is the module's mistake rather than the world's, so none of
|
||||
* them is a retry: a badly shaped resource will be just as badly shaped on the
|
||||
* second attempt. They are logged and dropped, and the step still counts as done
|
||||
* — because it IS done; something happened in the world, and refusing to record
|
||||
* it would be the one outcome worse than recording it imperfectly.
|
||||
*/
|
||||
function normalise(entry, actionId) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
return { ok: false, reason: `${actionId} reported a resource that is not an object` }
|
||||
}
|
||||
const kind = String(entry.kind || '')
|
||||
const ref = String(entry.ref === undefined || entry.ref === null ? '' : entry.ref)
|
||||
if (!kind || kind.length > MAX_KIND) {
|
||||
return { ok: false, reason: `${actionId} reported a resource with a bad kind "${entry.kind}"` }
|
||||
}
|
||||
if (kind === resourcesDb.STEP_KIND) {
|
||||
// Core's own. A module that could write one would be a module that could make
|
||||
// its own step's placeholder look resolved.
|
||||
return { ok: false, reason: `${actionId} reported a resource of the reserved kind "${kind}"` }
|
||||
}
|
||||
if (!ref || ref.length > MAX_REF) {
|
||||
return { ok: false, reason: `${actionId} reported a resource with a bad ref "${entry.ref}"` }
|
||||
}
|
||||
const memberKey = entry.memberKey === undefined || entry.memberKey === null ? null : String(entry.memberKey)
|
||||
if (memberKey !== null && memberKey.length > MAX_MEMBER_KEY) {
|
||||
return { ok: false, reason: `${actionId} reported a resource with an over-long memberKey` }
|
||||
}
|
||||
|
||||
let leaseUntil = null
|
||||
if (entry.until !== undefined && entry.until !== null) {
|
||||
const at = new Date(entry.until)
|
||||
if (Number.isNaN(at.getTime())) {
|
||||
return { ok: false, reason: `${actionId} reported a resource with a bad until "${entry.until}"` }
|
||||
}
|
||||
leaseUntil = at
|
||||
}
|
||||
|
||||
// A borrowed value must name a lease core knows how to give back. Core restores
|
||||
// an `override` through the lease registry — that is the split §F draws — so a
|
||||
// ref naming nothing registered is a resource core would be recording with no
|
||||
// way to undo it, which is the promise rule 2 exists to stop core making.
|
||||
// Through the ref parser: a targeted lease's ref carries its target after a
|
||||
// `#` (Phase 12b), and matching the whole string against the registry would
|
||||
// reject a lease that IS registered.
|
||||
if (kind === 'override' && !registries.eventLeaseForRef(ref)) {
|
||||
return { ok: false, reason: `${actionId} reported a lease "${ref}" no module registers` }
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
row: {
|
||||
kind,
|
||||
ref,
|
||||
payload: entry.payload === undefined ? null : entry.payload,
|
||||
leaseUntil,
|
||||
memberKey,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the pre-dispatch placeholder for a step that is about to change the
|
||||
* world. Answers the row id, or null when this action ledgers nothing.
|
||||
*
|
||||
* **A duplicate is not a failure.** A retry re-uses its step's idempotency key, so
|
||||
* the second attempt's placeholder collides with the first's — and finding it
|
||||
* already there is the correct answer, not an error. The existing row is reused.
|
||||
*/
|
||||
async function reserveStep(run, step, action) {
|
||||
if (!ledgers(action) || !PLACEHOLDER_CLASSES.includes(action.reversible)) return null
|
||||
|
||||
const owner = action.owner || 'core'
|
||||
const reserved = await resourcesDb.reserve({
|
||||
runId: run.id,
|
||||
stepId: step.id,
|
||||
owner,
|
||||
kind: resourcesDb.STEP_KIND,
|
||||
ref: step.idempotency_key,
|
||||
payload: { action: action.id, phase: step.phase, seq: step.seq },
|
||||
})
|
||||
if (reserved.ok) {
|
||||
await markRunDirty(run.id)
|
||||
return reserved.id
|
||||
}
|
||||
// The only way a '@step' row collides is with this step's own earlier attempt,
|
||||
// because an idempotency key is minted once per step and never varies by
|
||||
// attempt (§E). Reuse it.
|
||||
const existing = await resourcesDb.findByTarget(owner, resourcesDb.STEP_KIND, step.idempotency_key)
|
||||
return existing ? existing.id : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Record what a module said it made, and close out the placeholder.
|
||||
*
|
||||
* Answers `{ recorded, rejected }` — how many rows went in, and the reasons any
|
||||
* entry was dropped. Never throws: a step that changed the world has changed it,
|
||||
* and a ledger that threw would turn a bookkeeping problem into a failed step and
|
||||
* then into a retry of a world write that already happened.
|
||||
*/
|
||||
async function recordAnswer({ run, step, action, placeholderId, resources }) {
|
||||
const out = { recorded: 0, rejected: [] }
|
||||
if (!ledgers(action)) return out
|
||||
|
||||
const owner = action.owner || 'core'
|
||||
const list = Array.isArray(resources) ? resources : []
|
||||
|
||||
for (const entry of list) {
|
||||
const parsed = normalise(entry, action.id)
|
||||
if (!parsed.ok) {
|
||||
out.rejected.push(parsed.reason)
|
||||
log.warn('event resource rejected', { run: run.id, step: step.id, reason: parsed.reason })
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const reserved = await resourcesDb.reserve({
|
||||
runId: run.id,
|
||||
stepId: step.id,
|
||||
owner,
|
||||
kind: parsed.row.kind,
|
||||
ref: parsed.row.ref,
|
||||
payload: parsed.row.payload,
|
||||
leaseUntil: parsed.row.leaseUntil,
|
||||
memberKey: parsed.row.memberKey,
|
||||
})
|
||||
if (!reserved.ok) {
|
||||
// Already ledgered — by this step's own earlier attempt, or (a module bug
|
||||
// rather than a race) by another run that still holds the same target.
|
||||
// Either way there is a live row for it and a second would be the double
|
||||
// cleanup the unique key exists to prevent.
|
||||
if (reserved.holder && reserved.holder.run_id !== run.id) {
|
||||
out.rejected.push(`${parsed.row.kind} "${parsed.row.ref}" is already held by run ${reserved.holder.run_id}`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
await resourcesDb.confirm(reserved.id)
|
||||
out.recorded += 1
|
||||
} catch (err) {
|
||||
// Bookkeeping must not become the step's control flow.
|
||||
out.rejected.push(err.message)
|
||||
log.error('event resource insert failed', { run: run.id, step: step.id, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
if (out.recorded > 0) await markRunDirty(run.id)
|
||||
|
||||
// The placeholder's job is over the moment the real rows exist. It is resolved
|
||||
// even when the module reported nothing at all — an action that ledgers and
|
||||
// then answers `ok` with an empty list is saying "I made nothing", and holding
|
||||
// its placeholder open would make cleanup call `revert()` for a step that has
|
||||
// nothing to give back on every terminal path for ever.
|
||||
if (placeholderId) await resourcesDb.resolvePlaceholder(placeholderId)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* There is now something to clean up. Idempotent and guarded, so it can never
|
||||
* walk a run back from `complete` or `incomplete` to `pending` — only a human's
|
||||
* cleanup does that, and it does it deliberately.
|
||||
*/
|
||||
async function markRunDirty(runId) {
|
||||
await runsDb.setCleanupStatus(runId, 'pending', ['not_required'])
|
||||
}
|
||||
|
||||
module.exports = { ledgers, normalise, reserveStep, recordAnswer, markRunDirty, PLACEHOLDER_CLASSES }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user