Compare commits
56 Commits
feat/asset
...
62eaa7693d
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 655fbf3f69 |
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'
|
||||
@@ -90,6 +92,10 @@ export default function App() {
|
||||
the provider hooks are called once for the whole app rather than
|
||||
once per screen. */}
|
||||
<ModuleFeaturesProvider>
|
||||
{/* The public demo's strip and refusal toast (DEMO_MODE.md §3.1, §6).
|
||||
Both render nothing unless the server says this is the demo. */}
|
||||
<DemoBanner />
|
||||
<DemoToast />
|
||||
<Routes>
|
||||
{/* Landing hero — always public, even in maintenance mode. The hero is
|
||||
itself the pre-launch "coming soon" page, so it sits outside the
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Thin fetch wrapper. Always sends cookies (httpOnly JWT) and talks to the
|
||||
// same-origin API (/api/v1) — proxied to the Express server in dev.
|
||||
import { reportIfDemoLocked } from '../lib/demoNotice.js'
|
||||
|
||||
const BASE = '/api/v1'
|
||||
|
||||
// Prefix a non-empty query string with "?" (and nothing when it is empty), so
|
||||
@@ -28,6 +30,9 @@ async function req(path, { method = 'GET', body, headers, raw } = {}) {
|
||||
const text = await res.text()
|
||||
const data = text ? safeParse(text) : null
|
||||
if (!res.ok) {
|
||||
// The public demo's lock (DEMO_MODE.md §3.1): shown once, as a toast, for
|
||||
// every caller. The ApiError below still goes to the form as usual.
|
||||
reportIfDemoLocked(data)
|
||||
const message = (data && data.message) || res.statusText || 'Request failed'
|
||||
throw new ApiError(res.status, message, data)
|
||||
}
|
||||
@@ -133,8 +138,12 @@ export const api = {
|
||||
totpDisable: (code) => req('/auth/me/account/totp/disable', { method: 'POST', body: { code } }),
|
||||
// Linked SSO identities (self-service). Linking starts at /auth/sso/:id/link.
|
||||
myIdentities: () => req('/auth/me/account/identities'),
|
||||
unlinkIdentity: (provider) =>
|
||||
req(`/auth/me/account/identities/${encodeURIComponent(provider)}`, { method: 'DELETE' }),
|
||||
// With an identity id, only that one goes (a person may hold several Steam accounts).
|
||||
unlinkIdentity: (provider, id) =>
|
||||
req(
|
||||
`/auth/me/account/identities/${encodeURIComponent(provider)}${id != null ? `?id=${encodeURIComponent(id)}` : ''}`,
|
||||
{ method: 'DELETE' },
|
||||
),
|
||||
// Recovery (backup) codes. status → remaining count; generate → a fresh set,
|
||||
// returned ONCE (password step-up for accounts that have a password).
|
||||
recoveryCodesStatus: () => req('/auth/me/account/recovery-codes/status'),
|
||||
@@ -518,6 +527,10 @@ export const api = {
|
||||
const qs = q ? `?${new URLSearchParams({ q }).toString()}` : ''
|
||||
return req(`/admin/events/catalog/options/${encodeURIComponent(sourceId)}${qs}`)
|
||||
},
|
||||
// MODULE_API 1.13.0. Fill an event template in; the answer is a draft the
|
||||
// editor opens, and nothing is saved until the author saves it.
|
||||
buildEventTemplate: (templateId, inputs) =>
|
||||
req(`/admin/events/catalog/templates/${encodeURIComponent(templateId)}`, { method: 'POST', body: { inputs } }),
|
||||
// Phase 6. The dry run is admin+editor: it dispatches nothing, and the author
|
||||
// who wrote the definition is who should be able to price it against the caps
|
||||
// before asking an admin to publish it. A report with findings comes back 200
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
47
client/src/components/DemoToast.jsx
Normal file
47
client/src/components/DemoToast.jsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { onDemoLocked } from '../lib/demoNotice.js'
|
||||
|
||||
// The one toast for a write the public demo refused (DEMO_MODE.md §3.1). Fed
|
||||
// by api/client.js through lib/demoNotice.js, so every screen — and every
|
||||
// installed module's screens — gets it without handling the code itself. On a
|
||||
// real shard nothing is ever refused that way, so it never appears.
|
||||
const SHOW_MS = 5000
|
||||
|
||||
export default function DemoToast() {
|
||||
const [message, setMessage] = useState(null)
|
||||
|
||||
useEffect(() => onDemoLocked((text) => setMessage(text)), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!message) return undefined
|
||||
const t = setTimeout(() => setMessage(null), SHOW_MS)
|
||||
return () => clearTimeout(t)
|
||||
}, [message])
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
style={{ position: 'fixed', left: 16, right: 16, bottom: 20, zIndex: 1000, display: 'flex', justifyContent: 'center', pointerEvents: 'none' }}
|
||||
>
|
||||
{message && (
|
||||
<div
|
||||
className="sans"
|
||||
style={{
|
||||
maxWidth: 480,
|
||||
padding: '11px 16px',
|
||||
borderRadius: 10,
|
||||
fontSize: '0.88rem',
|
||||
color: 'var(--head)',
|
||||
background: 'var(--bg-deep)',
|
||||
border: '1px solid var(--accent)',
|
||||
boxShadow: '0 12px 32px rgba(0,0,0,0.45)',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
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`
|
||||
}
|
||||
@@ -133,7 +133,9 @@ const nextPhaseKey = (phases) => {
|
||||
export function blankStep(action) {
|
||||
const params = {}
|
||||
for (const p of action?.params || []) {
|
||||
if (p.required || p.example !== undefined) params[p.name] = p.example
|
||||
// A list's example is one value (MODULE_API 1.14.0), so the step starts
|
||||
// holding that one.
|
||||
if (p.required || p.example !== undefined) params[p.name] = p.multiple ? [p.example] : p.example
|
||||
}
|
||||
return {
|
||||
actionId: action?.id || '',
|
||||
@@ -219,6 +221,89 @@ export function whereFormFrom(where) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Starting from a trigger (Rust PLAN_REDESIGNS §14.3.12, D370) ───────────
|
||||
//
|
||||
// A start is a phase gate's trigger and condition plus a cooldown, so the form
|
||||
// holds it in the gate's own fields — and the same `WhereBuilder` draws it. The
|
||||
// server (`events/spec.js`) decides; this only carries the fields.
|
||||
|
||||
export const DEFAULT_COOLDOWN = '30m'
|
||||
|
||||
// Core's own lifecycle triggers cannot start a run; the editor does not offer
|
||||
// them, and the server refuses them if posted anyway.
|
||||
export const isStartableTrigger = (trigger) => !String(trigger?.id || '').startsWith('event.')
|
||||
|
||||
export function blankStart() {
|
||||
return { on: '', cooldown: DEFAULT_COOLDOWN, dormant: false, ...blankWhere() }
|
||||
}
|
||||
|
||||
/** The stored `starts`, as the form's rows. */
|
||||
export function startsFormFrom(starts) {
|
||||
return (starts || []).map((s) => ({
|
||||
...blankStart(),
|
||||
on: s.on || '',
|
||||
cooldown: s.cooldown ?? DEFAULT_COOLDOWN,
|
||||
dormant: Boolean(s.dormant),
|
||||
...whereFormFrom(s.where),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* The form's starts as the spec's list. A row with no trigger chosen is dropped
|
||||
* rather than posted — it is a half-added row, not a start the author wrote.
|
||||
*/
|
||||
export function startsPayload(rows, errors, triggersById = new Map()) {
|
||||
const out = []
|
||||
;(rows || []).forEach((row, i) => {
|
||||
if (!row.on) return
|
||||
const gate = advancePayload(
|
||||
{ ...row, kind: 'on' },
|
||||
`Start ${i + 1}`,
|
||||
errors,
|
||||
triggersById.get(row.on)?.variables || [],
|
||||
)
|
||||
const entry = { on: row.on, cooldown: String(row.cooldown ?? '').trim() || DEFAULT_COOLDOWN }
|
||||
if (gate?.where) entry.where = gate.where
|
||||
out.push(entry)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a run exists, when a game firing started it — the console's sentence.
|
||||
* Null for a run started by hand or by the schedule.
|
||||
*/
|
||||
export function describeOrigin(origin) {
|
||||
if (!origin || origin.kind !== 'trigger') return null
|
||||
const vars = Object.entries(origin.variables || {})
|
||||
.filter(([k]) => k !== '_truncated')
|
||||
.map(([k, v]) => `${k} ${typeof v === 'string' ? v : JSON.stringify(v)}`)
|
||||
const label = origin.label || origin.triggerId
|
||||
return [
|
||||
`Started because “${label}” fired`,
|
||||
origin.matched ? ` (matching ${origin.matched})` : '',
|
||||
vars.length ? `: ${vars.join(' · ')}` : '',
|
||||
].join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor's line about what its trigger starts have done — or null when a
|
||||
* trigger has never tried. "2 firings ignored while running, 1 in its cooldown".
|
||||
*/
|
||||
export function describeTriggerStarts(state) {
|
||||
if (!state) return null
|
||||
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`
|
||||
const ignored = [
|
||||
state.ignoredRunning ? `${plural(state.ignoredRunning, 'firing')} ignored while a run was going` : null,
|
||||
state.ignoredCooldown ? `${plural(state.ignoredCooldown, 'firing')} ignored in the cooldown` : null,
|
||||
state.ignoredUnverified
|
||||
? `${plural(state.ignoredUnverified, 'firing')} ignored because the published version is not verified`
|
||||
: null,
|
||||
].filter(Boolean)
|
||||
const last = state.lastRunId ? `Last started run #${state.lastRunId}` : 'No run started by a trigger yet'
|
||||
return ignored.length ? `${last}; since then ${ignored.join(', ')}.` : `${last}.`
|
||||
}
|
||||
|
||||
/** The editor's working state, from what `GET /admin/events/:id` returned. */
|
||||
export function formFromDefinition(event) {
|
||||
const spec = event?.spec || {}
|
||||
@@ -241,6 +326,7 @@ export function formFromDefinition(event) {
|
||||
// `false`, and `||` would quietly re-list it on the next save.
|
||||
listed: event?.listed ?? true,
|
||||
...scheduleFormFrom(spec.schedule),
|
||||
starts: startsFormFrom(spec.starts),
|
||||
phases: (spec.phases || []).map((p) => ({
|
||||
key: p.key || '',
|
||||
label: p.label || '',
|
||||
@@ -345,6 +431,8 @@ export function payloadFromForm(form, { triggersById = new Map() } = {}) {
|
||||
}
|
||||
})
|
||||
|
||||
const starts = startsPayload(form.starts, errors, triggersById)
|
||||
|
||||
if (errors.length) return { ok: false, errors }
|
||||
|
||||
return {
|
||||
@@ -361,7 +449,10 @@ export function payloadFromForm(form, { triggersById = new Map() } = {}) {
|
||||
timezone: form.timezone,
|
||||
listed: Boolean(form.listed),
|
||||
listed: Boolean(form.listed),
|
||||
spec: { schedule: scheduleFromForm(form), phases },
|
||||
// `starts` only when there is one, as the server stores it (D370).
|
||||
spec: starts.length
|
||||
? { schedule: scheduleFromForm(form), starts, phases }
|
||||
: { schedule: scheduleFromForm(form), phases },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -545,7 +636,9 @@ export const PARAM_JSON = 'json'
|
||||
* is what the author needs to see — and a form that dropped it would post a
|
||||
* step that saves cleanly having deleted something they typed.
|
||||
* • **a value no single control can hold** — an object or an array against a
|
||||
* scalar declaration.
|
||||
* scalar declaration, or anything but a flat list against a `multiple` one
|
||||
* (MODULE_API 1.14.0). A bare value there is a list of one, as the server
|
||||
* reads it, and the multi-select shows it as one chip.
|
||||
*/
|
||||
export function paramsRenderable(action, params) {
|
||||
if (!action) return { ok: false, reason: 'the module that registered this action is not installed' }
|
||||
@@ -554,6 +647,13 @@ export function paramsRenderable(action, params) {
|
||||
if (!declared.has(name)) {
|
||||
return { ok: false, reason: `this step carries "${name}", which ${action.id} does not declare` }
|
||||
}
|
||||
if (declared.get(name).multiple) {
|
||||
const items = Array.isArray(value) ? value : [value]
|
||||
if (items.some((v) => v !== null && typeof v === 'object')) {
|
||||
return { ok: false, reason: `"${name}" holds a structure, which a list of values cannot hold` }
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return { ok: false, reason: `"${name}" holds a ${Array.isArray(value) ? 'list' : 'structure'}, which no single field can hold` }
|
||||
}
|
||||
@@ -625,6 +725,227 @@ export function setParam(step, name, raw, type) {
|
||||
*/
|
||||
export const datetimeInputValue = (value) => (typeof value === 'string' ? value.slice(0, 16) : '')
|
||||
|
||||
// ── A param that holds several values (MODULE_API 1.14.0, Rust PLAN_REDESIGNS §12.3) ──
|
||||
|
||||
/**
|
||||
* A `multiple` param's value as the list the multi-select draws: an array as
|
||||
* stored, a bare value as a list of one (the server's own reading), and nothing
|
||||
* as the empty list.
|
||||
*/
|
||||
export function listValue(value) {
|
||||
if (value === undefined || value === null || value === '') return []
|
||||
return Array.isArray(value) ? value : [value]
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a `multiple` param's whole list, and give back the whole box.
|
||||
*
|
||||
* Each item is coerced as `setParam` coerces one value, duplicates dropped
|
||||
* keeping the first, and **an empty list removes the key** — `setParam`'s rule,
|
||||
* for its reason: the server reads absent and `[]` alike, and "is required" is
|
||||
* the error a required list left empty should get.
|
||||
*/
|
||||
export function setParamList(step, name, items, type) {
|
||||
const parsed = parseParams(step?.paramsText)
|
||||
if (parsed.error) return step?.paramsText || '{}'
|
||||
const next = { ...parsed.params }
|
||||
const out = []
|
||||
for (const raw of items || []) {
|
||||
if (raw === '' || raw === undefined || raw === null) continue
|
||||
const value = coerceLiteral(type, raw)
|
||||
if (!out.some((v) => v === value)) out.push(value)
|
||||
}
|
||||
if (out.length) next[name] = out
|
||||
else delete next[name]
|
||||
return JSON.stringify(next, null, 2)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add one value to a list, or say why not. `{ list }` or `{ refused }`, so the
|
||||
* control can put the sentence where the click was rather than silently doing
|
||||
* nothing past `max`.
|
||||
*/
|
||||
export function addToList(list, value, max) {
|
||||
const text = String(value ?? '').trim()
|
||||
if (!text) return { list }
|
||||
if (list.some((v) => String(v) === text)) return { list }
|
||||
if (max && list.length >= max) return { refused: `This takes at most ${max} — remove one first.` }
|
||||
return { list: [...list, text] }
|
||||
}
|
||||
|
||||
/**
|
||||
* The options a filter box leaves, matched on value, label and group alike,
|
||||
* case-insensitively. The order is the source's own — a source sorts its rows
|
||||
* (§12.2) and the editor keeps it.
|
||||
*/
|
||||
export function filterOptions(options, term) {
|
||||
const t = String(term || '').trim().toLowerCase()
|
||||
if (!t) return options || []
|
||||
return (options || []).filter((o) =>
|
||||
[o.value, o.label, o.group].some((s) => String(s || '').toLowerCase().includes(t)))
|
||||
}
|
||||
|
||||
/**
|
||||
* One step's params as a line the run console can show: `name: value`, a list
|
||||
* joined by commas, anything else as its JSON. Shown rather than the raw object
|
||||
* so a list of ten permissions reads as ten names, not as brackets and quotes.
|
||||
*/
|
||||
export function describeParams(params) {
|
||||
if (!params || typeof params !== 'object') return ''
|
||||
return Object.entries(params)
|
||||
.map(([k, v]) => {
|
||||
if (Array.isArray(v) && v.every((x) => x === null || typeof x !== 'object')) return `${k}: ${v.join(', ')}`
|
||||
return `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`
|
||||
})
|
||||
.join(' · ')
|
||||
}
|
||||
|
||||
// ── The step editor's own rules (MODULE_API 1.13.0; Rust PLAN_REDESIGNS §2) ──
|
||||
//
|
||||
// What the controls need to decide and nothing the server re-decides: which
|
||||
// control a field gets, which required fields are empty, and whether a phase
|
||||
// gives a timed step back before its time is up. The server still refuses a
|
||||
// missing param by name — these exist so the form can say it first, in place.
|
||||
|
||||
/**
|
||||
* A boolean as the control holds it. A lease's value is a string param that the
|
||||
* lease reads as a boolean, so "true" and "false" are booleans here too.
|
||||
*/
|
||||
export function boolValue(value) {
|
||||
if (value === true || value === 'true') return true
|
||||
if (value === false || value === 'false') return false
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Should this source be drawn as a row of buttons? (§2.4)
|
||||
*
|
||||
* Two or three choices that fit on one line, from a source that answered and is
|
||||
* not searched. A dropdown of two hides the other choice behind a click.
|
||||
*/
|
||||
export function isSegmentable(entry) {
|
||||
return Boolean(
|
||||
entry && entry.state === 'ok' && !entry.searchable &&
|
||||
entry.options.length >= 2 && entry.options.length <= 3,
|
||||
)
|
||||
}
|
||||
|
||||
/** The note on the chosen option, if it carries one (§2.6). */
|
||||
export function noteFor(entry, value) {
|
||||
if (!entry || entry.state !== 'ok' || value === undefined || value === null || value === '') return null
|
||||
const chosen = entry.options.find((o) => o.value === String(value))
|
||||
return chosen?.note || null
|
||||
}
|
||||
|
||||
/**
|
||||
* The required params a step leaves empty, by name. `core.lease`'s `target` is
|
||||
* optional on the action and required by a targeted LEASE, so the lease is
|
||||
* asked too when there is one.
|
||||
*/
|
||||
export function missingParams(step, action, leasesById = new Map()) {
|
||||
if (!action) return []
|
||||
const parsed = parseParams(step?.paramsText)
|
||||
if (parsed.error) return []
|
||||
const empty = (v) => v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0)
|
||||
const missing = (action.params || []).filter((p) => p.required && empty(parsed.params[p.name])).map((p) => p.name)
|
||||
if (action.id === 'core.lease') {
|
||||
const lease = leasesById.get(parsed.params.lease)
|
||||
if (lease?.target && empty(parsed.params.target)) missing.push('target')
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
/** `30m`, `2h`, `90s`, `1d` → minutes. Null for anything the server would refuse. */
|
||||
export function afterMinutes(after) {
|
||||
const m = /^(\d{1,6})(s|m|h|d)$/.exec(String(after ?? '').trim())
|
||||
if (!m) return null
|
||||
const n = Number(m[1])
|
||||
return { s: n / 60, m: n, h: n * 60, d: n * 1440 }[m[2]]
|
||||
}
|
||||
|
||||
/** The longest declared duration in a step, in minutes; 0 for an untimed one. */
|
||||
export function stepMinutes(step, action) {
|
||||
const parsed = parseParams(step?.paramsText)
|
||||
if (parsed.error || !action) return 0
|
||||
let longest = 0
|
||||
for (const p of action.params || []) {
|
||||
if (p.duration !== 'minutes') continue
|
||||
const n = Number(parsed.params[p.name])
|
||||
if (Number.isFinite(n) && n > longest) longest = n
|
||||
}
|
||||
return longest
|
||||
}
|
||||
|
||||
/** The longest timed step in a phase, in minutes. */
|
||||
export const phaseMinutes = (phase, actionById) =>
|
||||
Math.max(0, ...(phase?.steps || []).map((s) => stepMinutes(s, actionById.get(s.actionId))))
|
||||
|
||||
/**
|
||||
* How long, at the least, the run goes on from the start of phase `pi`, in
|
||||
* minutes — or Infinity when a phase from there on waits for something in the
|
||||
* game, which may take any time at all.
|
||||
*
|
||||
* **Why the RUN and not the phase.** Teardown gives everything back when the run
|
||||
* ends, so a 10-minute hold in phase 1 of three is safe if phases 2 and 3 take
|
||||
* ten minutes between them. A phase with no advance rule ends when its steps do,
|
||||
* which is counted as no time: a floor, never a promise.
|
||||
*/
|
||||
export function runFloorMinutes(phases, pi) {
|
||||
let total = 0
|
||||
for (const phase of (phases || []).slice(pi)) {
|
||||
const kind = phase.advance?.kind || ''
|
||||
if (kind === 'on') return Infinity
|
||||
if (kind === 'after') total += afterMinutes(phase.advance.after) || 0
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/**
|
||||
* Does phase `pi` give a timed step back before its time? (§2.2, U-6)
|
||||
*
|
||||
* `null`, or `{ longest, floor }` in minutes. Only a run that can END sooner than
|
||||
* the longest hold counts: teardown is what gives a lease back, not the phase.
|
||||
*/
|
||||
export function timedPhaseShortfall(phases, pi, actionById) {
|
||||
const longest = phaseMinutes(phases?.[pi], actionById)
|
||||
if (!longest) return null
|
||||
const floor = runFloorMinutes(phases, pi)
|
||||
return floor < longest ? { longest, floor } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The phase's advance after a timed step was added or changed (§2.2).
|
||||
*
|
||||
* A phase with NO advance rule that would give the step back early gets
|
||||
* `after: '<longest>m'`, visibly, and the author may change it. A phase the
|
||||
* author already gave a rule keeps it: the warning under the phase says what it
|
||||
* costs, and overriding an author's choice is not this function's to do.
|
||||
*/
|
||||
export function guardedAdvance(phases, pi, actionById) {
|
||||
const phase = phases?.[pi]
|
||||
if (!phase || (phase.advance?.kind || '') !== '') return null
|
||||
const short = timedPhaseShortfall(phases, pi, actionById)
|
||||
if (!short) return null
|
||||
return { ...(phase.advance || blankAdvance()), kind: 'after', after: `${Math.ceil(short.longest)}m` }
|
||||
}
|
||||
|
||||
/** "1 minute", "90 minutes", "2 hours". */
|
||||
export function minutesWord(n) {
|
||||
if (n >= 120 && n % 60 === 0) return `${n / 60} hours`
|
||||
return `${n} minute${n === 1 ? '' : 's'}`
|
||||
}
|
||||
|
||||
/** The sentence under a phase that ends a timed step early, or null. */
|
||||
export function shortfallSentence(phases, pi, actionById) {
|
||||
const short = timedPhaseShortfall(phases, pi, actionById)
|
||||
if (!short) return null
|
||||
const last = pi === (phases || []).length - 1
|
||||
const ends = short.floor === 0
|
||||
? (last ? 'This phase ends as soon as its steps are done, and the run with it' : 'The rest of this run can be over as soon as its steps are done')
|
||||
: (last ? `This phase ends after ${minutesWord(short.floor)}, and the run with it` : `The rest of this run can be over ${minutesWord(short.floor)} after this phase starts`)
|
||||
return `${ends}; what is held for ${minutesWord(short.longest)} is given back then, not after ${minutesWord(short.longest)}.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the meter needs out of the form, and nothing else.
|
||||
*
|
||||
@@ -734,6 +1055,15 @@ function detailValue(value) {
|
||||
|
||||
export const logKindWord = (kind) => KIND_WORDS[kind] || kind
|
||||
|
||||
// "20:10", in the viewer's own clock. Time only: the log line it sits in carries
|
||||
// its own timestamp, and a retry is at most thirty minutes after it.
|
||||
function clockTime(value) {
|
||||
if (!value) return ''
|
||||
const t = new Date(value)
|
||||
if (Number.isNaN(t.getTime())) return ''
|
||||
return `${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* One log line as a sentence.
|
||||
*
|
||||
@@ -760,14 +1090,20 @@ export function describeLogLine(line) {
|
||||
return `${line.phase} finished`
|
||||
case 'step.parked':
|
||||
return `${d.action} is waiting on a human`
|
||||
case 'step.retry':
|
||||
return `${d.action} failed, attempt ${d.attempt} of ${d.of}${d.error ? `: ${d.error}` : ''}`
|
||||
case 'step.retry': {
|
||||
// `retryAt` is on every retry line since MODULE_API 1.15.0; a line written
|
||||
// before it says what it always said.
|
||||
const at = clockTime(d.retryAt)
|
||||
return `${d.action} failed, attempt ${d.attempt} of ${d.of}${at ? `, retrying at ${at}` : ''}${d.error ? `: ${d.error}` : ''}`
|
||||
}
|
||||
case 'step.status':
|
||||
return d.control
|
||||
? `${d.action} → ${d.to}${by} — ${d.control}${d.note || d.reason ? `: ${d.note || d.reason}` : ''}`
|
||||
: `${d.action} → ${d.to}${d.error ? `: ${d.error}` : ''}`
|
||||
case 'run.created':
|
||||
return `Occurrence created from version ${d.version}${d.rehearsal ? ' (rehearsal)' : ''}`
|
||||
return `Occurrence created from version ${d.version}${d.rehearsal ? ' (rehearsal)' : ''}${
|
||||
d.source === 'trigger' && d.origin ? ` — ${describeOrigin(d.origin)}` : ''
|
||||
}`
|
||||
case 'phase.gate':
|
||||
return d.kind === 'after'
|
||||
? `${line.phase} advances ${d.after} after it started`
|
||||
|
||||
@@ -97,3 +97,13 @@ export function statusWord(status, scheduledFor, now = Date.now()) {
|
||||
const at = new Date(scheduledFor).getTime()
|
||||
return Number.isNaN(at) || at <= now ? 'Did not happen' : 'Cancelled'
|
||||
}
|
||||
|
||||
// A live run's progress line (MODULE_API 1.12.0), as text. Core accepts two
|
||||
// shapes from a module and interprets neither: a count, "Bandits: 3 of 8 left",
|
||||
// and a percentage, "The Juggernaut: 62%". Anything else renders nothing.
|
||||
export function progressText(line) {
|
||||
if (!line || typeof line.label !== 'string' || !line.label) return ''
|
||||
if (Number.isInteger(line.left) && Number.isInteger(line.of)) return `${line.label}: ${line.left} of ${line.of} left`
|
||||
if (typeof line.percent === 'number') return `${line.label}: ${line.percent}%`
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -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,7 +3,7 @@
|
||||
//
|
||||
// Plain JS with tests, following lib/moduleAdmin.js. The reason it is worth
|
||||
// splitting here specifically: this screen's job is to tell an operator the
|
||||
// difference between "the shard has no Teams" and "core has not been able to ask
|
||||
// difference between "the game server has no Teams" and "core has not been able to ask
|
||||
// for two hours", and those two produce almost the same page. Getting that
|
||||
// wording right is logic, not markup.
|
||||
|
||||
@@ -31,7 +31,7 @@ export function freshnessOf(sync = {}) {
|
||||
return {
|
||||
tone: TONE.bad,
|
||||
label: 'Never synced',
|
||||
detail: 'Core has never had an answer it could trust. What is shown below is not a confirmed empty shard.',
|
||||
detail: 'Core has never had an answer it could trust. What is shown below does not confirm that the game server has no Teams.',
|
||||
}
|
||||
}
|
||||
if (sync.stale) {
|
||||
|
||||
@@ -11,6 +11,37 @@
|
||||
// that the two files can drift, so a test asserts they agree
|
||||
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
|
||||
// both.
|
||||
// 1.19.0 — an event action may declare `demoRefuses(params)` (DEMO_MODE.md §3.4,
|
||||
// DM18): the demo refuses a step at save. Server-side only; the refusal reaches
|
||||
// the browser as an ordinary DEMO_LOCKED answer. This file bumps for the reason at the top.
|
||||
// 1.18.0 — the public demo (DEMO_MODE.md §3.2): `demoLocks` in module.json,
|
||||
// `ctx.demoMode`, `api.onDemoSeed`. Server-side; the client half is core's own
|
||||
// banner and refusal toast, which an installed module's requests reach through
|
||||
// the shared request primitive. This file bumps for the reason at the top.
|
||||
// 1.17.0 — a module may take part in linking a sign-in identity (STEAM_SIGNIN.md
|
||||
// §4): `ctx.identities.list`, `api.onIdentityLinking`, `api.onIdentityLinked`.
|
||||
// Server-side; the client half is core's own account pages showing Steam. This
|
||||
// file bumps for the reason at the top.
|
||||
// 1.16.0 — a dry run's `detail.warnings` (Rust PLAN_REDESIGNS §11.4, D391): a
|
||||
// module's advice, shown by core's own event editor as warning findings.
|
||||
// Server-side; this file bumps for the reason at the top.
|
||||
// 1.15.0 — a failed step's `retryAfterMs` (Rust PLAN_REDESIGNS §14.3.13): when
|
||||
// to try again. Server-side; the run console's retry line now says when. This
|
||||
// file bumps for the reason at the top.
|
||||
// 1.14.0 —an action param's `multiple` (Rust PLAN_REDESIGNS §12.3): a param
|
||||
// that holds several values, drawn by core's step editor as a multi-select.
|
||||
// Server-side declaration; this file bumps for the reason at the top.
|
||||
// 1.13.0 — the event step editor (Rust PLAN_REDESIGNS §2): event templates, a
|
||||
// param's `duration`, a lease's `example` and an option's `note`. All four are
|
||||
// server-side declarations the editor, core's own page, renders. This file bumps
|
||||
// for the reason at the top.
|
||||
// 1.12.0 — an event action's `progress()`: a live run's public page shows how
|
||||
// its steps stand. Server-side registration; the client half is core's own event
|
||||
// page rendering the lines. This file bumps for the reason at the top.
|
||||
// 1.11.0 — `ctx.events.expired({ kind, ref })` and the ledger's `expired` status
|
||||
// (Rust PLAN_FIXES D183): a module may say the game ended a ledgered resource at
|
||||
// its own deadline. Server-side only; the run console, which is core's own page,
|
||||
// shows the new status. This file bumps for the reason at the top.
|
||||
// 1.10.0 — the event contract opens to modules (EVENTS.md §F, EVENTS_PLAN.md
|
||||
// Phase 7): a module may register event actions, budget dimensions, leases and
|
||||
// param option sources. All four are server-side registrations and nothing on
|
||||
@@ -74,4 +105,4 @@
|
||||
// but the two halves state ONE version: a module declares a single coreApi range
|
||||
// and is served one chunk, so a client that claimed 1.0.0 while the server
|
||||
// answered 1.1.0 would be two answers to one question.
|
||||
export const MODULE_API_VERSION = '1.10.0'
|
||||
export const MODULE_API_VERSION = '1.19.0'
|
||||
|
||||
@@ -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,7 @@ import RecoveryCodesDisplay from '../../../components/security/RecoveryCodesDisp
|
||||
import TrustedDevicesPanel from '../../../components/security/TrustedDevicesPanel.jsx'
|
||||
import RecoveryCodesPanel from '../../../components/security/RecoveryCodesPanel.jsx'
|
||||
import EmailAddressPanel from '../../../components/security/EmailAddressPanel.jsx'
|
||||
import { IdentitySummary, linkBanner, linkableProviders, unlinkPrompt } from '../../../components/security/linkedIdentities.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Link/unlink external SSO identities to this account. Linking redirects through
|
||||
@@ -15,13 +16,7 @@ function LinkedAccounts() {
|
||||
const [available, setAvailable] = useState([])
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const banner = (() => {
|
||||
const q = new URLSearchParams(window.location.search)
|
||||
if (q.get('linked')) return { ok: true, text: 'Account linked.' }
|
||||
if (q.get('link_error') === 'in_use') return { ok: false, text: 'That external account is already linked to another user.' }
|
||||
if (q.get('link_error')) return { ok: false, text: 'Could not link that account. Please try again.' }
|
||||
return null
|
||||
})()
|
||||
const banner = linkBanner(window.location.search)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -40,12 +35,11 @@ function LinkedAccounts() {
|
||||
}, [load])
|
||||
|
||||
const nameFor = (id) => available.find((p) => p.id === id)?.name || id.charAt(0).toUpperCase() + id.slice(1)
|
||||
const iconFor = (id) => (id === 'google' || id === 'discord' ? id : 'oidc')
|
||||
|
||||
async function unlink(provider) {
|
||||
if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return
|
||||
async function unlink(identity) {
|
||||
if (!window.confirm(unlinkPrompt(identity, nameFor(identity.provider)))) return
|
||||
try {
|
||||
await api.unlinkIdentity(provider)
|
||||
await api.unlinkIdentity(identity.provider, identity.id)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not unlink.')
|
||||
@@ -55,8 +49,7 @@ function LinkedAccounts() {
|
||||
if (error) return <ErrorState message={error} />
|
||||
if (!linked) return null
|
||||
|
||||
const linkedIds = new Set(linked.map((i) => i.provider))
|
||||
const linkable = available.filter((p) => !linkedIds.has(p.id))
|
||||
const linkable = linkableProviders(available, linked)
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 28 }}>
|
||||
@@ -64,7 +57,7 @@ function LinkedAccounts() {
|
||||
Linked accounts
|
||||
</h2>
|
||||
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
|
||||
Link a Google, Discord, or other SSO account so you can sign in with it. SSO can only sign in
|
||||
Link a Google, Discord, Steam, or other SSO account so you can sign in with it. SSO can only sign in
|
||||
to an account it is linked to — linking here is what grants that access.
|
||||
</p>
|
||||
|
||||
@@ -77,15 +70,9 @@ function LinkedAccounts() {
|
||||
{linked.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
|
||||
{linked.map((i) => (
|
||||
<div key={i.provider} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||
<span style={{ display: 'inline-flex', width: 20, height: 20 }}>
|
||||
<ProviderIcon icon={iconFor(i.provider)} size={20} />
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>{nameFor(i.provider)}</div>
|
||||
{i.email && <div className="sans dim" style={{ fontSize: '0.78rem' }}>{i.email}</div>}
|
||||
</div>
|
||||
<button onClick={() => unlink(i.provider)} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
|
||||
<div key={i.id ?? i.provider} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||
<IdentitySummary identity={i} providerName={nameFor(i.provider)} />
|
||||
<button onClick={() => unlink(i)} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
|
||||
Unlink
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { useAsync } from '../../../lib/useAsync.js'
|
||||
import { ago, dateTime } from '../../../lib/format.js'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { isDemoLocked } from '../../../lib/demoNotice.js'
|
||||
import { useSite } from '../../../contexts/SiteContext.jsx'
|
||||
import { useAuth } from '../../../contexts/AuthContext.jsx'
|
||||
|
||||
@@ -49,11 +50,10 @@ export default function Dashboard() {
|
||||
await refreshSite()
|
||||
reload()
|
||||
} catch (err) {
|
||||
setModeError(
|
||||
err.status === 403
|
||||
? 'Only an administrator can change the site mode.'
|
||||
: 'Could not change the site mode. Try again.',
|
||||
)
|
||||
let message = 'Could not change the site mode. Try again.'
|
||||
if (isDemoLocked(err)) message = err.message
|
||||
else if (err.status === 403) message = 'Only an administrator can change the site mode.'
|
||||
setModeError(message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
@@ -166,7 +166,12 @@ export default function Dashboard() {
|
||||
className="sans"
|
||||
style={{ display: 'flex', gap: 14, alignItems: 'center', padding: '13px 18px', borderBottom: '1px solid var(--line-soft)', fontSize: '0.86rem' }}
|
||||
>
|
||||
<span style={{ flex: 'none', color: 'var(--accent)', fontSize: '0.66rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', width: 110, fontFamily: 'ui-monospace,Menlo,monospace' }}>
|
||||
{/* `minWidth` rather than `width`: the column still lines up for core's
|
||||
own short action names, and a longer one — a module's namespaced
|
||||
action, say `rust.account.unlink.staff` — grows the box instead of
|
||||
overflowing it and printing on top of the detail beside it. Found
|
||||
on a live dashboard with a module installed. */}
|
||||
<span style={{ flex: 'none', color: 'var(--accent)', fontSize: '0.66rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', minWidth: 110, fontFamily: 'ui-monospace,Menlo,monospace' }}>
|
||||
{a.action}
|
||||
</span>
|
||||
<span style={{ flex: 1, color: 'var(--text)' }}>{formatDetail(a)}</span>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,8 @@ import {
|
||||
runControlsFor,
|
||||
stepControlsFor,
|
||||
describeLogLine,
|
||||
describeParams,
|
||||
describeOrigin,
|
||||
} from '../../../lib/eventAuthoring.js'
|
||||
|
||||
// Admin → Events → the run console (EVENTS.md §I, Phase 3).
|
||||
@@ -59,7 +61,7 @@ const STATUS_COLOR = {
|
||||
completed: '#8fc79a',
|
||||
}
|
||||
|
||||
// The six ledger statuses, in the two groups that matter to a reader: green is
|
||||
// The seven ledger statuses, in the two groups that matter to a reader: green is
|
||||
// resolved, amber wants a person. `orphaned` and `drifted` are amber rather than
|
||||
// red because neither is a fault — one thing vanished, the other was taken by
|
||||
// somebody with every right to take it — and red is reserved for "this did not
|
||||
@@ -71,6 +73,9 @@ const RESOURCE_COLOR = {
|
||||
reverting: '#d9c184',
|
||||
drifted: '#d9c184',
|
||||
orphaned: '#d9c184',
|
||||
// Green, like `reverted`: the game ended it at the deadline it was given, which
|
||||
// is the plan working (MODULE_API 1.11.0). `orphaned` is the amber one.
|
||||
expired: '#8fc79a',
|
||||
}
|
||||
|
||||
const RESOURCE_WORD = {
|
||||
@@ -80,6 +85,7 @@ const RESOURCE_WORD = {
|
||||
reverted: 'given back',
|
||||
orphaned: 'gone',
|
||||
drifted: 'someone else moved it',
|
||||
expired: 'ended by the game on time',
|
||||
}
|
||||
|
||||
const STEP_COLOR = {
|
||||
@@ -126,7 +132,9 @@ function GateRow({ gate, current }) {
|
||||
<div style={{ padding: '8px 0', borderTop: '1px solid var(--rule)' }}>
|
||||
<div className="sans" style={{ fontSize: '0.86rem', color: colour }}>
|
||||
Phase <strong>{gate.phase}</strong>
|
||||
{current && !gate.satisfied ? ' has not started' : ''}
|
||||
{/* W2: the phase is running and its steps are done; it is the advance
|
||||
that waits, so "has not started" was the wrong sentence. */}
|
||||
{current && !gate.satisfied ? ': steps done, waiting to advance' : ''}
|
||||
{gate.satisfied && ` — released ${gate.satisfiedBy === 'forced' ? 'by hand' : `on its ${gate.satisfiedBy === 'elapsed' ? 'deadline' : 'condition'}`}`}
|
||||
{gate.stalled && ' — STALLED'}
|
||||
</div>
|
||||
@@ -289,6 +297,13 @@ export default function EventRun() {
|
||||
{run.rehearsal ? ' · rehearsal' : ''}
|
||||
{run.concurrencyKey ? ` · key ${run.concurrencyKey}` : ''}
|
||||
</p>
|
||||
{/* Why it exists, when a game firing started it (D370). */}
|
||||
{describeOrigin(run.origin) && (
|
||||
<p className="sans" style={{ margin: '4px 0 0', fontSize: '0.8rem' }}>
|
||||
{describeOrigin(run.origin)}
|
||||
<span className="dim"> · {when(run.origin.occurredAt)}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div className="sans" style={{ fontSize: '1rem', color: STATUS_COLOR[run.status] || undefined }}>
|
||||
@@ -376,7 +391,7 @@ export default function EventRun() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── Why this phase has not started (Phase 5) ──
|
||||
{/* ── Why this phase has not advanced (Phase 5) ──
|
||||
Above the step list for the same reason the parked cue is: a phase
|
||||
waiting on a condition is `running` and looks completely healthy, and
|
||||
the one screen an operator opens to find out why nothing is happening
|
||||
@@ -387,7 +402,7 @@ export default function EventRun() {
|
||||
style={{ padding: 14, marginBottom: 14, borderLeft: `3px solid ${waiting ? (waiting.stalled ? '#d98b84' : '#d9c184') : 'var(--rule)'}` }}
|
||||
>
|
||||
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>
|
||||
{waiting ? 'Why this phase has not started' : 'Phase advance conditions'}
|
||||
{waiting ? 'Why this phase has not advanced' : 'Phase advance conditions'}
|
||||
</h3>
|
||||
<p className="sans dim" style={{ margin: '0 0 4px', fontSize: '0.8rem' }}>
|
||||
{waiting ? (
|
||||
@@ -651,7 +666,7 @@ export default function EventRun() {
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
|
||||
<code style={{ fontSize: '0.78rem' }}>{step.actionId}</code>
|
||||
<div className="dim" style={{ fontSize: '0.74rem', maxWidth: 320, overflowWrap: 'anywhere' }}>
|
||||
{JSON.stringify(step.params)}
|
||||
{describeParams(step.params)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem', color: STEP_COLOR[step.status] || undefined }}>
|
||||
|
||||
@@ -14,7 +14,7 @@ import TeamVoice from './TeamVoice.jsx'
|
||||
// Three panels, in the order an operator needs them:
|
||||
//
|
||||
// 1. **Sync state**, verbatim, including the last error. The screen's first job
|
||||
// is to make "the shard has no Teams" and "core has not been able to ask for
|
||||
// is to make "the game server has no Teams" and "core has not been able to ask for
|
||||
// two hours" impossible to confuse — they render almost identically
|
||||
// otherwise, and one is fine while the other is an outage.
|
||||
// 2. **The review queue** — Teams auto-hidden because their name matched the
|
||||
|
||||
@@ -5,6 +5,7 @@ import RecoveryCodesDisplay from '../../components/security/RecoveryCodesDisplay
|
||||
import TrustedDevicesPanel from '../../components/security/TrustedDevicesPanel.jsx'
|
||||
import RecoveryCodesPanel from '../../components/security/RecoveryCodesPanel.jsx'
|
||||
import EmailAddressPanel from '../../components/security/EmailAddressPanel.jsx'
|
||||
import { IdentitySummary, linkBanner, linkableProviders, unlinkPrompt } from '../../components/security/linkedIdentities.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
@@ -224,13 +225,7 @@ function LinkedAccounts() {
|
||||
const [available, setAvailable] = useState([])
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const banner = (() => {
|
||||
const q = new URLSearchParams(window.location.search)
|
||||
if (q.get('linked')) return { ok: true, text: 'Account linked.' }
|
||||
if (q.get('link_error') === 'in_use') return { ok: false, text: 'That external account is already linked to another user.' }
|
||||
if (q.get('link_error')) return { ok: false, text: 'Could not link that account. Please try again.' }
|
||||
return null
|
||||
})()
|
||||
const banner = linkBanner(window.location.search)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -247,12 +242,11 @@ function LinkedAccounts() {
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const nameFor = (id) => available.find((p) => p.id === id)?.name || id.charAt(0).toUpperCase() + id.slice(1)
|
||||
const iconFor = (id) => (id === 'google' || id === 'discord' ? id : 'oidc')
|
||||
|
||||
async function unlink(provider) {
|
||||
if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return
|
||||
async function unlink(identity) {
|
||||
if (!window.confirm(unlinkPrompt(identity, nameFor(identity.provider)))) return
|
||||
try {
|
||||
await api.unlinkIdentity(provider)
|
||||
await api.unlinkIdentity(identity.provider, identity.id)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not unlink.')
|
||||
@@ -262,13 +256,12 @@ function LinkedAccounts() {
|
||||
if (error) return <ErrorState message={error} />
|
||||
if (!linked) return null
|
||||
|
||||
const linkedIds = new Set(linked.map((i) => i.provider))
|
||||
const linkable = available.filter((p) => !linkedIds.has(p.id))
|
||||
const linkable = linkableProviders(available, linked)
|
||||
|
||||
return (
|
||||
<Section title="Linked accounts">
|
||||
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||
Link a Google, Discord, or other provider so you can sign in with it.
|
||||
Link a Google, Discord, Steam, or other provider so you can sign in with it.
|
||||
</p>
|
||||
{banner && (
|
||||
<p className="sans" style={{ color: banner.ok ? '#7fd0a4' : '#d98b84', fontSize: '0.86rem' }}>{banner.text}</p>
|
||||
@@ -276,15 +269,9 @@ function LinkedAccounts() {
|
||||
{linked.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
|
||||
{linked.map((i) => (
|
||||
<div key={i.provider} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||
<span style={{ display: 'inline-flex', width: 20, height: 20 }}>
|
||||
<ProviderIcon icon={iconFor(i.provider)} size={20} />
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>{nameFor(i.provider)}</div>
|
||||
{i.email && <div className="sans dim" style={{ fontSize: '0.78rem' }}>{i.email}</div>}
|
||||
</div>
|
||||
<button onClick={() => unlink(i.provider)} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>Unlink</button>
|
||||
<div key={i.id ?? i.provider} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||
<IdentitySummary identity={i} providerName={nameFor(i.provider)} />
|
||||
<button onClick={() => unlink(i)} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>Unlink</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.')
|
||||
|
||||
@@ -14,13 +14,19 @@
|
||||
// branch left a failed load spinning for ever with nothing on screen naming the
|
||||
// problem. Order matters, and the order is error first.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useSearchParams, Link } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
import { eventDateTime, statusWord } from '../../lib/eventCalendar.js'
|
||||
import { eventDateTime, statusWord, progressText } from '../../lib/eventCalendar.js'
|
||||
|
||||
// How often a live event's progress lines are read again while the page is open.
|
||||
// The server caches each run's answer for five seconds, so this is the reader's
|
||||
// cadence, not load on the game.
|
||||
const PROGRESS_REFRESH_MS = 15_000
|
||||
|
||||
export default function EventPage() {
|
||||
const { slug } = useParams()
|
||||
@@ -28,6 +34,27 @@ export default function EventPage() {
|
||||
const run = params.get('run')
|
||||
const { loading, error, data } = useAsync(() => api.publicEvent(slug, run), [slug, run])
|
||||
|
||||
// The live occurrence's progress (MODULE_API 1.12.0), re-read on a timer while
|
||||
// it is live. Only the lines are replaced: re-running the page's own fetch
|
||||
// would blank the page to a spinner every fifteen seconds.
|
||||
const [progress, setProgress] = useState(null)
|
||||
const live = Boolean(data && data.event && data.event.live)
|
||||
useEffect(() => {
|
||||
setProgress(null)
|
||||
if (!live) return undefined
|
||||
let active = true
|
||||
const timer = setInterval(() => {
|
||||
api
|
||||
.publicEvent(slug, run)
|
||||
.then((fresh) => active && setProgress(fresh?.event?.current?.progress || []))
|
||||
.catch(() => {})
|
||||
}, PROGRESS_REFRESH_MS)
|
||||
return () => {
|
||||
active = false
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, [slug, run, live])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
@@ -91,6 +118,16 @@ export default function EventPage() {
|
||||
the event is never published. */}
|
||||
{event.current.phase || 'Under way'}
|
||||
</div>
|
||||
{/* How it stands, in the words the steps' modules chose. Core
|
||||
renders the two shapes it accepts and interprets neither. */}
|
||||
{(progress || event.current.progress || []).map((line, i) => {
|
||||
const text = progressText(line)
|
||||
return text ? (
|
||||
<div key={i} className="sans" style={{ marginTop: 4 }}>
|
||||
{text}
|
||||
</div>
|
||||
) : null
|
||||
})}
|
||||
</>
|
||||
) : event.next ? (
|
||||
<>
|
||||
|
||||
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)
|
||||
})
|
||||
@@ -347,6 +347,21 @@ test('the log lines a run produces all render as something', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('a retry line says when it will run (MODULE_API 1.15.0), and an older line still reads', () => {
|
||||
// Built from local parts so the assertion holds in any timezone the suite runs in.
|
||||
const at = new Date(2026, 9, 15, 20, 10, 0)
|
||||
const text = describeLogLine({
|
||||
kind: 'step.retry',
|
||||
detail: { action: 'rust.dt.start', attempt: 1, of: 3, error: 'not ready yet', retryAt: at.toISOString() },
|
||||
})
|
||||
assert.equal(text, 'rust.dt.start failed, attempt 1 of 3, retrying at 20:10: not ready yet')
|
||||
|
||||
const before = describeLogLine({ kind: 'step.retry', detail: { action: 'core.announce', attempt: 2, of: 3 } })
|
||||
assert.equal(before, 'core.announce failed, attempt 2 of 3')
|
||||
const junk = describeLogLine({ kind: 'step.retry', detail: { action: 'a', attempt: 1, of: 3, retryAt: 'soon' } })
|
||||
assert.equal(junk, 'a failed, attempt 1 of 3', 'an unreadable time is left out, not shown as NaN')
|
||||
})
|
||||
|
||||
// ── The one log line core did not compose (Phase 15) ──────────────────────
|
||||
|
||||
test('a module detail line renders the module keys, not the kind id', () => {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { eventTime, eventDateTime, readerDayLabel, statusWord } from '../src/lib/eventCalendar.js'
|
||||
import { eventTime, eventDateTime, readerDayLabel, statusWord, progressText } from '../src/lib/eventCalendar.js'
|
||||
|
||||
// 2026-09-12T00:00Z is 2026-09-11 20:00 in New York — deliberately an instant
|
||||
// whose DATE differs between the two zones, which is what makes the split
|
||||
@@ -87,3 +87,11 @@ test('the other three words do not depend on the clock at all', () => {
|
||||
test('an unreadable instant falls to the past-tense word rather than throwing', () => {
|
||||
assert.equal(statusWord('cancelled', 'not a date', NOW), 'Did not happen')
|
||||
})
|
||||
|
||||
test('a progress line is a count or a percent, and nothing else renders (MODULE_API 1.12.0)', () => {
|
||||
assert.equal(progressText({ label: 'Bandits', left: 3, of: 8 }), 'Bandits: 3 of 8 left')
|
||||
assert.equal(progressText({ label: 'The Juggernaut', percent: 62 }), 'The Juggernaut: 62%')
|
||||
assert.equal(progressText({ label: 'Odd' }), '')
|
||||
assert.equal(progressText({ left: 1, of: 2 }), '')
|
||||
assert.equal(progressText(null), '')
|
||||
})
|
||||
|
||||
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', () => {
|
||||
|
||||
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" } }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,7 +179,7 @@ CREATE TABLE IF NOT EXISTS activity_log (
|
||||
-- custom (oidc/oauth2) providers.
|
||||
CREATE TABLE IF NOT EXISTS auth_providers (
|
||||
id VARCHAR(64) PRIMARY KEY, -- 'google' | 'discord' | custom slug
|
||||
kind ENUM('google','discord','oidc','oauth2') NOT NULL,
|
||||
kind ENUM('google','discord','oidc','oauth2','steam') NOT NULL,
|
||||
name VARCHAR(80) NOT NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
client_id VARCHAR(255) NULL,
|
||||
@@ -203,6 +203,8 @@ CREATE TABLE IF NOT EXISTS user_identities (
|
||||
provider VARCHAR(64) NOT NULL, -- matches auth_providers.id
|
||||
subject VARCHAR(191) NOT NULL, -- external stable user id (sub / discord id)
|
||||
email VARCHAR(255) NULL,
|
||||
display_name VARCHAR(191) NULL, -- Steam persona name (D394); null for other providers
|
||||
avatar_url VARCHAR(500) NULL, -- Steam avatar, a Steam CDN https URL; null for others
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_identity_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY uq_identity_provider_subject (provider, subject),
|
||||
@@ -2560,7 +2562,7 @@ CREATE TABLE IF NOT EXISTS event_run_resources (
|
||||
-- defensible. This column is core's copy of that promise, for the console and
|
||||
-- for the boot-time check.
|
||||
lease_until DATETIME NULL,
|
||||
status ENUM('pending','confirmed','reverting','reverted','orphaned','drifted')
|
||||
status ENUM('pending','confirmed','reverting','reverted','orphaned','drifted','expired')
|
||||
NOT NULL DEFAULT 'pending',
|
||||
-- Bounded like a step's `attempts`, and for the same reason: a revert that can
|
||||
-- never succeed must become visible rather than cycling for ever. Engagement
|
||||
@@ -2587,6 +2589,14 @@ CREATE TABLE IF NOT EXISTS event_run_resources (
|
||||
INDEX idx_evres_live (status, lease_until)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- MODULE_API 1.11.0 (Rust PLAN_FIXES D183): `expired`, the game ending a resource
|
||||
-- at its own deadline, for databases created before it. Terminal, so `live_marker`
|
||||
-- releases the target. MODIFY has no IF NOT EXISTS form, but re-declaring the same
|
||||
-- ENUM is an idempotent no-op -- checked against MariaDB 11.8 with the stored
|
||||
-- `live_marker` column depending on it -- so it is safe on every boot.
|
||||
ALTER TABLE event_run_resources MODIFY COLUMN status
|
||||
ENUM('pending','confirmed','reverting','reverted','orphaned','drifted','expired') NOT NULL DEFAULT 'pending';
|
||||
|
||||
-- §K's last bound: "a scheduled definition that has never been verified is the
|
||||
-- case worth refusing to start". A version is immutable, so a dry run that passed
|
||||
-- against it stays true — which is what makes the pass a property of the VERSION
|
||||
@@ -2598,6 +2608,18 @@ CREATE TABLE IF NOT EXISTS event_run_resources (
|
||||
ALTER TABLE event_versions ADD COLUMN IF NOT EXISTS verified_at DATETIME NULL;
|
||||
ALTER TABLE event_versions ADD COLUMN IF NOT EXISTS verified_by INT NULL;
|
||||
|
||||
-- A dry run that passed on the DRAFT, and the fingerprint of the spec it passed
|
||||
-- on (W5, Rust PLAN_LAST_FIXES). A draft changes under the author's hands, so a
|
||||
-- pass on it is not a property of any version -- but publish compares this
|
||||
-- fingerprint with the spec it is about to freeze, and when they are equal the
|
||||
-- new version is stamped verified with this pass's reviewer and time. Without it
|
||||
-- an author who dry-ran and then published found v1 "not dry run", and its
|
||||
-- scheduled and trigger starts held, until they ran the same check again. An
|
||||
-- edit after the dry run changes the fingerprint, so nothing stale carries.
|
||||
ALTER TABLE event_definitions ADD COLUMN IF NOT EXISTS draft_verified_at DATETIME NULL;
|
||||
ALTER TABLE event_definitions ADD COLUMN IF NOT EXISTS draft_verified_by INT NULL;
|
||||
ALTER TABLE event_definitions ADD COLUMN IF NOT EXISTS draft_verified_hash CHAR(64) NULL;
|
||||
|
||||
-- Whether this definition appears on the PUBLIC calendar (Phase 14a).
|
||||
--
|
||||
-- **Not a second answer to the question `state` answers**, which is the trap the
|
||||
@@ -2696,3 +2718,52 @@ ALTER TABLE event_runs ADD COLUMN IF NOT EXISTS results_published_at DATETIME NU
|
||||
-- that no longer resolves answers that honestly.
|
||||
ALTER TABLE announce_jobs ADD COLUMN IF NOT EXISTS run_id BIGINT NULL;
|
||||
ALTER TABLE announce_jobs ADD INDEX IF NOT EXISTS idx_announce_run (run_id);
|
||||
|
||||
-- ── A trigger can start a run (Rust PLAN_REDESIGNS §14.3.12, D370) ─────────
|
||||
|
||||
-- Why a run exists, when a game firing started it: the trigger, when it fired,
|
||||
-- its subject and its variables, so the console can say "started because a
|
||||
-- champion boss is up at Abyss" and the audit can say what. NULL for a run an
|
||||
-- admin or the schedule started, which `run.created`'s `source` already says.
|
||||
--
|
||||
-- A column rather than only a log line because the run log is pruned and the
|
||||
-- run is not, and "why did this run happen" outlives "what did each step do".
|
||||
ALTER TABLE event_runs ADD COLUMN IF NOT EXISTS origin JSON NULL;
|
||||
|
||||
-- One row per definition that a trigger has tried to start: the cooldown's
|
||||
-- clock, the claim that makes "one run at a time" true, and the count of
|
||||
-- firings it ignored, which the editor shows ("2 firings ignored while
|
||||
-- running").
|
||||
--
|
||||
-- **The claim is a conditional UPDATE on this row**, the event_run_budget
|
||||
-- argument once more: two firings arriving together each try it, and the
|
||||
-- cooldown, the one-at-a-time check and a stale-claim guard are all in its
|
||||
-- WHERE, so exactly one of them owns the start. `claimed_at` is held only
|
||||
-- between the claim and the run row existing — a minute is a crashed process,
|
||||
-- not a slow insert.
|
||||
--
|
||||
-- The counts are since the LAST trigger-started run, and a new start zeroes
|
||||
-- them: what an operator asks is "is it ignoring things right now", and a
|
||||
-- lifetime total cannot answer that.
|
||||
CREATE TABLE IF NOT EXISTS event_trigger_starts (
|
||||
definition_id INT NOT NULL PRIMARY KEY,
|
||||
claimed_at DATETIME NULL,
|
||||
last_started_at DATETIME NULL, -- the firing's time, which is the run's scheduled_for
|
||||
last_run_id BIGINT NULL,
|
||||
ignored_running INT NOT NULL DEFAULT 0,
|
||||
ignored_cooldown INT NOT NULL DEFAULT 0,
|
||||
ignored_unverified INT NOT NULL DEFAULT 0,
|
||||
last_ignored_at DATETIME NULL,
|
||||
last_ignored_reason VARCHAR(32) NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_evtrig_def FOREIGN KEY (definition_id) REFERENCES event_definitions(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Sign in with Steam (STEAM_SIGNIN.md §4). Steam is a built-in provider beside
|
||||
-- Google and Discord, so its row needs the kind; and a linked Steam account
|
||||
-- shows its persona name and avatar (D394), kept on the identity and refreshed
|
||||
-- at each Steam sign-in. Both additive. MODIFY has no IF NOT EXISTS form, but
|
||||
-- re-declaring the same ENUM is an idempotent no-op.
|
||||
ALTER TABLE auth_providers MODIFY COLUMN kind ENUM('google','discord','oidc','oauth2','steam') NOT NULL;
|
||||
ALTER TABLE user_identities ADD COLUMN IF NOT EXISTS display_name VARCHAR(191) NULL;
|
||||
ALTER TABLE user_identities ADD COLUMN IF NOT EXISTS avatar_url VARCHAR(500) NULL;
|
||||
|
||||
254
server/db/seedDemo.js
Normal file
254
server/db/seedDemo.js
Normal file
@@ -0,0 +1,254 @@
|
||||
// ── `npm run seed:demo` — the public demo's base (DEMO_MODE.md §7.3) ────────
|
||||
//
|
||||
// Run by the wipe workflow inside the app container, after `compose down -v` and
|
||||
// a fresh `up` have left an empty database with the schema in place:
|
||||
//
|
||||
// docker compose exec app npm run seed:demo -- /app/deploy/demo/seed.json
|
||||
//
|
||||
// It writes, from one fixture file:
|
||||
// 1. the four static logins (§4) — upserted, so a password a visitor somehow
|
||||
// changed is back within one cycle and a re-run is harmless;
|
||||
// 2. the site settings the demo starts from (live, registration disabled, and
|
||||
// whatever the fixture adds);
|
||||
// 3. the seeded content — posts, wiki pages, pages, events — on the first run
|
||||
// against a database only, so a re-run never duplicates it;
|
||||
// 4. each installed module's own rows, through the module's `onDemoSeed` hook
|
||||
// (API 1.18.0, DM15): core never writes a module's tables;
|
||||
// 5. `demo_state` — when the next wipe is due and the published logins, for
|
||||
// the banner and the sign-in page (§6).
|
||||
//
|
||||
// A string `"$env:NAME"` anywhere in the fixture is replaced with that variable,
|
||||
// so the rig's sidecar token lives in the host's .env and never in the repo. An
|
||||
// unset variable fails the run rather than seeding an empty credential.
|
||||
//
|
||||
// Refuses to run without DEMO_MODE=true: pointed at a real shard by mistake, it
|
||||
// would reset four accounts' passwords and flip the site live.
|
||||
|
||||
require('dotenv').config()
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const { isDemo, DEMO_ACCOUNTS } = require('../src/demo/mode')
|
||||
|
||||
const ROLES = ['admin', 'editor', 'moderator', 'player']
|
||||
|
||||
const log = require('../src/utils/logger')('seed-demo')
|
||||
|
||||
// ── Pure helpers (tested in test/seedDemo.test.js) ─────────────────────────
|
||||
|
||||
/** Replace every `"$env:NAME"` string, at any depth. Throws on an unset one. */
|
||||
function resolveEnv(value, env = process.env, where = 'fixture') {
|
||||
if (typeof value === 'string') {
|
||||
const m = /^\$env:([A-Z_][A-Z0-9_]*)$/.exec(value)
|
||||
if (!m) return value
|
||||
const got = env[m[1]]
|
||||
if (got === undefined || got === '') throw new Error(`${where}: environment variable ${m[1]} is not set`)
|
||||
return got
|
||||
}
|
||||
if (Array.isArray(value)) return value.map((v, i) => resolveEnv(v, env, `${where}[${i}]`))
|
||||
if (value && typeof value === 'object') {
|
||||
const out = {}
|
||||
for (const [k, v] of Object.entries(value)) out[k] = resolveEnv(v, env, `${where}.${k}`)
|
||||
return out
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* When the next wipe is due: the next whole multiple of `everyHours` past
|
||||
* midnight UTC, strictly after `now` — the schedule the wipe workflow's cron
|
||||
* runs on (DM7: every 2 hours on the hour). The seed runs a minute or two after
|
||||
* a wipe starts, so "strictly after" lands on the following slot.
|
||||
*/
|
||||
function nextResetAt(now = new Date(), everyHours = 2) {
|
||||
const step = everyHours * 60 * 60 * 1000
|
||||
const midnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
|
||||
const slots = Math.floor((now.getTime() - midnight) / step) + 1
|
||||
return new Date(midnight + slots * step)
|
||||
}
|
||||
|
||||
/** Check the fixture's shape before anything is written. Returns a list of problems. */
|
||||
function checkFixture(fixture) {
|
||||
const problems = []
|
||||
if (!fixture || typeof fixture !== 'object' || Array.isArray(fixture)) return ['the fixture must be a JSON object']
|
||||
const accounts = fixture.accounts || {}
|
||||
for (const name of DEMO_ACCOUNTS) {
|
||||
const a = accounts[name]
|
||||
if (!a) {
|
||||
problems.push(`accounts.${name} is missing`)
|
||||
continue
|
||||
}
|
||||
if (typeof a.password !== 'string' || a.password.length < 8) {
|
||||
problems.push(`accounts.${name}.password must be a string of at least 8 characters`)
|
||||
}
|
||||
if (!ROLES.includes(a.role)) problems.push(`accounts.${name}.role must be one of ${ROLES.join(', ')}`)
|
||||
}
|
||||
for (const name of Object.keys(accounts)) {
|
||||
if (!DEMO_ACCOUNTS.includes(name)) {
|
||||
problems.push(`accounts.${name} is not a demo account (the lock only knows ${DEMO_ACCOUNTS.join(', ')})`)
|
||||
}
|
||||
}
|
||||
if (fixture.settings !== undefined) {
|
||||
if (!fixture.settings || typeof fixture.settings !== 'object' || Array.isArray(fixture.settings)) {
|
||||
problems.push('settings must be an object')
|
||||
} else if ('demo_state' in fixture.settings) {
|
||||
problems.push('settings.demo_state is written by the seed itself')
|
||||
}
|
||||
}
|
||||
for (const key of ['posts', 'wiki', 'pages', 'events']) {
|
||||
const list = fixture.content?.[key]
|
||||
if (list !== undefined && !Array.isArray(list)) problems.push(`content.${key} must be an array`)
|
||||
}
|
||||
if (fixture.modules !== undefined && (typeof fixture.modules !== 'object' || Array.isArray(fixture.modules))) {
|
||||
problems.push('modules must be an object keyed by module id')
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// ── The run ────────────────────────────────────────────────────────────────
|
||||
|
||||
/* eslint-disable global-require */
|
||||
async function upsertAccounts(fixture) {
|
||||
const users = require('../src/model/users/users.model')
|
||||
const ids = {}
|
||||
for (const username of DEMO_ACCOUNTS) {
|
||||
const { password, role } = fixture.accounts[username]
|
||||
const existing = await users.getRawByUsername(username)
|
||||
let id
|
||||
if (existing) {
|
||||
id = existing.id
|
||||
// A password change bumps the session cutoff, which signs every visitor out
|
||||
// of this account — the right outcome at a wipe, and on a fresh database
|
||||
// (the normal case) there is nobody to sign out.
|
||||
await users.update(id, { password, role, status: 'active', emailVerified: true })
|
||||
await users.disableTotp(id)
|
||||
} else {
|
||||
const created = await users.createUser({ username, password, role, status: 'active', emailVerified: true })
|
||||
id = created.id
|
||||
}
|
||||
ids[username] = id
|
||||
log.info(`account ${username} (${role}) ready`, { id })
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
async function writeSettings(fixture) {
|
||||
const settings = require('../src/model/settings/settings.model')
|
||||
await settings.setMany({
|
||||
site_mode: 'live',
|
||||
site_mode_changed_at: new Date().toISOString(),
|
||||
site_mode_changed_by: 'seed:demo',
|
||||
// §4: registration is closed, and the route is locked too (DM16).
|
||||
[settings.REGISTRATION_KEY]: 'disabled',
|
||||
...(fixture.settings || {}),
|
||||
})
|
||||
}
|
||||
|
||||
async function writeContent(fixture, authorId) {
|
||||
const content = fixture.content || {}
|
||||
const posts = require('../src/model/posts/posts.model')
|
||||
const wiki = require('../src/model/wiki/wiki.model')
|
||||
const pages = require('../src/model/pages/pages.model')
|
||||
const events = require('../src/model/events/eventDefinitions.model')
|
||||
|
||||
for (const p of content.posts || []) {
|
||||
const category = posts.toDbCategory(p.category || 'news')
|
||||
if (!category) throw new Error(`post "${p.title}": unknown category "${p.category}"`)
|
||||
await posts.create({ ...p, category, published: p.published !== false, author_id: authorId })
|
||||
}
|
||||
for (const w of content.wiki || []) {
|
||||
const category = w.category ? await wiki.getCategoryBySlug(w.category) : null
|
||||
if (w.category && !category) throw new Error(`wiki "${w.slug}": unknown category "${w.category}"`)
|
||||
await wiki.create({ ...w, categoryId: category ? category.id : null, updatedBy: authorId })
|
||||
}
|
||||
for (const pg of content.pages || []) {
|
||||
await pages.create(pg, authorId)
|
||||
}
|
||||
for (const e of content.events || []) {
|
||||
const { publish, ...input } = e
|
||||
const made = await events.create(input, authorId, { role: 'admin' })
|
||||
if (!made.ok) throw new Error(`event "${input.name || input.title}": ${(made.errors || []).join('; ')}`)
|
||||
if (publish) {
|
||||
const pub = await events.publish(made.id, authorId)
|
||||
if (!pub.ok) throw new Error(`event "${input.name || input.title}" did not publish: ${(pub.errors || []).join('; ')}`)
|
||||
}
|
||||
}
|
||||
log.info('content written', {
|
||||
posts: (content.posts || []).length,
|
||||
wiki: (content.wiki || []).length,
|
||||
pages: (content.pages || []).length,
|
||||
events: (content.events || []).length,
|
||||
})
|
||||
}
|
||||
|
||||
async function seedModules(fixture, accounts) {
|
||||
const modules = require('../src/modules/loader')
|
||||
const hooks = modules.demoSeedHooks()
|
||||
const wanted = fixture.modules || {}
|
||||
for (const id of Object.keys(wanted)) {
|
||||
if (!hooks.some((h) => h.id === id)) {
|
||||
throw new Error(`modules.${id}: no installed module "${id}" takes a demo seed (onDemoSeed)`)
|
||||
}
|
||||
}
|
||||
for (const { id, hook } of hooks) {
|
||||
if (wanted[id] === undefined) continue
|
||||
// The module gets its part of the fixture and the demo accounts' ids, so it
|
||||
// can link one of them to a game identity without reading core's users table.
|
||||
await hook(wanted[id], { accounts: { ...accounts } })
|
||||
log.info(`module ${id} seeded`)
|
||||
}
|
||||
}
|
||||
|
||||
async function writeState(fixture, now = new Date()) {
|
||||
const settings = require('../src/model/settings/settings.model')
|
||||
const everyHours = Number(process.env.DEMO_RESET_EVERY_HOURS) || 2
|
||||
const logins = DEMO_ACCOUNTS
|
||||
.filter((name) => fixture.accounts[name].published !== false)
|
||||
.map((name) => ({ username: name, password: fixture.accounts[name].password, role: fixture.accounts[name].role }))
|
||||
const state = { resetsAt: nextResetAt(now, everyHours).toISOString(), logins }
|
||||
await settings.set(settings.DEMO_STATE_KEY, JSON.stringify(state))
|
||||
log.info('demo state stamped', { resetsAt: state.resetsAt, logins: logins.length })
|
||||
}
|
||||
|
||||
async function run(file) {
|
||||
if (!isDemo()) throw new Error('seed:demo runs only with DEMO_MODE=true')
|
||||
if (!file) throw new Error('usage: npm run seed:demo -- <fixture.json> (or set DEMO_SEED_FILE)')
|
||||
|
||||
const fixture = resolveEnv(JSON.parse(fs.readFileSync(path.resolve(file), 'utf8')))
|
||||
const problems = checkFixture(fixture)
|
||||
if (problems.length) throw new Error(`the fixture has problems:\n - ${problems.join('\n - ')}`)
|
||||
|
||||
// The app itself, without listening: this is what loads the installed modules
|
||||
// (and core's registries, which an event definition is validated against)
|
||||
// exactly as the server does. Nothing here calls onBoot, so no module starts
|
||||
// its workers in this process.
|
||||
require('../src/app')
|
||||
const settings = require('../src/model/settings/settings.model')
|
||||
|
||||
const firstRun = !(await settings.get(settings.DEMO_STATE_KEY))
|
||||
const accounts = await upsertAccounts(fixture)
|
||||
await writeSettings(fixture)
|
||||
if (firstRun) await writeContent(fixture, accounts['demo-admin'])
|
||||
else log.info('content already seeded on this database; left as is')
|
||||
await seedModules(fixture, accounts)
|
||||
await writeState(fixture)
|
||||
}
|
||||
/* eslint-enable global-require */
|
||||
|
||||
if (require.main === module) {
|
||||
;(async () => {
|
||||
const { close } = require('../src/utils/db') // eslint-disable-line global-require
|
||||
try {
|
||||
await run(process.argv[2] || process.env.DEMO_SEED_FILE)
|
||||
log.info('demo seeded')
|
||||
} catch (err) {
|
||||
log.error(`seed:demo failed: ${err.message}`)
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
module.exports = { resolveEnv, nextResetAt, checkFixture, run }
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.",
|
||||
"moduleApiVersion": "1.10.0",
|
||||
"moduleApiVersion": "1.19.0",
|
||||
"triggers": [
|
||||
{
|
||||
"id": "event.phase.changed",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -545,6 +545,15 @@
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/catalog/templates/:templateId",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/price",
|
||||
@@ -1825,7 +1834,7 @@
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/auth/me/account/identities/:provider",
|
||||
"handlers": 3,
|
||||
"handlers": 4,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
@@ -2464,9 +2473,10 @@
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/events/:slug",
|
||||
"handlers": 2,
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"siteMode"
|
||||
"siteMode",
|
||||
"optionalAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -241,6 +241,10 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/catalog/options/:sourceId"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/catalog/templates/:templateId"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/price"
|
||||
|
||||
@@ -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, '..')
|
||||
|
||||
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
|
||||
@@ -137,14 +137,14 @@ const ACTIONS = [
|
||||
name: 'title',
|
||||
type: 'string',
|
||||
required: false,
|
||||
example: 'The gates of Britain open at dusk',
|
||||
example: 'The gates open at dusk',
|
||||
description: 'Optional heading, for legs that render one.',
|
||||
},
|
||||
{
|
||||
name: 'body',
|
||||
type: 'string',
|
||||
required: true,
|
||||
example: 'A caravan has been sighted on the road east of Cove.',
|
||||
example: 'A caravan has been sighted on the east road.',
|
||||
description: 'The announcement itself. Plain text.',
|
||||
},
|
||||
],
|
||||
@@ -250,7 +250,7 @@ const ACTIONS = [
|
||||
name: 'instruction',
|
||||
type: 'string',
|
||||
required: true,
|
||||
example: 'Open the north gate and read the herald script in Britain bank.',
|
||||
example: 'Open the north gate and read the herald script in the town square.',
|
||||
description: 'What the staff member is being asked to do.',
|
||||
},
|
||||
{
|
||||
@@ -306,7 +306,10 @@ const ACTIONS = [
|
||||
name: 'lease',
|
||||
type: 'string',
|
||||
required: true,
|
||||
example: 'uo.rate.skillgain',
|
||||
// Game-neutral (Rust PLAN_REDESIGNS §0.9): this was a UO rate, shown to
|
||||
// every deployment's authors. The editor now draws the chosen lease's own
|
||||
// form and examples (MODULE_API 1.13.0), so this is only ever a fallback.
|
||||
example: 'mygame.rate.gather',
|
||||
source: 'core.options.leases',
|
||||
description: 'Which declared value to borrow.',
|
||||
},
|
||||
@@ -347,6 +350,10 @@ const ACTIONS = [
|
||||
type: 'int',
|
||||
required: true,
|
||||
example: 120,
|
||||
// A timed step (MODULE_API 1.13.0): the editor keeps the phase open at
|
||||
// least this long, because teardown gives the value back when the run
|
||||
// ends, whatever this says.
|
||||
duration: 'minutes',
|
||||
description: 'How long to hold it. Core refuses more than the lease allows.',
|
||||
},
|
||||
],
|
||||
|
||||
246
server/src/demo/locks.js
Normal file
246
server/src/demo/locks.js
Normal file
@@ -0,0 +1,246 @@
|
||||
// ── The demo lock (docs/website/DEMO_MODE.md §3) ───────────────────────────
|
||||
//
|
||||
// With DEMO_MODE on, app.js mounts four middlewares from this file, and with it
|
||||
// off none of them is mounted at all, so a real shard runs none of this code.
|
||||
//
|
||||
// • `refuseMultipart` — any request with a multipart body, anywhere, before a
|
||||
// router sees it (§3.3 "Uploads — locked hard"). An upload route added later
|
||||
// is locked without anyone remembering to list it, and a module cannot open one.
|
||||
// • `lock` — each write request against core's list below plus every module's
|
||||
// declared `demoLocks`, at `/api/v1`, ahead of every router.
|
||||
// • `refuseDataUri` — any write whose JSON body carries a data: URI in any
|
||||
// string (DM25): a pasted image is an upload that did not use multipart.
|
||||
// • `noindex` + `robots` — nothing a visitor writes reaches a search engine.
|
||||
//
|
||||
// **The server is the lock (DM10).** Everything here is decided from the request
|
||||
// alone, so `curl`, an old app build and a hand-edited page are refused exactly
|
||||
// like a click. The client explains a refusal; it never enforces one.
|
||||
//
|
||||
// A refusal is always the same answer, so the web client and the app can each
|
||||
// recognise it by `code` and show one sentence for it.
|
||||
|
||||
const { compile, match } = require('./paths')
|
||||
const { isDemoUsername } = require('./mode')
|
||||
|
||||
const log = require('../utils/logger')('demo')
|
||||
|
||||
const CODE = 'DEMO_LOCKED'
|
||||
const MESSAGE = 'This is turned off on the demo site.'
|
||||
const WRITES = ['POST', 'PUT', 'PATCH', 'DELETE']
|
||||
|
||||
function refuse(res, extra = {}) {
|
||||
return res.status(403).json({ message: MESSAGE, code: CODE, ...extra })
|
||||
}
|
||||
|
||||
// ── Conditions ─────────────────────────────────────────────────────────────
|
||||
// A few rows lock a route only for some requests. Each condition is asked only
|
||||
// after the method and path have matched, so the DB is touched on a handful of
|
||||
// rare admin and self-service writes and on nothing else.
|
||||
|
||||
// Required lazily: this file is required by app.js, and the users model would
|
||||
// otherwise make the DB pool a startup dependency of the lock.
|
||||
/* eslint-disable global-require */
|
||||
const users = () => require('../model/users/users.model')
|
||||
const sessions = () => require('../auth/session.service')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
async function isDemoUserId(id) {
|
||||
const n = Number(id)
|
||||
if (!Number.isInteger(n) || n < 1) return false
|
||||
const user = await users().getById(n)
|
||||
return Boolean(user && isDemoUsername(user.username))
|
||||
}
|
||||
|
||||
// `PUT /admin/users/:id` and its kin: an admin acting on one of the shared logins.
|
||||
const targetIsDemo = (req, params) => isDemoUserId(params.id)
|
||||
|
||||
// `/auth/me/*`: the caller signed in as one of the shared logins. The identity is
|
||||
// read the way requireAuth reads it, and the account row decides — not the
|
||||
// username claim in the token. No session is not a refusal: the route's own
|
||||
// requireAuth answers that with its usual 401.
|
||||
async function callerIsDemo(req) {
|
||||
const session = sessions().validateSession(req)
|
||||
return Boolean(session && (await isDemoUserId(session.userId)))
|
||||
}
|
||||
|
||||
// `PUT /admin/settings` stays open (DM2), but that one route writes arbitrary
|
||||
// keys, and a few keys are another door into something Tier A locks: `site_mode`
|
||||
// is what `PUT /admin/site-mode` writes, `module_source_hosts` what the Modules
|
||||
// screen's sources do, `teams_voice_*` the voice settings. `demo_*` is the
|
||||
// banner and the published logins, which the seed owns. `hero_layout` is the
|
||||
// live homepage hero (DM21): the editor's draft (`hero_layout_draft`) and its
|
||||
// Preview stay open, its Publish is refused.
|
||||
const PROTECTED_SETTINGS = [/^site_mode/, /^module_source_hosts$/, /^teams_voice_/, /^demo_/, /^hero_layout$/]
|
||||
|
||||
function protectedKeys(body) {
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) return []
|
||||
return Object.keys(body).filter((key) => PROTECTED_SETTINGS.some((re) => re.test(key)))
|
||||
}
|
||||
|
||||
// ── Core's list (§3.3) ─────────────────────────────────────────────────────
|
||||
// Paths are relative to /api/v1 and written as the routers declare them;
|
||||
// test/demoLocks.test.js holds every row against routes.manifest.json, so a
|
||||
// row that stops naming a real route fails the build rather than reading as a lock.
|
||||
|
||||
const CORE_LOCKS = [
|
||||
// Tier A — site configuration.
|
||||
{ methods: ['PUT'], path: '/admin/site-mode' },
|
||||
{ methods: WRITES, path: '/admin/modules/*' },
|
||||
{ methods: WRITES, path: '/admin/auth/providers/*' },
|
||||
{ methods: ['PUT'], path: '/admin/email/config' },
|
||||
{ methods: ['POST'], path: '/admin/email/test' },
|
||||
{ methods: ['POST'], path: '/admin/email/disconnect' },
|
||||
{ methods: ['PUT'], path: '/admin/discord-bot/config' },
|
||||
{ methods: WRITES, path: '/admin/teams/voice/*' },
|
||||
{ methods: ['PUT'], path: '/admin/settings', when: (req) => protectedKeys(req.body).length > 0 },
|
||||
|
||||
// Tier A — the shared logins, by an admin.
|
||||
{ methods: ['PUT', 'DELETE'], path: '/admin/users/:id', when: targetIsDemo },
|
||||
{ methods: ['DELETE'], path: '/admin/users/:id/trusted-devices', when: targetIsDemo },
|
||||
{ methods: ['DELETE'], path: '/admin/users/:id/trusted-devices/:deviceId', when: targetIsDemo },
|
||||
{ methods: ['POST'], path: '/admin/users/:id/mfa/reset', when: targetIsDemo },
|
||||
|
||||
// Tier A — the shared logins, by whoever is signed in as one.
|
||||
{ methods: ['PATCH'], path: '/auth/me/account/username', when: callerIsDemo },
|
||||
{ methods: ['PATCH'], path: '/auth/me/account/password', when: callerIsDemo },
|
||||
{ methods: ['PATCH'], path: '/auth/me/account/email', when: callerIsDemo },
|
||||
{ methods: ['POST'], path: '/auth/me/account/totp/setup', when: callerIsDemo },
|
||||
{ methods: ['POST'], path: '/auth/me/account/totp/enable', when: callerIsDemo },
|
||||
{ methods: ['POST'], path: '/auth/me/account/totp/disable', when: callerIsDemo },
|
||||
{ methods: ['POST'], path: '/auth/me/account/recovery-codes/generate', when: callerIsDemo },
|
||||
{ methods: ['DELETE'], path: '/auth/me/account/identities/:provider', when: callerIsDemo },
|
||||
// DM17: the whole route, because a mobile access token carries no refresh-row
|
||||
// id and "a session that is not the caller's" cannot be decided. Signing out
|
||||
// (POST /auth/mobile/logout) revokes the caller's own row and stays open.
|
||||
{ methods: ['DELETE'], path: '/auth/me/sessions/:id', when: callerIsDemo },
|
||||
|
||||
// Tier A — credentials for everyone.
|
||||
{ methods: ['POST'], path: '/auth/password/forgot' },
|
||||
{ methods: ['POST'], path: '/auth/password/reset/:token' },
|
||||
// DM16: settings are open, so `player_registration` can be switched on; the
|
||||
// route is what keeps registration closed (§4).
|
||||
{ methods: ['POST'], path: '/auth/register' },
|
||||
|
||||
// Tier C's one exception: the contact form mails a person.
|
||||
{ methods: ['POST'], path: '/public/contact' },
|
||||
|
||||
// Uploads. `refuseMultipart` already stops every one of these; they are
|
||||
// listed too so the table in §3.3 and this file say the same thing, and so a
|
||||
// non-multipart body sent to an upload route is refused the same way.
|
||||
{ methods: ['POST'], path: '/admin/uploads' },
|
||||
{ methods: ['POST'], path: '/admin/posts/upload' },
|
||||
{ methods: ['POST'], path: '/admin/settings/brand-asset/:slot' },
|
||||
{ methods: ['POST'], path: '/player/teams/:slug/forum/uploads' },
|
||||
].map((rule) => ({ ...rule, source: 'core', compiled: compile(rule.path) }))
|
||||
|
||||
/**
|
||||
* Build the lock middleware.
|
||||
*
|
||||
* @param {() => object[]} moduleLocks returns the installed modules' compiled
|
||||
* locks — asked per request, because the loader runs after app.js mounts this.
|
||||
*/
|
||||
function createLock(moduleLocks = () => []) {
|
||||
return async function lock(req, res, next) {
|
||||
if (!WRITES.includes(req.method)) return next()
|
||||
const rules = CORE_LOCKS.concat(moduleLocks())
|
||||
for (const rule of rules) {
|
||||
if (!rule.methods.includes(req.method)) continue
|
||||
const params = match(rule.compiled, req.path)
|
||||
if (!params) continue
|
||||
if (rule.when) {
|
||||
let applies
|
||||
try {
|
||||
applies = await rule.when(req, params)
|
||||
} catch (err) {
|
||||
// Fail closed: a lock that cannot decide is a lock.
|
||||
log.warn('lock condition failed; refusing', { path: rule.path, message: err.message })
|
||||
applies = true
|
||||
}
|
||||
if (!applies) continue
|
||||
}
|
||||
log.info('refused', { method: req.method, path: req.originalUrl, rule: rule.path, source: rule.source })
|
||||
const keys = rule.path === '/admin/settings' ? { keys: protectedKeys(req.body) } : {}
|
||||
return refuse(res, keys)
|
||||
}
|
||||
return next()
|
||||
}
|
||||
}
|
||||
|
||||
// Any multipart content type, not only form-data: nothing on the demo takes one,
|
||||
// and a narrower test is one more spelling to get wrong. The header alone
|
||||
// decides, so a multipart request with no body is refused too.
|
||||
function refuseMultipart(req, res, next) {
|
||||
const type = String(req.headers['content-type'] || '').trim().toLowerCase()
|
||||
if (!type.startsWith('multipart/')) return next()
|
||||
log.info('refused upload', { method: req.method, path: req.originalUrl })
|
||||
return refuse(res)
|
||||
}
|
||||
|
||||
// DM25: an image pasted into rich text travels inside the JSON body as a data:
|
||||
// URI, which is an upload by another name. Any string anywhere in a write's
|
||||
// body is checked, so a field added later, a setting or a module's route is
|
||||
// covered without being listed, as with multipart.
|
||||
//
|
||||
// A data: URI is `data:` (not the tail of a word like "metadata:") followed by
|
||||
// a comma, a `;`, or a type/subtype and then a comma. Prose such as
|
||||
// "Data: none, yet" does not match. Before testing, HTML character references
|
||||
// are decoded and tabs and newlines removed, because a browser does both when
|
||||
// it reads an attribute: `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 }
|
||||
@@ -144,7 +144,9 @@ function priceOf(action, params) {
|
||||
function dimensionsOf(action) {
|
||||
const params = {}
|
||||
for (const p of action?.params || []) {
|
||||
if (p.example !== undefined && p.example !== null) params[p.name] = p.example
|
||||
// A `multiple` param's example is one value and `cost()` is handed a list
|
||||
// (1.14.0), so it is priced as a list of one.
|
||||
if (p.example !== undefined && p.example !== null) params[p.name] = p.multiple ? [p.example] : p.example
|
||||
}
|
||||
const priced = priceOf(action, params)
|
||||
return priced ? Object.keys(priced).sort() : []
|
||||
|
||||
@@ -320,6 +320,16 @@ async function sweep() {
|
||||
// cleanup, whose counters were spent deliberately.
|
||||
const candidates = await resourcesDb.runsNeedingCleanup(CLEANUP_RUN_BATCH, MAX_REVERT_ATTEMPTS)
|
||||
let swept = 0
|
||||
|
||||
// **A finished run with nothing left to give back is complete** (MODULE_API
|
||||
// 1.11.0). The scan above joins on an unresolved row, and until `expired` no
|
||||
// run could end `pending` without one. Now a zone can expire while its run is
|
||||
// still going — `expireResource` leaves a live run's status to its terminal
|
||||
// path — and when that run ends, nothing in the scan would ever select it:
|
||||
// `pending` for ever. Found by the step-2 walk, run 46.
|
||||
for (const runId of await resourcesDb.finishedRunsWithNothingLeft(CLEANUP_RUN_BATCH)) {
|
||||
if (await runsDb.setCleanupStatus(runId, 'complete', ['pending'])) swept += 1
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
if (!runsDb.TERMINAL.includes(candidate.status)) continue
|
||||
try {
|
||||
@@ -347,13 +357,16 @@ async function sweep() {
|
||||
* Reverting it would be core recording that it put something back when what
|
||||
* actually happened is that the thing vanished while nobody was looking, and the
|
||||
* two are different sentences to the operator reading the console afterwards.
|
||||
* One gone after its `until` is neither: the game ended it on time, so it is
|
||||
* `expired`, the line `expired()` would have written (W1).
|
||||
*
|
||||
* A module with no `reconcile()` on the action is not broken: core keeps
|
||||
* believing its own ledger, which is precisely the behaviour before this phase.
|
||||
*/
|
||||
async function reconcileModule(owner) {
|
||||
async function reconcileModule(owner, now = new Date()) {
|
||||
const rows = await resourcesDb.liveForModule(owner)
|
||||
const summary = { asked: 0, inForce: 0, orphaned: 0, unanswered: 0 }
|
||||
const summary = { asked: 0, inForce: 0, orphaned: 0, expired: 0, unanswered: 0 }
|
||||
const settled = new Set()
|
||||
if (!rows.length) return summary
|
||||
|
||||
const byStep = new Map()
|
||||
@@ -401,6 +414,19 @@ async function reconcileModule(owner) {
|
||||
summary.inForce += 1
|
||||
continue
|
||||
}
|
||||
// **Gone after its deadline is the deadline working** (W1, Rust
|
||||
// PLAN_LAST_FIXES). A resource that carries `until` is one the game ends
|
||||
// by itself, and on a reconnect the module's reconcile can run before the
|
||||
// game's backlog says so: the run log read `resource.orphaned` five seconds
|
||||
// before `resource.expired`. Past its `until`, gone is expired. Before it,
|
||||
// gone is still nobody-was-looking, which is what orphaned means.
|
||||
if (row.lease_until && new Date(row.lease_until).getTime() <= now.getTime()) {
|
||||
if (await settleExpired(owner, row)) {
|
||||
summary.expired += 1
|
||||
settled.add(row.run_id)
|
||||
}
|
||||
continue
|
||||
}
|
||||
await resourcesDb.markOrphaned(row.id, 'the module reports this is no longer in force')
|
||||
summary.orphaned += 1
|
||||
await logDb.write({
|
||||
@@ -410,9 +436,42 @@ async function reconcileModule(owner) {
|
||||
})
|
||||
}
|
||||
}
|
||||
await closeSettledRuns(settled)
|
||||
return summary
|
||||
}
|
||||
|
||||
/**
|
||||
* One row the game ended at its deadline: `expired`, and its log line. The same
|
||||
* line whichever way core learned it, from the module's `expired()` or from a
|
||||
* reconcile that found it gone past its `until`, so the console reads one way.
|
||||
*/
|
||||
async function settleExpired(owner, row) {
|
||||
if (!(await resourcesDb.markExpired(row.id, 'the game ended it at its deadline'))) return false
|
||||
await logDb.write({
|
||||
runId: row.run_id,
|
||||
kind: 'resource.expired',
|
||||
detail: { module: owner, resource: `${row.kind}:${row.ref}` },
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* A finished run whose last unresolved row just expired has nothing left to
|
||||
* clean up. The sweep would settle a `pending` one by itself, but not an
|
||||
* `incomplete` one — that status takes a run out of the sweep's scan, and it
|
||||
* would go on saying "not finished" about a ledger that is. A run still in
|
||||
* flight is left alone: its own terminal path computes the status.
|
||||
*/
|
||||
async function closeSettledRuns(runIds) {
|
||||
for (const runId of runIds) {
|
||||
const run = await runsDb.getById(runId)
|
||||
if (!run || !runsDb.TERMINAL.includes(run.status)) continue
|
||||
if ((await resourcesDb.unresolvedCount(runId)) === 0) {
|
||||
await runsDb.setCleanupStatus(runId, 'complete', ['pending', 'incomplete'])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Ask every module that owns a live row. Core's own boot-time sweep. */
|
||||
async function reconcileAll() {
|
||||
const owners = await resourcesDb.modulesWithLiveRows()
|
||||
@@ -427,6 +486,47 @@ async function reconcileAll() {
|
||||
return out
|
||||
}
|
||||
|
||||
// The bounds on what a module may name. They are the columns' own: a longer
|
||||
// value cannot be a row, so it is refused rather than truncated into a match
|
||||
// against somebody else's.
|
||||
const MAX_KIND = 64
|
||||
const MAX_REF = 190
|
||||
|
||||
/**
|
||||
* The game ended one of this module's resources by itself, as it was told to —
|
||||
* `ctx.events.expired({ kind, ref })`, MODULE_API 1.11.0 (Rust PLAN_FIXES D183).
|
||||
*
|
||||
* **Expired is not orphaned, and the difference is the reason this exists.** A
|
||||
* Rust zone is created with a deadline and the game erases it when the deadline
|
||||
* passes, without being asked again — the fail-safe that makes an unattended
|
||||
* world change defensible. Until a module could say so, core learned of it only
|
||||
* through reconcile, which files it as `orphaned`: a thing that vanished while
|
||||
* nobody was looking, amber on the console and still claimable for a revert.
|
||||
* An expiry is the plan working, so it is terminal and green like `reverted`.
|
||||
*
|
||||
* `owner` is bound by the loader, never taken from the module's arguments, for
|
||||
* `reconcile`'s reason: without the binding a module could close another
|
||||
* module's rows. Answers `{ expired }`; nothing matching is `{ expired: 0 }`,
|
||||
* because a module hears expiries of things no run ledgered too.
|
||||
*/
|
||||
async function expireResource(owner, { kind, ref } = {}) {
|
||||
if (typeof kind !== 'string' || !kind || kind.length > MAX_KIND) return { expired: 0 }
|
||||
if (typeof ref !== 'string' || !ref || ref.length > MAX_REF) return { expired: 0 }
|
||||
|
||||
const rows = await resourcesDb.expirableByTarget(owner, kind, ref)
|
||||
const runs = new Set()
|
||||
let expired = 0
|
||||
|
||||
for (const row of rows) {
|
||||
if (!(await settleExpired(owner, row))) continue
|
||||
expired += 1
|
||||
runs.add(row.run_id)
|
||||
}
|
||||
await closeSettledRuns(runs)
|
||||
|
||||
return { expired }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_REVERT_ATTEMPTS,
|
||||
classifyRevert,
|
||||
@@ -434,4 +534,5 @@ module.exports = {
|
||||
sweep,
|
||||
reconcileModule,
|
||||
reconcileAll,
|
||||
expireResource,
|
||||
}
|
||||
|
||||
@@ -44,6 +44,38 @@ const MAX_HOLD_SECONDS = 7 * 24 * 60 * 60
|
||||
// rendered half of one would be a second bug on top of the first.
|
||||
const MAX_DETAIL_BYTES = 4096
|
||||
|
||||
// The bounds on a failure's `retryAfterMs`, in milliseconds (MODULE_API 1.15.0,
|
||||
// Rust PLAN_REDESIGNS §14.3.13, D371). One minute to thirty. **Clamped, never
|
||||
// refused**: it is the module's hint about its own verb — "the treasure is still
|
||||
// warming up, try in ten minutes" — not an author's value, so a module that says
|
||||
// five seconds or five hours gets the nearest bound rather than a failed step.
|
||||
// The floor is the flat delay's own default, so no hint can make a retry come
|
||||
// sooner than an unhinted one would on a stock deployment; the ceiling keeps
|
||||
// `MAX_ATTEMPTS` tries of a step inside an hour and a half of the moment the
|
||||
// event was about.
|
||||
const MIN_RETRY_AFTER_MS = 60_000
|
||||
const MAX_RETRY_AFTER_MS = 30 * 60_000
|
||||
|
||||
/**
|
||||
* A failure's `retryAfterMs`, clamped, or null for "use the flat delay".
|
||||
*
|
||||
* A value that is not a number at all is the hint being absent rather than
|
||||
* wrong: it is dropped and logged, the step retries on the flat delay, and
|
||||
* nothing about the failure changes — the same posture as a bad `detail`.
|
||||
*/
|
||||
function safeRetryAfter(value, actionId) {
|
||||
if (value === undefined || value === null) return null
|
||||
const n = typeof value === 'number' ? value : Number.NaN
|
||||
if (!Number.isFinite(n)) {
|
||||
log.warn('event action answered a retryAfterMs that is not a number; using the flat delay', {
|
||||
action: actionId,
|
||||
retryAfterMs: String(value).slice(0, 40),
|
||||
})
|
||||
return null
|
||||
}
|
||||
return Math.min(MAX_RETRY_AFTER_MS, Math.max(MIN_RETRY_AFTER_MS, Math.round(n)))
|
||||
}
|
||||
|
||||
/**
|
||||
* A module's own account of what a successful step actually did.
|
||||
*
|
||||
@@ -127,7 +159,7 @@ function withDeadline(fn, ms, actionId) {
|
||||
|
||||
/**
|
||||
* Turn a raw `perform()` answer into
|
||||
* `{ outcome, error?, holdSeconds?, resources?, participants? }`.
|
||||
* `{ outcome, error?, retryAfterMs?, holdSeconds?, resources?, participants? }`.
|
||||
*
|
||||
* Exported and pure, so the classification rules are testable without a registry,
|
||||
* a database or a clock — which matters because they are the rules that decide
|
||||
@@ -150,10 +182,13 @@ function classify(result, actionId) {
|
||||
// says `retry: false`, and an envelope that forgot to say anything gets the
|
||||
// benefit of the doubt on the transient question but not on the success one.
|
||||
const retry = result.retry !== false
|
||||
return {
|
||||
outcome: retry ? 'retry' : 'terminal',
|
||||
error: result.error ? String(result.error) : `${actionId} refused`,
|
||||
}
|
||||
const error = result.error ? String(result.error) : `${actionId} refused`
|
||||
if (!retry) return { outcome: 'terminal', error }
|
||||
// **When to try again** is the module's to suggest and core's to bound. Read
|
||||
// on the retry shape only: a terminal failure is never attempted again, so a
|
||||
// delay beside `retry: false` describes nothing.
|
||||
const retryAfterMs = safeRetryAfter(result.retryAfterMs, actionId)
|
||||
return retryAfterMs === null ? { outcome: 'retry', error } : { outcome: 'retry', error, retryAfterMs }
|
||||
}
|
||||
|
||||
// ── The two success shapes that are not "finished" ──
|
||||
@@ -250,4 +285,15 @@ async function dispatchStep(step, { run, actor = null, verify = false } = {}) {
|
||||
return classification
|
||||
}
|
||||
|
||||
module.exports = { dispatchStep, classify, withDeadline, safeDetail, OUTCOMES, MAX_HOLD_SECONDS, MAX_DETAIL_BYTES }
|
||||
module.exports = {
|
||||
dispatchStep,
|
||||
classify,
|
||||
withDeadline,
|
||||
safeDetail,
|
||||
safeRetryAfter,
|
||||
OUTCOMES,
|
||||
MAX_HOLD_SECONDS,
|
||||
MAX_DETAIL_BYTES,
|
||||
MIN_RETRY_AFTER_MS,
|
||||
MAX_RETRY_AFTER_MS,
|
||||
}
|
||||
|
||||
188
server/src/events/progress.js
Normal file
188
server/src/events/progress.js
Normal file
@@ -0,0 +1,188 @@
|
||||
// ── A live run's progress lines ────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §I and MODULE_API 1.12.0. While a run is live, its public page can
|
||||
// say how it stands — "Bandits: 3 of 8 left", "The Juggernaut: 62%" — and only
|
||||
// the module that carried out a step knows the answer. So an action may declare
|
||||
// `progress(envelope)`, and this file asks each step of the run whose action has
|
||||
// one.
|
||||
//
|
||||
// **Core never interprets a line.** It accepts exactly two shapes, a count and a
|
||||
// percentage, each with a label, and renders them as text. Core cannot know what
|
||||
// a "bandit" is, and does not need to: it knows "label: left of total" and
|
||||
// "label: percent", which serve any game, and a UO module could fill them for its
|
||||
// own actions without a word of this file changing (D305).
|
||||
//
|
||||
// **Nothing here may hurt the page that asked.** A public event page is read by
|
||||
// anybody, so every failure — a module that throws, answers late, answers
|
||||
// nonsense or is uninstalled — is a line that is not shown, and a log line. The
|
||||
// page always renders. That is `registerTeamProvider`'s posture rather than
|
||||
// `dispatch.js`'s: a missing progress line costs a reader one sentence, and a page
|
||||
// that errored would cost them the event.
|
||||
//
|
||||
// **One answer per run and viewer level, for a few seconds.** A busy page is
|
||||
// read by many people at once, and each read reaching the game through a module
|
||||
// would turn the audience into load. The module's own caches (Rust's map answer)
|
||||
// sit behind this one; this one bounds how often core asks at all.
|
||||
|
||||
const registries = require('../modules/registries')
|
||||
const stepsDb = require('../model/events/eventRunSteps.db')
|
||||
const resourcesDb = require('../model/events/eventRunResources.db')
|
||||
const { withDeadline } = require('./dispatch')
|
||||
const log = require('../utils/logger')('events')
|
||||
|
||||
// The deadline for one `progress()` call. Short, because a reader is waiting on
|
||||
// it: the page renders without a late line rather than waiting for it.
|
||||
const PROGRESS_BUDGET_MS = 2000
|
||||
|
||||
// How long one run's lines are reused for, per viewer.
|
||||
const CACHE_MS = 5000
|
||||
|
||||
// The bound on a label, in characters. A label is a phrase beside a number.
|
||||
const MAX_LABEL = 80
|
||||
|
||||
// The bound on lines per run. A run with more Place steps than this has a page
|
||||
// that is a list of numbers, and the cap keeps a module bug from becoming a wall.
|
||||
const MAX_LINES = 20
|
||||
|
||||
// The ledger statuses that still mean "this run's".
|
||||
const HELD = new Set(resourcesDb.HELD)
|
||||
|
||||
/**
|
||||
* One module answer as a line, or null. Pure and exported: the two shapes are the
|
||||
* contract, and anything else is dropped rather than repaired.
|
||||
*/
|
||||
function toLine(answer, actionId) {
|
||||
if (answer === null || answer === undefined) return null
|
||||
if (typeof answer !== 'object' || Array.isArray(answer)) {
|
||||
log.warn('event progress is not an object', { action: actionId })
|
||||
return null
|
||||
}
|
||||
const label = typeof answer.label === 'string' ? answer.label.trim() : ''
|
||||
if (!label || label.length > MAX_LABEL) {
|
||||
log.warn('event progress has a bad label', { action: actionId })
|
||||
return null
|
||||
}
|
||||
const hasCount = answer.left !== undefined || answer.of !== undefined
|
||||
const hasPercent = answer.percent !== undefined
|
||||
if (hasCount === hasPercent) {
|
||||
log.warn('event progress must be a count or a percent', { action: actionId })
|
||||
return null
|
||||
}
|
||||
if (hasCount) {
|
||||
const { left, of } = answer
|
||||
if (!Number.isInteger(left) || !Number.isInteger(of) || left < 0 || of < 1 || left > of) {
|
||||
log.warn('event progress has a bad count', { action: actionId })
|
||||
return null
|
||||
}
|
||||
return { label, left, of }
|
||||
}
|
||||
const percent = Number(answer.percent)
|
||||
if (typeof answer.percent !== 'number' || !Number.isFinite(percent) || percent < 0 || percent > 100) {
|
||||
log.warn('event progress has a bad percent', { action: actionId })
|
||||
return null
|
||||
}
|
||||
return { label, percent: Math.round(percent) }
|
||||
}
|
||||
|
||||
/** run id + viewer key → { at, lines } and { pending } while one is in flight. */
|
||||
const cache = new Map()
|
||||
|
||||
// A key per reader, so the map is pruned of answers past their window once it
|
||||
// grows; an ended run's entries go with them.
|
||||
const PRUNE_AT = 1000
|
||||
|
||||
function prune(now) {
|
||||
if (cache.size < PRUNE_AT) return
|
||||
for (const [key, entry] of cache) {
|
||||
if (!entry.pending && now() - entry.at >= CACHE_MS) cache.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The lines one step's action answers, or null. Never throws.
|
||||
*/
|
||||
async function stepLine(step, run, resources, viewer) {
|
||||
const action = registries.eventAction(step.action_id)
|
||||
if (!action || typeof action.progress !== 'function') return null
|
||||
|
||||
const envelope = {
|
||||
runId: run.id,
|
||||
stepId: step.id,
|
||||
idempotencyKey: step.idempotency_key,
|
||||
scope: run.scope || '',
|
||||
params: step.params || {},
|
||||
// The ledger rows this step made that are still the run's, as the module
|
||||
// wrote them. Core adds nothing a module did not already hold.
|
||||
resources: resources.map((r) => ({ kind: r.kind, ref: r.ref, payload: r.payload })),
|
||||
// Who is reading, as an account id or null. The module decides what its own
|
||||
// visibility settings let that account see, from its own re-read of the row.
|
||||
viewer: { userId: viewer.userId == null ? null : viewer.userId },
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await withDeadline(() => action.progress(envelope), PROGRESS_BUDGET_MS, action.id)
|
||||
if (raw && raw.__timedOut) {
|
||||
log.warn('event progress timed out', { action: action.id, run: run.id, step: step.id })
|
||||
return null
|
||||
}
|
||||
return toLine(raw, action.id)
|
||||
} catch (err) {
|
||||
log.warn('event progress threw', { action: action.id, run: run.id, step: step.id, message: err.message })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The progress lines for one live run, in step order. Resolves `[]` when there
|
||||
* is nothing to say or anything went wrong; never rejects.
|
||||
*
|
||||
* `viewer.userId` is the reader's account id or null. The cache is keyed on it
|
||||
* because what a module shows can depend on who is reading.
|
||||
*/
|
||||
async function forRun(run, { viewer = { userId: null }, now = Date.now } = {}) {
|
||||
if (!run || run.id == null) return []
|
||||
const key = `${run.id}|${viewer.userId == null ? '' : viewer.userId}`
|
||||
const hit = cache.get(key)
|
||||
if (hit && hit.lines && now() - hit.at < CACHE_MS) return hit.lines
|
||||
if (hit && hit.pending) return hit.pending
|
||||
prune(now)
|
||||
|
||||
const pending = (async () => {
|
||||
let lines = []
|
||||
try {
|
||||
const steps = (await stepsDb.listForRun(run.id)).filter((s) => {
|
||||
const action = registries.eventAction(s.action_id)
|
||||
return action && typeof action.progress === 'function'
|
||||
})
|
||||
if (steps.length) {
|
||||
const ledger = (await resourcesDb.forRun(run.id)).filter(
|
||||
(r) => HELD.has(r.status) && r.kind !== resourcesDb.STEP_KIND,
|
||||
)
|
||||
const answers = await Promise.all(
|
||||
steps.map((s) => stepLine(s, run, ledger.filter((r) => String(r.step_id) === String(s.id)), viewer)),
|
||||
)
|
||||
lines = answers.filter(Boolean).slice(0, MAX_LINES)
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('event progress could not be read', { run: run.id, message: err.message })
|
||||
lines = []
|
||||
}
|
||||
cache.set(key, { at: now(), lines })
|
||||
return lines
|
||||
})()
|
||||
|
||||
cache.set(key, { ...(hit || {}), pending })
|
||||
try {
|
||||
return await pending
|
||||
} finally {
|
||||
const entry = cache.get(key)
|
||||
if (entry && entry.pending === pending) delete entry.pending
|
||||
}
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
function _reset() {
|
||||
cache.clear()
|
||||
}
|
||||
|
||||
module.exports = { forRun, toLine, PROGRESS_BUDGET_MS, CACHE_MS, MAX_LABEL, MAX_LINES, PRUNE_AT, _reset }
|
||||
@@ -12,13 +12,14 @@
|
||||
// one that decides. A spec arriving by any other route (a restore, a fixture, a
|
||||
// module shipping a definition as content) gets the same answer.
|
||||
//
|
||||
// **What this file knows, and what it deliberately refuses.** Two top-level keys
|
||||
// exist today: `schedule` and `phases`. Unknown top-level keys are REFUSED rather
|
||||
// **What this file knows, and what it deliberately refuses.** Three top-level
|
||||
// keys exist today: `schedule`, `starts` and `phases`. Unknown top-level keys are REFUSED rather
|
||||
// than preserved: a spec that silently carries `announcements` today is a spec
|
||||
// whose author believes announcements work, and the later phase that gives the
|
||||
// key meaning would inherit a corpus of unvalidated ones. The refusal list is the
|
||||
// changelog — Phase 4 added the recurrence shapes, Phase 5 added a phase's
|
||||
// `advance`, Phase 10 adds `announcements`.
|
||||
// `advance`, Phase 10 adds `announcements`, and Rust PLAN_REDESIGNS §14.3.12
|
||||
// added `starts`, the triggers that begin a run (D370).
|
||||
//
|
||||
// **Phase 4 widened `schedule` from one shape to four** — `manual`, `once`,
|
||||
// `weekly`, `monthly` — and every check on them is a check on SHAPE. The
|
||||
@@ -28,6 +29,8 @@
|
||||
// DST gap shifts it, it is never rejected), so a schedule that validates here
|
||||
// computes there.
|
||||
|
||||
const crypto = require('crypto')
|
||||
|
||||
const registries = require('../modules/registries')
|
||||
// For `priceOf` and `undeclaredDimensions` only — the save-time half of §F's
|
||||
// fail-closed budget rule (Phase 7). Nothing here reaches the database:
|
||||
@@ -76,6 +79,24 @@ const MAX_AFTER_SECONDS = 30 * 86_400
|
||||
// never advances written as one that eventually does.
|
||||
const MAX_ADVANCE_COUNT = 1000
|
||||
|
||||
// A definition's `starts` (Rust PLAN_REDESIGNS §14.3.12, D370): the triggers that
|
||||
// begin a run of it. Bounded for the reason MAX_PHASES is — a paste guard, not a
|
||||
// policy, and ten is more than any event here has wanted.
|
||||
const MAX_STARTS = 10
|
||||
|
||||
// How long after one trigger-started run another may start. Thirty minutes by
|
||||
// default, zero (one run at a time is then the only bound) up to a week. It is
|
||||
// the same one-integer-one-unit grammar as `after`, for the same proofreading
|
||||
// reason, and normalised the same way.
|
||||
const DEFAULT_COOLDOWN = '30m'
|
||||
const MAX_COOLDOWN_SECONDS = 7 * 86_400
|
||||
|
||||
// Core's own lifecycle triggers cannot start a run (D370). `event.run.started`
|
||||
// starting a run would be a run starting another, and the one-at-a-time rule is
|
||||
// per definition, so it would not hold a chain of two definitions starting each
|
||||
// other. Refused by prefix because every one of them is core's and named so.
|
||||
const CORE_EVENT_PREFIX = 'event.'
|
||||
|
||||
// The four closed shapes of §E. `manual` is first because it is the default and
|
||||
// what an unscheduled draft carries; the other three are recurrences the runner
|
||||
// expands into occurrences ahead of time.
|
||||
@@ -323,6 +344,105 @@ function validateAdvance(raw, path, errors) {
|
||||
return { on: triggerId, where: checked.conditions, count, dormant: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a cooldown into seconds, or answer null. `'0m'` (or a bare `0`) is
|
||||
* allowed here and nowhere else in the duration grammar: no cooldown at all is a
|
||||
* real choice, and then one run at a time is the only bound.
|
||||
*/
|
||||
function parseCooldown(raw) {
|
||||
if (raw === 0 || raw === '0') return 0
|
||||
const m = AFTER_RE.exec(String(raw ?? ''))
|
||||
if (!m) return null
|
||||
const seconds = Number(m[1]) * AFTER_UNIT_SECONDS[m[2]]
|
||||
return seconds <= MAX_COOLDOWN_SECONDS ? seconds : null
|
||||
}
|
||||
|
||||
const formatCooldown = (seconds) => (seconds === 0 ? '0m' : formatAfter(seconds))
|
||||
|
||||
/**
|
||||
* Check a definition's `starts` and answer the normalised list (D370).
|
||||
*
|
||||
* Each entry is a phase gate's `on` and `where` — the same catalogue, the same
|
||||
* grammar, the same dormant rule — plus a `cooldown`. A trigger nobody registers
|
||||
* makes the entry dormant: it saves, and it refuses to publish.
|
||||
*/
|
||||
function validateStarts(raw, errors) {
|
||||
if (raw === undefined || raw === null) return []
|
||||
if (!Array.isArray(raw)) {
|
||||
errors.push('spec.starts: expected an array')
|
||||
return []
|
||||
}
|
||||
if (raw.length > MAX_STARTS) {
|
||||
errors.push(`spec.starts: at most ${MAX_STARTS} triggers`)
|
||||
return []
|
||||
}
|
||||
|
||||
const out = []
|
||||
raw.forEach((entry, i) => {
|
||||
const path = `spec.starts[${i}]`
|
||||
if (!isPlainObject(entry)) {
|
||||
errors.push(`${path}: expected an object`)
|
||||
return
|
||||
}
|
||||
const extra = Object.keys(entry).filter((k) => !['on', 'where', 'cooldown', 'dormant'].includes(k))
|
||||
if (extra.length) {
|
||||
errors.push(`${path}: unknown key(s) ${extra.join(', ')}`)
|
||||
return
|
||||
}
|
||||
if (typeof entry.on === 'string' && entry.on.startsWith(CORE_EVENT_PREFIX)) {
|
||||
errors.push(`${path}.on: "${entry.on}" is one of the event system's own triggers, and a run cannot start another`)
|
||||
return
|
||||
}
|
||||
|
||||
const cooldownSeconds = parseCooldown(entry.cooldown ?? DEFAULT_COOLDOWN)
|
||||
if (cooldownSeconds === null) {
|
||||
errors.push(
|
||||
`${path}.cooldown: expected a duration like "30m" — a whole number of s, m, h or d, ` +
|
||||
`from 0 to ${formatAfter(MAX_COOLDOWN_SECONDS)}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// The trigger and its predicate are a gate's, so a gate's validator answers
|
||||
// for them: one grammar, one dormant rule, one set of messages.
|
||||
const gate = validateAdvance({ on: entry.on, where: entry.where ?? null }, path, errors)
|
||||
if (!gate) return
|
||||
out.push({ on: gate.on, where: gate.where, cooldown: formatCooldown(cooldownSeconds), dormant: gate.dormant })
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* A `multiple` param's value as the list it is (MODULE_API 1.14.0, Rust
|
||||
* PLAN_REDESIGNS §12.3).
|
||||
*
|
||||
* `{ value: [...] }` or `{ error }`. **A bare value is a list of one**, so a
|
||||
* spec written by hand or over the API before its author knew the param took
|
||||
* several does not trip on it. Absent, `null`, `''` and `[]` are all the empty
|
||||
* list, which the caller reads as unset — `required` means at least one.
|
||||
*
|
||||
* Each element is the type's literal, checked by the same `checkLiteral` a
|
||||
* single value is; an empty string inside the list is refused rather than kept,
|
||||
* because a blank chip is nothing anyone picked. Duplicates are dropped keeping
|
||||
* the first, so the order the author picked in is the order the module reads.
|
||||
*/
|
||||
function checkList(p, raw) {
|
||||
if (raw === undefined || raw === null || raw === '') return { value: [] }
|
||||
const items = Array.isArray(raw) ? raw : [raw]
|
||||
const out = []
|
||||
const seen = new Set()
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i] === '') return { error: `item ${i + 1} is empty` }
|
||||
const checked = checkLiteral(p.type, items[i])
|
||||
if (checked.error) return { error: `item ${i + 1} ${checked.error}` }
|
||||
if (seen.has(checked.value)) continue
|
||||
seen.add(checked.value)
|
||||
out.push(checked.value)
|
||||
}
|
||||
if (out.length > p.max) return { error: `holds ${out.length} values; the most is ${p.max}` }
|
||||
return { value: out }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check one authored param object against an action's declared params.
|
||||
*
|
||||
@@ -349,6 +469,14 @@ function checkParams(declaration, raw, path) {
|
||||
|
||||
for (const p of declaration.params || []) {
|
||||
const value = given[p.name]
|
||||
if (p.multiple) {
|
||||
const listed = checkList(p, value)
|
||||
if (listed.error) errors.push(`${path}.params: "${p.name}" ${listed.error}`)
|
||||
else if (!listed.value.length) {
|
||||
if (p.required) errors.push(`${path}.params: "${p.name}" is required`)
|
||||
} else params[p.name] = listed.value
|
||||
continue
|
||||
}
|
||||
if (value === undefined || value === null || value === '') {
|
||||
if (p.required) errors.push(`${path}.params: "${p.name}" is required`)
|
||||
continue
|
||||
@@ -388,7 +516,7 @@ function validate(raw, { knownActionIds = [] } = {}) {
|
||||
|
||||
if (!isPlainObject(raw)) return { ok: false, errors: ['spec: expected an object'] }
|
||||
|
||||
const allowed = new Set(['schedule', 'phases'])
|
||||
const allowed = new Set(['schedule', 'phases', 'starts'])
|
||||
for (const key of Object.keys(raw)) {
|
||||
if (!allowed.has(key)) {
|
||||
errors.push(`spec: unknown key "${key}" (Phase 1 understands ${[...allowed].join(', ')})`)
|
||||
@@ -413,6 +541,9 @@ function validate(raw, { knownActionIds = [] } = {}) {
|
||||
schedule = validateSchedule(kind, rawSchedule, errors)
|
||||
}
|
||||
|
||||
// ── starts (D370) ──
|
||||
const starts = validateStarts(raw.starts, errors)
|
||||
|
||||
// ── phases ──
|
||||
const rawPhases = raw.phases
|
||||
const phases = []
|
||||
@@ -566,7 +697,10 @@ function validate(raw, { knownActionIds = [] } = {}) {
|
||||
if (totalSteps > MAX_STEPS) errors.push(`spec: at most ${MAX_STEPS} steps in one definition`)
|
||||
|
||||
if (errors.length) return { ok: false, errors }
|
||||
return { ok: true, spec: { schedule, phases } }
|
||||
// `starts` is omitted when empty, for the reason a phase's `advance` is: a spec
|
||||
// carrying `"starts": []` everywhere is a version diff that says every
|
||||
// definition changed the first time one of them gained a trigger.
|
||||
return { ok: true, spec: starts.length ? { schedule, starts, phases } : { schedule, phases } }
|
||||
}
|
||||
|
||||
/** Every action id a spec names, dormant ones included. */
|
||||
@@ -591,10 +725,38 @@ function publishable(spec) {
|
||||
const dormant = [
|
||||
...phases.flatMap((p) => (p.steps || []).filter((s) => s.dormant).map((s) => s.actionId)),
|
||||
...phases.filter((p) => p.advance?.dormant).map((p) => p.advance.on),
|
||||
// A start on a trigger nothing registers, on the same rule (D370): a version
|
||||
// that starts on something nobody can emit is one that never starts.
|
||||
...(spec?.starts || []).filter((s) => s.dormant).map((s) => s.on),
|
||||
]
|
||||
return dormant.length ? { ok: false, dormant: [...new Set(dormant)] } : { ok: true, dormant: [] }
|
||||
}
|
||||
|
||||
/** JSON with every object's keys sorted, so equal specs serialise equally. */
|
||||
function canonical(value) {
|
||||
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
|
||||
if (value && typeof value === 'object') {
|
||||
const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort()
|
||||
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`
|
||||
}
|
||||
return JSON.stringify(value) ?? 'null'
|
||||
}
|
||||
|
||||
/**
|
||||
* A spec's fingerprint: the sha256 of its canonical JSON (W5, Rust
|
||||
* PLAN_LAST_FIXES).
|
||||
*
|
||||
* What lets a dry run that passed on a draft count for the version published
|
||||
* from it. The draft is fingerprinted when the dry run passes and the version
|
||||
* when it is published, both from `validate`'s output, so an edit in between is
|
||||
* a different fingerprint and nothing carries over. Key order is not part of it:
|
||||
* a spec that round-trips through the database with its keys reordered is the
|
||||
* same plan.
|
||||
*/
|
||||
function fingerprint(spec) {
|
||||
return crypto.createHash('sha256').update(canonical(spec)).digest('hex')
|
||||
}
|
||||
|
||||
/** An empty, valid spec — what a newly created draft carries. */
|
||||
const emptySpec = () => ({
|
||||
schedule: { kind: 'manual' },
|
||||
@@ -603,13 +765,22 @@ const emptySpec = () => ({
|
||||
|
||||
module.exports = {
|
||||
validate,
|
||||
// An event template's inputs are params, and are checked as one (MODULE_API
|
||||
// 1.13.0): eventDefinitions.model `buildFromTemplate`.
|
||||
checkParams,
|
||||
publishable,
|
||||
fingerprint,
|
||||
actionIdsIn,
|
||||
emptySpec,
|
||||
defaultOnFailure,
|
||||
parseAfter,
|
||||
formatAfter,
|
||||
parseCooldown,
|
||||
PHASE_KEY,
|
||||
MAX_STARTS,
|
||||
DEFAULT_COOLDOWN,
|
||||
MAX_COOLDOWN_SECONDS,
|
||||
CORE_EVENT_PREFIX,
|
||||
SCHEDULE_KINDS,
|
||||
ADVANCE_KINDS,
|
||||
MAX_ADVANCE_COUNT,
|
||||
|
||||
210
server/src/events/starts.js
Normal file
210
server/src/events/starts.js
Normal file
@@ -0,0 +1,210 @@
|
||||
// ── Starting a run from a trigger: the emit path's third subscriber ────────
|
||||
//
|
||||
// Rust PLAN_REDESIGNS §14.3.12 (D370), and EVENTS.md §E "Starting a run from a
|
||||
// trigger". *"When anyone destroys Bradley on Main, start Launch Site
|
||||
// Lockdown"*; *"when a champion's boss appears, start Champion's Call"*. Before
|
||||
// this a trigger could only advance a phase of a run already going (`gates.js`)
|
||||
// or send a notice (`engine.dispatch`); now a definition's published version may
|
||||
// carry `starts`, and a firing that matches one creates a run of it, now.
|
||||
//
|
||||
// **Same seam and posture as the other two subscribers**: called from
|
||||
// `engagementEmit.emit`, never awaited, never allowed to reject. Whether some
|
||||
// event wanted to start is core's business, and a failure of core's must not
|
||||
// become the module's control flow.
|
||||
//
|
||||
// **Four rules decide whether a matching firing starts anything:**
|
||||
//
|
||||
// 1. The version must have passed its dry run. A trigger start is unattended,
|
||||
// as a scheduled one is, and §K's gate falls on the path where nobody is.
|
||||
// 2. One run at a time per definition. A firing while a run of it is going
|
||||
// starts nothing and is counted.
|
||||
// 3. A cooldown after the last trigger-started run (default 30 minutes, 0 to
|
||||
// 7 days). A firing inside it starts nothing and is counted. Rules 2 and 3
|
||||
// are what stop twelve road Bradleys dying in a minute starting twelve runs.
|
||||
// 4. Core's own `event.*` triggers never start a run (refused at save, and
|
||||
// skipped here as well), so a run starting can never start another.
|
||||
//
|
||||
// Rules 2 and 3 are ONE conditional UPDATE (`eventTriggerStarts.db.claim`); the
|
||||
// occurrence key `UNIQUE (definition_id, scope, scheduled_for)` with
|
||||
// `scheduled_for` = the firing's time is what makes one firing reported twice
|
||||
// (two instances ingesting one feed) create one run.
|
||||
|
||||
const definitionsDb = require('../model/events/eventDefinitions.db')
|
||||
const startsDb = require('../model/events/eventTriggerStarts.db')
|
||||
const runsModel = require('../model/events/eventRuns.model')
|
||||
const conditions = require('../engagement/conditions')
|
||||
const registries = require('../modules/registries')
|
||||
const spec = require('./spec')
|
||||
const gates = require('./gates')
|
||||
const log = require('../utils/logger')('event-starts')
|
||||
|
||||
// What of a firing's data is kept on the run. The run row is admin-only, and it
|
||||
// is the answer to "why did this run happen", so the trigger's DECLARED
|
||||
// variables are kept and nothing else a module happened to send. Bounded, so
|
||||
// one oversized payload cannot make the insert the thing that fails.
|
||||
const MAX_ORIGIN_BYTES = 4096
|
||||
|
||||
/** The firing's time, in whole seconds (the column's precision), never in the future. */
|
||||
function firingTime(occurredAt, now) {
|
||||
const parsed = occurredAt ? new Date(occurredAt) : now
|
||||
const at = Number.isNaN(parsed.getTime()) || parsed > now ? now : parsed
|
||||
return new Date(Math.floor(at.getTime() / 1000) * 1000)
|
||||
}
|
||||
|
||||
const sameSecond = (a, b) =>
|
||||
a && b && Math.floor(new Date(a).getTime() / 1000) === Math.floor(new Date(b).getTime() / 1000)
|
||||
|
||||
/** The firing as the run will remember it. */
|
||||
function originOf(event, declaration, start, at) {
|
||||
const declared = (declaration?.variables || []).map((v) => v.name)
|
||||
const data = event.data || {}
|
||||
let variables = Object.fromEntries(declared.filter((n) => n in data).map((n) => [n, data[n]]))
|
||||
if (Buffer.byteLength(JSON.stringify(variables), 'utf8') > MAX_ORIGIN_BYTES) {
|
||||
// Too big to keep whole: keep the ones the start's condition named, which
|
||||
// are the reason it matched, and say the rest were dropped.
|
||||
const named = gates.variablesIn(start.where)
|
||||
variables = Object.fromEntries(named.filter((n) => n in data).map((n) => [n, data[n]]))
|
||||
variables._truncated = true
|
||||
}
|
||||
return {
|
||||
kind: 'trigger',
|
||||
triggerId: event.triggerId,
|
||||
label: declaration?.label || event.triggerId,
|
||||
occurredAt: at.toISOString(),
|
||||
subject: event.subject ?? null,
|
||||
// The start's condition in the builder's own words, so the console can say
|
||||
// what it matched on without re-reading the version.
|
||||
matched: gates.phrase(start.where),
|
||||
variables,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a firing that matched did not get the claim. A duplicate of the firing
|
||||
* that started the last run is not counted: it is the same thing happening once,
|
||||
* reported twice.
|
||||
*/
|
||||
async function whyNot(definitionId, at, now) {
|
||||
const state = await startsDb.forDefinition(definitionId)
|
||||
if (state && sameSecond(state.last_started_at, at)) return 'duplicate'
|
||||
if (await startsDb.isGoing(definitionId, now)) return 'running'
|
||||
// A live claim is another firing starting it this instant: the run is going in
|
||||
// every sense but the row.
|
||||
if (state?.claimed_at && now - new Date(state.claimed_at) < startsDb.CLAIM_STALE_MS) return 'running'
|
||||
return 'cooldown'
|
||||
}
|
||||
|
||||
/** One definition, one firing. Answers what happened, for the summary and the tests. */
|
||||
async function consider(definition, event, at, now) {
|
||||
const starts = (definition.version_spec?.starts || []).filter((s) => s.on === event.triggerId && !s.dormant)
|
||||
const start = starts.find((s) => conditions.evaluate(s.where, event.data || {}))
|
||||
if (!start) return 'unmatched'
|
||||
|
||||
if (now - at > Number(definition.grace_seconds || 0) * 1000) {
|
||||
// A firing older than the grace window would make a run the missed sweep
|
||||
// takes on its next tick. Said, and not started.
|
||||
log.warn('a firing too old to start a run', { definition: definition.id, trigger: event.triggerId, at })
|
||||
return 'stale'
|
||||
}
|
||||
|
||||
if (!definition.version_verified_at) {
|
||||
await startsDb.ignore(definition.id, 'unverified', now)
|
||||
log.warn('trigger start held: the published version has never been verified', {
|
||||
definition: definition.id,
|
||||
title: definition.title,
|
||||
trigger: event.triggerId,
|
||||
})
|
||||
return 'unverified'
|
||||
}
|
||||
|
||||
const cooldownSeconds = spec.parseCooldown(start.cooldown ?? spec.DEFAULT_COOLDOWN) ?? 0
|
||||
if (!(await startsDb.claim(definition.id, { at, cooldownSeconds, now }))) {
|
||||
const reason = await whyNot(definition.id, at, now)
|
||||
if (reason !== 'duplicate') await startsDb.ignore(definition.id, reason, now)
|
||||
return reason
|
||||
}
|
||||
|
||||
const declaration = registries.eventTrigger(event.triggerId)
|
||||
let result
|
||||
try {
|
||||
result = await runsModel.create(
|
||||
definition.id,
|
||||
// Scope is empty, as a scheduled occurrence's is (org lead, 2026-09-02): a
|
||||
// scope registry does not exist, and the condition is what narrows a start
|
||||
// to one server.
|
||||
{ scope: '', scheduledFor: at, source: 'trigger', origin: originOf(event, declaration, start, at) },
|
||||
null,
|
||||
)
|
||||
} catch (err) {
|
||||
await startsDb.release(definition.id)
|
||||
throw err
|
||||
}
|
||||
|
||||
if (!result.ok) {
|
||||
await startsDb.release(definition.id)
|
||||
if (result.code === 'unverified') await startsDb.ignore(definition.id, 'unverified', now)
|
||||
log.warn('trigger start refused', { definition: definition.id, errors: result.errors })
|
||||
return 'refused'
|
||||
}
|
||||
|
||||
if (!result.created) {
|
||||
// The occurrence already existed: this firing was reported twice, and the
|
||||
// claimant that made the run has recorded it. Nothing to count.
|
||||
await startsDb.release(definition.id)
|
||||
return 'duplicate'
|
||||
}
|
||||
await startsDb.started(definition.id, { at, runId: result.run.id })
|
||||
log.info('run started by a trigger', { definition: definition.id, run: result.run.id, trigger: event.triggerId })
|
||||
return 'started'
|
||||
}
|
||||
|
||||
/**
|
||||
* The trigger stream's third subscriber.
|
||||
*
|
||||
* The common answer is one indexed-by-nothing query over the ready definitions
|
||||
* returning no rows: a deployment has tens of definitions, not thousands, and a
|
||||
* JSON column cannot carry an index. If that ever changes, a side table of
|
||||
* (trigger, definition) written at publish is the fix.
|
||||
*/
|
||||
async function observe(event, { now = new Date() } = {}) {
|
||||
const summary = { definitions: 0, started: 0, ignored: 0 }
|
||||
try {
|
||||
if (!event?.triggerId || event.triggerId.startsWith(spec.CORE_EVENT_PREFIX)) return summary
|
||||
const candidates = await definitionsDb.findStartingOn(event.triggerId)
|
||||
summary.definitions = candidates.length
|
||||
if (!candidates.length) return summary
|
||||
|
||||
const at = firingTime(event.occurredAt, now)
|
||||
for (const definition of candidates) {
|
||||
try {
|
||||
const outcome = await consider(definition, event, at, now)
|
||||
if (outcome === 'started') summary.started += 1
|
||||
if (['running', 'cooldown', 'unverified'].includes(outcome)) summary.ignored += 1
|
||||
} catch (err) {
|
||||
log.error('trigger start failed', { definition: definition.id, trigger: event.triggerId, message: err.message })
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('trigger start observation failed', { trigger: event?.triggerId, message: err.message })
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor's line about a definition's trigger starts, from the state row.
|
||||
* Null when no trigger has ever tried to start it.
|
||||
*/
|
||||
function describeState(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
lastStartedAt: row.last_started_at || null,
|
||||
lastRunId: row.last_run_id || null,
|
||||
ignoredRunning: Number(row.ignored_running || 0),
|
||||
ignoredCooldown: Number(row.ignored_cooldown || 0),
|
||||
ignoredUnverified: Number(row.ignored_unverified || 0),
|
||||
lastIgnoredAt: row.last_ignored_at || null,
|
||||
lastIgnoredReason: row.last_ignored_reason || null,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { observe, consider, describeState, firingTime, originOf, MAX_ORIGIN_BYTES }
|
||||
@@ -36,6 +36,26 @@ const authorize = require('./authorize')
|
||||
const settingsDb = require('../model/events/eventActionSettings.db')
|
||||
const registries = require('../modules/registries')
|
||||
|
||||
/** The most advice lines one step's dry run carries, and the longest one. */
|
||||
const ADVICE_MAX = 10
|
||||
const ADVICE_CHARS = 500
|
||||
|
||||
/**
|
||||
* A dry run's `detail.warnings`, as the lines a dry run shows: strings only,
|
||||
* trimmed, the empty dropped, at most ten, each cut at 500 characters. Anything
|
||||
* else a module put there is ignored rather than refused — advice that cannot be
|
||||
* read is not a reason to fail a plan that would run.
|
||||
*/
|
||||
function adviceOf(detail) {
|
||||
if (!detail || typeof detail !== 'object' || !Array.isArray(detail.warnings)) return []
|
||||
return detail.warnings
|
||||
.filter((w) => typeof w === 'string')
|
||||
.map((w) => w.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, ADVICE_MAX)
|
||||
.map((w) => (w.length > ADVICE_CHARS ? `${w.slice(0, ADVICE_CHARS - 1)}…` : w))
|
||||
}
|
||||
|
||||
/**
|
||||
* Dry-run a spec.
|
||||
*
|
||||
@@ -108,13 +128,21 @@ async function verifySpec(spec, { user = null, scope = '' } = {}) {
|
||||
)
|
||||
if (result.outcome === 'retry' || result.outcome === 'terminal') {
|
||||
findings.push({ ...where, level: 'error', code: 'refused', message: result.error })
|
||||
} else if (result.actionVersionDrift) {
|
||||
findings.push({
|
||||
...where,
|
||||
level: 'warning',
|
||||
code: 'version-drift',
|
||||
message: `authored against version ${result.actionVersionDrift.authored}; ${step.actionId} is now version ${result.actionVersionDrift.registered}`,
|
||||
})
|
||||
} else {
|
||||
if (result.actionVersionDrift) {
|
||||
findings.push({
|
||||
...where,
|
||||
level: 'warning',
|
||||
code: 'version-drift',
|
||||
message: `authored against version ${result.actionVersionDrift.authored}; ${step.actionId} is now version ${result.actionVersionDrift.registered}`,
|
||||
})
|
||||
}
|
||||
// MODULE_API 1.16.0: a module's ADVICE on a step that would run — the
|
||||
// world as it stands now, which may change before the run does (Rust
|
||||
// PLAN_REDESIGNS §11.4, D391). A warning, so it never fails the dry run.
|
||||
for (const message of adviceOf(result.detail)) {
|
||||
findings.push({ ...where, level: 'warning', code: 'advice', message })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,4 +178,4 @@ async function verifySpec(spec, { user = null, scope = '' } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { verifySpec }
|
||||
module.exports = { verifySpec, adviceOf }
|
||||
|
||||
@@ -126,6 +126,18 @@ const markReady = (id, versionId, userId) =>
|
||||
[versionId, userId, id],
|
||||
)
|
||||
|
||||
/**
|
||||
* Record a dry run that passed on the draft, with the fingerprint of the spec it
|
||||
* passed on (W5). Publish reads it back through `getById`'s `d.*`.
|
||||
*/
|
||||
const markDraftVerified = (id, { at = new Date(), userId = null, hash }) =>
|
||||
query(
|
||||
`UPDATE event_definitions
|
||||
SET draft_verified_at = ?, draft_verified_by = ?, draft_verified_hash = ?
|
||||
WHERE id = ?`,
|
||||
[at, userId, hash, id],
|
||||
)
|
||||
|
||||
/**
|
||||
* Every definition the runner should expand a recurrence for (Phase 4).
|
||||
*
|
||||
@@ -159,6 +171,30 @@ const findSchedulable = async ({ listedOnly = false } = {}) => {
|
||||
return rows.map((row) => ({ ...row, version_spec: parseJson(row.version_spec, null) }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `ready` definition whose PUBLISHED version starts on this trigger
|
||||
* (Rust PLAN_REDESIGNS §14.3.12, D370). The emit path's question, asked on every
|
||||
* firing, so it answers in one query and the common answer is no rows.
|
||||
*
|
||||
* Read from the version, never the working copy, for the reason the schedule
|
||||
* expansion is: a start an author is midway through typing must not fire.
|
||||
* `JSON_EXTRACT` over `$.starts[*].on` is an array, or NULL for a version with
|
||||
* no starts, and `JSON_CONTAINS` of NULL is not true.
|
||||
*/
|
||||
const findStartingOn = async (triggerId) => {
|
||||
const rows = await query(
|
||||
`SELECT d.id, d.title, d.grace_seconds, d.current_version_id,
|
||||
v.spec AS version_spec, v.verified_at AS version_verified_at
|
||||
FROM event_definitions d
|
||||
JOIN event_versions v ON v.id = d.current_version_id
|
||||
WHERE d.state = 'ready'
|
||||
AND JSON_CONTAINS(JSON_EXTRACT(v.spec, '$.starts[*].on'), JSON_QUOTE(?))
|
||||
ORDER BY d.id`,
|
||||
[triggerId],
|
||||
)
|
||||
return rows.map((row) => ({ ...row, version_spec: parseJson(row.version_spec, null) }))
|
||||
}
|
||||
|
||||
/**
|
||||
* One definition by slug, for the PUBLIC event page (Phase 14a).
|
||||
*
|
||||
@@ -206,8 +242,10 @@ module.exports = {
|
||||
listPublicBySeries,
|
||||
slugTaken,
|
||||
findSchedulable,
|
||||
findStartingOn,
|
||||
insert,
|
||||
update,
|
||||
markReady,
|
||||
markDraftVerified,
|
||||
archive,
|
||||
}
|
||||
|
||||
@@ -29,8 +29,11 @@ const spec = require('../../events/spec')
|
||||
const authorize = require('../../events/authorize')
|
||||
const verifier = require('../../events/verify')
|
||||
const registries = require('../../modules/registries')
|
||||
const { isDemo } = require('../../demo/mode')
|
||||
const { CODE: DEMO_LOCKED } = require('../../demo/locks')
|
||||
const { slugify, uniqueSlug } = require('../teams/teamSlug')
|
||||
const { cleanBody } = require('../../utils/sanitizeHtml')
|
||||
const log = require('../../utils/logger')('events')
|
||||
|
||||
const MAX_TITLE = 200
|
||||
const MAX_SUMMARY = 500
|
||||
@@ -180,6 +183,8 @@ async function create(input, userId, { role = null } = {}) {
|
||||
if (!result.ok) return result
|
||||
const floor = checkRoleFloor(result.definition.spec, role)
|
||||
if (floor) return floor
|
||||
const refused = await checkDemoRefusals(result.definition.spec)
|
||||
if (refused) return refused
|
||||
const id = await db.insert({ ...result.definition, created_by: userId })
|
||||
return { ok: true, id, definition: await db.getById(id) }
|
||||
}
|
||||
@@ -202,6 +207,8 @@ async function save(id, input, userId, { role = null } = {}) {
|
||||
if (!result.ok) return result
|
||||
const floor = checkRoleFloor(result.definition.spec, role)
|
||||
if (floor) return floor
|
||||
const refused = await checkDemoRefusals(result.definition.spec)
|
||||
if (refused) return refused
|
||||
await db.update(id, { ...result.definition, updated_by: userId })
|
||||
return { ok: true, id, definition: await db.getById(id) }
|
||||
}
|
||||
@@ -227,6 +234,11 @@ async function publish(id, userId) {
|
||||
})
|
||||
if (!checked.ok) return { ok: false, status: 400, errors: checked.errors }
|
||||
|
||||
// Asked again here, as the registries are: a draft saved before the module's
|
||||
// list grew must not become a version that runs (DM18).
|
||||
const refused = await checkDemoRefusals(checked.spec)
|
||||
if (refused) return refused
|
||||
|
||||
const publishable = spec.publishable(checked.spec)
|
||||
if (!publishable.ok) {
|
||||
return {
|
||||
@@ -248,6 +260,16 @@ async function publish(id, userId) {
|
||||
const versionId = await versionsDb.insert(id, version, checked.spec, userId)
|
||||
await db.markReady(id, versionId, userId)
|
||||
|
||||
// A dry run that passed on this exact draft counts for the version made from
|
||||
// it (W5), stamped with that pass's reviewer and time rather than the
|
||||
// publisher's and now: "who last looked at this before it ran unattended" is
|
||||
// the person who ran the check. Any edit since is a different fingerprint.
|
||||
const verified = Boolean(
|
||||
existing.draft_verified_hash &&
|
||||
existing.draft_verified_hash === spec.fingerprint(checked.spec) &&
|
||||
(await versionsDb.markVerified(versionId, existing.draft_verified_by || null, existing.draft_verified_at || new Date())),
|
||||
)
|
||||
|
||||
// Occurrences already materialised ahead of their instant move to the new
|
||||
// version; ones that have begun do not (org lead, 2026-09-02). Logged per run
|
||||
// rather than only counted, because "which version did this run actually use"
|
||||
@@ -269,9 +291,18 @@ async function publish(id, userId) {
|
||||
by: userId,
|
||||
},
|
||||
})
|
||||
// The held occurrence is now going to start, and the line saying why
|
||||
// belongs on it, as it does when the version is dry-run by hand.
|
||||
if (verified) {
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
kind: 'version.verified',
|
||||
detail: { versionId, version, by: existing.draft_verified_by || null, fromDraft: true },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, versionId, version, repinned, definition: await db.getById(id) }
|
||||
return { ok: true, versionId, version, repinned, verified, definition: await db.getById(id) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -345,6 +376,37 @@ function checkRoleFloor(specValue, role) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The public demo's save-time refusal (MODULE_API 1.19.0, DEMO_MODE.md §3.4,
|
||||
* DM18): each step whose action declares `demoRefuses(params)` is asked, and
|
||||
* the first sentence back refuses the whole save `403 DEMO_LOCKED`. Null off
|
||||
* the demo, where nothing is asked.
|
||||
*
|
||||
* A hook that throws refuses too, with core's sentence: this is a lock, and a
|
||||
* lock that cannot decide fails closed, as the route lock does (§3.3).
|
||||
*/
|
||||
async function checkDemoRefusals(specValue) {
|
||||
if (!isDemo()) return null
|
||||
for (const phase of specValue?.phases || []) {
|
||||
for (const step of phase.steps || []) {
|
||||
const action = registries.eventAction(step.actionId)
|
||||
if (!action || !action.demoRefuses) continue
|
||||
let said
|
||||
try {
|
||||
said = await action.demoRefuses(step.params || {})
|
||||
} catch (err) {
|
||||
log.error('demoRefuses failed', { action: action.id, message: err.message })
|
||||
said = `"${action.label}" could not be checked, so it is turned off on the demo site.`
|
||||
}
|
||||
if (typeof said === 'string' && said.trim()) {
|
||||
const message = said.trim()
|
||||
return { ok: false, status: 403, code: DEMO_LOCKED, message, errors: [message] }
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Dry-run a definition, and record the pass when there is a version to record it
|
||||
* on (Phase 6).
|
||||
@@ -372,6 +434,19 @@ async function verify(id, user) {
|
||||
|
||||
const report = await verifier.verifySpec(target, { user })
|
||||
|
||||
// A pass on the draft is kept with the fingerprint of the spec it passed on,
|
||||
// for publish to compare (W5). Fingerprinted from `validate`'s output, as
|
||||
// publish fingerprints the spec it freezes; a draft that no longer validates
|
||||
// could not be published anyway, so there is nothing to keep.
|
||||
let draftRecorded = false
|
||||
if (!version && report.ok) {
|
||||
const checked = spec.validate(existing.spec, { knownActionIds: spec.actionIdsIn(existing.spec) })
|
||||
if (checked.ok) {
|
||||
await db.markDraftVerified(id, { userId: user?.id || null, hash: spec.fingerprint(checked.spec) })
|
||||
draftRecorded = true
|
||||
}
|
||||
}
|
||||
|
||||
if (version && report.ok) {
|
||||
await versionsDb.markVerified(version.id, user?.id || null)
|
||||
// Written to every run already pinned to this version, because that is where
|
||||
@@ -396,17 +471,123 @@ async function verify(id, user) {
|
||||
versionId: version?.id || null,
|
||||
version: version?.version || null,
|
||||
recorded: Boolean(version && report.ok),
|
||||
// A pass on the draft, kept so that publishing this spec unchanged makes a
|
||||
// verified version (W5).
|
||||
draftRecorded,
|
||||
}
|
||||
}
|
||||
|
||||
// How long a template's `build()` may take. It may read the module's own data or
|
||||
// ask a sidecar, and an author is waiting on the other side of the click.
|
||||
const TEMPLATE_BUILD_MS = 10_000
|
||||
|
||||
/**
|
||||
* Fill an event template in (MODULE_API 1.13.0; Rust PLAN_REDESIGNS §2.5, D192).
|
||||
*
|
||||
* `{ ok: true, draft: { title, summary, spec } }`, or `{ ok: false, status,
|
||||
* errors }`. **Nothing is saved.** The draft goes back to the editor, which opens
|
||||
* it like any new event; the author saves it through `create()` and its rules,
|
||||
* so a template cannot make a definition the save path would refuse.
|
||||
*
|
||||
* The module is not trusted at either end. The inputs are checked against the
|
||||
* template's declaration before `build()` sees them, and what `build()` returns
|
||||
* is validated as a spec after. A `build()` that refuses says why in
|
||||
* `{ ok: false, error }` — the author's sentence — and one that throws or hangs
|
||||
* is logged and answered with a sentence of core's, because a stack trace is
|
||||
* not something to put on an author's screen.
|
||||
*/
|
||||
async function buildFromTemplate(templateId, rawInputs) {
|
||||
const template = registries.eventTemplate(String(templateId || ''))
|
||||
if (!template) {
|
||||
return { ok: false, status: 404, errors: [`no module registers the event template "${templateId}"`] }
|
||||
}
|
||||
|
||||
const checked = spec.checkParams({ id: template.id, params: template.inputs }, rawInputs, 'inputs')
|
||||
if (checked.errors.length) {
|
||||
return { ok: false, status: 400, errors: checked.errors.map((e) => e.replace(/^inputs\.params: /, '')) }
|
||||
}
|
||||
|
||||
let built
|
||||
let timer
|
||||
try {
|
||||
built = await Promise.race([
|
||||
Promise.resolve(template.build({ inputs: checked.params })),
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(`build() took longer than ${TEMPLATE_BUILD_MS} ms`)), TEMPLATE_BUILD_MS)
|
||||
}),
|
||||
])
|
||||
} catch (err) {
|
||||
log.error('event template build failed', { template: template.id, owner: template.owner, message: err.message })
|
||||
return { ok: false, status: 502, errors: [`"${template.label}" could not be filled in — the module that provides it failed`] }
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
if (!built || typeof built !== 'object') {
|
||||
return { ok: false, status: 502, errors: [`"${template.label}" answered with no draft`] }
|
||||
}
|
||||
if (built.ok === false) {
|
||||
return { ok: false, status: 400, errors: [String(built.error || `"${template.label}" refused those inputs`)] }
|
||||
}
|
||||
|
||||
const checkedSpec = spec.validate(built.spec)
|
||||
if (!checkedSpec.ok) {
|
||||
// The module's mistake, not the author's, and named as such: the author
|
||||
// cannot fix a template by retyping its inputs.
|
||||
log.error('event template built an invalid spec', { template: template.id, errors: checkedSpec.errors })
|
||||
return {
|
||||
ok: false,
|
||||
status: 502,
|
||||
errors: [`"${template.label}" made a plan core cannot accept`, ...checkedSpec.errors],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
draft: {
|
||||
title: String(built.title || template.label).slice(0, MAX_TITLE),
|
||||
summary: built.summary ? String(built.summary).slice(0, MAX_SUMMARY) : null,
|
||||
spec: checkedSpec.spec,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the working copy differ from the published version? (Rust PLAN_REDESIGNS
|
||||
* §2.3, U-5.)
|
||||
*
|
||||
* A run is pinned to the PUBLISHED version, so an author who edited, saved and
|
||||
* pressed Start ran the old plan without being told. The editor asks only when
|
||||
* this is true. Compared as data, keys sorted, because the two are stored as
|
||||
* JSON by different writes and a key order is not an edit. A definition never
|
||||
* published has nothing to be ahead of, and answers false.
|
||||
*/
|
||||
async function draftAhead(row) {
|
||||
if (!row || !row.current_version_id) return false
|
||||
const version = await versionsDb.getById(row.current_version_id)
|
||||
if (!version) return false
|
||||
return stableJson(row.spec) !== stableJson(version.spec)
|
||||
}
|
||||
|
||||
function stableJson(value) {
|
||||
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`
|
||||
if (value && typeof value === 'object') {
|
||||
return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${stableJson(value[k])}`).join(',')}}`
|
||||
}
|
||||
return JSON.stringify(value === undefined ? null : value)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validate,
|
||||
create,
|
||||
buildFromTemplate,
|
||||
draftAhead,
|
||||
save,
|
||||
publish,
|
||||
archive,
|
||||
verify,
|
||||
checkRoleFloor,
|
||||
checkDemoRefusals,
|
||||
isTimezone,
|
||||
MIN_GRACE_SECONDS,
|
||||
MAX_GRACE_SECONDS,
|
||||
|
||||
@@ -36,6 +36,7 @@ const versionsDb = require('./eventVersions.db')
|
||||
const participantsDb = require('./eventRunParticipants.db')
|
||||
const calendarModel = require('./eventCalendar.model')
|
||||
const recurrence = require('../../events/recurrence')
|
||||
const progress = require('../../events/progress')
|
||||
|
||||
// The public calendar's window when a caller names neither end: a few days BACK
|
||||
// through a month out. A visitor arriving at /site/events wants "what is on", and
|
||||
@@ -91,21 +92,36 @@ const isLive = (status) => publicStatus(status) === 'live'
|
||||
/**
|
||||
* The label of the phase a run is in, resolved from the PINNED version's spec.
|
||||
*
|
||||
* A phase id is a slug an author typed and the label is what they meant it to
|
||||
* read as, so a page rendering the id would show `phase-2` to the public. A
|
||||
* A phase key is a slug an author typed and the label is what they meant it to
|
||||
* read as, so a page rendering the key would show `phase-2` to the public. A
|
||||
* phase the spec does not name answers null and the page shows nothing, which is
|
||||
* the right answer for a version edited since: the run pinned the old spec and
|
||||
* the old spec is what it is executing.
|
||||
*
|
||||
* **A run's `current_phase` is the phase's `key`**, which is what `spec.js`
|
||||
* validates and the engine writes. This matched `id` until RunicNPC stage 9
|
||||
* (D314), so every live run read "Under way" since Events Phase 14a; `id` is
|
||||
* still accepted second, for a spec stored in that older shape.
|
||||
*/
|
||||
function phaseLabel(spec, phaseId) {
|
||||
if (!phaseId || !spec || !Array.isArray(spec.phases)) return null
|
||||
const phase = spec.phases.find((p) => p && p.id === phaseId)
|
||||
return (phase && (phase.label || phase.id)) || null
|
||||
function phaseLabel(spec, phaseKey) {
|
||||
if (!phaseKey || !spec || !Array.isArray(spec.phases)) return null
|
||||
const phase =
|
||||
spec.phases.find((p) => p && p.key === phaseKey) || spec.phases.find((p) => p && p.id === phaseKey)
|
||||
return (phase && (phase.label || phase.key || phase.id)) || null
|
||||
}
|
||||
|
||||
/** One calendar entry, from a materialised run. */
|
||||
/**
|
||||
* One calendar entry, from a materialised run.
|
||||
*
|
||||
* **`runId` is published because the event page already publishes it** on every
|
||||
* occurrence, and `?run=` takes it. The calendar was the one public shape that
|
||||
* named a run without saying which, so a client holding a run id from elsewhere
|
||||
* (a module's map marker) had no way to find its event but to fetch every event
|
||||
* page. A projection has none: nothing is committed to it.
|
||||
*/
|
||||
const publicRunEntry = (run) => ({
|
||||
kind: 'run',
|
||||
runId: run.id,
|
||||
title: run.definition_title,
|
||||
slug: run.definition_slug,
|
||||
seriesName: run.series_name || null,
|
||||
@@ -280,7 +296,7 @@ const publicOccurrence = (run, spec) => ({
|
||||
* turn a stale link in a months-old mail into a dead end rather than a page
|
||||
* about the thing the mail was about.
|
||||
*/
|
||||
async function event(slug, { runId = null } = {}) {
|
||||
async function event(slug, { runId = null, userId = null } = {}) {
|
||||
const definition = await definitionsDb.getPublicBySlug(String(slug || ''))
|
||||
if (!definition) return { ok: false, status: 404, errors: ['Not found'] }
|
||||
|
||||
@@ -330,6 +346,13 @@ async function event(slug, { runId = null } = {}) {
|
||||
const specRun = named || live[0] || null
|
||||
const spec = specRun ? (await versionsDb.getById(specRun.version_id))?.spec || null : null
|
||||
|
||||
// How the live occurrence stands, in the words its steps' modules chose
|
||||
// (MODULE_API 1.12.0). Only for a live run: a finished one has results, and a
|
||||
// scheduled one has nothing to count yet. `progress.forRun` never rejects and
|
||||
// answers `[]` when nobody has anything to say.
|
||||
const current = live[0] ? publicOccurrence(live[0], spec) : null
|
||||
if (current) current.progress = await progress.forRun(live[0], { viewer: { userId } })
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
@@ -342,7 +365,7 @@ async function event(slug, { runId = null } = {}) {
|
||||
timezone: definition.timezone,
|
||||
series: series ? { name: series.name, slug: series.slug } : null,
|
||||
live: live.length > 0,
|
||||
current: live[0] ? publicOccurrence(live[0], spec) : null,
|
||||
current,
|
||||
next: next ? publicOccurrence(next, spec) : null,
|
||||
upcoming: upcoming.map((r) => publicOccurrence(r, spec)),
|
||||
past: past.map((r) => publicOccurrence(r, spec)),
|
||||
|
||||
@@ -53,6 +53,9 @@ const KINDS = [
|
||||
// question is about the world.
|
||||
'resource.recorded', // a step reported what it created or borrowed, and it is ledgered
|
||||
'resource.orphaned', // a module reports a ledgered resource is no longer in force
|
||||
// MODULE_API 1.11.0's one (Rust PLAN_FIXES D183): the game ended a resource at
|
||||
// its own deadline, which is the plan working rather than something vanishing.
|
||||
'resource.expired', // a module reports the game ended a ledgered resource by itself
|
||||
'cleanup.reverted', // a group of resources came back
|
||||
'cleanup.failed', // a group did not, with the reason and how it was left
|
||||
'cleanup.swept', // one pass over a run's ledger, and what it found
|
||||
|
||||
@@ -327,6 +327,42 @@ async function markOrphaned(id, detail = null) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows one module holds for one target that the game may have let go of on
|
||||
* its own — what `ctx.events.expired` looks for (MODULE_API 1.11.0).
|
||||
*
|
||||
* `orphaned` is included on purpose: a reconcile that ran before the module heard
|
||||
* the expiry filed the row as vanished, and the expiry is the better answer.
|
||||
* `reverting` is not: a revert already in flight finds the thing gone, which §L
|
||||
* calls a success, and it settles as `reverted` on its own.
|
||||
*/
|
||||
async function expirableByTarget(owner, kind, ref) {
|
||||
const rows = await query(
|
||||
`SELECT ${COLUMNS} FROM event_run_resources
|
||||
WHERE owner_module = ? AND kind = ? AND ref = ?
|
||||
AND status IN ('pending', 'confirmed', 'orphaned')
|
||||
ORDER BY id`,
|
||||
[owner, kind, ref],
|
||||
)
|
||||
return rows.map(hydrate)
|
||||
}
|
||||
|
||||
/**
|
||||
* The game ended it by itself, as it was told to — a zone reaching its deadline.
|
||||
* Terminal like `reverted`, so teardown never tries to give it back, and unlike
|
||||
* `orphaned`, which is a thing that vanished with nobody asking. Guarded on the
|
||||
* status so a row a revert has just claimed is left to that revert.
|
||||
*/
|
||||
async function markExpired(id, detail = null) {
|
||||
const result = await query(
|
||||
`UPDATE event_run_resources
|
||||
SET status = 'expired', last_error = ?
|
||||
WHERE id = ? AND status IN ('pending', 'confirmed', 'orphaned')`,
|
||||
[detail === null ? null : String(detail).slice(0, 500), id],
|
||||
)
|
||||
return (result.affectedRows || 0) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal runs that still owe the world something — the cleanup leg's scan.
|
||||
*
|
||||
@@ -366,8 +402,30 @@ async function runsNeedingCleanup(limit = 25, maxAttempts = 3) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Finished runs still marked `pending` that have no unresolved row at all — a
|
||||
* run whose last resource the game expired while it was still going (MODULE_API
|
||||
* 1.11.0). The sweep settles them `complete`; `runsNeedingCleanup` cannot see
|
||||
* them because it joins on an unresolved row.
|
||||
*/
|
||||
async function finishedRunsWithNothingLeft(limit = 25) {
|
||||
const rows = await query(
|
||||
`SELECT r.id FROM event_runs r
|
||||
WHERE r.status IN ('completed', 'cancelled', 'failed', 'missed')
|
||||
AND r.cleanup_status = 'pending'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM event_run_resources res
|
||||
WHERE res.run_id = r.id AND res.status IN (?, ?, ?, ?, ?))
|
||||
ORDER BY r.id
|
||||
LIMIT ?`,
|
||||
[...UNRESOLVED, Number(limit)],
|
||||
)
|
||||
return rows.map((row) => Number(row.id))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
STEP_KIND,
|
||||
finishedRunsWithNothingLeft,
|
||||
HELD,
|
||||
UNRESOLVED,
|
||||
reserve,
|
||||
@@ -385,5 +443,7 @@ module.exports = {
|
||||
liveForModule,
|
||||
modulesWithLiveRows,
|
||||
markOrphaned,
|
||||
expirableByTarget,
|
||||
markExpired,
|
||||
runsNeedingCleanup,
|
||||
}
|
||||
|
||||
@@ -15,7 +15,13 @@
|
||||
const { query } = require('../../utils/db')
|
||||
const { parseJson } = require('./eventJson')
|
||||
|
||||
const hydrate = (row) => row && { ...row, params: parseJson(row.params, null), rehearsal: Boolean(row.rehearsal) }
|
||||
const hydrate = (row) =>
|
||||
row && {
|
||||
...row,
|
||||
params: parseJson(row.params, null),
|
||||
origin: parseJson(row.origin, null),
|
||||
rehearsal: Boolean(row.rehearsal),
|
||||
}
|
||||
|
||||
// The statuses a run never leaves. A transition INTO one of these stamps
|
||||
// `ended_at` and drops the claim, and only rows in one of them are eligible for
|
||||
@@ -90,8 +96,8 @@ const materialise = async (run) => {
|
||||
const result = await query(
|
||||
`INSERT IGNORE INTO event_runs
|
||||
(definition_id, version_id, scope, scheduled_for, timezone, concurrency_key,
|
||||
params, rehearsal, started_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
params, rehearsal, started_by, origin)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
run.definition_id,
|
||||
run.version_id,
|
||||
@@ -102,6 +108,8 @@ const materialise = async (run) => {
|
||||
run.params === null || run.params === undefined ? null : JSON.stringify(run.params),
|
||||
run.rehearsal ? 1 : 0,
|
||||
run.started_by,
|
||||
// Why the run exists when a trigger started it (D370); NULL otherwise.
|
||||
run.origin ? JSON.stringify(run.origin) : null,
|
||||
],
|
||||
)
|
||||
return Number(result?.affectedRows || 0) === 1 ? result.insertId : null
|
||||
|
||||
@@ -69,7 +69,7 @@ function renderConcurrencyKey(template, params) {
|
||||
*/
|
||||
async function create(
|
||||
definitionId,
|
||||
{ scope = '', scheduledFor = null, rehearsal = false, params = null, source = 'manual' } = {},
|
||||
{ scope = '', scheduledFor = null, rehearsal = false, params = null, source = 'manual', origin = null } = {},
|
||||
userId,
|
||||
) {
|
||||
const definition = await definitionsDb.getById(definitionId)
|
||||
@@ -97,7 +97,10 @@ async function create(
|
||||
// nobody is. A version is immutable, so a dry run that passed against it stays
|
||||
// true, which is what makes the pass a property of the version rather than
|
||||
// something re-earned every occurrence.
|
||||
if (source === 'schedule' && !version.verified_at) {
|
||||
//
|
||||
// **A trigger start is unattended too** (D370): a boss dying at four in the
|
||||
// morning starts the run with nobody watching, exactly as a schedule does.
|
||||
if ((source === 'schedule' || source === 'trigger') && !version.verified_at) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 409,
|
||||
@@ -124,6 +127,7 @@ async function create(
|
||||
params,
|
||||
rehearsal,
|
||||
started_by: userId,
|
||||
origin,
|
||||
})
|
||||
|
||||
if (runId === null) {
|
||||
@@ -146,8 +150,11 @@ async function create(
|
||||
// a recurrence (Phase 4). Both produce the same row, and the log is the
|
||||
// only place the difference is recorded — `started_by` is NULL for both a
|
||||
// scheduled occurrence and one started by a since-deleted account.
|
||||
// 'trigger' is a game firing (D370), and it is the one source that also
|
||||
// says WHAT: the origin goes into the audit line as well as on the row.
|
||||
source,
|
||||
by: userId,
|
||||
...(origin ? { origin } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
106
server/src/model/events/eventTriggerStarts.db.js
Normal file
106
server/src/model/events/eventTriggerStarts.db.js
Normal file
@@ -0,0 +1,106 @@
|
||||
// ── event_trigger_starts — SQL only ────────────────────────────────────────
|
||||
//
|
||||
// Rust PLAN_REDESIGNS §14.3.12 (D370), and EVENTS.md §E "Starting a run from a
|
||||
// trigger". One row per definition a trigger has tried to start. `events/starts.js`
|
||||
// decides; this file holds the claim and the counts.
|
||||
//
|
||||
// **The claim is one statement with every rule inside it.** A firing may start a
|
||||
// run only when no claim is live, the last trigger-started run is out of its
|
||||
// cooldown, and no run of the definition is going. Read-then-write would let two
|
||||
// firings arriving together each see a quiet definition and both start, which is
|
||||
// exactly the twelve-Bradleys case the rule exists for.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// A claim older than this belongs to a process that died between the claim and
|
||||
// the run row existing. A minute is far longer than an insert takes.
|
||||
const CLAIM_STALE_MS = 60_000
|
||||
|
||||
// What "a run is going" means for the one-at-a-time rule: started and not ended,
|
||||
// or scheduled and already due — a trigger-started run is `scheduled` for up to
|
||||
// one tick before the runner claims it, and it is going from the moment it exists.
|
||||
const GOING_SQL = `
|
||||
SELECT 1 FROM event_runs r
|
||||
WHERE r.definition_id = t.definition_id
|
||||
AND (r.status IN ('starting','running','paused','ending')
|
||||
OR (r.status = 'scheduled' AND r.scheduled_for <= ?))`
|
||||
|
||||
const REASONS = ['running', 'cooldown', 'unverified']
|
||||
|
||||
/** The row, or null when no trigger has ever tried to start this definition. */
|
||||
const forDefinition = async (definitionId) => {
|
||||
const [row] = await query('SELECT * FROM event_trigger_starts WHERE definition_id = ?', [definitionId])
|
||||
return row || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to own the start. `true` when this caller may create the run.
|
||||
*
|
||||
* `at` is the firing's time (whole seconds), `cooldownSeconds` the matching
|
||||
* start's own. The row is made first with `INSERT IGNORE`, so the UPDATE always
|
||||
* has something to guard.
|
||||
*/
|
||||
async function claim(definitionId, { at, cooldownSeconds, now }) {
|
||||
await query('INSERT IGNORE INTO event_trigger_starts (definition_id) VALUES (?)', [definitionId])
|
||||
const result = await query(
|
||||
`UPDATE event_trigger_starts t
|
||||
SET t.claimed_at = ?
|
||||
WHERE t.definition_id = ?
|
||||
AND (t.claimed_at IS NULL OR t.claimed_at < ?)
|
||||
AND (t.last_started_at IS NULL OR t.last_started_at <= ?)
|
||||
AND NOT EXISTS (${GOING_SQL})`,
|
||||
[
|
||||
now,
|
||||
definitionId,
|
||||
new Date(now.getTime() - CLAIM_STALE_MS),
|
||||
new Date(at.getTime() - cooldownSeconds * 1000),
|
||||
now,
|
||||
],
|
||||
)
|
||||
return Number(result?.affectedRows || 0) === 1
|
||||
}
|
||||
|
||||
/** The run exists: record it, drop the claim, and start the counts again. */
|
||||
const started = (definitionId, { at, runId }) =>
|
||||
query(
|
||||
`UPDATE event_trigger_starts
|
||||
SET claimed_at = NULL, last_started_at = ?, last_run_id = ?,
|
||||
ignored_running = 0, ignored_cooldown = 0, ignored_unverified = 0,
|
||||
last_ignored_at = NULL, last_ignored_reason = NULL
|
||||
WHERE definition_id = ?`,
|
||||
[at, runId, definitionId],
|
||||
)
|
||||
|
||||
/** The claim came to nothing (the create refused): give it back. */
|
||||
const release = (definitionId) =>
|
||||
query('UPDATE event_trigger_starts SET claimed_at = NULL WHERE definition_id = ?', [definitionId])
|
||||
|
||||
/**
|
||||
* Count a firing that started nothing, by why. `reason` is one of REASONS and
|
||||
* names its own column, so it is checked against the list before it reaches SQL.
|
||||
*/
|
||||
async function ignore(definitionId, reason, now) {
|
||||
if (!REASONS.includes(reason)) throw new Error(`unknown ignore reason "${reason}"`)
|
||||
await query('INSERT IGNORE INTO event_trigger_starts (definition_id) VALUES (?)', [definitionId])
|
||||
await query(
|
||||
`UPDATE event_trigger_starts
|
||||
SET ignored_${reason} = ignored_${reason} + 1, last_ignored_at = ?, last_ignored_reason = ?
|
||||
WHERE definition_id = ?`,
|
||||
[now, reason, definitionId],
|
||||
)
|
||||
}
|
||||
|
||||
/** Is a run of this definition going right now? The ignore path's "why". */
|
||||
const isGoing = async (definitionId, now) => {
|
||||
const rows = await query(
|
||||
`SELECT 1 FROM event_runs r
|
||||
WHERE r.definition_id = ?
|
||||
AND (r.status IN ('starting','running','paused','ending')
|
||||
OR (r.status = 'scheduled' AND r.scheduled_for <= ?))
|
||||
LIMIT 1`,
|
||||
[definitionId, now],
|
||||
)
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
module.exports = { forDefinition, claim, started, release, ignore, isGoing, REASONS, CLAIM_STALE_MS }
|
||||
@@ -3,6 +3,7 @@ const brand = require('../../config/brand')
|
||||
const { parseJsonSetting } = require('../../utils/settingsJson')
|
||||
const { resolveThemeTokens } = require('../../utils/themeResolve')
|
||||
const { resolveBrandAssets } = require('../../utils/brandAssets')
|
||||
const { isDemo } = require('../../demo/mode')
|
||||
|
||||
// Keys safe to expose on the public site.
|
||||
const PUBLIC_KEYS = [
|
||||
@@ -215,9 +216,30 @@ async function getPublic() {
|
||||
// before doing so (the server would otherwise reject an unallowlisted one). The
|
||||
// custom-scheme callback works regardless of this flag.
|
||||
out.mobileAppLinks = String(all[MOBILE_APP_LINKS_KEY]) === 'true'
|
||||
// The public demo's banner and sign-in panel (DEMO_MODE.md §4, §6). Present
|
||||
// only with DEMO_MODE on, so a real shard's payload is unchanged. Written by
|
||||
// `seed:demo` at every wipe; the demo lock keeps `demo_*` out of PUT
|
||||
// /admin/settings, so a visitor cannot rewrite the published passwords.
|
||||
if (isDemo()) out.demo = demoBlock(all[DEMO_STATE_KEY])
|
||||
return out
|
||||
}
|
||||
|
||||
// `demo_state` is one JSON object: `{ resetsAt, logins: [{ username, password,
|
||||
// role }] }`. The passwords are public by design (§4) — the logins are printed
|
||||
// on the sign-in page and on runicgateway.com/beta. Read forgivingly: a missing
|
||||
// or broken row is a banner with no time and no logins, never a 500.
|
||||
const DEMO_STATE_KEY = 'demo_state'
|
||||
function demoBlock(raw) {
|
||||
const state = parseJsonSetting(raw) || {}
|
||||
const resetsAt = typeof state.resetsAt === 'string' && !Number.isNaN(Date.parse(state.resetsAt))
|
||||
? state.resetsAt
|
||||
: null
|
||||
const logins = (Array.isArray(state.logins) ? state.logins : [])
|
||||
.filter((l) => l && typeof l.username === 'string' && typeof l.password === 'string')
|
||||
.map((l) => ({ username: l.username, password: l.password, role: typeof l.role === 'string' ? l.role : null }))
|
||||
return { resetsAt, logins }
|
||||
}
|
||||
|
||||
/**
|
||||
* What the HTML shell needs, resolved exactly as getPublic() resolves it: the
|
||||
* effective favicon and logo, plus the theme token map for the boot <style>
|
||||
@@ -285,4 +307,5 @@ module.exports = {
|
||||
registrationFlags,
|
||||
MOBILE_APP_LINKS_KEY,
|
||||
isMobileAppLinksEnabled,
|
||||
DEMO_STATE_KEY,
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ async function syncStatus() {
|
||||
configured: true,
|
||||
lastSyncAt,
|
||||
// Never synced at all is stale: a page must not present an empty projection
|
||||
// as a confirmed empty shard.
|
||||
// as a confirmed empty one.
|
||||
stale: ageS > intervalS * STALE_INTERVALS,
|
||||
consecutiveFailures: state ? state.consecutive_failures : 0,
|
||||
}
|
||||
|
||||
@@ -13,21 +13,45 @@ async function findByProviderSubject(provider, subject) {
|
||||
// All identities linked to a given internal user (for the Account page).
|
||||
async function listForUser(userId) {
|
||||
return query(
|
||||
'SELECT id, provider, subject, email, created_at FROM user_identities WHERE user_id = ? ORDER BY provider',
|
||||
`SELECT id, provider, subject, email, display_name, avatar_url, created_at
|
||||
FROM user_identities WHERE user_id = ? ORDER BY provider, created_at`,
|
||||
[userId],
|
||||
)
|
||||
}
|
||||
|
||||
async function insert({ userId, provider, subject, email = null }) {
|
||||
async function insert({ userId, provider, subject, email = null, displayName = null, avatarUrl = null }) {
|
||||
const res = await query(
|
||||
'INSERT INTO user_identities (user_id, provider, subject, email) VALUES (?, ?, ?, ?)',
|
||||
[userId, provider, subject, email],
|
||||
`INSERT INTO user_identities (user_id, provider, subject, email, display_name, avatar_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[userId, provider, subject, email, displayName, avatarUrl],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
// Remove a user's link to a provider. Returns rows deleted.
|
||||
async function deleteForUserProvider(userId, provider) {
|
||||
// Refresh the name and avatar a provider reported at sign-in (Steam, D394). Only
|
||||
// what the provider sent is written: a lookup that failed leaves the last good
|
||||
// values in place rather than blanking them.
|
||||
async function updateProfile(id, { displayName, avatarUrl }) {
|
||||
const sets = []
|
||||
const vals = []
|
||||
if (displayName) { sets.push('display_name = ?'); vals.push(displayName) }
|
||||
if (avatarUrl) { sets.push('avatar_url = ?'); vals.push(avatarUrl) }
|
||||
if (!sets.length) return 0
|
||||
const res = await query(`UPDATE user_identities SET ${sets.join(', ')} WHERE id = ?`, [...vals, id])
|
||||
return Number(res.affectedRows || 0)
|
||||
}
|
||||
|
||||
// Remove a user's link to a provider — every identity of it, or, given an
|
||||
// identity id, that one only (a user may hold several Steam accounts). Returns
|
||||
// rows deleted.
|
||||
async function deleteForUserProvider(userId, provider, identityId = null) {
|
||||
if (identityId != null) {
|
||||
const res = await query(
|
||||
'DELETE FROM user_identities WHERE user_id = ? AND provider = ? AND id = ?',
|
||||
[userId, provider, identityId],
|
||||
)
|
||||
return Number(res.affectedRows || 0)
|
||||
}
|
||||
const res = await query(
|
||||
'DELETE FROM user_identities WHERE user_id = ? AND provider = ?',
|
||||
[userId, provider],
|
||||
@@ -35,4 +59,4 @@ async function deleteForUserProvider(userId, provider) {
|
||||
return Number(res.affectedRows || 0)
|
||||
}
|
||||
|
||||
module.exports = { findByProviderSubject, listForUser, insert, deleteForUserProvider }
|
||||
module.exports = { findByProviderSubject, listForUser, insert, updateProfile, deleteForUserProvider }
|
||||
|
||||
@@ -15,13 +15,21 @@ async function listForUser(userId) {
|
||||
|
||||
// Link an external identity to an internal user. Returns the new row id. The
|
||||
// (provider, subject) UNIQUE constraint enforces one-identity-one-user at the DB.
|
||||
async function link({ userId, provider, subject, email }) {
|
||||
return db.insert({ userId, provider, subject, email })
|
||||
// `displayName` / `avatarUrl` are set by providers that report them (Steam);
|
||||
// the others leave them null.
|
||||
async function link({ userId, provider, subject, email, displayName, avatarUrl }) {
|
||||
return db.insert({ userId, provider, subject, email, displayName, avatarUrl })
|
||||
}
|
||||
|
||||
// Unlink a provider from a user. Returns rows removed (0 if nothing was linked).
|
||||
async function unlink(userId, provider) {
|
||||
return db.deleteForUserProvider(userId, provider)
|
||||
// Refresh a linked identity's name and avatar from a sign-in's profile.
|
||||
async function refreshProfile(identityId, { displayName, avatarUrl } = {}) {
|
||||
return db.updateProfile(identityId, { displayName, avatarUrl })
|
||||
}
|
||||
|
||||
module.exports = { findByProviderSubject, listForUser, link, unlink }
|
||||
// Unlink a provider from a user: every identity of it, or the one `identityId`
|
||||
// names. Returns rows removed (0 if nothing was linked).
|
||||
async function unlink(userId, provider, identityId = null) {
|
||||
return db.deleteForUserProvider(userId, provider, identityId)
|
||||
}
|
||||
|
||||
module.exports = { findByProviderSubject, listForUser, link, refreshProfile, unlink }
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
// through modules/install.js, the same fetch-verify-unpack path the admin panel
|
||||
// uses, under the same host allowlist.
|
||||
//
|
||||
// **A site runs one module** (org lead, 2026-09-23), and install.js enforces it
|
||||
// for this path too: a declaration naming a second module installs the first,
|
||||
// and the second is refused — logged and kept for the admin screen like any
|
||||
// other failed entry, never fatal to the boot.
|
||||
//
|
||||
// Three things this file deliberately does not do:
|
||||
//
|
||||
// - **It does not decide whether a module RUNS.** Resolution owns what is on
|
||||
|
||||
@@ -290,6 +290,28 @@ function isInstalled(id) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every module on the volume, by id — the directories the loader would scan.
|
||||
*
|
||||
* The loader's own rule, restated: a directory whose name is a module id and
|
||||
* which holds a `module.json`. That excludes an install's scratch directory
|
||||
* (`.install-*`) and a swap's aside copy (`<id>.replaced-*`), neither of which
|
||||
* is a module and both of which can briefly exist beside one.
|
||||
*/
|
||||
function installedIds() {
|
||||
let entries
|
||||
try {
|
||||
entries = fs.readdirSync(loader.dir(), { withFileTypes: true })
|
||||
} catch {
|
||||
return [] // no modules directory is the normal case for a bare core
|
||||
}
|
||||
return entries
|
||||
.filter((e) => e.isDirectory() && ID.test(e.name))
|
||||
.filter((e) => fs.existsSync(path.join(loader.dir(), e.name, 'module.json')))
|
||||
.map((e) => e.name)
|
||||
.sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* The absolute path of a module's `purge.sql`, or null.
|
||||
*
|
||||
@@ -348,6 +370,25 @@ async function install({ url, hosts, expect = null, fetchImpl = fetch }) {
|
||||
)
|
||||
}
|
||||
|
||||
// One module per site (org lead, 2026-09-23). A site is one game, and the
|
||||
// contract has singletons that assume it: `registerTeamProvider` holds ONE
|
||||
// value per deployment, and a second module registering one fails its whole
|
||||
// load — with modules loaded alphabetically, installing `rust` beside `uo`
|
||||
// would have taken `uo` down, not `rust`. So an install is an UPGRADE of the
|
||||
// module already here, or it is refused before a byte is downloaded.
|
||||
//
|
||||
// Refused here rather than in the admin controller so the declared module set
|
||||
// (modules/declared.js) gets the same answer: an environment naming two
|
||||
// modules installs the first and is told why the second was not.
|
||||
const others = installedIds().filter((id) => id !== manifest.id)
|
||||
if (others.length) {
|
||||
throw new InstallError(
|
||||
`this site already runs the module "${others.join('", "')}", and a site runs one module. ` +
|
||||
`Upgrade it with its own release, or remove it before installing "${manifest.id}".`,
|
||||
{ status: 409 },
|
||||
)
|
||||
}
|
||||
|
||||
const target = moduleDir(manifest.id)
|
||||
const scratch = await fsp.mkdtemp(path.join(loader.dir(), `.install-${manifest.id}-`))
|
||||
const tarball = path.join(scratch, 'bundle.tar.gz')
|
||||
@@ -444,6 +485,7 @@ module.exports = {
|
||||
removeDir,
|
||||
moduleDir,
|
||||
isInstalled,
|
||||
installedIds,
|
||||
purgeFile,
|
||||
MAX_MANIFEST_BYTES,
|
||||
MAX_ARTIFACT_BYTES,
|
||||
|
||||
@@ -41,6 +41,8 @@ const { MODULE_API_VERSION } = require('./version')
|
||||
const semver = require('./semver')
|
||||
const registries = require('./registries')
|
||||
const { splitStatements } = require('../utils/sqlStatements')
|
||||
const demoPaths = require('../demo/paths')
|
||||
const { isDemo } = require('../demo/mode')
|
||||
|
||||
const log = require('../utils/logger')('modules')
|
||||
|
||||
@@ -62,9 +64,15 @@ const TIERS = ['public', 'admin', 'player']
|
||||
|
||||
const MANIFEST_KEYS = new Set([
|
||||
'id', 'name', 'version', 'coreApi', 'server', 'client',
|
||||
'schema', 'purge', 'mounts', 'extensions', 'capabilities',
|
||||
'schema', 'purge', 'mounts', 'extensions', 'capabilities', 'demoLocks',
|
||||
])
|
||||
|
||||
// What a `demoLocks` entry may lock (API 1.18.0, DEMO_MODE.md §3.2): writes
|
||||
// only. Reads are never locked by declaration — the one read the demo refuses
|
||||
// (a plugin config that holds credentials) depends on WHICH file is asked for,
|
||||
// so the module decides it behind `ctx.demoMode` rather than by route.
|
||||
const DEMO_LOCK_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE']
|
||||
|
||||
// Extension slots are declared by core, at require time, in the router that owns
|
||||
// the resource (registries.declareSlot). The loader asks the registry which exist
|
||||
// rather than keeping a list, for the same reason the prefix check probes the
|
||||
@@ -261,6 +269,27 @@ function buildCtx(id, moduleRoot) {
|
||||
(err) => { log.error('ctx.events.reconcile failed', { module: id, message: err.message }) },
|
||||
)
|
||||
},
|
||||
// MODULE_API 1.11.0 (Rust PLAN_FIXES D183). The game ended one of this
|
||||
// module's ledgered resources at its own deadline — a Rust zone erased when
|
||||
// its time was up. Recorded as `expired`: terminal, never given back, and a
|
||||
// different sentence on the console from `orphaned`, which is reconcile's
|
||||
// word for a thing that vanished with nobody asking.
|
||||
//
|
||||
// `id` bound, fire-and-forget and returns undefined, all three for
|
||||
// `reconcile`'s reasons directly above. A `{ kind, ref }` that names no
|
||||
// live row is not an error: a module hears expiries of things no run ever
|
||||
// ledgered, and telling it so would be noise it can do nothing with.
|
||||
expired: (resource) => {
|
||||
// eslint-disable-next-line global-require
|
||||
require('../events/cleanup')
|
||||
.expireResource(id, resource || {})
|
||||
.then(
|
||||
(summary) => {
|
||||
if (summary && summary.expired) log.info('event resource expired', { module: id, ...summary })
|
||||
},
|
||||
(err) => { log.error('ctx.events.expired failed', { module: id, message: err.message }) },
|
||||
)
|
||||
},
|
||||
},
|
||||
// The in-app sink (§5.1) — a module writing the inbox directly, without a
|
||||
// rule. Live from Phase 7; it threw until the `user_notifications` table
|
||||
@@ -290,6 +319,26 @@ function buildCtx(id, moduleRoot) {
|
||||
// exports creation, role changes and password handling, none of which is a
|
||||
// module's business.
|
||||
users: { getById: users.getById },
|
||||
// API 1.17.0 (STEAM_SIGNIN.md §4). READ only, one provider at a time: what a
|
||||
// user has linked, as core verified it — the Steam id a module can trust is
|
||||
// the one Steam's own `check_authentication` proved, never one a player typed.
|
||||
// Linking and unlinking stay core's (the hooks on `api` are how a module takes
|
||||
// part), and the row id and email are core's business, so they are not here.
|
||||
identities: {
|
||||
list: async (userId, provider) => {
|
||||
// eslint-disable-next-line global-require
|
||||
const rows = await require('../model/userIdentities/userIdentities.model').listForUser(userId)
|
||||
return rows
|
||||
.filter((r) => r.provider === provider)
|
||||
.map((r) => ({
|
||||
provider: r.provider,
|
||||
subject: r.subject,
|
||||
displayName: r.display_name || null,
|
||||
avatarUrl: r.avatar_url || null,
|
||||
linkedAt: r.created_at,
|
||||
}))
|
||||
},
|
||||
},
|
||||
// Where this deployment is reachable, for a module that has to build an
|
||||
// absolute link (an announcement carries one into a game window or a chat
|
||||
// message, where a relative path means nothing). §2.7 forbids a module
|
||||
@@ -297,6 +346,12 @@ function buildCtx(id, moduleRoot) {
|
||||
// configuration, so core answers it. A getter, not a captured string: the
|
||||
// value is read per call, so it cannot go stale against the env.
|
||||
site: { get baseUrl() { return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '') } },
|
||||
// API 1.18.0 (DEMO_MODE.md §3.4, §3.5). True on the public demo. For the
|
||||
// locks a route list cannot express — a console command by its first word,
|
||||
// a config file by which plugin owns it — which the module enforces in its
|
||||
// own handlers. A boolean, read once: the flag is the deployment's, and a
|
||||
// deployment does not change what it is while it runs.
|
||||
demoMode: isDemo(),
|
||||
}
|
||||
// A guard against accident, not against a hostile module — the boundary is
|
||||
// organisational, not a security boundary (MODULE_SYSTEM.md §2.2).
|
||||
@@ -438,12 +493,36 @@ function buildApi(record) {
|
||||
once('registerEventOptionSources')
|
||||
record.staged.registerEventOptionSources(sources)
|
||||
},
|
||||
// API 1.13.0. A named starting point for a new event (Rust PLAN_REDESIGNS
|
||||
// §2.5): inputs in, an ordinary draft out. Its own id space, like the four
|
||||
// above, and `once` for the same reason.
|
||||
registerEventTemplates(templates) {
|
||||
once('registerEventTemplates')
|
||||
record.staged.registerEventTemplates(templates)
|
||||
},
|
||||
// The two lifecycle hooks (§2.5). Registered here, dispatched from
|
||||
// lifecycle.js — this file runs with no database and the hooks run with one.
|
||||
// Both are optional: a module with no warm-up and nothing to close simply
|
||||
// never calls them.
|
||||
onBoot: hook('onBoot'),
|
||||
onShutdown: hook('onShutdown'),
|
||||
// API 1.17.0 (STEAM_SIGNIN.md §4). Core calling the module about an external
|
||||
// identity a signed-in user is linking, dispatched from auth/identityHooks.js.
|
||||
//
|
||||
// `onIdentityLinking` answers before anything is written and can only REFUSE
|
||||
// (D396: a Steam id already another account's Rust link). It runs inside the
|
||||
// link flow of a user who is already signed in, so it has no path to a session
|
||||
// and cannot let through a link core would refuse. `onIdentityLinked` hears a
|
||||
// link after it is written. There is no unlink hook: D393 leaves a module
|
||||
// nothing to undo when a sign-in identity goes.
|
||||
onIdentityLinking: hook('onIdentityLinking'),
|
||||
onIdentityLinked: hook('onIdentityLinked'),
|
||||
// API 1.18.0 (DEMO_MODE.md §7.3, DM15). Core's `seed:demo` handing the
|
||||
// module its part of the seed fixture, so the module writes its own rows —
|
||||
// a game server link, a player's game link — through its own model. Core
|
||||
// never writes a module's tables. Called from the seed script only, never
|
||||
// by the running server, and only with DEMO_MODE on.
|
||||
onDemoSeed: hook('onDemoSeed'),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -682,6 +761,8 @@ function readManifest(dir, id, tierRouters) {
|
||||
if (!registries.hasSlot(slot)) fail('extensions', `unknown extension slot "${slot}"`)
|
||||
}
|
||||
|
||||
checkDemoLocks(manifest)
|
||||
|
||||
if (manifest.client !== undefined) {
|
||||
if (typeof manifest.client !== 'object' || manifest.client === null || Array.isArray(manifest.client)) {
|
||||
fail('manifest', 'client must be an object')
|
||||
@@ -702,6 +783,40 @@ function readManifest(dir, id, tierRouters) {
|
||||
return manifest
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate `demoLocks` (API 1.18.0, DEMO_MODE.md §3.2), on every boot and not
|
||||
* only on the demo: a module whose declaration is wrong should find out on the
|
||||
* developer's machine, not on the one deployment where it is read.
|
||||
*
|
||||
* Each entry is `{ method, path }`, the path relative to /api/v1 and under one
|
||||
* of the module's own declared mounts — a module may lock its own routes and
|
||||
* nobody else's, the same boundary `mounts` draws for serving them.
|
||||
*/
|
||||
function checkDemoLocks(manifest) {
|
||||
if (manifest.demoLocks === undefined) return
|
||||
if (!Array.isArray(manifest.demoLocks)) fail('manifest', 'demoLocks must be an array')
|
||||
for (const entry of manifest.demoLocks) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
fail('manifest', 'each demoLocks entry must be an object')
|
||||
}
|
||||
for (const key of Object.keys(entry)) {
|
||||
if (key !== 'method' && key !== 'path') fail('manifest', `unknown key "demoLocks.${key}" in module.json`)
|
||||
}
|
||||
if (!DEMO_LOCK_METHODS.includes(entry.method)) {
|
||||
fail('manifest', `demoLocks method "${entry.method}" must be one of ${DEMO_LOCK_METHODS.join(', ')}`)
|
||||
}
|
||||
try {
|
||||
demoPaths.compile(entry.path)
|
||||
} catch (err) {
|
||||
fail('manifest', `demoLocks: ${err.message}`)
|
||||
}
|
||||
const [, tier, prefix] = entry.path.split('/')
|
||||
if (!(manifest.mounts?.[tier] || []).includes(`/${prefix}`)) {
|
||||
fail('mounts', `demoLocks path ${entry.path} is outside the module's mounts`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What the module registered must equal what it declared — in both directions.
|
||||
function checkDeclared(record) {
|
||||
const declared = record.manifest.mounts || {}
|
||||
@@ -754,10 +869,26 @@ function load(tierRouters) {
|
||||
return // no modules directory is the normal case for a bare core
|
||||
}
|
||||
|
||||
// One module per site, at boot as at install (`install.js`, org lead
|
||||
// 2026-09-23; W19 in Rust PLAN_LAST_FIXES, 2026-10-22). Install already
|
||||
// refuses a second module, so two on the volume means someone copied one in by
|
||||
// hand. The contract's singletons assume one: Module-uo and Module-Rust both
|
||||
// fill `admin.users.detail`, and the second used to fail at register with a
|
||||
// slot collision that named neither the rule nor the fix. Now the first in
|
||||
// scan order is the site's module, whether or not it then loads, and every
|
||||
// other is refused before a line of its code is required.
|
||||
let holder = null
|
||||
|
||||
for (const id of entries) {
|
||||
const dir = path.join(MODULES_DIR, id)
|
||||
if (!fs.existsSync(path.join(dir, 'module.json'))) continue
|
||||
|
||||
if (holder) {
|
||||
refuseSecond(id, dir, holder)
|
||||
continue
|
||||
}
|
||||
holder = id
|
||||
|
||||
const record = {
|
||||
id,
|
||||
dir,
|
||||
@@ -766,7 +897,9 @@ function load(tierRouters) {
|
||||
staged: registries.stage(id),
|
||||
tables: new Set(),
|
||||
called: new Set(),
|
||||
hooks: { onBoot: null, onShutdown: null },
|
||||
hooks: {
|
||||
onBoot: null, onShutdown: null, onIdentityLinking: null, onIdentityLinked: null, onDemoSeed: null,
|
||||
},
|
||||
client: null,
|
||||
ctx: null,
|
||||
state: 'installed',
|
||||
@@ -827,8 +960,10 @@ function load(tierRouters) {
|
||||
for (const record of modules.values()) {
|
||||
if (record.state !== 'registered') continue
|
||||
try {
|
||||
// Commit what this module staged. Collisions with core or with an earlier
|
||||
// module surface here, in scan order, and cost only this module.
|
||||
// Commit what this module staged. Collisions with core surface here and
|
||||
// cost only this module. (An earlier module is no longer possible — one
|
||||
// module per site, above — and the module-against-module checks stay as
|
||||
// the second line behind that rule.)
|
||||
registries.apply(record.staged.staged)
|
||||
} catch (err) {
|
||||
record.state = 'startup_failed'
|
||||
@@ -843,6 +978,45 @@ function load(tierRouters) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a module refused because another already holds the site (W19).
|
||||
*
|
||||
* `startup_failed` at the `manifest` stage, like any module that never reached
|
||||
* register: no routes, no nav, no tables, and Admin → Modules says why. The
|
||||
* name and version are read for that panel and nothing else; a module.json that does not
|
||||
* parse is still refused for the same reason, since the reason is not its
|
||||
* contents.
|
||||
*/
|
||||
function refuseSecond(id, dir, holder) {
|
||||
const manifest = { id, version: 'unknown' }
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'module.json'), 'utf8'))
|
||||
if (raw && typeof raw.version === 'string') manifest.version = raw.version
|
||||
if (raw && typeof raw.name === 'string') manifest.name = raw.name
|
||||
} catch {
|
||||
// the panel shows "unknown"; the refusal stands either way
|
||||
}
|
||||
const reason =
|
||||
`this site already runs the module "${holder}", and a site runs one module. ` +
|
||||
`Remove "${id}" or "${holder}" from the modules directory; the one that sorts first by name is the one that starts.`
|
||||
modules.set(id, {
|
||||
id,
|
||||
dir,
|
||||
manifest,
|
||||
routes: { public: new Map(), admin: new Map(), player: new Map() },
|
||||
staged: null,
|
||||
tables: new Set(),
|
||||
called: new Set(),
|
||||
hooks: { onBoot: null, onShutdown: null, onIdentityLinking: null, onIdentityLinked: null },
|
||||
client: null,
|
||||
ctx: null,
|
||||
state: 'startup_failed',
|
||||
stage: 'manifest',
|
||||
reason,
|
||||
})
|
||||
log.error(`module "${id}" was not started — continuing without it`, { stage: 'manifest', reason })
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount one module's routers onto the tier routers, behind the dispatch guard.
|
||||
*
|
||||
@@ -994,6 +1168,73 @@ function shutdownHooks() {
|
||||
.reverse()
|
||||
}
|
||||
|
||||
/**
|
||||
* The identity hooks of one name (`onIdentityLinking` | `onIdentityLinked`), in
|
||||
* registration order (API 1.17.0).
|
||||
*
|
||||
* `started` only, the rule every dispatch here follows: a module that is
|
||||
* disabled, or whose `onBoot` threw, is not serving and has no say in a link.
|
||||
* Empty in a process that never loaded modules (the seed script, unit tests), so
|
||||
* the link flow there behaves exactly as a bare core.
|
||||
*
|
||||
* @returns {{id: string, hook: Function}[]}
|
||||
*/
|
||||
function identityHooks(name) {
|
||||
if (!loaded) return []
|
||||
return [...modules.values()]
|
||||
.filter((r) => r.state === 'started' && r.hooks[name])
|
||||
.map((r) => ({ id: r.id, hook: r.hooks[name] }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Every installed module's `demoLocks`, compiled, for the demo lock in
|
||||
* demo/locks.js (API 1.18.0).
|
||||
*
|
||||
* Every module whose manifest validated, whatever its state. A disabled or
|
||||
* failed module's routes already answer 404 or 503, so its locks change
|
||||
* nothing — and a lock that disappeared whenever its module was between states
|
||||
* is a lock with a gap in it. Empty before load() has run, rather than throwing:
|
||||
* the middleware is mounted before the loader and asks per request.
|
||||
*
|
||||
* @returns {{methods: string[], path: string, compiled: object, source: string}[]}
|
||||
*/
|
||||
let demoLockCache = null
|
||||
function demoLocks() {
|
||||
if (!loaded) return []
|
||||
// Manifests are read once, in load(), which is synchronous — so the answer
|
||||
// cannot change after the first request asks, and is compiled once.
|
||||
if (demoLockCache) return demoLockCache
|
||||
const out = []
|
||||
for (const record of modules.values()) {
|
||||
for (const entry of record.manifest?.demoLocks || []) {
|
||||
out.push({
|
||||
methods: [entry.method],
|
||||
path: entry.path,
|
||||
compiled: demoPaths.compile(entry.path),
|
||||
source: record.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
demoLockCache = out
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The `onDemoSeed` hooks, for `npm run seed:demo` (API 1.18.0, DM15).
|
||||
*
|
||||
* `registered` or `started`: the seed is its own process, where nothing calls
|
||||
* onBoot, so its modules never get past `registered`. A module that failed to
|
||||
* load is not handed a fixture for tables it may not have.
|
||||
*
|
||||
* @returns {{id: string, hook: Function, ctx: object}[]}
|
||||
*/
|
||||
function demoSeedHooks() {
|
||||
assertLoaded('demoSeedHooks')
|
||||
return [...modules.values()]
|
||||
.filter((r) => (r.state === 'registered' || r.state === 'started') && r.hooks.onDemoSeed)
|
||||
.map((r) => ({ id: r.id, hook: r.hooks.onDemoSeed, ctx: r.ctx }))
|
||||
}
|
||||
|
||||
/**
|
||||
* One module's `onShutdown`, for stopping it on its own rather than at exit.
|
||||
*
|
||||
@@ -1119,6 +1360,9 @@ module.exports = {
|
||||
fragments,
|
||||
bootable,
|
||||
shutdownHooks,
|
||||
identityHooks,
|
||||
demoLocks,
|
||||
demoSeedHooks,
|
||||
stopHook,
|
||||
clientChunks,
|
||||
clientEntryUrls,
|
||||
|
||||
@@ -176,6 +176,16 @@ const eventLeases = new Map()
|
||||
// is worse than the typo the dropdown existed to prevent.
|
||||
const eventOptionSources = new Map()
|
||||
|
||||
// template id → { owner, id, label, description, inputs, build } (MODULE_API
|
||||
// 1.13.0; Rust PLAN_REDESIGNS §2.5, D192).
|
||||
//
|
||||
// **An authoring shortcut and nothing else.** A template turns a handful of
|
||||
// inputs into an ordinary draft — phases, steps, params — and the draft is the
|
||||
// only thing that survives: no column records which template made it, and the
|
||||
// runner never sees one. That is what lets a template change, or its module go,
|
||||
// without touching a single definition it was ever used for.
|
||||
const eventTemplates = new Map()
|
||||
|
||||
// owner → { templates: [...], ruleGroups: [...] } (ENGAGEMENT.md Phase 11b,
|
||||
// decision 7). What a module ships as CONTENT rather than as contract: the
|
||||
// bodies its triggers render through, and the rules an operator switches on.
|
||||
@@ -435,14 +445,14 @@ async function resolveAudience(id, params = {}) {
|
||||
/**
|
||||
* Every declaration WITHOUT its callables — what the admin catalog serves.
|
||||
*
|
||||
* `perform`, `revert`, `reconcile` and `cost` are stripped for the same reason `resolve` is
|
||||
* `perform`, `revert`, `reconcile`, `cost`, `progress` and `demoRefuses` are stripped for the same reason `resolve` is
|
||||
* stripped from an audience and `handler` from a slash command: this is the
|
||||
* object that leaves the process, and the browser's whole relationship with an
|
||||
* action is naming one by id. §F's "a module registers actions server-side and
|
||||
* adds no routes for them" is only true if the functions never ride out.
|
||||
*/
|
||||
const allEventActions = () =>
|
||||
[...eventActions.values()].map(({ perform, revert, reconcile, cost, ...rest }) => rest)
|
||||
[...eventActions.values()].map(({ perform, revert, reconcile, cost, progress, demoRefuses, ...rest }) => rest)
|
||||
|
||||
/** One declaration, callables included. The runner's lookup (Phase 2). */
|
||||
const eventAction = (id) => eventActions.get(id) || null
|
||||
@@ -531,6 +541,9 @@ const allEventOptionSources = () =>
|
||||
// still answers something, where refusing would blank a form mid-keystroke.
|
||||
const MAX_OPTION_QUERY = 120
|
||||
|
||||
// The longest note an option may carry, in characters.
|
||||
const MAX_OPTION_NOTE = 300
|
||||
|
||||
/**
|
||||
* Resolve one option source, or say why not. Never throws.
|
||||
*
|
||||
@@ -584,6 +597,12 @@ async function resolveOptionSource(id, { q = '' } = {}) {
|
||||
if (o.value === undefined || o.value === null || o.value === '') continue
|
||||
const option = { value: String(o.value), label: String(o.label ?? o.value) }
|
||||
if (o.group) option.group = String(o.group)
|
||||
// A sentence the editor shows under the field while this option is the
|
||||
// chosen one (MODULE_API 1.13.0; Rust PLAN_REDESIGNS §2.6) — advice about
|
||||
// the value, like "9 of 41 linked players have used this kit up". Bounded,
|
||||
// because it is rendered as-is and one option's essay is a form nobody
|
||||
// can read.
|
||||
if (o.note) option.note = String(o.note).slice(0, MAX_OPTION_NOTE)
|
||||
options.push(option)
|
||||
}
|
||||
// `searchable` is reported so the authoring form knows which field is a
|
||||
@@ -1005,9 +1024,30 @@ const DEFAULT_BUDGET_MS = 10_000
|
||||
// and Phase 2's lease has to be longer than this to mean anything.
|
||||
const MAX_BUDGET_MS = 3_600_000
|
||||
|
||||
function checkActionParam(actionId, entry, seen) {
|
||||
const { name, type, required, example, description, source } = entry || {}
|
||||
const where = `registerEventActions: ${actionId}`
|
||||
// A param that is a length of time says which unit it is in (MODULE_API 1.13.0;
|
||||
// Rust PLAN_REDESIGNS §2.2). One unit, minutes, because every timed param
|
||||
// registered so far is one — a lease's `minutes`, a zone's `minutes` — and a
|
||||
// vocabulary of one is a vocabulary that can grow without a version.
|
||||
const PARAM_DURATIONS = ['minutes']
|
||||
|
||||
// A param that holds several values (MODULE_API 1.14.0; Rust PLAN_REDESIGNS
|
||||
// §12.3, D344). Two types, because the one step that needed it picks strings
|
||||
// from a source and an int list is the same check one type over; a list of
|
||||
// booleans, dates or links is a shape nothing has asked for, and every control
|
||||
// the editor draws for a list is a picker or a tag box.
|
||||
const MULTIPLE_PARAM_TYPES = ['string', 'int']
|
||||
// `max` when the declaration leaves it out, and the most it may say. The ceiling
|
||||
// is a bound on one step's params, not a number anyone should reach: a list of a
|
||||
// hundred is a step nobody can read back.
|
||||
const DEFAULT_MULTIPLE_MAX = 20
|
||||
const MAX_MULTIPLE_MAX = 100
|
||||
|
||||
// `call` names the registration in the error, because a template's inputs are
|
||||
// checked by this same function (they ARE params: one control each, with a
|
||||
// name, a type, an example and maybe a source).
|
||||
function checkActionParam(actionId, entry, seen, call = 'registerEventActions') {
|
||||
const { name, type, required, example, description, source, duration, multiple, max } = entry || {}
|
||||
const where = `${call}: ${actionId}`
|
||||
if (!PARAM_NAME.test(name || '')) throw new Error(`${where}: bad param name "${name}"`)
|
||||
if (seen.has(name)) throw new Error(`${where}: param "${name}" declared twice`)
|
||||
seen.add(name)
|
||||
@@ -1030,6 +1070,49 @@ function checkActionParam(actionId, entry, seen) {
|
||||
if (source !== undefined && !ACTION_ID.test(source || '')) {
|
||||
throw new Error(`${where}: param "${name}" has a bad option source "${source}"`)
|
||||
}
|
||||
// **A duration is what lets the editor see a timed step** (§2.2). A phase that
|
||||
// ends before its longest timed step gives the thing back early — a lease held
|
||||
// "for 10 minutes" in a phase that ends at once lasted one second — and the
|
||||
// editor can only say so about a param that says it is a length of time.
|
||||
// Numeric only: a duration typed as a string would be a number the editor
|
||||
// has to parse with rules nobody declared.
|
||||
if (duration !== undefined && duration !== null) {
|
||||
if (!PARAM_DURATIONS.includes(duration)) {
|
||||
throw new Error(`${where}: param "${name}" has duration "${duration}"; the one unit is ${PARAM_DURATIONS.join(', ')}`)
|
||||
}
|
||||
if (type !== 'int' && type !== 'float') {
|
||||
throw new Error(`${where}: param "${name}" is a duration and must be int or float, not ${type}`)
|
||||
}
|
||||
}
|
||||
// **`multiple` makes the value a list** (1.14.0, §12.3). The example stays ONE
|
||||
// value — it is the placeholder of the box a value is typed into — and the
|
||||
// value a step stores is a JSON array of them (`events/spec.js`).
|
||||
//
|
||||
// Refused on a duration, because a list of lengths of time is not a hold the
|
||||
// editor can keep a phase open for, and on a template's inputs, because
|
||||
// `build()` is a module's own code reading `inputs.x` as one value and this
|
||||
// version does not change what it is handed.
|
||||
if (multiple !== undefined && multiple !== null && multiple !== false) {
|
||||
if (multiple !== true) throw new Error(`${where}: param "${name}" multiple must be true or left out`)
|
||||
if (call === 'registerEventTemplates') {
|
||||
throw new Error(`${where}: input "${name}" cannot be multiple; a template's inputs are single values`)
|
||||
}
|
||||
if (!MULTIPLE_PARAM_TYPES.includes(type)) {
|
||||
throw new Error(`${where}: param "${name}" is multiple and must be ${MULTIPLE_PARAM_TYPES.join(' or ')}, not ${type}`)
|
||||
}
|
||||
if (duration !== undefined && duration !== null) {
|
||||
throw new Error(`${where}: param "${name}" cannot be both multiple and a duration`)
|
||||
}
|
||||
if (Array.isArray(example)) {
|
||||
throw new Error(`${where}: param "${name}" needs ONE example, not a list (it is the placeholder of one value)`)
|
||||
}
|
||||
}
|
||||
if (max !== undefined && max !== null) {
|
||||
if (multiple !== true) throw new Error(`${where}: param "${name}" has a max but is not multiple`)
|
||||
if (!Number.isInteger(max) || max < 1 || max > MAX_MULTIPLE_MAX) {
|
||||
throw new Error(`${where}: param "${name}" max must be 1..${MAX_MULTIPLE_MAX}, got "${max}"`)
|
||||
}
|
||||
}
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
@@ -1037,11 +1120,15 @@ function checkActionParam(actionId, entry, seen) {
|
||||
example,
|
||||
source: source === undefined ? null : source,
|
||||
description: description || '',
|
||||
...(duration ? { duration } : {}),
|
||||
// Absent rather than `false` on every other param, as `duration` is, so a
|
||||
// module that never says it reads back exactly the declaration it wrote.
|
||||
...(multiple === true ? { multiple: true, max: max ?? DEFAULT_MULTIPLE_MAX } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `registerEventActions([{ id, label, risk, reversible, version, budgetMs, cost, params, perform, revert, reconcile }])`.
|
||||
* `registerEventActions([{ id, label, risk, reversible, version, budgetMs, cost, params, perform, revert, reconcile, progress, demoRefuses }])`.
|
||||
*
|
||||
* A typed verb core may ask a registrant to carry out. Everything decidable from
|
||||
* the argument alone is decided here, at the call; the collision — is this id
|
||||
@@ -1118,6 +1205,19 @@ function checkEventActionShape(entry) {
|
||||
if (a.cost !== undefined && typeof a.cost !== 'function') {
|
||||
throw new Error(`registerEventActions: ${a.id} cost must be a function of its params`)
|
||||
}
|
||||
// 1.12.0, optional: how a live run's step stands, for its public page
|
||||
// (`events/progress.js`). Any action may declare it; an action that ledgers
|
||||
// nothing can still say something about the world it changed.
|
||||
if (a.progress !== undefined && typeof a.progress !== 'function') {
|
||||
throw new Error(`registerEventActions: ${a.id} progress must be a function`)
|
||||
}
|
||||
// 1.19.0, optional: the public demo's save-time refusal (DEMO_MODE.md §3.4,
|
||||
// DM18). Asked only with DEMO_MODE on, by the definition save path; a
|
||||
// sentence back refuses the save `403 DEMO_LOCKED`. The module still checks
|
||||
// at run, because a definition can predate the list it is checked against.
|
||||
if (a.demoRefuses !== undefined && typeof a.demoRefuses !== 'function') {
|
||||
throw new Error(`registerEventActions: ${a.id} demoRefuses must be a function of its params`)
|
||||
}
|
||||
|
||||
const version = a.version === undefined ? 1 : a.version
|
||||
if (!Number.isInteger(version) || version < 1) {
|
||||
@@ -1150,6 +1250,8 @@ function checkEventActionShape(entry) {
|
||||
perform: a.perform,
|
||||
revert: a.revert || null,
|
||||
reconcile: a.reconcile || null,
|
||||
progress: a.progress || null,
|
||||
demoRefuses: a.demoRefuses || null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1323,6 +1425,19 @@ function checkEventLeaseShape(entry) {
|
||||
values = l.values.map((v) => String(v))
|
||||
}
|
||||
|
||||
// **The lease's own example for its value** (MODULE_API 1.13.0; Rust
|
||||
// PLAN_REDESIGNS §2.1, U-3). `core.lease`'s `value` param has one example for
|
||||
// every lease there is, and a Rust admin borrowing a group permission was shown
|
||||
// a skill-gain rate. Optional; a scalar, because it is a placeholder in one
|
||||
// field. Not range-checked: it is shown, never sent.
|
||||
let example = null
|
||||
if (l.example !== undefined && l.example !== null && l.example !== '') {
|
||||
if (!['string', 'number', 'boolean'].includes(typeof l.example)) {
|
||||
throw new Error(`registerEventLeases: ${l.id} example must be a single value`)
|
||||
}
|
||||
example = l.example
|
||||
}
|
||||
|
||||
return {
|
||||
id: l.id,
|
||||
label: l.label,
|
||||
@@ -1331,6 +1446,7 @@ function checkEventLeaseShape(entry) {
|
||||
min,
|
||||
max,
|
||||
values,
|
||||
example,
|
||||
target,
|
||||
maxDurationMs,
|
||||
read: l.read,
|
||||
@@ -1377,6 +1493,56 @@ function checkEventOptionSourceShape(entry) {
|
||||
}
|
||||
}
|
||||
|
||||
// The most inputs a template may ask for. A template is a shortcut past the
|
||||
// editor, and one that asks for a dozen things is the editor with fewer labels.
|
||||
const MAX_TEMPLATE_INPUTS = 8
|
||||
|
||||
/**
|
||||
* `registerEventTemplates([{ id, label, description, inputs, build }])`
|
||||
* (MODULE_API 1.13.0; Rust PLAN_REDESIGNS §2.5, D192).
|
||||
*
|
||||
* A named starting point for a new event: the author fills in `inputs`, core
|
||||
* calls `build({ inputs })`, and what comes back is a DRAFT the editor opens like
|
||||
* any other — `{ title, summary?, spec: { phases } }`. Nothing about the template
|
||||
* survives into the definition.
|
||||
*
|
||||
* **`inputs` are params, checked by the same function**, because they are drawn
|
||||
* by the same widget: a name, a type, an example, maybe a source. That gives an
|
||||
* input the dropdown, the search box and the duration unit for free, and gives
|
||||
* the author one form language rather than two.
|
||||
*
|
||||
* `build` may be async and may read the module's own data (the kit weekend needs
|
||||
* the kit's permission). It is never trusted: core checks the inputs before the
|
||||
* call and validates the spec after it, and a throw is the author's error
|
||||
* message rather than a 500 (`buildEventTemplate` in eventDefinitions.model).
|
||||
*/
|
||||
function checkEventTemplateShape(entry) {
|
||||
const t = entry || {}
|
||||
if (!EVENT_ID.test(t.id || '')) throw new Error(`registerEventTemplates: bad template id "${t.id}"`)
|
||||
if (!t.label) throw new Error(`registerEventTemplates: template "${t.id}" has no label`)
|
||||
if (typeof t.build !== 'function') throw new Error(`registerEventTemplates: ${t.id} has no build()`)
|
||||
const rawInputs = t.inputs === undefined ? [] : t.inputs
|
||||
if (!Array.isArray(rawInputs)) throw new Error(`registerEventTemplates: ${t.id} inputs must be an array`)
|
||||
if (rawInputs.length > MAX_TEMPLATE_INPUTS) {
|
||||
throw new Error(`registerEventTemplates: ${t.id} asks for ${rawInputs.length} inputs; the most is ${MAX_TEMPLATE_INPUTS}`)
|
||||
}
|
||||
const seen = new Set()
|
||||
return {
|
||||
id: t.id,
|
||||
label: t.label,
|
||||
description: t.description || '',
|
||||
inputs: rawInputs.map((p) => checkActionParam(t.id, p, seen, 'registerEventTemplates')),
|
||||
build: t.build,
|
||||
}
|
||||
}
|
||||
|
||||
/** Every template WITHOUT its `build` — what the catalog serves. */
|
||||
const allEventTemplates = () =>
|
||||
[...eventTemplates.values()].map(({ build, ...rest }) => rest)
|
||||
|
||||
/** One template, `build` included. */
|
||||
const eventTemplate = (id) => eventTemplates.get(id) || null
|
||||
|
||||
|
||||
// ── Engagement seeds (Phase 11b, decision 7) ───────────────────────────────
|
||||
//
|
||||
@@ -1621,6 +1787,7 @@ function stage(owner) {
|
||||
eventBudgets: [],
|
||||
eventLeases: [],
|
||||
eventOptionSources: [],
|
||||
eventTemplates: [],
|
||||
}
|
||||
return {
|
||||
staged,
|
||||
@@ -1679,6 +1846,12 @@ function stage(owner) {
|
||||
if (!Array.isArray(entries)) throw new Error('registerEventOptionSources: expected an array')
|
||||
for (const e of entries) staged.eventOptionSources.push(checkEventOptionSourceShape(e))
|
||||
},
|
||||
// MODULE_API 1.13.0. Core registers none; the first is Module-Rust's kit
|
||||
// weekend (Rust PLAN_REDESIGNS §2.5).
|
||||
registerEventTemplates(entries) {
|
||||
if (!Array.isArray(entries)) throw new Error('registerEventTemplates: expected an array')
|
||||
for (const e of entries) staged.eventTemplates.push(checkEventTemplateShape(e))
|
||||
},
|
||||
registerEngagementSeeds(entry) {
|
||||
staged.engagementSeeds.push(checkEngagementSeeds(owner, entry))
|
||||
},
|
||||
@@ -1708,6 +1881,7 @@ function apply({
|
||||
eventBudgets: newEventBudgets = [],
|
||||
eventLeases: newEventLeases = [],
|
||||
eventOptionSources: newEventOptionSources = [],
|
||||
eventTemplates: newEventTemplates = [],
|
||||
engagementSeeds: newSeeds = [],
|
||||
}) {
|
||||
// ── validate ──
|
||||
@@ -1812,6 +1986,17 @@ function apply({
|
||||
seenLeases.add(l.id)
|
||||
}
|
||||
|
||||
const seenTemplates = new Set()
|
||||
for (const t of newEventTemplates) {
|
||||
const held = eventTemplates.get(t.id)
|
||||
if (held) throw new Error(`event template "${t.id}" is already registered by "${held.owner}"`)
|
||||
if (seenTemplates.has(t.id)) throw new Error(`event template "${t.id}" registered twice`)
|
||||
if (!namespaced(owner, t.id, {})) {
|
||||
throw new Error(`event template "${t.id}" is not namespaced "${owner}."`)
|
||||
}
|
||||
seenTemplates.add(t.id)
|
||||
}
|
||||
|
||||
const seenSources = new Set()
|
||||
for (const s of newEventOptionSources) {
|
||||
const held = eventOptionSources.get(s.id)
|
||||
@@ -1891,6 +2076,7 @@ function apply({
|
||||
for (const b of newEventBudgets) eventBudgets.set(b.id, { owner, ...b })
|
||||
for (const l of newEventLeases) eventLeases.set(l.id, { owner, ...l })
|
||||
for (const s of newEventOptionSources) eventOptionSources.set(s.id, { owner, ...s })
|
||||
for (const t of newEventTemplates) eventTemplates.set(t.id, { owner, ...t })
|
||||
for (const seeds of newSeeds) engagementSeeds.set(owner, seeds)
|
||||
}
|
||||
|
||||
@@ -1986,6 +2172,7 @@ function _reset() {
|
||||
eventBudgets.clear()
|
||||
eventLeases.clear()
|
||||
eventOptionSources.clear()
|
||||
eventTemplates.clear()
|
||||
engagementSeeds.clear()
|
||||
coreRegistered = false
|
||||
}
|
||||
@@ -2026,6 +2213,12 @@ module.exports = {
|
||||
parseLeaseRef,
|
||||
leaseRef,
|
||||
allEventOptionSources,
|
||||
allEventTemplates,
|
||||
eventTemplate,
|
||||
PARAM_DURATIONS,
|
||||
MULTIPLE_PARAM_TYPES,
|
||||
DEFAULT_MULTIPLE_MAX,
|
||||
MAX_MULTIPLE_MAX,
|
||||
resolveOptionSource,
|
||||
allEngagementSeeds,
|
||||
engagementSeedsFor,
|
||||
|
||||
@@ -9,6 +9,105 @@
|
||||
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
|
||||
// has nothing to say about a website module) and from any module's own version.
|
||||
|
||||
// 1.19.0 — the public demo refuses a step at SAVE (docs/website/DEMO_MODE.md
|
||||
// §3.4, DM18). An event action may declare `demoRefuses(params)`, returning a
|
||||
// sentence or null. With DEMO_MODE on, core asks it for every step when a
|
||||
// definition is created, saved or published, and a sentence refuses the whole
|
||||
// write `403 DEMO_LOCKED` with that sentence as `message`; a hook that throws
|
||||
// refuses too. Off the demo it is validated and never called. One optional
|
||||
// action member, so minor. Module-uo declares `^1.10.0` and declares none.
|
||||
// Module-Rust's console-command deny list is the first user.
|
||||
//
|
||||
// 1.18.0 — the public demo (docs/website/DEMO_MODE.md §3.2, §3.4, §7.3; website#222).
|
||||
// Three additions, each optional:
|
||||
// • `demoLocks` in module.json — `[{ method, path }]`, writes only, each path
|
||||
// under one of the module's own mounts. With DEMO_MODE on, core answers a
|
||||
// matching request `403 DEMO_LOCKED` before the module's handler runs; with
|
||||
// it off the list is validated and otherwise unread;
|
||||
// • `ctx.demoMode` — true on the demo, for the locks a route cannot express,
|
||||
// which the module enforces in its own handlers;
|
||||
// • `api.onDemoSeed(fn)` — `npm run seed:demo` hands the module its part of
|
||||
// the seed fixture (DM15), so it writes its own rows.
|
||||
// Additions only, so minor. Module-uo declares `^1.10.0` and uses none of them.
|
||||
// A module that DECLARES `demoLocks` needs `^1.18.0`, because older cores refuse
|
||||
// a manifest key they do not know.
|
||||
//
|
||||
// 1.17.0 — a module may take part in linking an external sign-in identity
|
||||
// (docs/website/STEAM_SIGNIN.md §4; Rust PLAN_REDESIGNS §15, D392 and D396).
|
||||
// Three additions, each optional:
|
||||
// • `ctx.identities.list(userId, provider)` — the identities a user has linked
|
||||
// for one provider, as core verified them (a Steam id proved by Steam's own
|
||||
// `check_authentication`), with the display name and avatar when there are;
|
||||
// • `api.onIdentityLinking(fn)` — asked before a link is written, and able only
|
||||
// to refuse it: `{ ok: false, reason }`, where `reason: 'in_use'` reads to the
|
||||
// player as "already linked to another account" and anything else as a
|
||||
// plain refusal. A module's own text is logged, never shown;
|
||||
// • `api.onIdentityLinked(fn)` — told after a link is written.
|
||||
// No unlink hook (D393). Additions only, so minor. Module-uo declares `^1.10.0`
|
||||
// and uses none of them: with no hook registered a link is checked and written
|
||||
// exactly as before.
|
||||
//
|
||||
// 1.16.0 — a dry run carries a module's advice: an action that answers
|
||||
// `verify: true` with `{ ok: true, detail: { warnings: [...] } }` has each line
|
||||
// shown as a WARNING finding (code `advice`) that does not fail the dry run
|
||||
// (Rust PLAN_REDESIGNS §11.4, D391: a raid base's spot is checked against the
|
||||
// world as it stands now, which may change before the run). Strings only, at most
|
||||
// ten, each cut at 500 characters. One optional reading of an existing member, so
|
||||
// minor. Module-uo declares `^1.10.0` and says no warnings: its dry runs read
|
||||
// exactly as they did. STEAM_SIGNIN.md's claim moves to the next minor again.
|
||||
//
|
||||
// 1.15.0 — a failed step may say when to try again: `{ ok: false, retry: true,
|
||||
// retryAfterMs }` (Rust PLAN_REDESIGNS §14.3.13, D371; docs/website/EVENTS.md §F).
|
||||
// Clamped to one minute–thirty minutes, never refused; absent, the flat
|
||||
// `EVENT_STEP_RETRY_MS` as before. It is one attempt of `EVENT_STEP_MAX_ATTEMPTS`
|
||||
// like any retry, and ignored beside `retry: false`. One optional envelope
|
||||
// member, so minor. Module-uo declares `^1.10.0` and answers none: an envelope
|
||||
// without it is classified exactly as it was. This took 1.15.0 ahead of
|
||||
// STEAM_SIGNIN.md, which had claimed it and moves to the next minor.
|
||||
//
|
||||
// 1.14.0 —an action param may hold several values: `multiple: true`, with an
|
||||
// optional `max` (1–100, 20 if left out), on a `string` or `int` param (Rust
|
||||
// PLAN_REDESIGNS §12.3, D344; docs/website/EVENTS.md §F). The step stores a JSON
|
||||
// array; a bare value is read as a list of one; `required` means at least one;
|
||||
// `perform()` and `cost()` are handed the array. Refused on a duration and on a
|
||||
// template's inputs. The editor draws it as a multi-select.
|
||||
// One optional flag, so minor. Module-uo declares `^1.10.0` and uses none of it:
|
||||
// a param that does not say `multiple` is checked, stored and drawn exactly as it
|
||||
// was, and the declaration the registry hands back does not grow the key.
|
||||
//
|
||||
// 1.13.0 — the event step editor (Rust PLAN_REDESIGNS §2; docs/website/EVENTS.md
|
||||
// §F, §I). Four additions, each optional:
|
||||
// • `api.registerEventTemplates([{ id, label, description, inputs, build }])` —
|
||||
// a named starting point for a new event; inputs in, an ordinary draft out,
|
||||
// and nothing about the template kept in the definition (D192);
|
||||
// • a param's `duration: 'minutes'`, so the editor can see a timed step and
|
||||
// keep its phase open long enough for it;
|
||||
// • a lease's `example` for its value, so a lease step shows that lease's
|
||||
// example rather than core's one for every lease;
|
||||
// • an option row's `note`, shown under the field while it is the chosen one.
|
||||
// Additions only, so minor. Module-uo declares `^1.10.0` and uses none of them:
|
||||
// its actions, leases and sources come out of the registry exactly as they went
|
||||
// in, and its steps render with the new controls and nothing else changed.
|
||||
//
|
||||
// 1.12.0 — an event action may declare `progress(envelope)`: how a live run's
|
||||
// step stands, for its public page (docs/website/EVENTS.md §I; RunicNPC D304,
|
||||
// D305). Core accepts two shapes, `{ label, left, of }` and `{ label, percent }`,
|
||||
// puts them in the public run as `progress` and renders them as text it does not
|
||||
// interpret. Optional and additive, so minor: Module-uo declares `^1.10.0` and
|
||||
// registers no action with it, so its actions and pages are unchanged.
|
||||
//
|
||||
// 1.11.0 — `ctx.events.expired({ kind, ref })`, and `expired` as a resource
|
||||
// ledger status (docs/website/EVENTS.md §L; Rust PLAN_FIXES D183). A game that
|
||||
// ends something at its own deadline — a Rust zone erased when its time is up —
|
||||
// could until now reach core only through `ctx.events.reconcile()`, which files
|
||||
// it `orphaned`: amber, "it vanished", and still claimable for a revert. The new
|
||||
// call files it as the plan working: terminal, green, never taken back.
|
||||
//
|
||||
// Additions only, so minor, and checked against the one module already built on
|
||||
// 1.10.0: Module-uo never calls it, reads no ledger status, and every existing
|
||||
// status keeps its meaning — `expired` joins neither `HELD` nor `UNRESOLVED`, so
|
||||
// the sweep, the manual retry and `cleanup_status` see exactly what they saw.
|
||||
|
||||
// 1.10.0 — the event contract opens to modules: `api.registerEventActions`,
|
||||
// `api.registerEventBudgets`, `api.registerEventLeases` and
|
||||
// `api.registerEventOptionSources` (docs/website/EVENTS.md §F, EVENTS_PLAN.md
|
||||
@@ -155,6 +254,6 @@
|
||||
// an admin action a module performs belongs in core's one audit log, the
|
||||
// extension slot needs the user its prefix names, and §2.7 forbids a module
|
||||
// reading core's `APP_BASE_URL` for itself. Additions only, so minor.
|
||||
const MODULE_API_VERSION = '1.10.0'
|
||||
const MODULE_API_VERSION = '1.19.0'
|
||||
|
||||
module.exports = { MODULE_API_VERSION }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// ── Admin: auth provider configuration ─────────────────────────────────────
|
||||
//
|
||||
// CRUD for SSO providers. Built-ins (google, discord) are configured here too but
|
||||
// CRUD for SSO providers. Built-ins (google, discord, steam) are configured here too but
|
||||
// can only be enabled/disabled and given a client id/secret — their kind, name,
|
||||
// and endpoints are fixed in code and cannot be edited or deleted. Custom
|
||||
// (oidc/oauth2) providers are fully editable.
|
||||
@@ -80,6 +80,18 @@ async function update(req, res) {
|
||||
// Built-ins: kind/name/priority are fixed; only enable + credentials change.
|
||||
const meta = registry.BUILTINS.find((x) => x.id === id)
|
||||
fields = { kind: meta.kind, name: meta.name, priority: meta.priority, enabled: b.enabled, clientId: b.clientId, secret: b.secret }
|
||||
if (meta.kind === 'steam') {
|
||||
// Steam has no client id; its secret is the Steam Web API key, and it
|
||||
// cannot be enabled without one (D394) — refused here, with a sentence,
|
||||
// rather than saved as an "Incomplete" provider nobody can use.
|
||||
fields.clientId = undefined
|
||||
if (b.enabled === true && !b.secret) {
|
||||
const current = await authProviders.get(id)
|
||||
if (!current || !current.client_secret_enc) {
|
||||
return res.status(400).json({ message: 'Steam needs a Steam Web API key before it can be enabled.' })
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!(await authProviders.get(id))) {
|
||||
return res.status(404).json({ message: 'Provider not found.' })
|
||||
|
||||
@@ -59,11 +59,12 @@ providersRouter.put(
|
||||
'/providers/:id',
|
||||
// #swagger.tags = ['Admin · Auth Providers']
|
||||
// #swagger.summary = 'Update an SSO provider (admin only)'
|
||||
// #swagger.description = 'Built-ins (google, discord, steam) take only enabled + credentials. Steam has no client id: its secret is a Steam Web API key, and enabling Steam without one stored or given is refused with 400 (STEAM_SIGNIN.md D394).'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' }
|
||||
/* #swagger.requestBody = { content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderCreateRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated provider', content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderConfig" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error, or Steam enabled without a Steam Web API key', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Provider not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
|
||||
@@ -34,12 +34,24 @@ const activity = require('../../../model/activity/activity.model')
|
||||
const settingsDb = require('../../../model/events/eventActionSettings.db')
|
||||
const authorize = require('../../../events/authorize')
|
||||
const price = require('../../../events/price')
|
||||
const starts = require('../../../events/starts')
|
||||
const triggerStartsDb = require('../../../model/events/eventTriggerStarts.db')
|
||||
|
||||
const asId = (raw) => {
|
||||
const n = Number(raw)
|
||||
return Number.isInteger(n) && n > 0 ? n : null
|
||||
}
|
||||
|
||||
/**
|
||||
* A refused save's body: the errors, plus `code` and `message` when the model
|
||||
* set them. The demo's step refusal does (DM18), so the client's one
|
||||
* `DEMO_LOCKED` toast fires as it does for a route lock.
|
||||
*/
|
||||
const refusal = (result) => ({
|
||||
errors: result.errors,
|
||||
...(result.code ? { code: result.code, message: result.message } : {}),
|
||||
})
|
||||
|
||||
/**
|
||||
* The shape a definition takes on the wire.
|
||||
*
|
||||
@@ -78,6 +90,19 @@ const shapeDefinition = (d) => ({
|
||||
updatedAt: d.updated_at,
|
||||
})
|
||||
|
||||
/**
|
||||
* The definition as the EDITOR reads it: the shape above, and whether the working
|
||||
* copy has moved on from the published version (Rust PLAN_REDESIGNS §2.3). One
|
||||
* extra read, so only the three routes the editor loads from carry it.
|
||||
*/
|
||||
const shapeEditable = async (row) => ({
|
||||
...shapeDefinition(row),
|
||||
draftAhead: await definitions.draftAhead(row),
|
||||
// What its trigger starts have done lately (D370): the last run one started
|
||||
// and the firings ignored since, by why. Null until a trigger first tries.
|
||||
triggerStarts: starts.describeState(await triggerStartsDb.forDefinition(row.id)),
|
||||
})
|
||||
|
||||
const shapeRun = (r) => ({
|
||||
id: r.id,
|
||||
definitionId: r.definition_id,
|
||||
@@ -95,6 +120,10 @@ const shapeRun = (r) => ({
|
||||
concurrencyKey: r.concurrency_key,
|
||||
params: r.params,
|
||||
rehearsal: r.rehearsal,
|
||||
// What started it, when a game firing did (D370): the trigger, when, its
|
||||
// subject, the condition it matched and its declared variables. Null for a
|
||||
// run started by hand or by the schedule.
|
||||
origin: r.origin || null,
|
||||
startedAt: r.started_at,
|
||||
endedAt: r.ended_at,
|
||||
// When the results table was ranked and published (Phase 10). On the LIST as
|
||||
@@ -203,6 +232,9 @@ exports.catalog = (_req, res) => {
|
||||
budgets: registries.allEventBudgets(),
|
||||
leases: registries.allEventLeases(),
|
||||
optionSources: registries.allEventOptionSources(),
|
||||
// MODULE_API 1.13.0 (Rust PLAN_REDESIGNS §2.5). Without `build`, like every
|
||||
// callable above; filling one in is its own request.
|
||||
templates: registries.allEventTemplates(),
|
||||
limits: {
|
||||
maxPhases: spec.MAX_PHASES,
|
||||
maxStepsPerPhase: spec.MAX_STEPS_PER_PHASE,
|
||||
@@ -210,6 +242,9 @@ exports.catalog = (_req, res) => {
|
||||
defaultBudgetMs: registries.DEFAULT_BUDGET_MS,
|
||||
maxAdvanceCount: spec.MAX_ADVANCE_COUNT,
|
||||
maxAfterSeconds: spec.MAX_AFTER_SECONDS,
|
||||
// A definition's `starts` (D370): how many, and the cooldown's ceiling.
|
||||
maxStarts: spec.MAX_STARTS,
|
||||
maxCooldownSeconds: spec.MAX_COOLDOWN_SECONDS,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -363,7 +398,7 @@ exports.get = async (req, res) => {
|
||||
if (!id) return res.status(400).json({ error: 'bad event id' })
|
||||
const row = await definitionsDb.getById(id)
|
||||
if (!row) return res.status(404).json({ error: 'no such event definition' })
|
||||
return res.json({ event: shapeDefinition(row) })
|
||||
return res.json({ event: await shapeEditable(row) })
|
||||
}
|
||||
|
||||
/** GET /api/v1/admin/events/:id/versions */
|
||||
@@ -388,7 +423,7 @@ exports.listVersions = async (req, res) => {
|
||||
/** POST /api/v1/admin/events */
|
||||
exports.create = async (req, res) => {
|
||||
const result = await definitions.create(req.body, req.user.id, { role: req.user.role })
|
||||
if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
|
||||
if (!result.ok) return res.status(result.status || 400).json(refusal(result))
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'event.definition.created',
|
||||
@@ -402,13 +437,13 @@ exports.update = async (req, res) => {
|
||||
const id = asId(req.params.id)
|
||||
if (!id) return res.status(400).json({ error: 'bad event id' })
|
||||
const result = await definitions.save(id, req.body, req.user.id, { role: req.user.role })
|
||||
if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
|
||||
if (!result.ok) return res.status(result.status || 400).json(refusal(result))
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'event.definition.updated',
|
||||
detail: { id, title: result.definition.title },
|
||||
})
|
||||
return res.json({ event: shapeDefinition(result.definition) })
|
||||
return res.json({ event: await shapeEditable(result.definition) })
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/events/:id/publish */
|
||||
@@ -416,20 +451,29 @@ exports.publish = async (req, res) => {
|
||||
const id = asId(req.params.id)
|
||||
if (!id) return res.status(400).json({ error: 'bad event id' })
|
||||
const result = await definitions.publish(id, req.user.id)
|
||||
if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
|
||||
if (!result.ok) return res.status(result.status || 400).json(refusal(result))
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'event.definition.published',
|
||||
detail: { id, version: result.version, versionId: result.versionId, repinned: result.repinned },
|
||||
detail: {
|
||||
id,
|
||||
version: result.version,
|
||||
versionId: result.versionId,
|
||||
repinned: result.repinned,
|
||||
verified: result.verified,
|
||||
},
|
||||
})
|
||||
return res.json({
|
||||
event: shapeDefinition(result.definition),
|
||||
event: await shapeEditable(result.definition),
|
||||
version: result.version,
|
||||
versionId: result.versionId,
|
||||
// How many already-materialised occurrences moved to this version. The
|
||||
// screen says so, because "my fix did not reach next Friday" is otherwise
|
||||
// found out on Friday.
|
||||
repinned: result.repinned,
|
||||
// Whether a dry run that passed on this exact draft made the version
|
||||
// verified at birth (W5), so the screen can say it needs no second one.
|
||||
verified: result.verified,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -467,6 +511,7 @@ exports.verify = async (req, res) => {
|
||||
versionId: result.versionId,
|
||||
version: result.version,
|
||||
recorded: result.recorded,
|
||||
draftRecorded: result.draftRecorded,
|
||||
report: result.report,
|
||||
})
|
||||
}
|
||||
@@ -585,6 +630,19 @@ exports.options = async (req, res) => {
|
||||
return res.json(result)
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/events/catalog/templates/:templateId — fill a template in.
|
||||
*
|
||||
* MODULE_API 1.13.0 (Rust PLAN_REDESIGNS §2.5). Answers a draft and writes
|
||||
* nothing, so there is nothing to put in the activity log: the definition the
|
||||
* draft becomes is logged when it is created, by the route that creates it.
|
||||
*/
|
||||
exports.buildTemplate = async (req, res) => {
|
||||
const result = await definitions.buildFromTemplate(req.params.templateId, req.body?.inputs)
|
||||
if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
|
||||
return res.json({ draft: result.draft })
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/admin/events/actions — set one action's switch and caps.
|
||||
*
|
||||
|
||||
@@ -51,7 +51,7 @@ eventsRouter.get(
|
||||
// #swagger.summary = 'List every registered event action, with its param schema, risk class and reversibility'
|
||||
// #swagger.description = 'Served from the module registries, not from a table: an action is declared in code by core or by an installed module, so this is whatever registered on this boot, and an uninstalled module simply stops appearing. Core always declares core.announce, core.wait and core.cue. Also carries the closed vocabularies the authoring form renders — risk classes, reversibility classes, param types, failure dispositions and the spec size limits — so the editor offers exactly the set the save path checks against. Phase 5 added `triggers` and `operators`: the trigger catalog a module already ships IS the catalog of things a phase can advance on, and it is served here rather than borrowed from /admin/engagement/triggers because that route is admin-only while an event definition is authored by admin AND editor. Each trigger is reduced to its id, label and declared variables — a trigger's audience and ceiling are about who gets mailed, which is not this screen's question. Phase 7 added `budgets`, `leases` and `optionSources`: the other three registrations of the module contract, served beside the actions because the step editor needs all four to draw ONE step — the action says what params it takes, a param source names a dropdown, and a cap box is a budget label and unit. A source resolves its VALUES on a request of its own (/options/:sourceId), because a source can be slow or down and must not take the catalog with it.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The registered actions and triggers, and the vocabularies over them', content: { "application/json": { schema: { type: "object", properties: { actions: { type: "array", items: { type: "object", additionalProperties: true } }, triggers: { type: "array", items: { type: "object", additionalProperties: true } }, operators: { type: "array", items: { type: "object", additionalProperties: true } }, risks: { type: "array", items: { type: "string" } }, reversible: { type: "array", items: { type: "string" } }, paramTypes: { type: "array", items: { type: "string" } }, onFailure: { type: "array", items: { type: "string" } }, onFailureByRisk: { type: "object", additionalProperties: true }, scheduleKinds: { type: "array", items: { type: "string" } }, advanceKinds: { type: "array", items: { type: "string" } }, limits: { type: "object", additionalProperties: true } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'The registered actions and triggers, and the vocabularies over them', content: { "application/json": { schema: { type: "object", properties: { actions: { type: "array", description: "Each action with its declared params. MODULE_API 1.14.0: a param may carry multiple: true and max (1 to 100, 20 when left out), and its value is then a JSON array of at most max values.", items: { type: "object", additionalProperties: true } }, triggers: { type: "array", items: { type: "object", additionalProperties: true } }, operators: { type: "array", items: { type: "object", additionalProperties: true } }, risks: { type: "array", items: { type: "string" } }, reversible: { type: "array", items: { type: "string" } }, paramTypes: { type: "array", items: { type: "string" } }, onFailure: { type: "array", items: { type: "string" } }, onFailureByRisk: { type: "object", additionalProperties: true }, scheduleKinds: { type: "array", items: { type: "string" } }, advanceKinds: { type: "array", items: { type: "string" } }, limits: { type: "object", additionalProperties: true }, templates: { type: "array", description: "Event templates (MODULE_API 1.13.0): id, label, description, owner and inputs (each shaped like an action param). build() is never served.", items: { type: "object", additionalProperties: true } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
controller.catalog,
|
||||
)
|
||||
@@ -78,11 +78,35 @@ eventsRouter.get(
|
||||
// object schema whose properties are `type` and `maxLength`, which documents a
|
||||
// query parameter that takes a JSON object. The bound is stated in the description.
|
||||
/* #swagger.parameters['q'] = { in: 'query', description: 'Narrow the list. Honoured only by a source that declares itself searchable; ignored, never refused, by the rest. Bounded to 120 characters.', required: false, schema: { type: 'string' } } */
|
||||
/* #swagger.responses[200] = { description: 'The options, or the reason there are none', content: { "application/json": { schema: { type: "object", properties: { ok: { type: "boolean" }, id: { type: "string" }, label: { type: "string" }, owner: { type: "string" }, searchable: { type: "boolean" }, q: { type: "string" }, reason: { type: "string" }, options: { type: "array", items: { type: "object", properties: { value: { type: "string" }, label: { type: "string" }, group: { type: "string" } } } } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'The options, or the reason there are none', content: { "application/json": { schema: { type: "object", properties: { ok: { type: "boolean" }, id: { type: "string" }, label: { type: "string" }, owner: { type: "string" }, searchable: { type: "boolean" }, q: { type: "string" }, reason: { type: "string" }, options: { type: "array", items: { type: "object", properties: { value: { type: "string" }, label: { type: "string" }, group: { type: "string" }, note: { type: "string", description: "Advice shown under the field while this option is chosen (MODULE_API 1.13.0). At most 300 characters." } } } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
controller.options,
|
||||
)
|
||||
|
||||
// ── Event templates (MODULE_API 1.13.0; Rust PLAN_REDESIGNS §2.5) ─────────
|
||||
//
|
||||
// Under `/catalog` for the reason the option sources are: a template is catalog
|
||||
// data, and the nesting keeps it out of `/:id`'s way. A POST because the inputs
|
||||
// are a body, not because anything is written — the answer is a draft the
|
||||
// editor opens, and saving it is `POST /` with every rule that route has.
|
||||
// `admin, editor`, because filling a template in is authoring.
|
||||
|
||||
eventsRouter.post(
|
||||
'/catalog/templates/:templateId',
|
||||
adminOrEditor,
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'Fill an event template in, and get back a draft'
|
||||
// #swagger.description = 'A module may register named event templates (MODULE_API 1.13.0). The catalog lists them with their inputs; this route checks the inputs against the template, calls the module to build the plan, validates the plan as a spec and answers it as a draft: a title, a summary and a spec. Nothing is saved, and nothing about the template is kept: the editor opens the draft like any new event and the author saves it through POST /admin/events. A refusal of the inputs is a 400 naming each problem; a module that fails or builds a plan core cannot accept is a 502.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { inputs: { type: "object", additionalProperties: true, description: "One value per declared input, keyed by its name" } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'The draft', content: { "application/json": { schema: { type: "object", properties: { draft: { type: "object", properties: { title: { type: "string" }, summary: { type: "string", nullable: true }, spec: { type: "object", additionalProperties: true } } } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'The inputs were refused', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin or editor', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No module registers that template', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[502] = { description: 'The module failed to build the plan, or built one core cannot accept', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
|
||||
controller.buildTemplate,
|
||||
)
|
||||
|
||||
// ── The live cap meter (Phase 13) ──────────────────────────────────
|
||||
//
|
||||
// A literal path for the same reason `/actions` is one, and `admin, editor` for
|
||||
@@ -237,7 +261,7 @@ eventsRouter.get(
|
||||
'/runs/:runId',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'One run: its status, health, cleanup state and every step with its params and idempotency key'
|
||||
// #swagger.description = 'The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder’s own words — `gte` as "is at least", `present` as "is present" — with the tally, how long it has waited, and the last related firing whether or not it matched. A phase is waiting on its gate only once every one of its steps is terminal; `stalled` means an `on` gate has waited past EVENT_PHASE_STALL_MS, which is visibility and never a timeout — nothing advances a phase but its condition or a human. `budget` is the cap meter (Phase 6), and `resources` is the cleanup ledger (Phase 8): every object this run created and every value it borrowed, with what became of each — `confirmed` is still out there, `reverted` came back, `drifted` means somebody moved it and core left it alone, and `orphaned` means the module reports it is gone. `unresolvedResources` counts the ones still wanting something, including a placeholder left standing by a lost acknowledgement, which is why it can exceed the length of the list. `participants` is who took part (Phase 10), best first, as a module reported them: `memberKey` is module-opaque, `userId` is filled in only where the module could link the player to an account, and `rank` is null until `core.results.publish` has ranked them — a run whose participants are collected but unranked is a real and visible state, not an error. The run itself carries `resultsPublishedAt`, which is when that table was last published.'
|
||||
// #swagger.description = 'The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder’s own words — `gte` as "is at least", `present` as "is present" — with the tally, how long it has waited, and the last related firing whether or not it matched. A phase is waiting on its gate only once every one of its steps is terminal; `stalled` means an `on` gate has waited past EVENT_PHASE_STALL_MS, which is visibility and never a timeout — nothing advances a phase but its condition or a human. `budget` is the cap meter (Phase 6), and `resources` is the cleanup ledger (Phase 8): every object this run created and every value it borrowed, with what became of each — `confirmed` is still out there, `reverted` came back, `drifted` means somebody moved it and core left it alone, `orphaned` means the module reports it is gone, and `expired` means the game ended it at its own deadline, as it was told to (terminal, like `reverted`). `unresolvedResources` counts the ones still wanting something, including a placeholder left standing by a lost acknowledgement, which is why it can exceed the length of the list. `participants` is who took part (Phase 10), best first, as a module reported them: `memberKey` is module-opaque, `userId` is filled in only where the module could link the player to an account, and `rank` is null until `core.results.publish` has ranked them — a run whose participants are collected but unranked is a real and visible state, not an error. The run itself carries `resultsPublishedAt`, which is when that table was last published, and `origin` when a game firing started it (Rust PLAN_REDESIGNS 14.3.12): the trigger, its label, when it fired, its subject, the start condition it matched in the builder words, and its declared variables. Null for a run started by hand or by the schedule.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The run, its steps, the status counts, the phase gates, the cap meter, the resource ledger and the participants', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true }, steps: { type: "array", items: { type: "object", additionalProperties: true } }, counts: { type: "object", additionalProperties: true }, gates: { type: "array", items: { type: "object", additionalProperties: true } }, budget: { type: "array", items: { type: "object", additionalProperties: true } }, resources: { type: "array", items: { type: "object", additionalProperties: true } }, unresolvedResources: { type: "integer" }, participants: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such run', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
@@ -415,7 +439,7 @@ eventsRouter.get(
|
||||
'/:id',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'One event definition, including its working spec'
|
||||
// #swagger.description = 'The editor reads this. The list route serves a summary; this is the whole authored tree, phases and steps included.'
|
||||
// #swagger.description = 'The editor reads this. The list route serves a summary; this is the whole authored tree, phases and steps included. The spec may carry `starts` (Rust PLAN_REDESIGNS 14.3.12): the triggers that begin a run of the published version, each a phase gate `on` and `where` plus a `cooldown` (0m to 7d, 30m when left out); core own event.* triggers are refused. `triggerStarts` is what they have done lately: the last run a trigger started and the firings ignored since, by why (a run was going, inside the cooldown, or the published version is not verified). Null until a trigger first tries.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The definition', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true } } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such definition', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
@@ -451,9 +475,9 @@ eventsRouter.post(
|
||||
'/:id/verify',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'Dry run: dispatch every step with verify true, change nothing, and report the cost against the caps'
|
||||
// #swagger.description = 'EVENTS.md I. admin AND editor rather than admin, deliberately: a dry run dispatches nothing, and the author who wrote the definition is exactly who should be able to price it before asking an admin to publish it. What is verified follows the state -- a ready definition is checked against its PUBLISHED version, which is the only thing that ever actually runs, and a draft against the working spec the author is still holding; `target` says which. A pass against a version is RECORDED on it, and that is EVENTS.md K last bound: a scheduled occurrence of a version that has never been verified is held rather than started unattended. Findings come back with a 200 -- the request succeeded, the plan has problems -- and the whole-plan cost check is the one thing no other path makes: three steps each spawning 15 under a cap of 30 pass every individual check and breach it on the third, at two in the morning, with the world half-changed.'
|
||||
// #swagger.description = 'EVENTS.md I. admin AND editor rather than admin, deliberately: a dry run dispatches nothing, and the author who wrote the definition is exactly who should be able to price it before asking an admin to publish it. What is verified follows the state -- a ready definition is checked against its PUBLISHED version, which is the only thing that ever actually runs, and a draft against the working spec the author is still holding; `target` says which. A pass against a version is RECORDED on it, and that is EVENTS.md K last bound: a scheduled occurrence of a version that has never been verified is held rather than started unattended. A pass against a draft is kept with the fingerprint of the spec it passed on (`draftRecorded`), and publishing that spec unchanged makes a version that is verified from the start; an edit in between changes the fingerprint, and nothing carries over. Findings come back with a 200 -- the request succeeded, the plan has problems -- and the whole-plan cost check is the one thing no other path makes: three steps each spawning 15 under a cap of 30 pass every individual check and breach it on the third, at two in the morning, with the world half-changed.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The report: findings per step, and the total cost per budget dimension', content: { "application/json": { schema: { type: "object", properties: { target: { type: "string", example: "version" }, versionId: { type: "integer" }, version: { type: "integer" }, recorded: { type: "boolean" }, report: { type: "object", properties: { ok: { type: "boolean" }, steps: { type: "integer" }, findings: { type: "array", items: { type: "object", additionalProperties: true } }, cost: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'The report: findings per step, and the total cost per budget dimension', content: { "application/json": { schema: { type: "object", properties: { target: { type: "string", example: "version" }, versionId: { type: "integer" }, version: { type: "integer" }, recorded: { type: "boolean" }, draftRecorded: { type: "boolean" }, report: { type: "object", properties: { ok: { type: "boolean" }, steps: { type: "integer" }, findings: { type: "array", items: { type: "object", additionalProperties: true } }, cost: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such definition', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'The definition is archived, or has no phases to verify', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin or editor', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
@@ -465,9 +489,9 @@ eventsRouter.post(
|
||||
'/:id/publish',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'Snapshot the working spec into an immutable version and mark the definition ready'
|
||||
// #swagger.description = 'Admin only, deliberately, and not the same gate as the live run controls: publishing commits a definition that a schedule will later start unattended. The spec is re-validated against the registries as they stand right now rather than trusted from the save that wrote it, so a module uninstalled in between blocks the publish instead of producing a run that fails at dispatch. Publishing also RE-PINS every occurrence of this definition that is still scheduled and has not started, and `repinned` says how many moved: occurrences are materialised a fortnight ahead, so without this an edit would reach none of the runs already on the calendar. A run that has begun keeps the version it pinned.'
|
||||
// #swagger.description = 'Admin only, deliberately, and not the same gate as the live run controls: publishing commits a definition that a schedule will later start unattended. The spec is re-validated against the registries as they stand right now rather than trusted from the save that wrote it, so a module uninstalled in between blocks the publish instead of producing a run that fails at dispatch. Publishing also RE-PINS every occurrence of this definition that is still scheduled and has not started, and `repinned` says how many moved: occurrences are materialised a fortnight ahead, so without this an edit would reach none of the runs already on the calendar. A run that has begun keeps the version it pinned. `verified` is true when a dry run that passed on this exact draft made the new version verified, with that dry run reviewer and time, so it needs no second dry run before it starts unattended.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The definition, now ready, the version that was cut, and how many scheduled occurrences moved to it', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true }, version: { type: "integer" }, versionId: { type: "integer" }, repinned: { type: "integer" } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'The definition, now ready, the version that was cut, and how many scheduled occurrences moved to it', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true }, version: { type: "integer" }, versionId: { type: "integer" }, repinned: { type: "integer" }, verified: { type: "boolean" } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'The spec is invalid, or no phase has any steps', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[409] = { description: 'A step names an action no module registers, or the definition is archived', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
|
||||
@@ -41,11 +41,12 @@ modulesRouter.post(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Modules']
|
||||
// #swagger.summary = 'Install or upgrade a module from a release install-manifest URL'
|
||||
// #swagger.description = 'Downloads the artifact the manifest names, verifies its sha256, inspects the archive in full and unpacks it onto the modules volume. The module mounts on the next restart.'
|
||||
// #swagger.description = 'Downloads the artifact the manifest names, verifies its sha256, inspects the archive in full and unpacks it onto the modules volume. The module mounts on the next restart. A site runs ONE module: installing a module other than the one already on the volume is refused with 409 before anything is downloaded, and only an upgrade of the installed module is accepted.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["url"], properties: { url: { type: "string", description: "https URL of the release install manifest, on an allowed host" } } } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Installed — restart to mount it', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[400] = { description: 'The URL, the manifest, the hash or the archive was refused', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'A different module is already installed; a site runs one module', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[502] = { description: 'The source host could not be reached or answered badly', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
body('url').isString().trim().isLength({ min: 1, max: 2048 }),
|
||||
|
||||
@@ -339,20 +339,36 @@ async function totpDisable(req, res) {
|
||||
async function listIdentities(req, res) {
|
||||
try {
|
||||
const rows = await userIdentities.listForUser(req.user.id)
|
||||
return res.json(rows.map((r) => ({ provider: r.provider, email: r.email, linked_at: r.created_at })))
|
||||
// `id` lets one identity be unlinked when a provider has several (a player
|
||||
// may link more than one Steam account). `subject` is the user's OWN external
|
||||
// id — for Steam, the SteamID64 shown beside the persona name (D394).
|
||||
return res.json(
|
||||
rows.map((r) => ({
|
||||
id: r.id,
|
||||
provider: r.provider,
|
||||
subject: r.subject,
|
||||
email: r.email,
|
||||
display_name: r.display_name || null,
|
||||
avatar_url: r.avatar_url || null,
|
||||
linked_at: r.created_at,
|
||||
})),
|
||||
)
|
||||
} catch (err) {
|
||||
log.error('listIdentities', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// Remove a linked SSO identity from the current user's account.
|
||||
// Remove a linked SSO identity from the current user's account: every identity
|
||||
// of that provider, or with `?id=` the one identity named. Only the sign-in goes —
|
||||
// a module's own link made from it (Rust's, D392) is the module's to remove (D393).
|
||||
async function unlinkIdentity(req, res) {
|
||||
const { provider } = req.params
|
||||
const identityId = req.query.id != null ? Number(req.query.id) : null
|
||||
try {
|
||||
const removed = await userIdentities.unlink(req.user.id, provider)
|
||||
const removed = await userIdentities.unlink(req.user.id, provider, identityId)
|
||||
if (!removed) return res.status(404).json({ message: 'No linked account for that provider.' })
|
||||
await activity.log({ req, action: 'auth.sso.unlink', detail: { provider } })
|
||||
await activity.log({ req, action: 'auth.sso.unlink', detail: { provider, ...(identityId != null ? { identityId } : {}) } })
|
||||
log.info('sso identity unlinked', { provider, id: req.user.id })
|
||||
return res.json({ unlinked: true })
|
||||
} catch (err) {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
// expect. Mounted at /me by auth/index.js, so paths below are /auth/me/account*.
|
||||
|
||||
const express = require('express')
|
||||
const { body, param } = require('express-validator')
|
||||
const { body, param, query } = require('express-validator')
|
||||
|
||||
const account = require('./account.controller')
|
||||
const { requireAuth } = require('../../../auth/session.middleware')
|
||||
@@ -177,10 +177,14 @@ meRouter.delete(
|
||||
// #swagger.tags = ['Auth · Me']
|
||||
// #swagger.summary = 'Unlink an SSO identity (self)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.description = 'Removes every identity of the provider, or with ?id= the one identity named (a user may link several Steam accounts). Only the sign-in identity goes: a game module link made from it stays (STEAM_SIGNIN.md D393).'
|
||||
// #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' }
|
||||
// #swagger.parameters['id'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'One identity to unlink (its id from GET /auth/me/account/identities).' }
|
||||
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { $ref: "#/components/schemas/UnlinkedFlag" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No linked account for that provider', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('provider').matches(/^[a-z0-9-]+$/),
|
||||
query('id').optional().isInt({ min: 1 }),
|
||||
validate,
|
||||
account.unlinkIdentity,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// ── SSO (OAuth2 / OIDC) controller ─────────────────────────────────────────
|
||||
//
|
||||
// Drives the redirect flow for built-in (Google, Discord) and custom providers:
|
||||
// Drives the redirect flow for built-in (Google, Discord, Steam) and custom
|
||||
// providers. Steam is OpenID 2.0 rather than OAuth2 (STEAM_SIGNIN.md §3); the
|
||||
// difference is confined to readCallback() and the provider class:
|
||||
// GET /auth/providers → public discovery (enabled + valid providers)
|
||||
// GET /auth/sso/:provider/start → begin login (redirect to the IdP)
|
||||
// GET /auth/sso/:provider/link → begin account linking (requireAuth)
|
||||
@@ -17,6 +19,8 @@ const authProviders = require('../../../model/authProviders/authProviders.model'
|
||||
const userIdentities = require('../../../model/userIdentities/userIdentities.model')
|
||||
const settings = require('../../../model/settings/settings.model')
|
||||
const registry = require('../../../auth/providers/registry')
|
||||
const SteamProvider = require('../../../auth/providers/steam.provider')
|
||||
const identityHooks = require('../../../auth/identityHooks')
|
||||
const sessionService = require('../../../auth/session.service')
|
||||
const mobileBridge = require('../../../model/mobileAuthBridge/mobileAuthBridge.model')
|
||||
const ssoState = require('../../../auth/ssoState')
|
||||
@@ -148,20 +152,43 @@ async function beginFlow(req, res, mode) {
|
||||
const start = (req, res) => beginFlow(req, res, 'login')
|
||||
const linkStart = (req, res) => beginFlow(req, res, 'link')
|
||||
|
||||
// The callback's own answer from the IdP, read the way its protocol writes it.
|
||||
// OAuth2/OIDC sends `code` (or `error`); Steam's OpenID 2.0 sends `openid.*`
|
||||
// fields, with `openid.mode=cancel` when the person declined (STEAM_SIGNIN.md §3).
|
||||
// Our `state` arrives as a query parameter either way — for Steam because it
|
||||
// rides inside the `return_to` we sent.
|
||||
function readCallback(req, providerId) {
|
||||
const q = req.query || {}
|
||||
if (registry.isOpenId(providerId)) {
|
||||
const mode = typeof q['openid.mode'] === 'string' ? q['openid.mode'] : ''
|
||||
return { state: q.state, denied: mode === 'cancel' || mode === 'error', present: mode === 'id_res' }
|
||||
}
|
||||
return { state: q.state, denied: Boolean(q.error), present: Boolean(q.code), error: q.error }
|
||||
}
|
||||
|
||||
// What the provider's handleCallback needs. Steam takes the whole query, and its
|
||||
// `return_to` must match what we sent (the callback URL plus this very state)
|
||||
// byte for byte; OAuth2 takes the code and the PKCE verifier.
|
||||
function callbackParams(req, providerId, tx, answer) {
|
||||
const redirectUri = redirectUriFor(req, providerId)
|
||||
if (registry.isOpenId(providerId)) return { query: req.query, redirectUri, state: answer.state }
|
||||
return { code: req.query.code, redirectUri, codeVerifier: tx.verifier }
|
||||
}
|
||||
|
||||
// GET /auth/sso/:provider/callback
|
||||
async function callback(req, res) {
|
||||
const providerId = req.params.provider
|
||||
const txToken = req.cookies && req.cookies[ssoState.TX_COOKIE]
|
||||
const { code, state, error: oauthError } = req.query
|
||||
const answer = readCallback(req, providerId)
|
||||
// The tx cookie is single-use — clear it no matter the outcome.
|
||||
res.clearCookie(ssoState.TX_COOKIE, token.cookieOptions(req))
|
||||
|
||||
if (oauthError) {
|
||||
log.warn('sso callback: provider returned error', { provider: providerId, error: String(oauthError).slice(0, 60) })
|
||||
if (answer.denied) {
|
||||
log.warn('sso callback: provider returned error', { provider: providerId, error: String(answer.error || 'cancel').slice(0, 60) })
|
||||
return res.redirect(loginError('denied'))
|
||||
}
|
||||
const tx = ssoState.verifyTx(txToken, state)
|
||||
if (!tx || tx.provider !== providerId || !code) {
|
||||
const tx = ssoState.verifyTx(txToken, answer.state)
|
||||
if (!tx || tx.provider !== providerId || !answer.present) {
|
||||
log.warn('sso callback: bad state', { provider: providerId })
|
||||
return res.redirect(loginError('bad_state'))
|
||||
}
|
||||
@@ -175,20 +202,34 @@ async function callback(req, res) {
|
||||
return res.redirect(failFor('unavailable'))
|
||||
}
|
||||
const provider = registry.instantiate(row)
|
||||
const profile = await provider.handleCallback({
|
||||
code,
|
||||
redirectUri: redirectUriFor(req, providerId),
|
||||
codeVerifier: tx.verifier,
|
||||
})
|
||||
const profile = await provider.handleCallback(callbackParams(req, providerId, tx, answer))
|
||||
if (tx.mode === 'link') return finishLink(req, res, providerId, tx, profile)
|
||||
if (tx.mode === 'mobile') return finishMobileLogin(req, res, providerId, row.kind, tx, profile)
|
||||
return finishLogin(req, res, providerId, row.kind, tx, profile)
|
||||
} catch (err) {
|
||||
// A Steam assertion that failed one of the §3 checks is a refused sign-in, not
|
||||
// a server fault: it is what a replayed, forged or tampered callback looks like.
|
||||
if (err instanceof SteamProvider.SteamAssertionError) {
|
||||
log.warn('sso callback: steam assertion refused', { provider: providerId, reason: err.reason })
|
||||
return res.redirect(failFor('bad_state'))
|
||||
}
|
||||
log.error('sso callback', err)
|
||||
return res.redirect(failFor('error'))
|
||||
}
|
||||
}
|
||||
|
||||
// A profile's name and avatar written back onto an identity the user already
|
||||
// holds, when the provider reports them (Steam, at every sign-in). Decoration:
|
||||
// a failure here is logged and never stops the sign-in it rides on.
|
||||
async function refreshIdentityProfile(identity, profile) {
|
||||
if (!identity || !(profile.displayName || profile.avatarUrl)) return
|
||||
try {
|
||||
await userIdentities.refreshProfile(identity.id, { displayName: profile.displayName, avatarUrl: profile.avatarUrl })
|
||||
} catch (err) {
|
||||
log.warn('sso: could not refresh a linked identity profile', { provider: identity.provider, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-provision a `player` from an SSO profile when no identity is linked yet
|
||||
// and registration allows SSO sign-up. Derives a unique username (reserved-name
|
||||
// safe) with a bounded retry against the UNIQUE index, captures the provider
|
||||
@@ -288,10 +329,12 @@ async function finishLogin(req, res, providerId, kind, tx, profile) {
|
||||
if (identity) {
|
||||
user = await users.getById(identity.user_id)
|
||||
if (!user) return res.redirect(loginError('not_linked', portal))
|
||||
await refreshIdentityProfile(identity, profile)
|
||||
} else {
|
||||
// Unknown identity: auto-provision only if registration opts into SSO sign-up.
|
||||
// Unknown identity: auto-provision only if registration opts into SSO sign-up,
|
||||
// and never through a provider that signs nobody up (Steam, D395).
|
||||
const mode = await settings.getRegistrationMode()
|
||||
if (mode !== 'sso' && mode !== 'both') {
|
||||
if ((mode !== 'sso' && mode !== 'both') || registry.neverProvisions(kind)) {
|
||||
log.warn('sso login refused: no linked account', { provider: providerId })
|
||||
return res.redirect(loginError('not_linked', portal))
|
||||
}
|
||||
@@ -397,7 +440,7 @@ async function requireValidBridgeSession(res, tx, providerId) {
|
||||
// Resolve the linked user for a mobile SSO login (link-only, with opt-in
|
||||
// provisioning when registration mode allows it). On refusal, sends the redirect
|
||||
// and returns null.
|
||||
async function resolveMobileSsoUser(req, res, sess, providerId, profile) {
|
||||
async function resolveMobileSsoUser(req, res, sess, providerId, kind, profile) {
|
||||
const identity = await userIdentities.findByProviderSubject(providerId, profile.subject)
|
||||
if (identity) {
|
||||
const user = await users.getById(identity.user_id)
|
||||
@@ -405,10 +448,11 @@ async function resolveMobileSsoUser(req, res, sess, providerId, profile) {
|
||||
res.redirect(appError(sess, 'not_linked'))
|
||||
return null
|
||||
}
|
||||
await refreshIdentityProfile(identity, profile)
|
||||
return user
|
||||
}
|
||||
const mode = await settings.getRegistrationMode()
|
||||
if (mode !== 'sso' && mode !== 'both') {
|
||||
if ((mode !== 'sso' && mode !== 'both') || registry.neverProvisions(kind)) {
|
||||
log.warn('mobile sso login refused: no linked account', { provider: providerId })
|
||||
res.redirect(appError(sess, 'not_linked'))
|
||||
return null
|
||||
@@ -425,7 +469,7 @@ async function finishMobileLogin(req, res, providerId, kind, tx, profile) {
|
||||
const sess = await requireValidBridgeSession(res, tx, providerId)
|
||||
if (!sess) return
|
||||
|
||||
const user = await resolveMobileSsoUser(req, res, sess, providerId, profile)
|
||||
const user = await resolveMobileSsoUser(req, res, sess, providerId, kind, profile)
|
||||
if (!user) return
|
||||
|
||||
if (user.status && user.status !== 'active') {
|
||||
@@ -555,11 +599,38 @@ async function finishLink(req, res, providerId, tx, profile) {
|
||||
if (existing && existing.user_id !== userId) {
|
||||
return res.redirect(accountError('in_use', portal)) // external identity belongs to another account
|
||||
}
|
||||
if (!existing) {
|
||||
await userIdentities.link({ userId, provider: providerId, subject: profile.subject, email: profile.email })
|
||||
await activity.log({ req, userId, action: 'auth.sso.link', detail: { provider: providerId } })
|
||||
log.info('sso account linked', { provider: providerId, userId })
|
||||
if (existing) {
|
||||
// Already theirs: linking twice is not an error, and it is a fresh look at
|
||||
// the name and avatar.
|
||||
await refreshIdentityProfile(existing, profile)
|
||||
return res.redirect(`${accountPath(portal)}?linked=${providerId}`)
|
||||
}
|
||||
|
||||
// Modules may refuse before anything is written (API 1.17.0) — Rust, for a
|
||||
// Steam id that is already another account's Rust link (D396). Only ever a
|
||||
// refusal: this user is already signed in, and this decides a link, not a session.
|
||||
const check = await identityHooks.checkLink({ userId, provider: providerId, subject: profile.subject })
|
||||
if (!check.ok) {
|
||||
await activity.log({ req, userId, action: 'auth.sso.link.refused', detail: { provider: providerId, module: check.module, reason: check.reason } })
|
||||
return res.redirect(accountError(check.reason, portal))
|
||||
}
|
||||
|
||||
await userIdentities.link({
|
||||
userId,
|
||||
provider: providerId,
|
||||
subject: profile.subject,
|
||||
email: profile.email,
|
||||
displayName: profile.displayName || null,
|
||||
avatarUrl: profile.avatarUrl || null,
|
||||
})
|
||||
await activity.log({ req, userId, action: 'auth.sso.link', detail: { provider: providerId } })
|
||||
log.info('sso account linked', { provider: providerId, userId })
|
||||
await identityHooks.notifyLinked({
|
||||
userId,
|
||||
provider: providerId,
|
||||
subject: profile.subject,
|
||||
displayName: profile.displayName || null,
|
||||
})
|
||||
return res.redirect(`${accountPath(portal)}?linked=${providerId}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -52,10 +52,13 @@ ssoRouter.get(
|
||||
'/sso/:provider/callback',
|
||||
// #swagger.tags = ['Auth · SSO']
|
||||
// #swagger.summary = 'OAuth redirect target — completes login or linking'
|
||||
// #swagger.description = 'The provider redirects here with code + state. On success sets the session cookie (login) or links the identity (link), then 302-redirects into /admin. Login is link-only: unknown identities are refused (sso_error=not_linked).'
|
||||
// #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id (e.g. google, discord).' }
|
||||
// #swagger.parameters['code'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'OAuth authorization code.' }
|
||||
// #swagger.parameters['state'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'OAuth state (matched against the tx cookie).' }
|
||||
// #swagger.description = 'The provider redirects here with code + state. On success sets the session cookie (login) or links the identity (link), then 302-redirects into /admin. Login is link-only: unknown identities are refused (sso_error=not_linked). Steam (OpenID 2.0) sends openid.* fields instead of code; the server verifies them with Steam (check_authentication) before anything else, and Steam never signs anybody up (STEAM_SIGNIN.md D395). A module may refuse a link (link_error=in_use or refused; MODULE_API 1.17.0).'
|
||||
// #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id (e.g. google, discord, steam).' }
|
||||
// #swagger.parameters['code'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'OAuth authorization code (OAuth2/OIDC providers).' }
|
||||
// #swagger.parameters['state'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'OAuth state (matched against the tx cookie). For Steam it rides inside openid.return_to and comes back as this parameter.' }
|
||||
// #swagger.parameters['error'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'OAuth error (the user declined, or the IdP refused).' }
|
||||
// #swagger.parameters['openid.mode'] = { in: 'query', required: false, schema: { type: 'string', enum: ['id_res', 'cancel'] }, description: 'Steam only: id_res with an assertion, or cancel. The other openid.* fields of the assertion (ns, op_endpoint, claimed_id, identity, return_to, response_nonce, assoc_handle, signed, sig) are passed back to Steam to verify.' }
|
||||
// #swagger.parameters['openid.claimed_id'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Steam only: https://steamcommunity.com/openid/id/<SteamID64>.' }
|
||||
/* #swagger.responses[302] = { description: 'Redirect into /admin on success, or back to login/account with an error code' } */
|
||||
ctrl.callback,
|
||||
)
|
||||
|
||||
@@ -50,7 +50,12 @@ async function getEvent(req, res) {
|
||||
// string: the model matches it against this definition's own runs and
|
||||
// ignores anything else, so a garbage value renders the page rather than an
|
||||
// error. See the model's note on why it is not refused.
|
||||
const result = await events.event(req.params.slug, { runId: req.query.run || null })
|
||||
// `userId` is the reader's, when they sent a session (`optionalAuth`): a
|
||||
// module's progress line may show a signed-in reader more than a stranger.
|
||||
const result = await events.event(req.params.slug, {
|
||||
runId: req.query.run || null,
|
||||
userId: req.user && req.user.id != null ? req.user.id : null,
|
||||
})
|
||||
return answer(res, result)
|
||||
} catch (err) {
|
||||
return fail(res, err, 'public event')
|
||||
|
||||
@@ -15,6 +15,7 @@ const express = require('express')
|
||||
|
||||
const ctrl = require('./events.controller')
|
||||
const siteMode = require('../../../middleware/siteMode')
|
||||
const { optionalAuth } = require('../../../auth/session.middleware')
|
||||
|
||||
const eventsRouter = express.Router()
|
||||
|
||||
@@ -48,12 +49,14 @@ eventsRouter.get(
|
||||
'/:slug',
|
||||
// #swagger.tags = ['Public · Events']
|
||||
// #swagger.summary = 'One event'
|
||||
// #swagger.description = 'The storyline, the arc it belongs to, what is live, what is next, what happened recently, and a results table once one has been published. A draft, an archived definition and an unlisted one all answer 404, indistinguishable from a slug that never existed. The plan behind the event — phases, steps, actions and their params — is never published; a live run carries the LABEL of the phase it is in and nothing more.'
|
||||
// #swagger.description = 'The storyline, the arc it belongs to, what is live, what is next, what happened recently, and a results table once one has been published. A draft, an archived definition and an unlisted one all answer 404, indistinguishable from a slug that never existed. The plan behind the event — phases, steps, actions and their params — is never published; a live run carries the LABEL of the phase it is in and nothing more, and `progress`: how its steps stand, each line a label with either `left` of `of` or a `percent`, in the words the steps\' modules chose. What a module shows may depend on who is reading, so sending a session is optional and may widen it.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The event slug.' }
|
||||
// #swagger.parameters['run'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Which occurrence the results are about — what an announcement\'s link carries, so a mail about last Friday does not open next Friday\'s. A run that does not belong to this event is ignored rather than refused.' }
|
||||
// #swagger.security = [{}, { "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The event', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicEvent" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such public event', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
siteMode,
|
||||
optionalAuth,
|
||||
ctrl.getEvent,
|
||||
)
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ const registries = require('../modules/registries')
|
||||
const ceilings = require('../modules/ceilings')
|
||||
const engine = require('../engagement/engine')
|
||||
const eventGates = require('../events/gates')
|
||||
const eventStarts = require('../events/starts')
|
||||
const scopedPrefs = require('../engagement/scopedPrefs')
|
||||
const createLogger = require('./logger')
|
||||
|
||||
@@ -320,6 +321,15 @@ function emit(owner, triggerId, envelope = {}) {
|
||||
.observe(event)
|
||||
.catch((err) => log.error('gate observation rejected', { trigger: triggerId, message: err.message }))
|
||||
|
||||
// **The third subscriber** (Rust PLAN_REDESIGNS §14.3.12, D370): a definition
|
||||
// whose published version starts on this trigger gets a run, now. Separate
|
||||
// from the gate observer for the same reason that one is separate from
|
||||
// `dispatch` — starting a run and advancing one decide different things, and
|
||||
// neither may fail the other.
|
||||
eventStarts
|
||||
.observe(event)
|
||||
.catch((err) => log.error('trigger start rejected', { trigger: triggerId, message: err.message }))
|
||||
|
||||
return { ok: true, event }
|
||||
}
|
||||
|
||||
|
||||
@@ -428,13 +428,28 @@ async function drainStep(run, step, now, carry = {}) {
|
||||
}
|
||||
|
||||
if (result.outcome === 'retry' && step.attempts + 1 < MAX_ATTEMPTS) {
|
||||
await stepsDb.reschedule(step.id, new Date(now.getTime() + RETRY_MS), result.error)
|
||||
// The module's `retryAfterMs` when it gave one (already clamped by the
|
||||
// dispatcher), else the flat delay. **It is still one attempt** of
|
||||
// MAX_ATTEMPTS: a hint changes when, never how many, so a module that answers
|
||||
// "not yet" for ever fails the step on schedule like any other. A run paused
|
||||
// or cancelled in the wait is not picked up — `findDue` reads only running
|
||||
// runs — and the staff Retry on a paused step is unaffected.
|
||||
const delayMs = result.retryAfterMs || RETRY_MS
|
||||
const retryAt = new Date(now.getTime() + delayMs)
|
||||
await stepsDb.reschedule(step.id, retryAt, result.error)
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
stepId: step.id,
|
||||
kind: 'step.retry',
|
||||
phase: step.phase,
|
||||
detail: { action: step.action_id, attempt: step.attempts + 1, of: MAX_ATTEMPTS, error: result.error },
|
||||
detail: {
|
||||
action: step.action_id,
|
||||
attempt: step.attempts + 1,
|
||||
of: MAX_ATTEMPTS,
|
||||
error: result.error,
|
||||
retryAt: retryAt.toISOString(),
|
||||
...(result.retryAfterMs ? { retryAfterMs: result.retryAfterMs } : {}),
|
||||
},
|
||||
})
|
||||
// Degraded from the FIRST retry, not from the eventual failure. An event
|
||||
// whose announcements are landing on the second attempt is having trouble
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"info": {
|
||||
"title": "Runic Gateway API",
|
||||
"version": "1.0.0",
|
||||
"description": "REST API for the Runic Gateway website, wiki and admin panel.\n\nThis document is core. Installed modules add their own paths, tags and schemas to it at request time from the fragment each one ships, so `/api/docs.json` on a running instance describes more than `npm run swagger` generates here (docs/website/MODULE_API.md §6.1a).\n\n### Authentication\n- **Web / admin panel** uses an httpOnly session cookie (`rg_token`) issued by `POST /api/v1/auth/login` (plus `/login/totp` when 2FA is enabled).\n- **Native / mobile clients** use bearer access tokens from `POST /api/v1/auth/mobile/login`, refreshed via `/auth/mobile/refresh`.\n\nEndpoints under `/api/v1/admin/**` require a valid session; some are further restricted to the `admin` role (editors are limited to content)."
|
||||
"description": "REST API for the Runic Gateway website, wiki and admin panel.\n\nThis document is core. Installed modules add their own paths, tags and schemas to it at request time from the fragment each one ships, so `/api/docs.json` on a running instance describes more than `npm run swagger` generates here (docs/website/MODULE_API.md §6.1a).\n\n### Authentication\n- **Web / admin panel** uses an httpOnly session cookie (`rg_token`) issued by `POST /api/v1/auth/login` (plus `/login/totp` when 2FA is enabled).\n- **Native / mobile clients** use bearer access tokens from `POST /api/v1/auth/mobile/login`, refreshed via `/auth/mobile/refresh`.\n\nEndpoints under `/api/v1/admin/**` require a valid session; some are further restricted to the `admin` role (editors are limited to content).\n\n### The public demo\nA deployment run with `DEMO_MODE=true` (the public demo, docs/website/DEMO_MODE.md) refuses a fixed set of write routes, and any `multipart/*` request, before the route runs. **Any write route may then answer** `403` with the `DemoLocked` body (`code: \"DEMO_LOCKED\"`); clients recognise the refusal by `code`, not by status. Reads are never refused this way. With the flag unset no route answers it."
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
@@ -352,7 +352,7 @@
|
||||
"Admin · Auth Providers"
|
||||
],
|
||||
"summary": "Update an SSO provider (admin only)",
|
||||
"description": "",
|
||||
"description": "Built-ins (google, discord, steam) take only enabled + credentials. Steam has no client id: its secret is a Steam Web API key, and enabling Steam without one stored or given is refused with 400 (STEAM_SIGNIN.md D394).",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
@@ -376,7 +376,7 @@
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Validation error",
|
||||
"description": "Validation error, or Steam enabled without a Steam Web API key",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -3869,6 +3869,7 @@
|
||||
"properties": {
|
||||
"actions": {
|
||||
"type": "array",
|
||||
"description": "Each action with its declared params. MODULE_API 1.14.0: a param may carry multiple: true and max (1 to 100, 20 when left out), and its value is then a JSON array of at most max values.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
@@ -3931,6 +3932,14 @@
|
||||
"limits": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"templates": {
|
||||
"type": "array",
|
||||
"description": "Event templates (MODULE_API 1.13.0): id, label, description, owner and inputs (each shaped like an action param). build() is never served.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4026,6 +4035,10 @@
|
||||
},
|
||||
"group": {
|
||||
"type": "string"
|
||||
},
|
||||
"note": {
|
||||
"type": "string",
|
||||
"description": "Advice shown under the field while this option is chosen (MODULE_API 1.13.0). At most 300 characters."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4056,6 +4069,144 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/events/catalog/templates/{templateId}": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin · Events"
|
||||
],
|
||||
"summary": "Fill an event template in, and get back a draft",
|
||||
"description": "A module may register named event templates (MODULE_API 1.13.0). The catalog lists them with their inputs; this route checks the inputs against the template, calls the module to build the plan, validates the plan as a spec and answers it as a draft: a title, a summary and a spec. Nothing is saved, and nothing about the template is kept: the editor opens the draft like any new event and the author saves it through POST /admin/events. A refusal of the inputs is a 400 naming each problem; a module that fails or builds a plan core cannot accept is a 502.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "templateId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The draft",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"draft": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"spec": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "The inputs were refused",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"errors": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Not an admin or editor",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "No module registers that template",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"errors": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"502": {
|
||||
"description": "The module failed to build the plan, or built one core cannot accept",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"errors": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"inputs": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"description": "One value per declared input, keyed by its name"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/events/price": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -4271,7 +4422,7 @@
|
||||
"Admin · Events"
|
||||
],
|
||||
"summary": "One run: its status, health, cleanup state and every step with its params and idempotency key",
|
||||
"description": "The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder’s own words — `gte` as \"is at least\", `present` as \"is present\" — with the tally, how long it has waited, and the last related firing whether or not it matched. A phase is waiting on its gate only once every one of its steps is terminal; `stalled` means an `on` gate has waited past EVENT_PHASE_STALL_MS, which is visibility and never a timeout — nothing advances a phase but its condition or a human. `budget` is the cap meter (Phase 6), and `resources` is the cleanup ledger (Phase 8): every object this run created and every value it borrowed, with what became of each — `confirmed` is still out there, `reverted` came back, `drifted` means somebody moved it and core left it alone, and `orphaned` means the module reports it is gone. `unresolvedResources` counts the ones still wanting something, including a placeholder left standing by a lost acknowledgement, which is why it can exceed the length of the list. `participants` is who took part (Phase 10), best first, as a module reported them: `memberKey` is module-opaque, `userId` is filled in only where the module could link the player to an account, and `rank` is null until `core.results.publish` has ranked them — a run whose participants are collected but unranked is a real and visible state, not an error. The run itself carries `resultsPublishedAt`, which is when that table was last published.",
|
||||
"description": "The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder’s own words — `gte` as \"is at least\", `present` as \"is present\" — with the tally, how long it has waited, and the last related firing whether or not it matched. A phase is waiting on its gate only once every one of its steps is terminal; `stalled` means an `on` gate has waited past EVENT_PHASE_STALL_MS, which is visibility and never a timeout — nothing advances a phase but its condition or a human. `budget` is the cap meter (Phase 6), and `resources` is the cleanup ledger (Phase 8): every object this run created and every value it borrowed, with what became of each — `confirmed` is still out there, `reverted` came back, `drifted` means somebody moved it and core left it alone, `orphaned` means the module reports it is gone, and `expired` means the game ended it at its own deadline, as it was told to (terminal, like `reverted`). `unresolvedResources` counts the ones still wanting something, including a placeholder left standing by a lost acknowledgement, which is why it can exceed the length of the list. `participants` is who took part (Phase 10), best first, as a module reported them: `memberKey` is module-opaque, `userId` is filled in only where the module could link the player to an account, and `rank` is null until `core.results.publish` has ranked them — a run whose participants are collected but unranked is a real and visible state, not an error. The run itself carries `resultsPublishedAt`, which is when that table was last published, and `origin` when a game firing started it (Rust PLAN_REDESIGNS 14.3.12): the trigger, its label, when it fired, its subject, the start condition it matched in the builder words, and its declared variables. Null for a run started by hand or by the schedule.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "runId",
|
||||
@@ -5552,7 +5703,7 @@
|
||||
"Admin · Events"
|
||||
],
|
||||
"summary": "One event definition, including its working spec",
|
||||
"description": "The editor reads this. The list route serves a summary; this is the whole authored tree, phases and steps included.",
|
||||
"description": "The editor reads this. The list route serves a summary; this is the whole authored tree, phases and steps included. The spec may carry `starts` (Rust PLAN_REDESIGNS 14.3.12): the triggers that begin a run of the published version, each a phase gate `on` and `where` plus a `cooldown` (0m to 7d, 30m when left out); core own event.* triggers are refused. `triggerStarts` is what they have done lately: the last run a trigger started and the firings ignored since, by why (a run was going, inside the cooldown, or the published version is not verified). Null until a trigger first tries.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
@@ -5811,7 +5962,7 @@
|
||||
"Admin · Events"
|
||||
],
|
||||
"summary": "Snapshot the working spec into an immutable version and mark the definition ready",
|
||||
"description": "Admin only, deliberately, and not the same gate as the live run controls: publishing commits a definition that a schedule will later start unattended. The spec is re-validated against the registries as they stand right now rather than trusted from the save that wrote it, so a module uninstalled in between blocks the publish instead of producing a run that fails at dispatch. Publishing also RE-PINS every occurrence of this definition that is still scheduled and has not started, and `repinned` says how many moved: occurrences are materialised a fortnight ahead, so without this an edit would reach none of the runs already on the calendar. A run that has begun keeps the version it pinned.",
|
||||
"description": "Admin only, deliberately, and not the same gate as the live run controls: publishing commits a definition that a schedule will later start unattended. The spec is re-validated against the registries as they stand right now rather than trusted from the save that wrote it, so a module uninstalled in between blocks the publish instead of producing a run that fails at dispatch. Publishing also RE-PINS every occurrence of this definition that is still scheduled and has not started, and `repinned` says how many moved: occurrences are materialised a fortnight ahead, so without this an edit would reach none of the runs already on the calendar. A run that has begun keeps the version it pinned. `verified` is true when a dry run that passed on this exact draft made the new version verified, with that dry run reviewer and time, so it needs no second dry run before it starts unattended.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
@@ -5842,6 +5993,9 @@
|
||||
},
|
||||
"repinned": {
|
||||
"type": "integer"
|
||||
},
|
||||
"verified": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6037,7 +6191,7 @@
|
||||
"Admin · Events"
|
||||
],
|
||||
"summary": "Dry run: dispatch every step with verify true, change nothing, and report the cost against the caps",
|
||||
"description": "EVENTS.md I. admin AND editor rather than admin, deliberately: a dry run dispatches nothing, and the author who wrote the definition is exactly who should be able to price it before asking an admin to publish it. What is verified follows the state -- a ready definition is checked against its PUBLISHED version, which is the only thing that ever actually runs, and a draft against the working spec the author is still holding; `target` says which. A pass against a version is RECORDED on it, and that is EVENTS.md K last bound: a scheduled occurrence of a version that has never been verified is held rather than started unattended. Findings come back with a 200 -- the request succeeded, the plan has problems -- and the whole-plan cost check is the one thing no other path makes: three steps each spawning 15 under a cap of 30 pass every individual check and breach it on the third, at two in the morning, with the world half-changed.",
|
||||
"description": "EVENTS.md I. admin AND editor rather than admin, deliberately: a dry run dispatches nothing, and the author who wrote the definition is exactly who should be able to price it before asking an admin to publish it. What is verified follows the state -- a ready definition is checked against its PUBLISHED version, which is the only thing that ever actually runs, and a draft against the working spec the author is still holding; `target` says which. A pass against a version is RECORDED on it, and that is EVENTS.md K last bound: a scheduled occurrence of a version that has never been verified is held rather than started unattended. A pass against a draft is kept with the fingerprint of the spec it passed on (`draftRecorded`), and publishing that spec unchanged makes a version that is verified from the start; an edit in between changes the fingerprint, and nothing carries over. Findings come back with a 200 -- the request succeeded, the plan has problems -- and the whole-plan cost check is the one thing no other path makes: three steps each spawning 15 under a cap of 30 pass every individual check and breach it on the third, at two in the morning, with the world half-changed.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
@@ -6069,6 +6223,9 @@
|
||||
"recorded": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"draftRecorded": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"report": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -7211,7 +7368,7 @@
|
||||
"Admin · Modules"
|
||||
],
|
||||
"summary": "Install or upgrade a module from a release install-manifest URL",
|
||||
"description": "Downloads the artifact the manifest names, verifies its sha256, inspects the archive in full and unpacks it onto the modules volume. The module mounts on the next restart.",
|
||||
"description": "Downloads the artifact the manifest names, verifies its sha256, inspects the archive in full and unpacks it onto the modules volume. The module mounts on the next restart. A site runs ONE module: installing a module other than the one already on the volume is refused with 409 before anything is downloaded, and only an upgrade of the installed module is accepted.",
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Installed — restart to mount it",
|
||||
@@ -7234,6 +7391,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "A different module is already installed; a site runs one module",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
},
|
||||
@@ -12928,7 +13095,7 @@
|
||||
"Auth · Me"
|
||||
],
|
||||
"summary": "Unlink an SSO identity (self)",
|
||||
"description": "",
|
||||
"description": "Removes every identity of the provider, or with ?id= the one identity named (a user may link several Steam accounts). Only the sign-in identity goes: a game module link made from it stays (STEAM_SIGNIN.md D393).",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "provider",
|
||||
@@ -12938,6 +13105,15 @@
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Provider id."
|
||||
},
|
||||
{
|
||||
"name": "id",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "One identity to unlink (its id from GET /auth/me/account/identities)."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -12952,7 +13128,14 @@
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
"description": "Validation error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
@@ -15306,7 +15489,7 @@
|
||||
"Auth · SSO"
|
||||
],
|
||||
"summary": "OAuth redirect target — completes login or linking",
|
||||
"description": "The provider redirects here with code + state. On success sets the session cookie (login) or links the identity (link), then 302-redirects into /admin. Login is link-only: unknown identities are refused (sso_error=not_linked).",
|
||||
"description": "The provider redirects here with code + state. On success sets the session cookie (login) or links the identity (link), then 302-redirects into /admin. Login is link-only: unknown identities are refused (sso_error=not_linked). Steam (OpenID 2.0) sends openid.* fields instead of code; the server verifies them with Steam (check_authentication) before anything else, and Steam never signs anybody up (STEAM_SIGNIN.md D395). A module may refuse a link (link_error=in_use or refused; MODULE_API 1.17.0).",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "provider",
|
||||
@@ -15315,14 +15498,7 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Provider id (e.g. google, discord)."
|
||||
},
|
||||
{
|
||||
"name": "error",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
"description": "Provider id (e.g. google, discord, steam)."
|
||||
},
|
||||
{
|
||||
"name": "code",
|
||||
@@ -15331,7 +15507,7 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "OAuth authorization code."
|
||||
"description": "OAuth authorization code (OAuth2/OIDC providers)."
|
||||
},
|
||||
{
|
||||
"name": "state",
|
||||
@@ -15340,7 +15516,50 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "OAuth state (matched against the tx cookie)."
|
||||
"description": "OAuth state (matched against the tx cookie). For Steam it rides inside openid.return_to and comes back as this parameter."
|
||||
},
|
||||
{
|
||||
"name": "error",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "OAuth error (the user declined, or the IdP refused)."
|
||||
},
|
||||
{
|
||||
"name": "openid.mode",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"enum": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
"id_res",
|
||||
"cancel"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Steam only: id_res with an assertion, or cancel. The other openid.* fields of the assertion (ns, op_endpoint, claimed_id, identity, return_to, response_nonce, assoc_handle, signed, sig) are passed back to Steam to verify."
|
||||
},
|
||||
{
|
||||
"name": "openid.claimed_id",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Steam only: https://steamcommunity.com/openid/id/<SteamID64>."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -17338,7 +17557,7 @@
|
||||
"Public · Events"
|
||||
],
|
||||
"summary": "One event",
|
||||
"description": "The storyline, the arc it belongs to, what is live, what is next, what happened recently, and a results table once one has been published. A draft, an archived definition and an unlisted one all answer 404, indistinguishable from a slug that never existed. The plan behind the event — phases, steps, actions and their params — is never published; a live run carries the LABEL of the phase it is in and nothing more.",
|
||||
"description": "The storyline, the arc it belongs to, what is live, what is next, what happened recently, and a results table once one has been published. A draft, an archived definition and an unlisted one all answer 404, indistinguishable from a slug that never existed. The plan behind the event — phases, steps, actions and their params — is never published; a live run carries the LABEL of the phase it is in and nothing more, and `progress`: how its steps stand, each line a label with either `left` of `of` or a `percent`, in the words the steps\\' modules chose. What a module shows may depend on who is reading, so sending a session is optional and may widen it.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "slug",
|
||||
@@ -17383,7 +17602,16 @@
|
||||
"503": {
|
||||
"description": "Service Unavailable"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{},
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/public/modules": {
|
||||
@@ -18316,6 +18544,119 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"DemoLocked": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "The public demo refused a write (DEMO_MODE.md §3). Only on a deployment with `DEMO_MODE=true`. `keys` is present on `PUT /admin/settings` and names the settings the demo keeps. `errors` is present when an event save or publish is refused because a step's action refused its params (MODULE_API 1.19.0 `demoRefuses`, DM18); `message` is then the module's sentence."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "This is turned off on the demo site."
|
||||
}
|
||||
}
|
||||
},
|
||||
"code": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"enum": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
"DEMO_LOCKED"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"keys": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
"site_mode"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
"\"quit\" is turned off on the demo site."
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
"message",
|
||||
"code"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ValidationError": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -19657,7 +19998,7 @@
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Icon hint — the provider kind ('google' | 'discord' | 'oidc' | 'oauth2')."
|
||||
"example": "Icon hint — the provider kind ('google' | 'discord' | 'steam' | 'oidc' | 'oauth2')."
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
@@ -19738,6 +20079,7 @@
|
||||
"example": [
|
||||
"google",
|
||||
"discord",
|
||||
"steam",
|
||||
"oidc",
|
||||
"oauth2"
|
||||
],
|
||||
@@ -19795,7 +20137,7 @@
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Whether a client secret is stored (the secret itself is never returned)."
|
||||
"example": "Whether a client secret is stored (the secret itself is never returned). For steam, the Steam Web API key it cannot be enabled without."
|
||||
},
|
||||
"example": {
|
||||
"type": "boolean",
|
||||
@@ -19877,7 +20219,7 @@
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "True for the fixed google/discord providers."
|
||||
"example": "True for the fixed google/discord/steam providers."
|
||||
},
|
||||
"example": {
|
||||
"type": "boolean",
|
||||
@@ -24570,6 +24912,23 @@
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Identity id, for unlinking one of several (DELETE ...?id=)."
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 12
|
||||
}
|
||||
}
|
||||
},
|
||||
"provider": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -24604,6 +24963,65 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"subject": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "The user’s own external id at the provider; for steam, the 17-digit SteamID64."
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "76561197960287930"
|
||||
}
|
||||
}
|
||||
},
|
||||
"display_name": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Persona name the provider reported (steam only)."
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "CoolGuy99"
|
||||
}
|
||||
}
|
||||
},
|
||||
"avatar_url": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"example": "uri"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Avatar on the provider’s CDN (steam only)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"linked_at": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -25295,6 +25713,111 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"demo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Present only on the public demo (`DEMO_MODE=true`, DEMO_MODE.md §4, §6). `resetsAt` is when the next wipe is due, for the banner; `logins` are the published static sign-ins — public by design, not secrets. Both are written by `npm run seed:demo` at every wipe."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"resetsAt": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"example": "date-time"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "2026-10-24T14:00:00.000Z"
|
||||
}
|
||||
}
|
||||
},
|
||||
"logins": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"username": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "demo-admin"
|
||||
}
|
||||
}
|
||||
},
|
||||
"password": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "demo-admin"
|
||||
}
|
||||
}
|
||||
},
|
||||
"role": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "admin"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -26295,6 +26818,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"runId": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 3692
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Runs only: the run this entry is, the same id `PublicEventOccurrence.runId` carries and `/events/{slug}?run=` takes. A projected entry has none, because nothing is committed to it."
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -26724,6 +27264,100 @@
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"progress": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "On the live occurrence only: how its steps stand, in step order, in the words their modules chose (MODULE_API 1.12.0). Each line is a `label` with either `left` of `of` (\"Bandits: 3 of 8 left\") or a `percent` (\"The Juggernaut: 62%\"). Core renders them as text and interprets nothing. Empty when no step has anything to say, or its module did not answer in time."
|
||||
},
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PublicEventProgressLine"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"PublicEventProgressLine": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "One line of a live run’s progress: a `label` with either `left` of `of`, or a `percent`. Never both."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "Bandits"
|
||||
}
|
||||
}
|
||||
},
|
||||
"left": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 3
|
||||
}
|
||||
}
|
||||
},
|
||||
"of": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 8
|
||||
}
|
||||
}
|
||||
},
|
||||
"percent": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"minimum": {
|
||||
"type": "number",
|
||||
"example": 0
|
||||
},
|
||||
"maximum": {
|
||||
"type": "number",
|
||||
"example": 100
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 62
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,13 @@ const doc = {
|
||||
'- **Native / mobile clients** use bearer access tokens from ' +
|
||||
'`POST /api/v1/auth/mobile/login`, refreshed via `/auth/mobile/refresh`.\n\n' +
|
||||
'Endpoints under `/api/v1/admin/**` require a valid session; some are further ' +
|
||||
'restricted to the `admin` role (editors are limited to content).',
|
||||
'restricted to the `admin` role (editors are limited to content).\n\n' +
|
||||
'### The public demo\n' +
|
||||
'A deployment run with `DEMO_MODE=true` (the public demo, docs/website/DEMO_MODE.md) ' +
|
||||
'refuses a fixed set of write routes, and any `multipart/*` request, before the ' +
|
||||
'route runs. **Any write route may then answer** `403` with the `DemoLocked` body ' +
|
||||
'(`code: "DEMO_LOCKED"`); clients recognise the refusal by `code`, not by status. ' +
|
||||
'Reads are never refused this way. With the flag unset no route answers it.',
|
||||
},
|
||||
servers: [
|
||||
{ url: '/', description: 'Same-origin (current host)' },
|
||||
@@ -100,6 +106,18 @@ const doc = {
|
||||
type: 'object',
|
||||
properties: { message: { type: 'string', example: 'Not found' } },
|
||||
},
|
||||
DemoLocked: {
|
||||
type: 'object',
|
||||
description:
|
||||
'The public demo refused a write (DEMO_MODE.md §3). Only on a deployment with `DEMO_MODE=true`. `keys` is present on `PUT /admin/settings` and names the settings the demo keeps. `errors` is present when an event save or publish is refused because a step\'s action refused its params (MODULE_API 1.19.0 `demoRefuses`, DM18); `message` is then the module\'s sentence.',
|
||||
properties: {
|
||||
message: { type: 'string', example: 'This is turned off on the demo site.' },
|
||||
code: { type: 'string', enum: ['DEMO_LOCKED'] },
|
||||
keys: { type: 'array', items: { type: 'string' }, example: ['site_mode'] },
|
||||
errors: { type: 'array', items: { type: 'string' }, example: ['"quit" is turned off on the demo site.'] },
|
||||
},
|
||||
required: ['message', 'code'],
|
||||
},
|
||||
ValidationError: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -294,7 +312,7 @@ const doc = {
|
||||
name: { type: 'string', example: 'Google' },
|
||||
icon: {
|
||||
type: 'string',
|
||||
description: "Icon hint — the provider kind ('google' | 'discord' | 'oidc' | 'oauth2').",
|
||||
description: "Icon hint — the provider kind ('google' | 'discord' | 'steam' | 'oidc' | 'oauth2').",
|
||||
example: 'google',
|
||||
},
|
||||
loginUrl: {
|
||||
@@ -313,17 +331,17 @@ const doc = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'okta' },
|
||||
kind: { type: 'string', enum: ['google', 'discord', 'oidc', 'oauth2'], example: 'oidc' },
|
||||
kind: { type: 'string', enum: ['google', 'discord', 'steam', 'oidc', 'oauth2'], example: 'oidc' },
|
||||
name: { type: 'string', example: 'Okta' },
|
||||
enabled: { type: 'boolean', example: true },
|
||||
clientId: { type: 'string' },
|
||||
hasSecret: { type: 'boolean', description: 'Whether a client secret is stored (the secret itself is never returned).', example: true },
|
||||
hasSecret: { type: 'boolean', description: 'Whether a client secret is stored (the secret itself is never returned). For steam, the Steam Web API key it cannot be enabled without.', example: true },
|
||||
authorizeUrl: { type: 'string', format: 'uri' },
|
||||
tokenUrl: { type: 'string', format: 'uri' },
|
||||
userinfoUrl: { type: 'string', format: 'uri' },
|
||||
scopes: { type: 'string', example: 'openid email profile' },
|
||||
priority: { type: 'integer', example: 10 },
|
||||
builtin: { type: 'boolean', description: 'True for the fixed google/discord providers.', example: false },
|
||||
builtin: { type: 'boolean', description: 'True for the fixed google/discord/steam providers.', example: false },
|
||||
health: { $ref: '#/components/schemas/ProviderHealth' },
|
||||
},
|
||||
},
|
||||
@@ -961,8 +979,12 @@ const doc = {
|
||||
LinkedIdentity: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer', description: 'Identity id, for unlinking one of several (DELETE ...?id=).', example: 12 },
|
||||
provider: { type: 'string', example: 'google' },
|
||||
email: { type: 'string', format: 'email', nullable: true, example: 'user@example.com' },
|
||||
subject: { type: 'string', description: 'The user’s own external id at the provider; for steam, the 17-digit SteamID64.', example: '76561197960287930' },
|
||||
display_name: { type: 'string', nullable: true, description: 'Persona name the provider reported (steam only).', example: 'CoolGuy99' },
|
||||
avatar_url: { type: 'string', format: 'uri', nullable: true, description: 'Avatar on the provider’s CDN (steam only).' },
|
||||
linked_at: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
@@ -1076,6 +1098,25 @@ const doc = {
|
||||
ntfyUrl: { type: 'string', nullable: true, example: 'https://ntfy.example.com' },
|
||||
},
|
||||
},
|
||||
demo: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Present only on the public demo (`DEMO_MODE=true`, DEMO_MODE.md §4, §6). `resetsAt` is when the next wipe is due, for the banner; `logins` are the published static sign-ins — public by design, not secrets. Both are written by `npm run seed:demo` at every wipe.',
|
||||
properties: {
|
||||
resetsAt: { type: 'string', format: 'date-time', nullable: true, example: '2026-10-24T14:00:00.000Z' },
|
||||
logins: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
username: { type: 'string', example: 'demo-admin' },
|
||||
password: { type: 'string', example: 'demo-admin' },
|
||||
role: { type: 'string', nullable: true, example: 'admin' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
additionalProperties: true,
|
||||
},
|
||||
@@ -1247,6 +1288,12 @@ const doc = {
|
||||
'One calendar entry. `kind` says which of two things it is: a `run` is a materialised occurrence, a `projected` entry is arithmetic past the materialisation horizon — a forecast with nothing committed to it, which a client should draw as such.',
|
||||
properties: {
|
||||
kind: { type: 'string', enum: ['run', 'projected'], example: 'run' },
|
||||
runId: {
|
||||
type: 'integer',
|
||||
example: 3692,
|
||||
description:
|
||||
'Runs only: the run this entry is, the same id `PublicEventOccurrence.runId` carries and `/events/{slug}?run=` takes. A projected entry has none, because nothing is committed to it.',
|
||||
},
|
||||
title: { type: 'string', example: 'The Yew Invasion' },
|
||||
slug: { type: 'string', example: 'the-yew-invasion' },
|
||||
seriesName: { type: 'string', nullable: true, example: 'The Yew Campaign' },
|
||||
@@ -1312,6 +1359,23 @@ const doc = {
|
||||
'The LABEL of the phase a live run is in, resolved from the version the run pinned. Null unless it is live. The plan behind the event — phases, steps, actions and their params — is never published.',
|
||||
},
|
||||
resultsPublishedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
progress: {
|
||||
type: 'array',
|
||||
description:
|
||||
'On the live occurrence only: how its steps stand, in step order, in the words their modules chose (MODULE_API 1.12.0). Each line is a `label` with either `left` of `of` ("Bandits: 3 of 8 left") or a `percent` ("The Juggernaut: 62%"). Core renders them as text and interprets nothing. Empty when no step has anything to say, or its module did not answer in time.',
|
||||
items: { $ref: '#/components/schemas/PublicEventProgressLine' },
|
||||
},
|
||||
},
|
||||
},
|
||||
PublicEventProgressLine: {
|
||||
type: 'object',
|
||||
description:
|
||||
'One line of a live run’s progress: a `label` with either `left` of `of`, or a `percent`. Never both.',
|
||||
properties: {
|
||||
label: { type: 'string', example: 'Bandits' },
|
||||
left: { type: 'integer', example: 3 },
|
||||
of: { type: 'integer', example: 8 },
|
||||
percent: { type: 'integer', minimum: 0, maximum: 100, example: 62 },
|
||||
},
|
||||
},
|
||||
PublicEventParticipant: {
|
||||
|
||||
86
server/test/demoApp.test.js
Normal file
86
server/test/demoApp.test.js
Normal file
@@ -0,0 +1,86 @@
|
||||
// ── The demo lock, wired into the real app (DEMO_MODE.md §3.1) ─────────────
|
||||
//
|
||||
// demoLocks.test.js proves each rule against a stand-in app. This file proves
|
||||
// the wiring: with DEMO_MODE=true, app.js mounts the lock ahead of every
|
||||
// router, so a locked route is refused before its handler — before even its
|
||||
// auth check — and an open one still reaches it. One process per flag value,
|
||||
// since app.js decides at require time: this file is the demo, and every other
|
||||
// test file that requires app.js is the normal deployment.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
process.env.DEMO_MODE = 'true'
|
||||
process.env.MODULES_DIR = require('path').join(require('os').tmpdir(), 'rg-demo-app-no-modules')
|
||||
|
||||
const { test, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const app = require('../src/app')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
async function withServer(fn) {
|
||||
const server = await new Promise((resolve) => {
|
||||
const s = app.listen(0, '127.0.0.1', () => resolve(s))
|
||||
})
|
||||
try {
|
||||
return await fn(`http://127.0.0.1:${server.address().port}`)
|
||||
} finally {
|
||||
server.closeAllConnections()
|
||||
await new Promise((resolve) => server.close(resolve))
|
||||
}
|
||||
}
|
||||
|
||||
test('a locked route answers DEMO_LOCKED before its own auth check', async () => {
|
||||
await withServer(async (base) => {
|
||||
const res = await fetch(`${base}/api/v1/admin/site-mode`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode: 'maintenance' }),
|
||||
})
|
||||
assert.equal(res.status, 403)
|
||||
assert.equal((await res.json()).code, 'DEMO_LOCKED')
|
||||
})
|
||||
})
|
||||
|
||||
test('an open route still reaches its handler, which answers as usual', async () => {
|
||||
await withServer(async (base) => {
|
||||
const res = await fetch(`${base}/api/v1/admin/posts`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: 'x' }),
|
||||
})
|
||||
assert.equal(res.status, 401) // requireAuth, not the lock
|
||||
})
|
||||
})
|
||||
|
||||
test('an upload is refused before multer or auth sees it', async () => {
|
||||
await withServer(async (base) => {
|
||||
const form = new FormData()
|
||||
form.append('image', new Blob(['x'], { type: 'image/png' }), 'x.png')
|
||||
const res = await fetch(`${base}/api/v1/admin/uploads`, { method: 'POST', body: form })
|
||||
assert.equal(res.status, 403)
|
||||
assert.equal((await res.json()).code, 'DEMO_LOCKED')
|
||||
})
|
||||
})
|
||||
|
||||
test('a pasted data: image is refused before auth sees it, on an open route', async () => {
|
||||
await withServer(async (base) => {
|
||||
const res = await fetch(`${base}/api/v1/admin/posts`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: 'x', body: '<p><img src="data:image/png;base64,iVBORw0KGgo="></p>' }),
|
||||
})
|
||||
assert.equal(res.status, 403)
|
||||
assert.equal((await res.json()).code, 'DEMO_LOCKED')
|
||||
})
|
||||
})
|
||||
|
||||
test('every response carries noindex, and robots.txt disallows everything', async () => {
|
||||
await withServer(async (base) => {
|
||||
const health = await fetch(`${base}/api/health`)
|
||||
assert.equal(health.headers.get('x-robots-tag'), 'noindex, nofollow')
|
||||
const robots = await fetch(`${base}/robots.txt`)
|
||||
assert.equal(await robots.text(), 'User-agent: *\nDisallow: /\n')
|
||||
})
|
||||
})
|
||||
37
server/test/demoAppOff.test.js
Normal file
37
server/test/demoAppOff.test.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// ── Without DEMO_MODE, none of the demo is mounted (DEMO_MODE.md §2) ───────
|
||||
//
|
||||
// The other half of demoApp.test.js, in its own process because app.js reads
|
||||
// the flag at require time. A normal shard must answer exactly as it did before
|
||||
// demo mode existed: its own auth check, no noindex header, its own robots.txt.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
delete process.env.DEMO_MODE
|
||||
process.env.MODULES_DIR = require('path').join(require('os').tmpdir(), 'rg-demo-app-no-modules')
|
||||
|
||||
const { test, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const app = require('../src/app')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
test('a route the demo locks answers its own 401 on a normal deployment', async () => {
|
||||
const server = await new Promise((resolve) => {
|
||||
const s = app.listen(0, '127.0.0.1', () => resolve(s))
|
||||
})
|
||||
try {
|
||||
const base = `http://127.0.0.1:${server.address().port}`
|
||||
const res = await fetch(`${base}/api/v1/admin/site-mode`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode: 'maintenance' }),
|
||||
})
|
||||
assert.equal(res.status, 401)
|
||||
const health = await fetch(`${base}/api/health`)
|
||||
assert.equal(health.headers.get('x-robots-tag'), null)
|
||||
} finally {
|
||||
server.closeAllConnections()
|
||||
await new Promise((resolve) => server.close(resolve))
|
||||
}
|
||||
})
|
||||
378
server/test/demoLocks.test.js
Normal file
378
server/test/demoLocks.test.js
Normal file
@@ -0,0 +1,378 @@
|
||||
// ── The demo lock (docs/website/DEMO_MODE.md §3) ───────────────────────────
|
||||
//
|
||||
// Every row of §3.3 that core owns, the matcher's two express defaults, the
|
||||
// conditions on the shared-login rows, the settings keys that are another door
|
||||
// into a Tier A lock, the multipart rule, and the module locks. The whole-app
|
||||
// wiring — mounted with DEMO_MODE on, absent with it off — is demoApp.test.js.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, afterEach, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const db = require('../src/utils/db')
|
||||
const users = require('../src/model/users/users.model')
|
||||
const sessionService = require('../src/auth/session.service')
|
||||
const { compile, match } = require('../src/demo/paths')
|
||||
const locks = require('../src/demo/locks')
|
||||
const { isDemo, isDemoUsername, DEMO_ACCOUNTS } = require('../src/demo/mode')
|
||||
const manifest = require('../routes.manifest.json')
|
||||
const { startApp } = require('./_helper')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
const originalGetById = users.getById
|
||||
const originalValidate = sessionService.validateSession
|
||||
afterEach(() => {
|
||||
users.getById = originalGetById
|
||||
sessionService.validateSession = originalValidate
|
||||
delete process.env.DEMO_MODE
|
||||
})
|
||||
|
||||
// id 1-4 are the shared logins, 9 is somebody an admin created on the demo.
|
||||
const ACCOUNTS = { 1: 'demo-admin', 2: 'demo-moderator', 3: 'demo-player', 4: 'play-review', 9: 'alice' }
|
||||
function stubUsers() {
|
||||
users.getById = async (id) => (ACCOUNTS[id] ? { id, username: ACCOUNTS[id], role: 'admin' } : null)
|
||||
}
|
||||
function signedInAs(userId) {
|
||||
sessionService.validateSession = () => (userId ? { userId, username: ACCOUNTS[userId], role: 'admin' } : null)
|
||||
}
|
||||
|
||||
async function withLock(moduleLocks, fn) {
|
||||
const app = await startApp((a) => {
|
||||
a.use(locks.refuseMultipart)
|
||||
a.use('/api/v1', locks.createLock(moduleLocks))
|
||||
a.all('*', (req, res) => res.json({ reached: true }))
|
||||
})
|
||||
try {
|
||||
return await fn((method, path, body, headers = {}) =>
|
||||
fetch(`${app.url}/api/v1${path}`, {
|
||||
method,
|
||||
headers: body === undefined ? headers : { 'Content-Type': 'application/json', ...headers },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}))
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function expectLocked(res) {
|
||||
assert.equal(res.status, 403)
|
||||
const body = await res.json()
|
||||
assert.equal(body.code, 'DEMO_LOCKED')
|
||||
assert.equal(body.message, 'This is turned off on the demo site.')
|
||||
return body
|
||||
}
|
||||
async function expectReached(res) {
|
||||
assert.equal(res.status, 200)
|
||||
assert.deepEqual(await res.json(), { reached: true })
|
||||
}
|
||||
|
||||
// ── The flag and the accounts ──────────────────────────────────────────────
|
||||
|
||||
test('only DEMO_MODE=true is the demo', () => {
|
||||
for (const v of [undefined, '', '1', 'yes', 'TRUE', 'false']) {
|
||||
if (v === undefined) delete process.env.DEMO_MODE
|
||||
else process.env.DEMO_MODE = v
|
||||
assert.equal(isDemo(), false, String(v))
|
||||
}
|
||||
process.env.DEMO_MODE = 'true'
|
||||
assert.equal(isDemo(), true)
|
||||
})
|
||||
|
||||
test('the four shared logins are recognised whatever their case', () => {
|
||||
assert.deepEqual([...DEMO_ACCOUNTS], ['demo-admin', 'demo-moderator', 'demo-player', 'play-review'])
|
||||
assert.equal(isDemoUsername('Demo-Admin'), true)
|
||||
assert.equal(isDemoUsername('alice'), false)
|
||||
assert.equal(isDemoUsername(null), false)
|
||||
})
|
||||
|
||||
// ── The matcher ────────────────────────────────────────────────────────────
|
||||
|
||||
test('patterns match the way express routes: any case, optional trailing slash', () => {
|
||||
const p = compile('/admin/users/:id')
|
||||
assert.deepEqual(match(p, '/admin/users/7'), { id: '7' })
|
||||
assert.deepEqual(match(p, '/Admin/USERS/7/'), { id: '7' })
|
||||
assert.equal(match(p, '/admin/users'), null)
|
||||
assert.equal(match(p, '/admin/users/7/mfa/reset'), null)
|
||||
assert.deepEqual(match(compile('/a/:x'), '/a/b%20c'), { x: 'b c' })
|
||||
})
|
||||
|
||||
test('a trailing * is the path and everything under it', () => {
|
||||
const p = compile('/admin/modules/*')
|
||||
for (const path of ['/admin/modules', '/admin/modules/', '/admin/modules/rust/enable', '/admin/modules/sources']) {
|
||||
assert.ok(match(p, path), path)
|
||||
}
|
||||
assert.equal(match(p, '/admin/modulesx'), null)
|
||||
})
|
||||
|
||||
test('a pattern that would compile to something else is refused', () => {
|
||||
for (const bad of ['admin/x', '/admin//x', '/admin/*/x', '/admin/x y', '/admin/:']) {
|
||||
assert.throws(() => compile(bad), /lock path/, bad)
|
||||
}
|
||||
})
|
||||
|
||||
// ── Core's list against the real routes ────────────────────────────────────
|
||||
|
||||
test("every row of core's list names a route the server actually serves", () => {
|
||||
// A row that matched nothing would read as a lock and lock nothing. Held
|
||||
// against routes.manifest.json, which `npm run routes:manifest` keeps exact.
|
||||
const served = manifest.public
|
||||
.filter((r) => r.path.startsWith('/api/v1/'))
|
||||
.map((r) => ({ method: r.method, path: r.path.slice('/api/v1'.length) }))
|
||||
for (const rule of locks.CORE_LOCKS) {
|
||||
const hits = served.filter((r) => rule.methods.includes(r.method) && match(rule.compiled, r.path))
|
||||
assert.ok(hits.length > 0, `${rule.methods.join('/')} ${rule.path} matches no served route`)
|
||||
}
|
||||
})
|
||||
|
||||
test('every upload route in the manifest is locked by name as well as by the multipart rule', () => {
|
||||
const uploads = manifest.public.filter((r) => r.method === 'POST' && /upload|brand-asset/.test(r.path))
|
||||
assert.ok(uploads.length >= 4)
|
||||
for (const r of uploads) {
|
||||
const path = r.path.slice('/api/v1'.length)
|
||||
assert.ok(locks.CORE_LOCKS.some((l) => l.methods.includes('POST') && match(l.compiled, path)), path)
|
||||
}
|
||||
})
|
||||
|
||||
// ── Tier A, unconditional ──────────────────────────────────────────────────
|
||||
|
||||
test('the Tier A rows are refused, and reads of the same paths are not', async () => {
|
||||
await withLock(undefined, async (req) => {
|
||||
await expectLocked(await req('PUT', '/admin/site-mode', { mode: 'maintenance' }))
|
||||
await expectLocked(await req('PUT', '/Admin/Site-Mode/', { mode: 'maintenance' }))
|
||||
await expectLocked(await req('POST', '/admin/modules/rust/disable'))
|
||||
await expectLocked(await req('DELETE', '/admin/modules/rust'))
|
||||
await expectLocked(await req('POST', '/admin/modules/restart'))
|
||||
await expectLocked(await req('POST', '/admin/auth/providers', { name: 'x' }))
|
||||
await expectLocked(await req('PUT', '/admin/email/config', {}))
|
||||
await expectLocked(await req('POST', '/admin/email/test'))
|
||||
await expectLocked(await req('POST', '/admin/email/disconnect'))
|
||||
await expectLocked(await req('PUT', '/admin/discord-bot/config', {}))
|
||||
await expectLocked(await req('POST', '/admin/teams/voice/sync'))
|
||||
await expectLocked(await req('POST', '/auth/password/forgot', { email: 'a@b.c' }))
|
||||
await expectLocked(await req('POST', '/auth/password/reset/abc', { password: 'x' }))
|
||||
await expectLocked(await req('POST', '/auth/register', { username: 'x' }))
|
||||
await expectLocked(await req('POST', '/public/contact', { message: 'hi' }))
|
||||
await expectLocked(await req('POST', '/admin/uploads'))
|
||||
|
||||
await expectReached(await req('GET', '/admin/site-mode'))
|
||||
await expectReached(await req('GET', '/admin/modules'))
|
||||
await expectReached(await req('GET', '/admin/email/config'))
|
||||
await expectReached(await req('GET', '/auth/password/reset/abc'))
|
||||
})
|
||||
})
|
||||
|
||||
test('content, the rig and the rest of Tier C stay open', async () => {
|
||||
await withLock(undefined, async (req) => {
|
||||
await expectReached(await req('POST', '/admin/posts', { title: 'x' }))
|
||||
await expectReached(await req('PUT', '/admin/wiki/guides', { title: 'x' }))
|
||||
await expectReached(await req('POST', '/admin/events', {}))
|
||||
await expectReached(await req('POST', '/admin/invites', {}))
|
||||
await expectReached(await req('POST', '/admin/users', { username: 'bob' }))
|
||||
await expectReached(await req('POST', '/auth/login', { username: 'demo-admin' }))
|
||||
await expectReached(await req('POST', '/auth/mobile/logout', {}))
|
||||
})
|
||||
})
|
||||
|
||||
// ── Settings: open, except the keys that reach a Tier A lock ───────────────
|
||||
|
||||
test('PUT /admin/settings is open for theme and nav', async () => {
|
||||
await withLock(undefined, async (req) => {
|
||||
await expectReached(await req('PUT', '/admin/settings', { theme_visual: { preset: 'fantasy' }, nav_public: {} }))
|
||||
await expectReached(await req('PUT', '/admin/settings', { player_registration: 'password' }))
|
||||
})
|
||||
})
|
||||
|
||||
// DM21: the hero editor's autosave and Preview write only the draft; Publish
|
||||
// writes both, and the live key refuses the whole body.
|
||||
test('the hero draft is open, publishing the hero is refused', async () => {
|
||||
await withLock(undefined, async (req) => {
|
||||
await expectReached(await req('PUT', '/admin/settings', { hero_layout_draft: '{"v":1}' }))
|
||||
const body = await expectLocked(
|
||||
await req('PUT', '/admin/settings', { hero_layout: '{"v":1}', hero_layout_draft: '{"v":1}' }),
|
||||
)
|
||||
assert.deepEqual(body.keys, ['hero_layout'])
|
||||
})
|
||||
})
|
||||
|
||||
test('PUT /admin/settings naming a protected key is refused whole, with the keys named', async () => {
|
||||
await withLock(undefined, async (req) => {
|
||||
for (const key of ['site_mode', 'site_mode_changed_by', 'module_source_hosts', 'teams_voice_enabled', 'demo_state']) {
|
||||
const body = await expectLocked(await req('PUT', '/admin/settings', { theme_visual: {}, [key]: 'x' }))
|
||||
assert.deepEqual(body.keys, [key])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ── The shared logins ──────────────────────────────────────────────────────
|
||||
|
||||
test('an admin cannot change, delete or reset a shared login, but can anybody else', async () => {
|
||||
stubUsers()
|
||||
await withLock(undefined, async (req) => {
|
||||
for (const id of [1, 2, 3, 4]) {
|
||||
await expectLocked(await req('PUT', `/admin/users/${id}`, { role: 'player' }))
|
||||
await expectLocked(await req('DELETE', `/admin/users/${id}`))
|
||||
await expectLocked(await req('DELETE', `/admin/users/${id}/trusted-devices`))
|
||||
await expectLocked(await req('DELETE', `/admin/users/${id}/trusted-devices/5`))
|
||||
await expectLocked(await req('POST', `/admin/users/${id}/mfa/reset`))
|
||||
}
|
||||
await expectReached(await req('PUT', '/admin/users/9', { role: 'player' }))
|
||||
await expectReached(await req('DELETE', '/admin/users/9'))
|
||||
await expectReached(await req('PUT', '/admin/users/404', { role: 'player' }))
|
||||
await expectReached(await req('PUT', '/admin/users/not-a-number', {}))
|
||||
})
|
||||
})
|
||||
|
||||
const SELF_ROWS = [
|
||||
['PATCH', '/auth/me/account/username'],
|
||||
['PATCH', '/auth/me/account/password'],
|
||||
['PATCH', '/auth/me/account/email'],
|
||||
['POST', '/auth/me/account/totp/setup'],
|
||||
['POST', '/auth/me/account/totp/enable'],
|
||||
['POST', '/auth/me/account/totp/disable'],
|
||||
['POST', '/auth/me/account/recovery-codes/generate'],
|
||||
['DELETE', '/auth/me/account/identities/steam'],
|
||||
['DELETE', '/auth/me/sessions/12'], // DM17
|
||||
]
|
||||
|
||||
test('signed in as a shared login, the account cannot be changed or other sessions revoked', async () => {
|
||||
stubUsers()
|
||||
signedInAs(4)
|
||||
await withLock(undefined, async (req) => {
|
||||
for (const [method, path] of SELF_ROWS) await expectLocked(await req(method, path, {}))
|
||||
// Self-service that cannot lock anyone else out stays open.
|
||||
await expectReached(await req('PUT', '/auth/me/notifications/channels', {}))
|
||||
})
|
||||
})
|
||||
|
||||
test('signed in as anyone else, or not at all, the same routes go to their handlers', async () => {
|
||||
stubUsers()
|
||||
signedInAs(9)
|
||||
await withLock(undefined, async (req) => {
|
||||
for (const [method, path] of SELF_ROWS) await expectReached(await req(method, path, {}))
|
||||
})
|
||||
signedInAs(null) // the route's own requireAuth answers 401
|
||||
await withLock(undefined, async (req) => {
|
||||
for (const [method, path] of SELF_ROWS) await expectReached(await req(method, path, {}))
|
||||
})
|
||||
})
|
||||
|
||||
test('a lock that cannot decide refuses', async () => {
|
||||
users.getById = async () => {
|
||||
throw new Error('pool down')
|
||||
}
|
||||
await withLock(undefined, async (req) => {
|
||||
await expectLocked(await req('PUT', '/admin/users/9', {}))
|
||||
})
|
||||
})
|
||||
|
||||
// ── Uploads ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('any multipart request is refused before a router sees it, wherever it is sent', async () => {
|
||||
await withLock(undefined, async (req) => {
|
||||
const multipart = { 'Content-Type': 'multipart/form-data; boundary=x' }
|
||||
await expectLocked(await req('POST', '/admin/posts', undefined, multipart))
|
||||
await expectLocked(await req('POST', '/rust/anything', undefined, { 'Content-Type': 'Multipart/Mixed; boundary=y' }))
|
||||
await expectLocked(await req('GET', '/public/settings', undefined, multipart))
|
||||
await expectReached(await req('POST', '/admin/posts', { title: 'json is fine' }))
|
||||
})
|
||||
})
|
||||
|
||||
// DM25: a data: URI in any string of a write's body is an upload too.
|
||||
async function withDataUriLock(fn) {
|
||||
const app = await startApp((a) => {
|
||||
a.use(locks.refuseDataUri)
|
||||
a.all('*', (req, res) => res.json({ reached: true }))
|
||||
})
|
||||
try {
|
||||
return await fn((method, path, body) =>
|
||||
fetch(`${app.url}/api/v1${path}`, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}))
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
}
|
||||
|
||||
test('a data: URI anywhere in a write body is refused, on any route and any write method', async () => {
|
||||
await withDataUriLock(async (req) => {
|
||||
const img = '<p>hi</p><img src="data:image/png;base64,iVBORw0KGgo=">'
|
||||
await expectLocked(await req('POST', '/admin/posts', { title: 't', body: img }))
|
||||
await expectLocked(await req('PUT', '/admin/wiki/1', { content: img }))
|
||||
await expectLocked(await req('PATCH', '/rust/anything', { deep: [{ x: { y: [img] } }] }))
|
||||
await expectLocked(await req('PUT', '/admin/settings', { hero_layout_draft: JSON.stringify([{ src: 'data:image/webp;base64,UklG' }]) }))
|
||||
await expectLocked(await req('DELETE', '/player/teams/x/forum/1', { reason: img }))
|
||||
})
|
||||
})
|
||||
|
||||
test('every data: URI shape a browser would load is refused', async () => {
|
||||
const shapes = [
|
||||
'DATA:image/png;base64,AAAA',
|
||||
'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg"/>',
|
||||
'data:text/html;base64,PHNjcmlwdD4=',
|
||||
'data:,hello',
|
||||
'data:;base64,AAAA',
|
||||
'data: image/png;base64,AAAA',
|
||||
'<div style="background:url(data:image/gif;base64,R0lG)">',
|
||||
'<img src="data:image/png;base64,AAAA">',
|
||||
'<img src="data:image/png;base64,AAAA">',
|
||||
'<img src="data:image/png;base64,AAAA">',
|
||||
'<img src="da\nta:image/png;base64,AAAA">',
|
||||
'<img src="data:im\tage/png;base64,AAAA">',
|
||||
]
|
||||
for (const s of shapes) assert.equal(locks.hasDataUri({ body: s }), true, s)
|
||||
})
|
||||
|
||||
test('prose that mentions data, reads, and bodies without strings pass', async () => {
|
||||
for (const s of ['metadata: none', 'Data: none, yet', 'the data: it is fine', 'see https://example.com/data:x', 'mydata:image/png,x']) {
|
||||
assert.equal(locks.hasDataUri({ body: s }), false, s)
|
||||
}
|
||||
assert.equal(locks.hasDataUri(undefined), false)
|
||||
assert.equal(locks.hasDataUri({ n: 1, ok: true, list: [null, 2] }), false)
|
||||
await withDataUriLock(async (req) => {
|
||||
await expectReached(await req('POST', '/admin/posts', { title: 'json is fine', body: '<p>metadata: none</p>' }))
|
||||
})
|
||||
// A read is never refused, whatever it carries.
|
||||
const app = await startApp((a) => {
|
||||
a.use(locks.refuseDataUri)
|
||||
a.all('*', (req, res) => res.json({ reached: true }))
|
||||
})
|
||||
try {
|
||||
await expectReached(await fetch(`${app.url}/api/v1/public/settings?x=data:image/png;base64,AAAA`))
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
// ── Module locks ───────────────────────────────────────────────────────────
|
||||
|
||||
test("a module's declared locks are refused alongside core's", async () => {
|
||||
const declared = [{ methods: ['PUT'], path: '/admin/rust/servers/:id', compiled: compile('/admin/rust/servers/:id'), source: 'rust' }]
|
||||
await withLock(() => declared, async (req) => {
|
||||
await expectLocked(await req('PUT', '/admin/rust/servers/demo', {}))
|
||||
await expectReached(await req('GET', '/admin/rust/servers/demo'))
|
||||
await expectReached(await req('DELETE', '/admin/rust/servers/demo'))
|
||||
await expectReached(await req('POST', '/admin/rust/servers/demo/test'))
|
||||
})
|
||||
})
|
||||
|
||||
// ── Search engines ─────────────────────────────────────────────────────────
|
||||
|
||||
test('noindex and robots keep the demo out of search engines', async () => {
|
||||
const app = await startApp((a) => {
|
||||
a.use(locks.noindex)
|
||||
a.get('/robots.txt', locks.robots)
|
||||
a.get('/', (req, res) => res.send('ok'))
|
||||
})
|
||||
try {
|
||||
const page = await fetch(`${app.url}/`)
|
||||
assert.equal(page.headers.get('x-robots-tag'), 'noindex, nofollow')
|
||||
const robots = await fetch(`${app.url}/robots.txt`)
|
||||
assert.equal(await robots.text(), 'User-agent: *\nDisallow: /\n')
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
218
server/test/demoModule.test.js
Normal file
218
server/test/demoModule.test.js
Normal file
@@ -0,0 +1,218 @@
|
||||
// ── The demo's module contract (MODULE_API 1.18.0) and the seed's helpers ──
|
||||
//
|
||||
// `demoLocks` in module.json, `ctx.demoMode`, `api.onDemoSeed` (DM15), the
|
||||
// `demo` block on GET /public/settings, and the pure parts of `seed:demo`.
|
||||
// Each loader test builds a throwaway modules directory and re-requires the
|
||||
// loader, the way moduleLoader.test.js does and for the same reason.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const fs = require('fs')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
|
||||
const { test, afterEach, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const express = require('express')
|
||||
|
||||
const db = require('../src/utils/db')
|
||||
const registries = require('../src/modules/registries')
|
||||
const settingsDb = require('../src/model/settings/settings.db')
|
||||
const settings = require('../src/model/settings/settings.model')
|
||||
const { resolveEnv, nextResetAt, checkFixture } = require('../db/seedDemo')
|
||||
|
||||
after(() => db.close())
|
||||
afterEach(() => {
|
||||
delete process.env.DEMO_MODE
|
||||
})
|
||||
|
||||
const emptyTiers = () => ({ public: express.Router(), admin: express.Router(), player: express.Router() })
|
||||
|
||||
function loadOne(manifest, server = 'module.exports = (ctx, api) => { const r = ctx.express.Router(); api.registerRoutes({ admin: { "/rust": r } }) }') {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-demo-'))
|
||||
const dir = path.join(root, 'rust')
|
||||
fs.mkdirSync(dir)
|
||||
fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify({
|
||||
id: 'rust',
|
||||
name: 'Rust',
|
||||
version: '1.0.0',
|
||||
coreApi: '^1.18.0',
|
||||
server: 'index.js',
|
||||
mounts: { admin: ['/rust'] },
|
||||
...manifest,
|
||||
}))
|
||||
fs.writeFileSync(path.join(dir, 'index.js'), server)
|
||||
process.env.MODULES_DIR = root
|
||||
registries._reset()
|
||||
delete require.cache[require.resolve('../src/modules/loader')]
|
||||
// eslint-disable-next-line global-require
|
||||
const loader = require('../src/modules/loader')
|
||||
loader.load(emptyTiers())
|
||||
return loader
|
||||
}
|
||||
const stateOf = (loader) => loader.list().find((m) => m.id === 'rust')
|
||||
|
||||
// ── demoLocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('declared demoLocks load and come back compiled', () => {
|
||||
const loader = loadOne({
|
||||
demoLocks: [
|
||||
{ method: 'PUT', path: '/admin/rust/servers/:id' },
|
||||
{ method: 'DELETE', path: '/admin/rust/servers/:id' },
|
||||
],
|
||||
})
|
||||
assert.equal(stateOf(loader).state, 'registered')
|
||||
const got = loader.demoLocks()
|
||||
assert.deepEqual(got.map((l) => [l.methods, l.path, l.source]), [
|
||||
[['PUT'], '/admin/rust/servers/:id', 'rust'],
|
||||
[['DELETE'], '/admin/rust/servers/:id', 'rust'],
|
||||
])
|
||||
assert.ok(got[0].compiled.re.test('/admin/rust/servers/demo'))
|
||||
})
|
||||
|
||||
test('a module without demoLocks declares none', () => {
|
||||
assert.deepEqual(loadOne({}).demoLocks(), [])
|
||||
})
|
||||
|
||||
test('a bad demoLocks entry fails the module at load, demo or not', () => {
|
||||
const cases = [
|
||||
[{ demoLocks: {} }, /demoLocks must be an array/],
|
||||
[{ demoLocks: ['PUT /x'] }, /must be an object/],
|
||||
[{ demoLocks: [{ method: 'GET', path: '/admin/rust/x' }] }, /must be one of POST, PUT, PATCH, DELETE/],
|
||||
[{ demoLocks: [{ method: 'PUT', path: '/admin/rust/x', why: 'y' }] }, /unknown key "demoLocks.why"/],
|
||||
[{ demoLocks: [{ method: 'PUT', path: '/admin/rust/*/x' }] }, /\* must be the last segment/],
|
||||
// Outside the module's own mounts: a module locks its routes and nobody else's.
|
||||
[{ demoLocks: [{ method: 'PUT', path: '/admin/site-mode' }] }, /outside the module's mounts/],
|
||||
[{ demoLocks: [{ method: 'PUT', path: '/public/rust/x' }] }, /outside the module's mounts/],
|
||||
[{ demoLocks: [{ method: 'PUT', path: '/admin/*' }] }, /outside the module's mounts/],
|
||||
]
|
||||
for (const [manifest, reason] of cases) {
|
||||
const loader = loadOne(manifest)
|
||||
const m = stateOf(loader)
|
||||
assert.equal(m.state, 'startup_failed', JSON.stringify(manifest))
|
||||
assert.match(m.reason, reason)
|
||||
}
|
||||
})
|
||||
|
||||
test('a module declaring ^1.18.0 and an empty demoLocks loads on this core', () => {
|
||||
const loader = loadOne({ coreApi: '^1.18.0', demoLocks: [] })
|
||||
assert.equal(stateOf(loader).state, 'registered')
|
||||
})
|
||||
|
||||
// ── ctx.demoMode ───────────────────────────────────────────────────────────
|
||||
|
||||
const PROBE = `module.exports = (ctx, api) => {
|
||||
global.__demoProbe = ctx.demoMode
|
||||
api.registerRoutes({ admin: { '/rust': ctx.express.Router() } })
|
||||
}`
|
||||
|
||||
test('ctx.demoMode is false on a normal deployment and true on the demo', () => {
|
||||
loadOne({}, PROBE)
|
||||
assert.equal(global.__demoProbe, false)
|
||||
process.env.DEMO_MODE = 'true'
|
||||
loadOne({}, PROBE)
|
||||
assert.equal(global.__demoProbe, true)
|
||||
delete global.__demoProbe
|
||||
})
|
||||
|
||||
// ── onDemoSeed ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('onDemoSeed is collected for the seed and handed the fixture and account ids', async () => {
|
||||
const loader = loadOne({}, `module.exports = (ctx, api) => {
|
||||
api.registerRoutes({ admin: { '/rust': ctx.express.Router() } })
|
||||
api.onDemoSeed(async (data, { accounts }) => { global.__demoSeed = { data, accounts } })
|
||||
}`)
|
||||
const hooks = loader.demoSeedHooks()
|
||||
assert.deepEqual(hooks.map((h) => h.id), ['rust'])
|
||||
await hooks[0].hook({ servers: [] }, { accounts: { 'demo-player': 3 } })
|
||||
assert.deepEqual(global.__demoSeed, { data: { servers: [] }, accounts: { 'demo-player': 3 } })
|
||||
delete global.__demoSeed
|
||||
})
|
||||
|
||||
test('onDemoSeed twice is a registration error, like every other hook', () => {
|
||||
const loader = loadOne({}, `module.exports = (ctx, api) => {
|
||||
api.registerRoutes({ admin: { '/rust': ctx.express.Router() } })
|
||||
api.onDemoSeed(() => {})
|
||||
api.onDemoSeed(() => {})
|
||||
}`)
|
||||
assert.equal(stateOf(loader).state, 'startup_failed')
|
||||
assert.match(stateOf(loader).reason, /onDemoSeed\(\) called twice/)
|
||||
})
|
||||
|
||||
// ── GET /public/settings ───────────────────────────────────────────────────
|
||||
|
||||
const originalGetAll = settingsDb.getAll
|
||||
afterEach(() => {
|
||||
settingsDb.getAll = originalGetAll
|
||||
})
|
||||
|
||||
test('the demo block is present only on the demo, and read forgivingly', async () => {
|
||||
const state = {
|
||||
resetsAt: '2026-10-24T14:00:00.000Z',
|
||||
logins: [{ username: 'demo-admin', password: 'demo-admin-pass', role: 'admin' }, { username: 7 }],
|
||||
}
|
||||
settingsDb.getAll = async () => [{ key: 'demo_state', value: JSON.stringify(state) }]
|
||||
const normal = await settings.getPublic()
|
||||
assert.equal('demo' in normal, false)
|
||||
|
||||
process.env.DEMO_MODE = 'true'
|
||||
const demo = await settings.getPublic()
|
||||
assert.deepEqual(demo.demo, {
|
||||
resetsAt: '2026-10-24T14:00:00.000Z',
|
||||
logins: [{ username: 'demo-admin', password: 'demo-admin-pass', role: 'admin' }],
|
||||
})
|
||||
|
||||
settingsDb.getAll = async () => [{ key: 'demo_state', value: '{broken' }]
|
||||
assert.deepEqual((await settings.getPublic()).demo, { resetsAt: null, logins: [] })
|
||||
})
|
||||
|
||||
// ── seed:demo's pure helpers ───────────────────────────────────────────────
|
||||
|
||||
test('resolveEnv replaces $env: strings at any depth and refuses an unset one', () => {
|
||||
const env = { DEMO_RUST_SIDECAR_TOKEN: 'tok', DEMO_RUST_SIDECAR_URL: 'http://rig:7790' }
|
||||
assert.deepEqual(
|
||||
resolveEnv({ a: '$env:DEMO_RUST_SIDECAR_URL', b: [{ t: '$env:DEMO_RUST_SIDECAR_TOKEN' }], c: 'plain $env:X', d: 3 }, env),
|
||||
{ a: 'http://rig:7790', b: [{ t: 'tok' }], c: 'plain $env:X', d: 3 },
|
||||
)
|
||||
assert.throws(() => resolveEnv({ m: { t: '$env:NOPE' } }, env), /fixture\.m\.t: environment variable NOPE is not set/)
|
||||
assert.throws(() => resolveEnv('$env:EMPTY', { EMPTY: '' }), /EMPTY is not set/)
|
||||
})
|
||||
|
||||
test('nextResetAt is the next two-hour slot on the hour, UTC', () => {
|
||||
const at = (iso) => nextResetAt(new Date(iso), 2).toISOString()
|
||||
assert.equal(at('2026-10-24T14:02:10Z'), '2026-10-24T16:00:00.000Z')
|
||||
assert.equal(at('2026-10-24T13:59:59Z'), '2026-10-24T14:00:00.000Z')
|
||||
assert.equal(at('2026-10-24T14:00:00Z'), '2026-10-24T16:00:00.000Z')
|
||||
assert.equal(at('2026-10-24T23:30:00Z'), '2026-10-25T00:00:00.000Z')
|
||||
assert.equal(nextResetAt(new Date('2026-10-24T14:02:00Z'), 3).toISOString(), '2026-10-24T15:00:00.000Z')
|
||||
})
|
||||
|
||||
const GOOD = {
|
||||
accounts: {
|
||||
'demo-admin': { password: 'demo-admin-pass', role: 'admin' },
|
||||
'demo-moderator': { password: 'demo-moderator-pass', role: 'moderator' },
|
||||
'demo-player': { password: 'demo-player-pass', role: 'player' },
|
||||
'play-review': { password: 'play-review-pass', role: 'admin' },
|
||||
},
|
||||
}
|
||||
|
||||
test('checkFixture accepts the four accounts and refuses anything that would leave the lock wrong', () => {
|
||||
assert.deepEqual(checkFixture(GOOD), [])
|
||||
const { 'play-review': _gone, ...three } = GOOD.accounts
|
||||
assert.match(checkFixture({ accounts: three }).join('\n'), /accounts\.play-review is missing/)
|
||||
assert.match(checkFixture({ accounts: { ...GOOD.accounts, alice: { password: 'alicealice', role: 'admin' } } }).join('\n'),
|
||||
/accounts\.alice is not a demo account/)
|
||||
assert.match(checkFixture({ accounts: { ...GOOD.accounts, 'demo-admin': { password: 'short', role: 'admin' } } }).join('\n'),
|
||||
/at least 8 characters/)
|
||||
assert.match(checkFixture({ accounts: { ...GOOD.accounts, 'demo-admin': { password: 'longenough', role: 'root' } } }).join('\n'),
|
||||
/role must be one of/)
|
||||
assert.match(checkFixture({ ...GOOD, settings: { demo_state: '{}' } }).join('\n'), /written by the seed itself/)
|
||||
assert.match(checkFixture({ ...GOOD, content: { posts: {} } }).join('\n'), /content\.posts must be an array/)
|
||||
assert.match(checkFixture({ ...GOOD, modules: [] }).join('\n'), /modules must be an object/)
|
||||
})
|
||||
|
||||
test('seed:demo refuses to run off the demo', async () => {
|
||||
const { run } = require('../db/seedDemo') // eslint-disable-line global-require
|
||||
await assert.rejects(() => run('anything.json'), /only with DEMO_MODE=true/)
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user