Path 3's WRITE half. The resolver landed in phase 2; this is who may hand access
out, to whom, and what stops a leader turning a Team forum into open hosting on
the operator's site.
Two authorities, and not one authority with different reach. Staff may act on any
Team, uncapped, and may revoke anything. A leader may grant and revoke ordinary
access on their own Team, is capped at `teams_max_grants_per_team` (default 50),
is rate-limited, and may NOT revoke a staff-issued grant — which is what stops a
leader undoing a moderation decision. The issuer's role is checked at revoke time
rather than stored, so an account that has since lost its staff role stops
protecting the grants it made.
Nothing on this path writes team_members, in either direction. A grant may name any
account, including one with no linked game identity — that is the point of it — and
that account stays off the roster, out of every count, and ineligible for external
platforms.
Announcements are a degenerate thread rather than their own object, so phase 5 adds
no migration. Moderation records WHICH authority was exercised: a staff action also
writes activity_log, a leader's writes only the Team's own ledger. Merging the two
would make a guild leader locking a thread an appealable Discord sanction.
Every forum route answers 404 while the switch is off, and 404 — never 403 — to a
caller with no access: in a private room the contents and the existence are the
same secret. The grant routes deliberately answer even while the forum is OFF,
because a toggle-off revokes no grant and the access list has to stay manageable.
Under /player rather than /admin: a leader is a player, and the /admin tier gate is
requireRole('admin','editor','moderator') — putting a leader endpoint behind it
would mean widening that gate.
Co-Authored-By: Claude <noreply@anthropic.com>
269 lines
19 KiB
JavaScript
269 lines
19 KiB
JavaScript
// Admin · Teams — sync state, the review queue, the approval queue, and the staff
|
||
// actions on a Team (TEAMS.md §2.11).
|
||
//
|
||
// Mounted at /api/v1/admin/teams by admin/index.js, which already applied
|
||
// `noindex, isLoggedIn, staffOnly`. Staff-wide, like /admin/activity: a moderator
|
||
// runs the review queue, and the three actions that PUBLISH untrusted
|
||
// game-sourced strings are gated per request inside the controller rather than
|
||
// per route here — a moderator may call them, and calling them files a request
|
||
// instead of applying one.
|
||
//
|
||
// **Declaration order matters in this file.** `/review`, `/requests` and `/resync`
|
||
// are literal paths that would otherwise be captured by `/:id`, so every literal
|
||
// route is declared before the first :param route. Express is first-match-wins and
|
||
// a `/:id` ahead of `/review` would silently turn a queue into a lookup for a Team
|
||
// whose id is "review".
|
||
|
||
const express = require('express')
|
||
const { body, param, query } = require('express-validator')
|
||
|
||
const ctrl = require('./teams.controller')
|
||
const validate = require('../../../middleware/validate')
|
||
|
||
const teamsRouter = express.Router()
|
||
|
||
// ── Literal paths, first ───────────────────────────────────────────────────
|
||
|
||
teamsRouter.get(
|
||
'/',
|
||
// #swagger.tags = ['Admin · Teams']
|
||
// #swagger.summary = 'List Teams with sync state'
|
||
// #swagger.description = 'Includes hidden Teams and the module’s sync state verbatim — last attempt, last success, consecutive failures and the last error — which is what an operator debugging a stale projection needs.'
|
||
// #swagger.parameters['archived'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Set to 1 to include archived Teams.' }
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'Teams and sync state', content: { "application/json": { schema: { $ref: "#/components/schemas/AdminTeamList" } } } } */
|
||
query('archived').optional().isIn(['0', '1']),
|
||
validate,
|
||
ctrl.listTeams,
|
||
)
|
||
|
||
teamsRouter.post(
|
||
'/resync',
|
||
// #swagger.tags = ['Admin · Teams']
|
||
// #swagger.summary = 'Run a reconciliation now'
|
||
// #swagger.description = 'Awaited, so the response carries the outcome including the provider’s own error when it refused. The four refusal gates still apply — a manual resync cannot make core act on an answer it does not trust.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'The reconciliation result', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamResyncResult" } } } } */
|
||
ctrl.resync,
|
||
)
|
||
|
||
teamsRouter.get(
|
||
'/review',
|
||
// #swagger.tags = ['Admin · Teams']
|
||
// #swagger.summary = 'The reserved-name review queue'
|
||
// #swagger.description = 'Teams auto-hidden because their name matched a reserved term, each showing which term matched. A Team a human has already ruled on leaves the queue and is never re-hidden by a later sweep.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'Auto-hidden Teams awaiting review', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamReviewQueue" } } } } */
|
||
ctrl.reviewQueue,
|
||
)
|
||
|
||
teamsRouter.get(
|
||
'/requests',
|
||
// #swagger.tags = ['Admin · Teams']
|
||
// #swagger.summary = 'The moderation approval queue'
|
||
// #swagger.description = 'Requests filed by moderators for the three actions that publish untrusted game-sourced strings. Decided rows are kept — the record that a moderator asked to publish a name and an admin refused is the part worth having.'
|
||
// #swagger.parameters['status'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'pending (default) | approved | rejected | withdrawn | all' }
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'Moderation requests', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamRequestQueue" } } } } */
|
||
query('status').optional().isIn(['pending', 'approved', 'rejected', 'withdrawn', 'all']),
|
||
validate,
|
||
ctrl.listRequests,
|
||
)
|
||
|
||
teamsRouter.post(
|
||
'/requests/:id/decide',
|
||
// #swagger.tags = ['Admin · Teams']
|
||
// #swagger.summary = 'Approve or reject a moderation request (admin only)'
|
||
// #swagger.description = 'Admin only, checked live against the database rather than from a token claim. Approving applies the action; rejecting keeps the row and changes nothing. A request already decided returns 409, so two admins deciding at once cannot double-apply.'
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Request id.' }
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamDecideRequest" } } } } */
|
||
/* #swagger.responses[200] = { description: 'Decided', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
|
||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||
/* #swagger.responses[403] = { description: 'Only an admin may decide a request', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[404] = { description: 'No such request', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[409] = { description: 'Already decided', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('id').isInt({ min: 1 }).toInt(),
|
||
body('status').isIn(['approved', 'rejected']),
|
||
body('note').optional().isString().trim().isLength({ max: 255 }),
|
||
validate,
|
||
ctrl.decideRequest,
|
||
)
|
||
|
||
// ── :id paths ──────────────────────────────────────────────────────────────
|
||
|
||
// Both literal, and both under '/forum' rather than '/:id/forum', so they cannot
|
||
// be captured by the '/:id' lookup below — 'forum' is not an integer, but relying
|
||
// on the validator to reject it would mean the route table's meaning depended on
|
||
// a param check three lines further down.
|
||
teamsRouter.get(
|
||
'/forum/uploads',
|
||
// #swagger.tags = ['Admin · Teams']
|
||
// #swagger.summary = 'Upload attribution across every Team forum'
|
||
// #swagger.description = 'Who uploaded what, when and how much. This view is why an attribution table exists at all: the liability an operator accepts before enabling uploads is meaningless if "who uploaded this" cannot be answered afterwards. Deleted rows are excluded unless `deleted=1` — a soft-deleted upload still has bytes on disk until the sweep runs.'
|
||
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size (default 100).' }
|
||
// #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' }
|
||
// #swagger.parameters['deleted'] = { in: 'query', required: false, schema: { type: 'string', enum: ['0','1'] }, description: 'Include soft-deleted uploads.' }
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'Uploads with their attribution', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumUploadList" } } } } */
|
||
query('limit').optional().isInt({ min: 1, max: 500 }).toInt(),
|
||
query('offset').optional().isInt({ min: 0 }).toInt(),
|
||
query('deleted').optional().isIn(['0', '1']),
|
||
validate,
|
||
ctrl.forumUploads,
|
||
)
|
||
|
||
teamsRouter.get(
|
||
'/forum/settings',
|
||
// #swagger.tags = ['Admin · Teams']
|
||
// #swagger.summary = 'The forum switch, the image policy, and the acknowledgement’s state'
|
||
// #swagger.description = 'The two settings themselves ride the ordinary admin settings endpoint and are published to every client; this route adds the one thing that is NOT public — whether the uploads acknowledgement has been given, by whom, and whether the notice has been reworded since. A stale acknowledgement does not disable uploads: it raises a banner and freezes every other forum setting until it is re-given.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'Forum settings state', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumSettingsState" } } } } */
|
||
ctrl.forumSettingsState,
|
||
)
|
||
|
||
teamsRouter.get(
|
||
'/:id',
|
||
// #swagger.tags = ['Admin · Teams']
|
||
// #swagger.summary = 'Get one Team, with its roster, grant ledger and pending requests'
|
||
// #swagger.description = 'The roster carries the resolved leadership and what the game actually said, so an override is visible as a decision rather than presented as fact. Departed members are included.'
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'The Team', content: { "application/json": { schema: { $ref: "#/components/schemas/AdminTeam" } } } } */
|
||
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('id').isInt({ min: 1 }).toInt(),
|
||
validate,
|
||
ctrl.getTeam,
|
||
)
|
||
|
||
teamsRouter.get(
|
||
'/:id/grants',
|
||
// #swagger.tags = ['Admin · Teams']
|
||
// #swagger.summary = 'The full forum-grant ledger for a Team, revoked rows included'
|
||
// #swagger.description = 'The structured record the access resolver reads. The grant/revoke flow itself lands in the forum phase; this is the read side.'
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'The grant ledger', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamGrantLedger" } } } } */
|
||
param('id').isInt({ min: 1 }).toInt(),
|
||
validate,
|
||
ctrl.grants,
|
||
)
|
||
|
||
teamsRouter.get(
|
||
'/:id/forum/moderation',
|
||
// #swagger.tags = ['Admin · Teams']
|
||
// #swagger.summary = 'A Team’s forum moderation ledger'
|
||
// #swagger.description = 'Append-only, and deliberately separate from the site’s mod_actions/appeals pair (§5.3): that one is Discord-sanction-shaped and bot-owned, and routing a guild leader locking a thread through it would make ordinary housekeeping an appealable sanction. `actorRole` records which authority was exercised — a leader’s action appears only here, a staffer’s appears here AND in activity_log. Answers whether or not the forum is switched on.'
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The Team id.' }
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'The ledger, newest first', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumModerationLedger" } } } } */
|
||
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('id').isInt({ min: 1 }).toInt(),
|
||
validate,
|
||
ctrl.forumModeration,
|
||
)
|
||
|
||
teamsRouter.post(
|
||
'/:id/archive',
|
||
// #swagger.tags = ['Admin · Teams']
|
||
// #swagger.summary = 'Archive a Team (staff)'
|
||
// #swagger.description = 'Not gated: archiving withdraws a Team from public surfaces rather than publishing anything.'
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamReasonRequest" } } } } */
|
||
/* #swagger.responses[200] = { description: 'Archived', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
|
||
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('id').isInt({ min: 1 }).toInt(),
|
||
body('reason').optional().isString().trim().isLength({ max: 255 }),
|
||
validate,
|
||
ctrl.archive,
|
||
)
|
||
|
||
teamsRouter.post(
|
||
'/:id/hide',
|
||
// #swagger.tags = ['Admin · Teams']
|
||
// #swagger.summary = 'Hide a Team from public surfaces (staff)'
|
||
// #swagger.description = 'Deliberately NOT gated. Publishing untrusted data needs a second pair of eyes; withdrawing it needs to be possible at once, by whoever is on duty.'
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamReasonRequest" } } } } */
|
||
/* #swagger.responses[200] = { description: 'Hidden', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
|
||
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('id').isInt({ min: 1 }).toInt(),
|
||
body('reason').optional().isString().trim().isLength({ max: 255 }),
|
||
validate,
|
||
ctrl.hide,
|
||
)
|
||
|
||
teamsRouter.post(
|
||
'/:id/unhide',
|
||
// #swagger.tags = ['Admin · Teams']
|
||
// #swagger.summary = 'Un-hide a Team — admin applies, moderator requests'
|
||
// #swagger.description = 'One of the three gated actions: it publishes a name that tripped the impersonation list. An admin applies it at once; a moderator files a pending request and nothing changes publicly until an admin approves.'
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamReasonRequest" } } } } */
|
||
/* #swagger.responses[200] = { description: 'Applied, or filed for approval — see `pending`', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamModerationResult" } } } } */
|
||
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('id').isInt({ min: 1 }).toInt(),
|
||
body('reason').optional().isString().trim().isLength({ max: 255 }),
|
||
validate,
|
||
ctrl.unhide,
|
||
)
|
||
|
||
teamsRouter.post(
|
||
'/:id/display-name',
|
||
// #swagger.tags = ['Admin · Teams']
|
||
// #swagger.summary = 'Set or clear a Team’s display name — admin applies, moderator requests'
|
||
// #swagger.description = 'Gated for the same reason as un-hiding: it substitutes free text into the same public surfaces. Identity is untouched — the Team’s `name` stays frozen for the life of the row, and only what is rendered changes. An empty displayName clears the override.'
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamDisplayNameRequest" } } } } */
|
||
/* #swagger.responses[200] = { description: 'Applied, or filed for approval — see `pending`', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamModerationResult" } } } } */
|
||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('id').isInt({ min: 1 }).toInt(),
|
||
body('displayName').optional({ nullable: true }).isString().trim().isLength({ max: 160 }),
|
||
body('reason').optional().isString().trim().isLength({ max: 255 }),
|
||
validate,
|
||
ctrl.displayName,
|
||
)
|
||
|
||
teamsRouter.post(
|
||
'/:id/leader-override',
|
||
// #swagger.tags = ['Admin · Teams']
|
||
// #swagger.summary = 'Grant or deny leadership for one member (staff)'
|
||
// #swagger.description = 'Applied on top of the synced value at READ time; the projection is never mutated. That is what makes an override survive a resync — one written into team_members would be undone by the next reconciliation. Not gated: it publishes no game-sourced string.'
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamLeaderOverrideRequest" } } } } */
|
||
/* #swagger.responses[200] = { description: 'Override set', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
|
||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('id').isInt({ min: 1 }).toInt(),
|
||
body('memberKey').isString().trim().isLength({ min: 1, max: 191 }),
|
||
body('effect').isIn(['grant', 'deny']),
|
||
body('reason').optional().isString().trim().isLength({ max: 255 }),
|
||
validate,
|
||
ctrl.setLeaderOverride,
|
||
)
|
||
|
||
teamsRouter.delete(
|
||
'/:id/leader-override/:memberKey',
|
||
// #swagger.tags = ['Admin · Teams']
|
||
// #swagger.summary = 'Clear a leadership override (staff)'
|
||
// #swagger.description = 'The member reverts to whatever the game says at the next read; nothing in the projection changes, because nothing in it was ever changed.'
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
|
||
// #swagger.parameters['memberKey'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The module’s member key.' }
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'Override cleared', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
|
||
/* #swagger.responses[404] = { description: 'No such override', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('id').isInt({ min: 1 }).toInt(),
|
||
param('memberKey').isString().trim().isLength({ min: 1, max: 191 }),
|
||
validate,
|
||
ctrl.clearLeaderOverride,
|
||
)
|
||
|
||
module.exports = teamsRouter
|