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>
141 lines
5.2 KiB
JavaScript
141 lines
5.2 KiB
JavaScript
// SQL for the two tables the access resolver reads: forum grants (path 3) and
|
|
// staff leadership overrides (§2.5.1).
|
|
//
|
|
// Kept separate from teams.db.js on purpose. The four authority paths are four
|
|
// tables answering four questions, and the single most important structural rule
|
|
// in TEAMS.md is that no resolver reads another path's table — a file boundary is
|
|
// a cheap way to make crossing one visible in a diff.
|
|
|
|
const { query } = require('../../utils/db')
|
|
|
|
// ── team_forum_grants (path 3) ─────────────────────────────────────────────
|
|
|
|
const GRANT_COLUMNS = `
|
|
id, team_id, user_id, username, granted_by, granted_username, granted_at, reason,
|
|
revoked_by, revoked_username, revoked_at, revoke_reason`
|
|
|
|
/** The caller's ACTIVE grant on a team, or undefined. At most one, by the unique key. */
|
|
async function activeGrant(teamId, userId) {
|
|
const rows = await query(
|
|
`SELECT ${GRANT_COLUMNS} FROM team_forum_grants
|
|
WHERE team_id = ? AND user_id = ? AND revoked_at IS NULL`,
|
|
[teamId, userId],
|
|
)
|
|
return rows[0]
|
|
}
|
|
|
|
/** The whole ledger for a team, revoked rows included — the admin grant view. */
|
|
async function grantLedger(teamId) {
|
|
return query(
|
|
`SELECT ${GRANT_COLUMNS} FROM team_forum_grants WHERE team_id = ? ORDER BY granted_at DESC, id DESC`,
|
|
[teamId],
|
|
)
|
|
}
|
|
|
|
/** Active grants only, for the "Forum guests" list and the per-team cap. */
|
|
async function activeGrants(teamId) {
|
|
return query(
|
|
`SELECT ${GRANT_COLUMNS} FROM team_forum_grants WHERE team_id = ? AND revoked_at IS NULL
|
|
ORDER BY granted_at`,
|
|
[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'
|
|
|
|
async function overridesForTeam(teamId) {
|
|
return query(`SELECT ${OVERRIDE_COLUMNS} FROM team_leader_overrides WHERE team_id = ? ORDER BY member_key`,
|
|
[teamId])
|
|
}
|
|
|
|
async function overrideFor(teamId, memberKey) {
|
|
const rows = await query(
|
|
`SELECT ${OVERRIDE_COLUMNS} FROM team_leader_overrides WHERE team_id = ? AND member_key = ?`,
|
|
[teamId, memberKey],
|
|
)
|
|
return rows[0]
|
|
}
|
|
|
|
/**
|
|
* Set or replace one override.
|
|
*
|
|
* The projection is never touched by this — `team_members.is_leader` keeps saying
|
|
* what the game says and this keeps saying what staff decided, which is the entire
|
|
* point (§2.5.1). An override applied INTO the projection would be clobbered by
|
|
* the next sync, fifteen minutes later.
|
|
*/
|
|
async function setOverride({ teamId, memberKey, effect, actorUserId, actorUsername, reason }) {
|
|
await query(
|
|
`INSERT INTO team_leader_overrides (team_id, member_key, effect, actor_user_id, actor_username, reason)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
ON DUPLICATE KEY UPDATE
|
|
effect = VALUES(effect), actor_user_id = VALUES(actor_user_id),
|
|
actor_username = VALUES(actor_username), reason = VALUES(reason), created_at = NOW()`,
|
|
[teamId, memberKey, effect, actorUserId, actorUsername, reason],
|
|
)
|
|
}
|
|
|
|
async function clearOverride(teamId, memberKey) {
|
|
const res = await query('DELETE FROM team_leader_overrides WHERE team_id = ? AND member_key = ?',
|
|
[teamId, memberKey])
|
|
return res.affectedRows > 0
|
|
}
|
|
|
|
module.exports = {
|
|
activeGrant,
|
|
grantLedger,
|
|
activeGrants,
|
|
activeGrantCount,
|
|
insertGrant,
|
|
revokeGrant,
|
|
overridesForTeam,
|
|
overrideFor,
|
|
setOverride,
|
|
clearOverride,
|
|
}
|