feat(teams): the grant flow, announcements, and the routes behind both guards
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>
This commit is contained in:
@@ -92,6 +92,37 @@ teamsRouter.post(
|
||||
|
||||
// ── :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']
|
||||
@@ -119,6 +150,20 @@ teamsRouter.get(
|
||||
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']
|
||||
|
||||
Reference in New Issue
Block a user