// ── The grant/revoke flow (TEAMS.md §2.5 path 3) ─────────────────────────── // // The RESOLVER lives in teamAccess.model.js and answers "may this account use the // forum". This file is the WRITE half: who may hand that access out, to whom, and // what stops a leader turning a Team forum into open hosting on the operator's // site. // // **Two authorities, and they are not the same authority with different reach.** // // staff (admin | moderator) — any Team, no cap, may revoke anything // leader (path 2, on THIS Team) — own Team, capped, may not revoke a staff grant // // The last clause is the one worth stating: a leader who could revoke a // staff-issued grant could undo a moderation decision, which is the whole reason // `granted_by` is retained rather than collapsed into a boolean. // // **Nothing here writes `team_members`, in either direction, ever.** A grant is // not a membership: it may name any Runic Gateway account, including one with no // linked game identity at all — that is the point of it, since letting an unlinked // guildmate into the forum must not be a staff ticket. `teams.model.js` keeps such // an account off the roster and out of every membership count, and path 4 keeps it // off external platforms. const accessDb = require('./teamAccess.db') const teamsDb = require('./teams.db') const access = require('./teamAccess.model') const usersDb = require('../users/users.db') const settingsDb = require('../settings/settings.db') // The per-Team ceiling on ACTIVE leader-issued grants. A leader admitting // unlimited arbitrary accounts to a private space on the operator's host is a // quiet way to turn a Team forum into free hosting; the cap is what makes it a // decision the operator made rather than one a leader made for them. const CAP_KEY = 'teams_max_grants_per_team' const DEFAULT_CAP = 50 const STAFF_ROLES = ['admin', 'moderator'] async function grantCap() { const raw = await settingsDb.get(CAP_KEY) const n = Number.parseInt(raw, 10) return Number.isFinite(n) && n > 0 ? n : DEFAULT_CAP } const isStaff = (actor) => STAFF_ROLES.includes(actor?.role) /** * What may this actor do with grants on this Team? * * Resolved once and returned whole, so the controller asks a question rather than * assembling the answer from three booleans — the shape that lets a leader check * and a staff check drift apart. */ async function authorityFor(teamId, actor) { if (isStaff(actor)) return { may: true, as: 'staff' } const leads = await access.isLeaderByUser(teamId, actor?.id) return { may: leads, as: leads ? 'leader' : null } } /** * Issue a grant. Returns the model result shape the Teams controllers translate: * `{ ok }` or `{ ok: false, status, error }`. * * `warning` on a staff grant past the cap is deliberate and is not an error: * staff are exempt, and silently exceeding a ceiling the operator configured is * worth saying out loud on the way past. */ async function grant({ team, actor, userId, username, reason }) { const authority = await authorityFor(team.id, actor) if (!authority.may) return { ok: false, status: 403, error: 'Not a leader of this Team' } const target = userId ? await usersDb.findById(userId) : await usersDb.findByUsername(username) if (!target) return { ok: false, status: 404, error: 'No such account' } const existing = await accessDb.activeGrant(team.id, target.id) if (existing) return { ok: false, status: 409, error: 'That account already has an active grant' } const cap = await grantCap() const count = await accessDb.activeGrantCount(team.id) let warning = null if (count >= cap) { if (authority.as === 'leader') { return { ok: false, status: 409, error: `This Team has reached its limit of ${cap} forum guests` } } warning = `This Team is past the configured limit of ${cap} forum guests` } await accessDb.insertGrant({ teamId: team.id, userId: target.id, username: target.username, grantedBy: actor.id, grantedUsername: actor.username, reason, }) return { ok: true, as: authority.as, grantee: target.username, ...(warning ? { warning } : {}) } } /** * Revoke a grant. * * The one asymmetry with `grant`: a leader may not revoke what staff issued. * Checked against `granted_by`'s role AT REVOKE TIME rather than against a stored * flag, so an account that has since lost its staff role stops protecting the * grants it made — which is the behaviour an operator demoting someone expects. */ async function revoke({ team, actor, userId, reason }) { const authority = await authorityFor(team.id, actor) if (!authority.may) return { ok: false, status: 403, error: 'Not a leader of this Team' } const existing = await accessDb.activeGrant(team.id, userId) if (!existing) return { ok: false, status: 404, error: 'No active grant for that account' } if (authority.as === 'leader' && existing.granted_by) { const issuer = await usersDb.findById(existing.granted_by) if (isStaff(issuer)) { return { ok: false, status: 403, error: 'That access was granted by staff and only staff may revoke it' } } } await accessDb.revokeGrant({ teamId: team.id, userId, revokedBy: actor.id, revokedUsername: actor.username, reason, }) return { ok: true, as: authority.as, grantee: existing.username } } /** * The Team's forum guests — active grants for accounts that are NOT members. * * The subtraction is the §3.2 "Forum guests" list: someone who is both a member * and a grantee is a member, listed on the roster, and appears here not at all. * Both facts stay true in the ledger; only the presentation picks one. */ async function forumGuests(teamId) { const [grants, members] = await Promise.all([ accessDb.activeGrants(teamId), teamsDb.membersByTeam(teamId, { includeDeparted: false }), ]) const memberUserIds = new Set(members.map((m) => m.user_id).filter((id) => id != null)) return grants .filter((g) => g.user_id == null || !memberUserIds.has(g.user_id)) .map((g) => ({ userId: g.user_id, username: g.username, grantedBy: g.granted_username, grantedAt: g.granted_at, reason: g.reason, })) } module.exports = { CAP_KEY, DEFAULT_CAP, // Exported since phase 7: the Discord dispatcher's `access: 'staff'` has to // mean the same two roles every other Team surface means by it, and a second // copy of the list is a copy that drifts. STAFF_ROLES, grantCap, authorityFor, grant, revoke, forumGuests, }