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>
170 lines
6.5 KiB
JavaScript
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,
|
|
}
|