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:
2026-08-18 07:23:47 -05:00
parent fb70013adf
commit e27c368234
9 changed files with 1242 additions and 0 deletions

View File

@@ -41,6 +41,49 @@ async function activeGrants(teamId) {
)
}
/** How many active grants a team currently holds — the §2.5 per-Team cap reads this. */
async function activeGrantCount(teamId) {
const rows = await query(
'SELECT COUNT(*) AS n FROM team_forum_grants WHERE team_id = ? AND revoked_at IS NULL',
[teamId],
)
return Number(rows[0]?.n || 0)
}
/**
* Issue a grant.
*
* Writes nothing but this table — that is the non-contamination invariant, and it
* is a property of this function being the ONLY writer on the grant path rather
* than of anyone remembering it at the call site. The username snapshots are
* taken here so the ledger still reads after either account is deleted (§2.10).
*/
async function insertGrant({ teamId, userId, username, grantedBy, grantedUsername, reason }) {
const res = await query(
`INSERT INTO team_forum_grants (team_id, user_id, username, granted_by, granted_username, reason)
VALUES (?, ?, ?, ?, ?, ?)`,
[teamId, userId, username, grantedBy, grantedUsername, reason ?? null],
)
return res.insertId
}
/**
* Revoke the active grant, if there is one.
*
* An UPDATE of the existing row rather than a delete: the table is a ledger as
* well as the current state, and `revoked_at` is what moves a row out of the
* unique key (the generated `active_marker` goes NULL) while keeping the history.
*/
async function revokeGrant({ teamId, userId, revokedBy, revokedUsername, reason }) {
const res = await query(
`UPDATE team_forum_grants
SET revoked_at = NOW(), revoked_by = ?, revoked_username = ?, revoke_reason = ?
WHERE team_id = ? AND user_id = ? AND revoked_at IS NULL`,
[revokedBy, revokedUsername, reason ?? null, teamId, userId],
)
return res.affectedRows > 0
}
// ── team_leader_overrides (§2.5.1) ─────────────────────────────────────────
const OVERRIDE_COLUMNS = 'team_id, member_key, effect, actor_user_id, actor_username, reason, created_at'
@@ -87,6 +130,9 @@ module.exports = {
activeGrant,
grantLedger,
activeGrants,
activeGrantCount,
insertGrant,
revokeGrant,
overridesForTeam,
overrideFor,
setOverride,