Files
website/server/src/model/teams/teamGrants.model.js
Claude cecd72915f feat(teams): the slash-command seam, and the first command through it
Phase 7 of TEAMS.md. `api.registerSlashCommands` stops throwing: a module
registers a command's DEFINITION and its HANDLER together, the bot pulls the
definitions over the internal listener and runs none of our code, and the
handler executes here — forced by the bot container having no `modules` volume,
and the right boundary anyway.

Registration validates what Discord would reject as a batch (names, description
lengths, the four option types, required-before-optional), because the bot
registers the whole set in one PUT and a single bad entry costs every command
including the bot's own. Commands are not namespaced under their owner — there
is no dot in Discord's name grammar — so collisions are first-come with the
holder named.

The dispatcher is the access boundary: `linked` has no Discord equivalent, so
the platform-side permission default can only ever be advertising. It resolves
the actor by `auth_providers.kind` rather than the id slug, treats a banned
account as unlinked, bounds a handler under the bot's own timeout, and keeps
`ok` outside the envelope so a handler cannot forge it.

Liveness is asked at both the pull and the dispatch. The registries have no
removal path, so a module an operator disables at runtime would otherwise keep
a live handler behind a command Discord still advertises.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 18:53:34 -05:00

170 lines
6.5 KiB
JavaScript

// ── 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,
}