Files
website/server/src/router/v1/admin/index.js
wtclaude 61abb3ec89 feat(teams): phase 9 — one voice channel per Team, granted by a role
TEAMS.md §7.3. Each qualifying Team gets a Discord voice channel of its own
and a role that opens it, kept in step by a reconciler that rides the Team
reconcile it already depends on.

Access is a per-Team ROLE, always. §7.3 designed per-member overwrites with
escalation to a role above ~90 members; the org lead settled on roles always
(2026-08-18), which deletes `voice_overwrite_max`, the escalation and the
`mode` column — and moves the ceiling. Overwrites are capped per channel, so
the old shape's limit was "how big can one Team be"; roles are capped per
guild at 250, so the new one is "how many Teams can have voice at all". That
is a limit an operator must be told about before they hit it, so the panel
reports it and the pass refuses the create rather than letting Discord do it.

Three things §7.3 named that this codebase does not have, all settled by
asking the operator because nothing in the data model can answer:

  - "the staff role" — there is no staff-role concept anywhere. Now a list of
    role ids the admin designates; empty is a normal answer, since guild
    administrators bypass overwrites and what is really missing is a way to
    let NON-admin staff in.
  - the parent category — §7.3 said the bot creates it and gave the id nowhere
    to live (`team_integrations.team_id` is NOT NULL). The bot creates it and
    the server stores the id in settings.
  - whether the bot can act at all — nothing has ever checked. The operator
    invites the bot by hand and no invite URL with a permission integer exists
    in the tree, so a deployment can be one unticked box from every call
    failing. A preflight is now a PRECONDITION to enabling (422), not a
    per-Team error discovered afterwards.

Two more, decided rather than asked:

  - the threshold counts every active member, not linked ones. §7.3 wrote
    `voice_min_linked_members`; the operator is judging whether a Team is real,
    and link state answers a different question.
  - hidden Teams are never provisioned. A channel name is a game-sourced string
    published outside the site, which is exactly §2.8's concern —
    reservedNames.js already names "and eventually a Discord channel name" as a
    surface it protects — so the screen that suppresses a Team's page suppresses
    its channel, and a Team that becomes hidden takes the grace window.

Turning voice OFF tears nothing down: the pass suspends in both directions and
the panel offers per-row removal. A checkbox must not delete structure in
somebody's guild.

Fixes a phase 8 defect that blocks this phase's own artifact: `npm run swagger`
has been unable to run on `edge` at all. `param('teamId').custom((v) => ... ||
/^[0-9]+$/.test(v))` makes swagger-autogen's parser run away — a regex literal
followed directly by `.test(`. Hoisted to a const, as modules.router.js
already does. Underneath it, `teams.router.js` sits exactly at that parser's
per-file limit: at twenty `teamsRouter.*` statements it dies, at nineteen it
generates, and one more statement of ANY shape tips it — an unannotated route
does, and so does a bare `use`. So the voice routes are their own router file
mounted from `admin/index.js`, and teams.router.js keeps its nineteen.

Also breaks a require cycle this phase would have introduced:
teamSync -> teamVoiceSync -> teams.model -> teamSync left `teams.model` holding
the reconciler's exports object as it stood mid-load — the empty one, since
`module.exports = {…}` replaces rather than fills. The symptom is not in the
new code: it is `teamSync.intervalSeconds is not a function` thrown out of
`syncStatus()`, the freshness banner on every public Team page.

Tests: 1160 server (+40), 53 bot (+21), 284 client (+21). Swagger, routes
manifest and guards regenerated; the guard shape of the four new routes is
byte-identical to the existing admin-only ones.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 23:49:28 -05:00

107 lines
5.6 KiB
JavaScript

// /api/v1/admin — the admin surface, assembled from per-capability routers.
//
// This file owns exactly two things: the gate every admin route shares, and the
// mount table. No route is declared here. Each capability router mounts at the
// prefix it already owned inside the old monolithic admin.routes.js, so the
// emitted URL set is byte-identical — proved per PR by a zero-line diff in
// server/routes.manifest.json (`npm run routes:manifest`).
//
// The admin group is fully split as of PR 4: admin.routes.js is gone and every
// one of the 110 admin routes is declared in a capability router below.
//
// See docs/website/API_V2_PLAN.md § Phase 2 for the split.
const express = require('express')
const { isLoggedIn, requireRole } = require('../../../utils/auth')
const noindex = require('../../../middleware/noindex')
const accountRouter = require('./account.router')
const usersRouter = require('./users.router')
const invitesRouter = require('./invites.router')
const authProvidersRouter = require('./authProviders.router')
const moderationRouter = require('./moderation.router')
const botActivityRouter = require('./botActivity.router')
const activityRouter = require('./activity.router')
const postsRouter = require('./posts.router')
const uploadsRouter = require('./uploads.router')
const wikiRouter = require('./wiki.router')
const pagesRouter = require('./pages.router')
const emailRouter = require('./email.router')
const discordBotRouter = require('./discordBot.router')
const settingsRouter = require('./settings.router')
const modulesRouter = require('./modules.router')
const teamsRouter = require('./teams.router')
const teamsVoiceRouter = require('./teamsVoice.router')
const dashboardRouter = require('./dashboard.router')
const adminRouter = express.Router()
// Every admin route requires auth, a STAFF role, and is kept out of search
// indexes. The staff gate matters now that `player` is a logged-in-but-untrusted
// role: without it, the editor-tier routes below (dashboard, posts, wiki,
// uploads) that are only guarded by isLoggedIn would be reachable by players.
// Players get 403 here and use the self-scoped /player group instead.
//
// It lives here, ahead of every mount, so a capability router extracted in a
// later PR cannot silently ship without it.
const staffOnly = requireRole('admin', 'editor', 'moderator')
adminRouter.use(noindex, isLoggedIn, staffOnly)
adminRouter.use('/account', accountRouter)
adminRouter.use('/users', usersRouter)
adminRouter.use('/invites', invitesRouter)
// Mounted at /auth, not /auth/providers: /admin/auth is the capability, and the
// routes inside read as /providers[/:id].
adminRouter.use('/auth', authProvidersRouter)
// /moderation carries its own moderator gate; /bot-activity is admin-only per
// route. /activity is staff-wide — the audit log, not the bot-scoring state.
adminRouter.use('/moderation', moderationRouter)
adminRouter.use('/bot-activity', botActivityRouter)
adminRouter.use('/activity', activityRouter)
// Content, all editor-tier (no gate beyond staffOnly above). /uploads is the
// rich-text editors' generalized upload; /posts owns its own /posts/upload.
adminRouter.use('/posts', postsRouter)
adminRouter.use('/uploads', uploadsRouter)
adminRouter.use('/wiki', wikiRouter)
adminRouter.use('/pages', pagesRouter)
// Ops and configuration.
//
// `/shard` and `/uo-link` are absent here and are still served: they are
// module-uo's, mounted onto this same router by the loader after every core
// mount above (MODULE_API.md §2.4). The URLs did not move — the code did. That
// ordering is also what makes the prefixes unclaimable by anyone else: the
// loader asks this live router what core owns, so a second module claiming
// `/shard` is rejected against the mounts actually present, not against a list.
adminRouter.use('/email', emailRouter)
adminRouter.use('/discord-bot', discordBotRouter)
adminRouter.use('/settings', settingsRouter)
// The Modules screen (Phase 4). Core's, not a module's — and it has to be
// core's: it is how a module gets onto the volume in the first place. Mounted
// here alongside the other configuration capabilities, and admin-only per route
// rather than at this line, so the gate sits next to what it is guarding.
adminRouter.use('/modules', modulesRouter)
// Teams. Staff-wide, like /activity: a moderator runs the reserved-name review
// queue. The three actions that PUBLISH untrusted game-sourced strings are gated
// per request inside the controller, not per route — a moderator may call them,
// and calling them files a request rather than applying one (TEAMS.md §2.9).
// Voice channels (TEAMS.md §7.3, phase 9) are mounted at the more specific prefix
// FIRST, so /teams/voice/* never reaches the teams router's `/:id`.
//
// They live out here rather than inside `teams.router.js` beside the bridge they
// belong with, for a mechanical reason worth recording: that file sits exactly at
// swagger-autogen's per-file limit. At twenty `teamsRouter.*` statements
// `npm run swagger` dies with "invalid array length — heap out of memory"; at
// nineteen it generates. One more statement of any shape tips it, a mount
// included, so the mount is here and the file keeps its nineteen.
adminRouter.use('/teams/voice', teamsVoiceRouter)
adminRouter.use('/teams', teamsRouter)
// The two singletons that own no path segment of their own: GET /dashboard and
// PUT /site-mode. Mounted at the group root, last, exactly where the residual
// admin.routes.js used to sit — safe because dashboard.router.js declares no
// router-level middleware, only its two routes.
adminRouter.use('/', dashboardRouter)
module.exports = adminRouter