// ── The four authority paths ─────────────────────────────────────────────── // // The single most important structural rule in TEAMS.md (§2.5): these are four // tables answering four questions, and **no resolver reads another path's table.** // // 1. Is this account a member? module team_members // 2. Does this account lead the Team? module team_members.is_leader, // plus a staff override // 3. May it use the Team forum? CORE team_forum_grants OR path 1 // 4. May it get external-platform CORE, nothing of its own // access? derived // // The temptation this file exists to resist is collapsing 1 and 3 into one // boolean. They answer different questions about different populations: a forum // grant may name any Runic Gateway account, including one with no game identity // at all — that is the point of it, since letting an unlinked guildmate into the // forum must not require a staff ticket. Treating "has forum access" as "is a // member" would put that person on the roster, in the member count, and into the // external-platform grant, which is where it stops being a modelling preference // and becomes an impersonation risk (path 4 below). // // Non-contamination is the invariant: a manual grant never writes the membership // projection, in either direction, ever. Both facts coexist and neither migrates // into the other. const accessDb = require('./teamAccess.db') const teamsDb = require('./teams.db') const identities = require('../userIdentities/userIdentities.model') /** * Path 3 — forum access. Two reads, OR'd, and nothing else. * * `viaGrant` is reported even when membership also holds, deliberately: both * facts are true, the UI presents membership as the current reason, and the grant * survives as audit history. Collapsing them into one boolean is what loses the * record of who let this person in and why. */ async function forumAccess(teamId, userId) { if (!userId) return { allowed: false, viaMembership: false, viaGrant: false, isLeader: false } const [grant, member] = await Promise.all([ accessDb.activeGrant(teamId, userId), // path 3's own table teamsDb.activeByUser(teamId, userId), // path 1 ]) return { allowed: Boolean(grant) || Boolean(member), viaMembership: Boolean(member), viaGrant: Boolean(grant), isLeader: member ? await isLeader(teamId, member) : false, } } /** * Path 2 — leadership, with the staff override applied ON TOP of the synced value * at read time (§2.5.1). * * Applied at read rather than written into the projection because the sync owns * that column and rewrites it every interval. An override that lived in * `team_members` would be undone fifteen minutes after staff set it, which is the * whole reason this is a separate table read here. */ async function isLeader(teamId, member) { if (!member) return false const override = await accessDb.overrideFor(teamId, member.member_key) if (override) return override.effect === 'grant' return Boolean(member.is_leader) } /** Leadership for a caller identified by user id rather than by a member row. */ async function isLeaderByUser(teamId, userId) { if (!userId) return false const member = await teamsDb.activeByUser(teamId, userId) return isLeader(teamId, member) } /** * Path 4 — external-platform eligibility. Computed, no table of its own, and * deliberately blind to path 3. * * The reason, stated so nobody "fixes" it later: an integration cannot verify * that an unlinked, forum-granted account corresponds to a real game member, so * it must not hand that account a privilege on a platform where impersonation has * consequences. A forum is a room on the operator's own site with a known * moderator; a Discord role is an identity claim in someone else's space. */ async function externalEligible(teamId, userId, platform) { if (!userId || !platform) return false const member = await teamsDb.activeByUser(teamId, userId) // path 1 ONLY if (!member || member.user_id == null) return false // must be a LINKED game member const linked = await identities.listForUser(userId) return linked.some((i) => i.provider === platform) } /** * A team's roster with overrides folded in, for the admin view and the Team page. * * The rows returned carry `is_leader` as RESOLVED — synced value plus override — * and `is_leader_synced` as what the game actually said, so the admin surface can * show that a decision was made rather than silently presenting it as fact. */ async function rosterWithOverrides(teamId, { includeDeparted = false } = {}) { const [members, overrides] = await Promise.all([ teamsDb.membersByTeam(teamId, { includeDeparted }), accessDb.overridesForTeam(teamId), ]) const byKey = new Map(overrides.map((o) => [o.member_key, o])) return members.map((m) => { const override = byKey.get(m.member_key) return { ...m, is_leader_synced: Boolean(m.is_leader), is_leader: override ? override.effect === 'grant' : Boolean(m.is_leader), leader_override: override ? { effect: override.effect, reason: override.reason, by: override.actor_username, at: override.created_at } : null, } }) } module.exports = { forumAccess, isLeader, isLeaderByUser, externalEligible, rosterWithOverrides, setLeaderOverride: accessDb.setOverride, clearLeaderOverride: accessDb.clearOverride, grantLedger: accessDb.grantLedger, activeGrants: accessDb.activeGrants, }