// 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 { requireRole } = require('../../../utils/auth') const teamsRouter = express.Router() // The one ADMIN-only corner of a staff-wide router (§7.2, phase 8). Configuring // where a Team's events leave the site for is not the §2.9 kind of decision a // moderator files a request for; it is deployment configuration, and it sits with // the role that already holds the bot token. // Both of these are hoisted rather than written inline, and both have to stay // that way. **Nothing that reaches `.test(` may sit inside a route statement**: // swagger-autogen's static parser runs away on it and `npm run swagger` dies with // "invalid array length - Allocation failed", generating no spec at all. // // Two rounds of that. Phase 8 shipped the regex as a bare LITERAL before `.test(` // and phase 9 hoisted it — but left the PREDICATE inline, which is the same // runaway even with the regex behind a const, and the generator stayed broken. // Bisected to exactly the `param('teamId').custom(...)` statement below: a // three-route file carrying only it dies, so this is not about how much is in the // file. // // That last point corrects what this file used to say. Phase 9 read the symptom // as a per-file ROUTE LIMIT, measured it at twenty `teamsRouter.*` statements, // split `teamsVoice.router.js` out and left this file sitting at nineteen "one // under the edge". There is no such edge to sit under — the count was a proxy for // how much text the parser chewed before hitting the real trigger. const TEAM_ID = /^[0-9]+$/ const isTeamIdOrDefault = (v) => v === 'default' || TEAM_ID.test(v) const adminOnly = requireRole('admin') // ── 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, ) // ── The integration bridge (§7.2) — literal, and before /:id ────────────── teamsRouter.get( '/integrations', // #swagger.tags = ['Admin · Teams'] // #swagger.summary = 'The Team notification bridge’s configuration (admin only)' // #swagger.description = 'Every configured destination for the platform, the deployment-wide default first, alongside the events that may be bridged and which of them are members-only. A members-only event carries content nobody outside the Team may read, so enabling one requires an acknowledgement that the destination channel is restricted to that Team’s members — recorded here with who gave it.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.responses[200] = { description: 'Bridge configuration', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamIntegrationConfig" } } } } */ /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ adminOnly, ctrl.integrationConfig, ) teamsRouter.put( '/integrations', // #swagger.tags = ['Admin · Teams'] // #swagger.summary = 'Create or replace one bridge destination (admin only)' // #swagger.description = 'Omit teamId (or send null) to edit the deployment-wide default; a per-Team row overrides it. Enabling a bridge that carries team.forum.post or team.announcement without membersAck is refused 422 — the events are members-only always, and core cannot see a Discord channel’s permissions, so the operator’s acknowledgement is the only thing that can stand in for the check. Changing the channel clears a previous acknowledgement: it was given for a destination, not for a row.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.responses[200] = { description: 'The saved row', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamIntegrationRow" } } } } */ /* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[422] = { description: 'Not enableable — no channel, no events, or a members-only event without the acknowledgement', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ adminOnly, body('teamId').optional({ nullable: true }).isInt({ min: 1 }).toInt(), body('events').isArray({ max: 8 }), body('channelRef').optional({ nullable: true }).isString().trim().isLength({ max: 64 }), body('enabled').optional().isBoolean().toBoolean(), body('membersAck').optional().isBoolean().toBoolean(), validate, ctrl.saveIntegrationConfig, ) teamsRouter.delete( '/integrations/:teamId', // #swagger.tags = ['Admin · Teams'] // #swagger.summary = 'Remove one bridge destination (admin only)' // #swagger.description = 'Pass the literal string default to remove the deployment-wide row. Removing a per-Team override makes that Team fall back to the default, which is not the same as disabling it — disable the row instead if that is what is wanted.' // #swagger.parameters['teamId'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Team id, or the literal string default.' } // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.responses[200] = { description: 'Removed', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */ /* #swagger.responses[404] = { description: 'Nothing configured for that Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ adminOnly, param('teamId').custom(isTeamIdOrDefault), validate, ctrl.deleteIntegrationConfig, ) 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