The four authority paths of docs/website/TEAMS.md §2.5, and the rule that they stay four: 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 + override 3. May it use the Team forum? CORE team_forum_grants OR path 1 4. May it get external access? CORE derived, nothing of its own The temptation this resists 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 a forum must not require a staff ticket. Reading "has forum access" as "is a member" would put that person on the public roster, into every membership count, and into the external-platform grant, which is where a modelling preference becomes an impersonation risk. Path 4 is deliberately blind to path 3, and the reason is written down so nobody "fixes" it: 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. Leadership overrides are applied ON TOP of the synced value at read time, never written into the projection. The sync owns that column and rewrites it every interval, so an override stored there would be undone fifteen minutes after staff set it -- which is the whole reason §2.5.1 is a separate table. The roster carries both the resolved answer and `is_leader_synced`, so an admin sees that a decision was made rather than being shown it as fact. Three tests are named INVARIANT rather than for behaviour, because what they protect is structural and a reasonable-looking refactor destroys it silently: a grant never writes the membership projection, a granted user is absent from the roster, and a grant does not confer external eligibility. None of those failures appears on a screen as a bug -- the first shows up as a stranger on a public roster, the second as a Discord role handed to an account nobody can tie to a real player. Every unit test here stubs the db layer, so the SQL itself was verified separately: all 44 statements across teams.db.js and teamAccess.db.js were run against MariaDB 11 with a throwaway module id and cleaned up after. That run also confirmed live what the reconciler's tests could only assert against a stub -- an upsert does not overwrite is_leader, a revoked grant frees the unique key for a new one while the ledger keeps both, and an archived team stays resolvable at its old slug while its external_id is free for the successor row. 19 tests. Full suite 828 passed, 0 failed. Refs docs/website/TEAMS.md §2.5, §2.5.1, §2.6, Part 12 phase 2 Co-Authored-By: Claude <noreply@anthropic.com>
132 lines
5.6 KiB
JavaScript
132 lines
5.6 KiB
JavaScript
// ── 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,
|
|
}
|