Two new tables — event_action_settings (the deployment switchboard) and event_run_budget (what a run has spent and the most it may) — plus verified_at and verified_by on event_versions. The whole authorisation decision moves behind one function, events/authorize.js: role, enablement, cap, and the shard's own switch named as the layer core deliberately does not duplicate. Three routes, none moved: GET/PUT /admin/events/actions (admin in both directions) and POST /admin/events/:id/verify (admin, editor — a dry run dispatches nothing). Four decisions, settled by the org lead 2026-09-03: - The default-off line falls between inspect and change, not between notify and inspect. Read literally, §K shipped core.wait disabled. The same line is the role floor. - The tightest cap wins where two actions spend one dimension, pinned into the run at creation with the action it came from. - A refusal follows the step's on_failure and takes health to degraded — its own status and its own log kind, because a refusal is not an outage. - The verify gate is enforced for scheduled starts only: a human pressing Start now is the review the gate exists to require. Derived and flagged for review: a dry run fails rather than warns on a disabled action or an over-cap plan, and the unattended path does not re-check the starter's role. +111 tests (1921/1847/73/1 — the one failure pre-existing and environmental), including a 403 walk over the real router and two concurrent spends against one cap on a real MariaDB. The live walk found two defects, both fixed here: the run console route dropped the budget it was handed, and the role refusal used a plural verb over a one-item list. Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
317 lines
17 KiB
JavaScript
317 lines
17 KiB
JavaScript
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 RequireAuth from './components/RequireAuth.jsx'
|
|
import RequirePlayer from './components/RequirePlayer.jsx'
|
|
import RoleGate from './components/RoleGate.jsx'
|
|
import { routesFor } from './modules/registry.js'
|
|
import { ModuleFeaturesProvider } from './modules/features.jsx'
|
|
|
|
// Public
|
|
import Portal from './routes/public/Portal.jsx'
|
|
import Website from './routes/public/Website.jsx'
|
|
import News from './routes/public/News.jsx'
|
|
import Screenshots from './routes/public/Screenshots.jsx'
|
|
import FiveOnFriday from './routes/public/FiveOnFriday.jsx'
|
|
import Newsletter from './routes/public/Newsletter.jsx'
|
|
import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
|
|
import About from './routes/public/About.jsx'
|
|
import Status from './routes/public/Status.jsx'
|
|
import Wiki from './routes/wiki/Wiki.jsx'
|
|
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
|
import CmsPage from './routes/public/CmsPage.jsx'
|
|
|
|
// Admin
|
|
import AdminLogin from './routes/admin/AdminLogin.jsx'
|
|
import AdminLayout from './routes/admin/AdminLayout.jsx'
|
|
import Dashboard from './routes/admin/views/Dashboard.jsx'
|
|
import PostsAdmin from './routes/admin/views/PostsAdmin.jsx'
|
|
import PagesAdmin from './routes/admin/views/PagesAdmin.jsx'
|
|
import PageBuilder from './routes/admin/views/PageBuilder.jsx'
|
|
import WikiAdmin from './routes/admin/views/WikiAdmin.jsx'
|
|
import HeroEditor from './routes/admin/views/HeroEditor.jsx'
|
|
import AppearanceAdmin from './routes/admin/views/AppearanceAdmin.jsx'
|
|
import NavEditor from './routes/admin/views/NavEditor.jsx'
|
|
import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx'
|
|
import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
|
|
import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx'
|
|
import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx'
|
|
import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx'
|
|
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
|
|
import UserDetail from './routes/admin/views/UserDetail.jsx'
|
|
import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx'
|
|
import ModulesAdmin from './routes/admin/views/ModulesAdmin.jsx'
|
|
import EngagementRules from './routes/admin/views/EngagementRules.jsx'
|
|
import EngagementAudiences from './routes/admin/views/EngagementAudiences.jsx'
|
|
import EngagementTemplates from './routes/admin/views/EngagementTemplates.jsx'
|
|
import EngagementTriggers from './routes/admin/views/EngagementTriggers.jsx'
|
|
import EngagementSendLog from './routes/admin/views/EngagementSendLog.jsx'
|
|
import EngagementSuppressions from './routes/admin/views/EngagementSuppressions.jsx'
|
|
import EngagementRetention from './routes/admin/views/EngagementRetention.jsx'
|
|
import EventsAdmin from './routes/admin/views/EventsAdmin.jsx'
|
|
import EventsCalendar from './routes/admin/views/EventsCalendar.jsx'
|
|
import EventEditor from './routes/admin/views/EventEditor.jsx'
|
|
import EventRun from './routes/admin/views/EventRun.jsx'
|
|
import EventActions from './routes/admin/views/EventActions.jsx'
|
|
import TeamsAdmin from './routes/admin/views/TeamsAdmin.jsx'
|
|
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
|
import Moderation from './routes/admin/views/Moderation.jsx'
|
|
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
|
|
import Appeals from './routes/admin/views/Appeals.jsx'
|
|
import ContentReports from './routes/admin/views/ContentReports.jsx'
|
|
|
|
// Player portal
|
|
import PlayerLogin from './routes/player/PlayerLogin.jsx'
|
|
import PlayerRegister from './routes/player/PlayerRegister.jsx'
|
|
import ForgotPassword from './routes/player/ForgotPassword.jsx'
|
|
import ResetPassword from './routes/player/ResetPassword.jsx'
|
|
import VerifyEmail from './routes/player/VerifyEmail.jsx'
|
|
import AcceptInvite from './routes/player/AcceptInvite.jsx'
|
|
import PlayerPortalLayout, { PlayerIndex } from './routes/player/PlayerPortalLayout.jsx'
|
|
import PlayerAccount from './routes/player/PlayerAccount.jsx'
|
|
import PlayerNotifications from './routes/player/PlayerNotifications.jsx'
|
|
import PlayerInbox from './routes/player/PlayerInbox.jsx'
|
|
import Unsubscribe from './routes/player/Unsubscribe.jsx'
|
|
import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
|
|
|
|
export default function App() {
|
|
return (
|
|
<AuthProvider>
|
|
<SiteProvider>
|
|
{/* Inside the auth and site contexts, because a feature provider is a
|
|
hook that may well read either — a live-status one does, indirectly,
|
|
by asking an endpoint whose answer depends on the session. Outside the
|
|
routes, so the nav in every layout is filtered by the same gate and
|
|
the provider hooks are called once for the whole app rather than
|
|
once per screen. */}
|
|
<ModuleFeaturesProvider>
|
|
<Routes>
|
|
{/* Landing hero — always public, even in maintenance mode. The hero is
|
|
itself the pre-launch "coming soon" page, so it sits outside the
|
|
MaintenanceGate and every visitor sees it regardless of auth/site mode. */}
|
|
<Route path="/" element={<Portal />} />
|
|
|
|
{/* Rest of the public site — gated by maintenance mode (admins preview through it) */}
|
|
<Route
|
|
element={
|
|
<MaintenanceGate>
|
|
<Outlet />
|
|
</MaintenanceGate>
|
|
}
|
|
>
|
|
<Route path="/site" element={<Website />} />
|
|
<Route path="/site/news" element={<News />} />
|
|
<Route path="/site/screenshots" element={<Screenshots />} />
|
|
<Route path="/site/five-on-friday" element={<FiveOnFriday />} />
|
|
<Route path="/site/newsletter" element={<Newsletter />} />
|
|
<Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
|
|
<Route path="/site/about" element={<About />} />
|
|
<Route path="/site/status" element={<Status />} />
|
|
<Route path="/wiki" element={<Wiki />} />
|
|
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
|
{/* Installed modules' public pages, namespaced `/<id>/…` — the
|
|
registry prefixes the segment, so a module cannot spell its way
|
|
out of it (docs/website/MODULE_API.md §3.3). Declared before the
|
|
CMS catch-all below: React Router ranks a static segment over a
|
|
dynamic one, so the order is not what saves us, but keeping the
|
|
two adjacent makes the relationship visible to whoever adds the
|
|
next route here. */}
|
|
{routesFor('public').map((r) => (
|
|
<Route key={r.path} path={`/${r.path}`} element={r.element} />
|
|
))}
|
|
{/* CMS pages: top-level /:slug, matched only after the named routes
|
|
above (React Router ranks static routes over this dynamic one). */}
|
|
<Route path="/:slug" element={<CmsPage />} />
|
|
</Route>
|
|
|
|
{/* Draft-preview link (token-gated). Outside the maintenance gate so a
|
|
preview link works regardless of site mode. */}
|
|
<Route path="/preview/:id/:token" element={<CmsPage preview />} />
|
|
|
|
{/* Admin */}
|
|
<Route path="/admin/login" element={<AdminLogin />} />
|
|
<Route
|
|
path="/admin"
|
|
element={
|
|
<RequireAuth>
|
|
<AdminLayout />
|
|
</RequireAuth>
|
|
}
|
|
>
|
|
<Route index element={<Dashboard />} />
|
|
<Route path="posts" element={<PostsAdmin />} />
|
|
<Route path="pages" element={<PagesAdmin />} />
|
|
<Route path="pages/new" element={<PageBuilder />} />
|
|
<Route path="pages/:id" element={<PageBuilder />} />
|
|
<Route path="wiki" element={<WikiAdmin />} />
|
|
<Route path="hero" element={<HeroEditor />} />
|
|
{/* Theme editing writes an admin-only settings key; the route sits
|
|
behind the same RoleGate as the sidebar entry that reaches it,
|
|
and PUT/DELETE /admin/settings is admin-only server-side too. */}
|
|
<Route
|
|
path="appearance"
|
|
element={
|
|
<RoleGate roles={['admin']}>
|
|
<AppearanceAdmin />
|
|
</RoleGate>
|
|
}
|
|
/>
|
|
{/* Same reasoning as Appearance: the nav overrides are an admin-only
|
|
settings key, so the route carries the same RoleGate as the
|
|
sidebar entry that reaches it. */}
|
|
<Route
|
|
path="navigation"
|
|
element={
|
|
<RoleGate roles={['admin']}>
|
|
<NavEditor />
|
|
</RoleGate>
|
|
}
|
|
/>
|
|
<Route path="settings" element={<SettingsAdmin />} />
|
|
<Route
|
|
path="moderation"
|
|
element={
|
|
<RoleGate roles={['admin', 'moderator']}>
|
|
<Outlet />
|
|
</RoleGate>
|
|
}
|
|
>
|
|
<Route index element={<Moderation />} />
|
|
<Route path="user/:discordId" element={<ModerationUser />} />
|
|
<Route path="appeals" element={<Appeals />} />
|
|
<Route path="reports" element={<ContentReports />} />
|
|
</Route>
|
|
<Route path="activity" element={<ActivityAdmin />} />
|
|
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
|
<Route path="discord-bot" element={<DiscordBotAdmin />} />
|
|
<Route path="auth-providers" element={<AuthProvidersAdmin />} />
|
|
<Route path="users" element={<UsersAdmin />} />
|
|
<Route path="users/:id" element={<UserDetail />} />
|
|
<Route path="invites" element={<InvitesAdmin />} />
|
|
{/* Core's own screen, and it has to be: it is how a module reaches
|
|
the volume in the first place. Declared here with the rest of
|
|
core's routes, above the module-supplied ones below. */}
|
|
<Route path="modules" element={<ModulesAdmin />} />
|
|
{/* Staff-wide, like the moderation queues: the gate on the three
|
|
actions that publish a game-written name is applied per request
|
|
on the server, from the caller's live role (TEAMS.md 2.9). */}
|
|
<Route path="teams" element={<TeamsAdmin />} />
|
|
{/* Events (EVENTS.md §I, Phase 3). Staff-wide, unlike Engagement:
|
|
§K makes every read here `staff`, and the moderator's whole
|
|
power over this feature is the run console — cancelling a run
|
|
that is doing something wrong at 2am. The narrower gates are
|
|
applied per action instead: authoring is admin+editor, publish
|
|
and start are admin only (§N2), and each button follows the
|
|
route it calls. `runs/:runId` is declared before `:id` so the
|
|
literal segment is never read as a definition id. */}
|
|
<Route path="events" element={<EventsAdmin />} />
|
|
<Route path="events/calendar" element={<EventsCalendar />} />
|
|
{/* The switchboard (Phase 6). A literal segment, declared before
|
|
`events/:id` the way the router declares `/actions` before
|
|
`/:id` — the same collision, on the other side of the wire. */}
|
|
<Route path="events/actions" element={<EventActions />} />
|
|
<Route path="events/runs/:runId" element={<EventRun />} />
|
|
<Route path="events/new" element={<EventEditor />} />
|
|
<Route path="events/:id" element={<EventEditor />} />
|
|
{/* Engagement (ENGAGEMENT.md Phases 4b and 5b). Admin-only, matching the
|
|
server: every route under /admin/engagement re-gates to `admin`
|
|
on top of the group's staff gate, because this is the group that
|
|
decides who receives mail. */}
|
|
<Route
|
|
path="engagement"
|
|
element={
|
|
<RoleGate roles={['admin']}>
|
|
<Outlet />
|
|
</RoleGate>
|
|
}
|
|
>
|
|
<Route index element={<Navigate to="rules" replace />} />
|
|
<Route path="rules" element={<EngagementRules />} />
|
|
<Route path="audiences" element={<EngagementAudiences />} />
|
|
<Route path="templates" element={<EngagementTemplates />} />
|
|
<Route path="triggers" element={<EngagementTriggers />} />
|
|
<Route path="sends" element={<EngagementSendLog />} />
|
|
<Route path="suppressions" element={<EngagementSuppressions />} />
|
|
<Route path="retention" element={<EngagementRetention />} />
|
|
</Route>
|
|
<Route path="account" element={<AccountAdmin />} />
|
|
{/* Staff have an inbox and channel preferences like anyone else —
|
|
`/auth/me/notifications` is behind requireAuth only — but
|
|
`RequirePlayer` sends them out of the player portal, so the two
|
|
screens are mounted here as well. Same components, same API,
|
|
two paths; `lib/notificationPaths.js` is the one mapping. */}
|
|
<Route path="notifications" element={<PlayerInbox />} />
|
|
<Route path="notifications/settings" element={<PlayerNotifications />} />
|
|
{/* Installed modules' admin pages, at /admin/<id>/…, already inside
|
|
RequireAuth + AdminLayout. A module cannot supply its own auth
|
|
wrapper — only an optional { roles }, which core applies as the
|
|
same RoleGate its own routes above use, so the sidebar and the
|
|
route table cannot disagree about who may see what. Before the
|
|
`*` redirect, which would otherwise swallow every one of them. */}
|
|
{routesFor('admin').map((r) => (
|
|
<Route
|
|
key={r.path}
|
|
path={r.path}
|
|
element={r.gate ? <RoleGate roles={r.gate.roles}>{r.element}</RoleGate> : r.element}
|
|
/>
|
|
))}
|
|
<Route path="*" element={<Navigate to="/admin" replace />} />
|
|
</Route>
|
|
|
|
{/* Player portal */}
|
|
<Route path="/account/login" element={<PlayerLogin />} />
|
|
<Route path="/account/register" element={<PlayerRegister />} />
|
|
<Route path="/account/forgot" element={<ForgotPassword />} />
|
|
<Route path="/account/reset/:token" element={<ResetPassword />} />
|
|
{/* Opened from a mailbox, so public like the reset page above — the
|
|
token is the proof, and confirming issues no session. */}
|
|
<Route path="/account/verify-email/:token" element={<VerifyEmail />} />
|
|
<Route path="/invite/:token" element={<AcceptInvite />} />
|
|
{/* PUBLIC, and grouped with the other tokened landings above rather
|
|
than with the portal below: the person following an unsubscribe
|
|
link is reading their mail, not signed in (TEAMS.md §6.4). */}
|
|
<Route path="/unsubscribe/:token" element={<Unsubscribe />} />
|
|
<Route
|
|
element={
|
|
<RequirePlayer>
|
|
<PlayerPortalLayout />
|
|
</RequirePlayer>
|
|
}
|
|
>
|
|
{/* The portal index resolves to the first nav row this viewer can
|
|
reach rather than naming a page: `PlayerCharacters` was a UO
|
|
page and left with the client half (MODULE_SYSTEM.md §2.7.1).
|
|
With the UO module installed that is still Characters. */}
|
|
<Route path="/player" element={<PlayerIndex />} />
|
|
<Route path="/account" element={<PlayerAccount />} />
|
|
<Route path="/account/appeals" element={<PlayerAppeals />} />
|
|
{/* The inbox took `/account/notifications` in engagement Phase 7
|
|
and the preferences screen moved under it. Content and
|
|
settings are different kinds of thing, and the plain word
|
|
belongs to the one a person means when they say it — which is
|
|
also what the bell in the header opens. The server's routes
|
|
split at the same place. */}
|
|
<Route path="/account/notifications" element={<PlayerInbox />} />
|
|
<Route path="/account/notifications/settings" element={<PlayerNotifications />} />
|
|
{/* Installed modules' player-portal pages, at /player/<id>/…. This
|
|
group's own routes are absolute (its layout route has no path),
|
|
so the prefix is written here rather than inherited — the one
|
|
place the three areas do not read alike. */}
|
|
{routesFor('player').map((r) => (
|
|
<Route
|
|
key={r.path}
|
|
path={`/player/${r.path}`}
|
|
element={r.gate ? <RoleGate roles={r.gate.roles}>{r.element}</RoleGate> : r.element}
|
|
/>
|
|
))}
|
|
</Route>
|
|
|
|
<Route path="*" element={<Navigate to="/" replace />} />
|
|
</Routes>
|
|
</ModuleFeaturesProvider>
|
|
</SiteProvider>
|
|
</AuthProvider>
|
|
)
|
|
}
|