feat(teams): Team core — the reconciler, the four authority paths, and the impersonation controls #151

Merged
whitlocktech merged 6 commits from feat/teams-phase2-team-core into edge 2026-08-17 22:20:34 +00:00
3 changed files with 434 additions and 0 deletions
Showing only changes of commit bfd844e8fb - Show all commits

View File

@@ -0,0 +1,94 @@
// SQL for the two tables the access resolver reads: forum grants (path 3) and
// staff leadership overrides (§2.5.1).
//
// Kept separate from teams.db.js on purpose. The four authority paths are four
// tables answering four questions, and the single most important structural rule
// in TEAMS.md is that no resolver reads another path's table — a file boundary is
// a cheap way to make crossing one visible in a diff.
const { query } = require('../../utils/db')
// ── team_forum_grants (path 3) ─────────────────────────────────────────────
const GRANT_COLUMNS = `
id, team_id, user_id, username, granted_by, granted_username, granted_at, reason,
revoked_by, revoked_username, revoked_at, revoke_reason`
/** The caller's ACTIVE grant on a team, or undefined. At most one, by the unique key. */
async function activeGrant(teamId, userId) {
const rows = await query(
`SELECT ${GRANT_COLUMNS} FROM team_forum_grants
WHERE team_id = ? AND user_id = ? AND revoked_at IS NULL`,
[teamId, userId],
)
return rows[0]
}
/** The whole ledger for a team, revoked rows included — the admin grant view. */
async function grantLedger(teamId) {
return query(
`SELECT ${GRANT_COLUMNS} FROM team_forum_grants WHERE team_id = ? ORDER BY granted_at DESC, id DESC`,
[teamId],
)
}
/** Active grants only, for the "Forum guests" list and the per-team cap. */
async function activeGrants(teamId) {
return query(
`SELECT ${GRANT_COLUMNS} FROM team_forum_grants WHERE team_id = ? AND revoked_at IS NULL
ORDER BY granted_at`,
[teamId],
)
}
// ── team_leader_overrides (§2.5.1) ─────────────────────────────────────────
const OVERRIDE_COLUMNS = 'team_id, member_key, effect, actor_user_id, actor_username, reason, created_at'
async function overridesForTeam(teamId) {
return query(`SELECT ${OVERRIDE_COLUMNS} FROM team_leader_overrides WHERE team_id = ? ORDER BY member_key`,
[teamId])
}
async function overrideFor(teamId, memberKey) {
const rows = await query(
`SELECT ${OVERRIDE_COLUMNS} FROM team_leader_overrides WHERE team_id = ? AND member_key = ?`,
[teamId, memberKey],
)
return rows[0]
}
/**
* Set or replace one override.
*
* The projection is never touched by this — `team_members.is_leader` keeps saying
* what the game says and this keeps saying what staff decided, which is the entire
* point (§2.5.1). An override applied INTO the projection would be clobbered by
* the next sync, fifteen minutes later.
*/
async function setOverride({ teamId, memberKey, effect, actorUserId, actorUsername, reason }) {
await query(
`INSERT INTO team_leader_overrides (team_id, member_key, effect, actor_user_id, actor_username, reason)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
effect = VALUES(effect), actor_user_id = VALUES(actor_user_id),
actor_username = VALUES(actor_username), reason = VALUES(reason), created_at = NOW()`,
[teamId, memberKey, effect, actorUserId, actorUsername, reason],
)
}
async function clearOverride(teamId, memberKey) {
const res = await query('DELETE FROM team_leader_overrides WHERE team_id = ? AND member_key = ?',
[teamId, memberKey])
return res.affectedRows > 0
}
module.exports = {
activeGrant,
grantLedger,
activeGrants,
overridesForTeam,
overrideFor,
setOverride,
clearOverride,
}

View File

@@ -0,0 +1,131 @@
// ── 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,
}

View File

@@ -0,0 +1,209 @@
// The four authority paths, and the rule that they stay four
// (docs/website/TEAMS.md §2.5).
//
// Two of these tests are named for invariants rather than for behaviour, because
// what they protect is a structural property that a perfectly reasonable-looking
// refactor destroys: "has forum access" is never read as "is a member", and a
// grant never writes the membership projection. Both are one `||` away from being
// wrong, and neither failure is visible on any screen — 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.
const { test, beforeEach, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const accessDb = require('../src/model/teams/teamAccess.db')
const teamsDb = require('../src/model/teams/teams.db')
const identities = require('../src/model/userIdentities/userIdentities.model')
const access = require('../src/model/teams/teamAccess.model')
const saved = []
function patch(mod, name, fn) {
saved.push([mod, name, mod[name]])
mod[name] = fn
}
// Every read the four paths can make, stubbed to "nothing there". Each test then
// states only the fact it is about, which is what makes a crossed path obvious:
// a resolver reading a table it should not would come back empty and the
// assertion would say so.
function stubAll() {
patch(accessDb, 'activeGrant', async () => undefined)
patch(accessDb, 'overrideFor', async () => undefined)
patch(accessDb, 'overridesForTeam', async () => [])
patch(teamsDb, 'activeByUser', async () => undefined)
patch(teamsDb, 'membersByTeam', async () => [])
patch(identities, 'listForUser', async () => [])
}
const memberRow = (extra = {}) => ({
team_id: 1, member_key: '0x1', user_id: 7, is_leader: 0, status: 'active', display_name: 'Aldric', ...extra,
})
const grantRow = (extra = {}) => ({ id: 1, team_id: 1, user_id: 7, granted_by: 2, revoked_at: null, ...extra })
beforeEach(stubAll)
afterEach(() => {
while (saved.length) {
const [mod, name, fn] = saved.pop()
mod[name] = fn
}
})
// ── Path 3: forum access is membership OR a grant ──────────────────────────
test('a member has forum access via membership', async () => {
patch(teamsDb, 'activeByUser', async () => memberRow())
const result = await access.forumAccess(1, 7)
assert.deepEqual(result, { allowed: true, viaMembership: true, viaGrant: false, isLeader: false })
})
test('a granted non-member has forum access via the grant', async () => {
patch(accessDb, 'activeGrant', async () => grantRow())
const result = await access.forumAccess(1, 7)
assert.deepEqual(result, { allowed: true, viaMembership: false, viaGrant: true, isLeader: false })
})
test('both reasons are reported when both hold', async () => {
// Not collapsed into one boolean: both facts are true, membership is what the
// UI shows as the current reason, and the grant stays as the record of who let
// this person in before they were a member.
patch(accessDb, 'activeGrant', async () => grantRow())
patch(teamsDb, 'activeByUser', async () => memberRow())
const result = await access.forumAccess(1, 7)
assert.equal(result.viaMembership, true)
assert.equal(result.viaGrant, true)
})
test('a revoked grant and no membership is no access', async () => {
// activeGrant returns nothing for a revoked row — the resolver never sees one.
const result = await access.forumAccess(1, 7)
assert.equal(result.allowed, false)
})
test('an anonymous caller is refused without touching a table', async () => {
let reads = 0
patch(accessDb, 'activeGrant', async () => { reads += 1 })
patch(teamsDb, 'activeByUser', async () => { reads += 1 })
const result = await access.forumAccess(1, null)
assert.equal(result.allowed, false)
assert.equal(reads, 0)
})
// ── Invariant 3: non-contamination ─────────────────────────────────────────
test('INVARIANT — a grant never writes the membership projection', async () => {
// The grant path reads its own table and nothing else. Asserted by making every
// membership WRITE explode: if resolving a grant ever wrote a member row, this
// is where it would surface.
for (const name of ['upsertMember', 'markDeparted', 'setLeaders', 'setMemberLeader']) {
patch(teamsDb, name, async () => { throw new Error(`forumAccess wrote team_members via ${name}`) })
}
patch(accessDb, 'activeGrant', async () => grantRow())
const result = await access.forumAccess(1, 7)
assert.equal(result.allowed, true)
assert.equal(result.viaMembership, false, 'a grant is not a membership, in either direction')
})
test('INVARIANT — a granted, unlinked user is not on the roster', async () => {
// The roster is path 1's table alone. A granted user with no membership row
// appears nowhere in it, which is what keeps them out of every membership count.
patch(accessDb, 'activeGrant', async () => grantRow({ user_id: 99 }))
patch(teamsDb, 'membersByTeam', async () => [memberRow()])
const roster = await access.rosterWithOverrides(1)
assert.equal(roster.length, 1)
assert.equal(roster.every((m) => m.user_id !== 99), true, 'a forum guest is not a member')
})
// ── Path 4: external access is blind to path 3 ─────────────────────────────
test('INVARIANT — a forum grant does not make an account externally eligible', async () => {
// The named test from §2.5. 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.
patch(accessDb, 'activeGrant', async () => grantRow())
patch(identities, 'listForUser', async () => [{ provider: 'discord', subject: 'd1' }])
assert.equal(await access.externalEligible(1, 7, 'discord'), false)
})
test('a linked member with a linked Discord identity is eligible', async () => {
patch(teamsDb, 'activeByUser', async () => memberRow({ user_id: 7 }))
patch(identities, 'listForUser', async () => [{ provider: 'discord', subject: 'd1' }])
assert.equal(await access.externalEligible(1, 7, 'discord'), true)
})
test('a member with no Discord identity is not eligible — hop 3 of the chain', async () => {
patch(teamsDb, 'activeByUser', async () => memberRow())
patch(identities, 'listForUser', async () => [{ provider: 'google', subject: 'g1' }])
assert.equal(await access.externalEligible(1, 7, 'discord'), false)
})
test('eligibility is per platform, not "linked to anything"', async () => {
patch(teamsDb, 'activeByUser', async () => memberRow())
patch(identities, 'listForUser', async () => [{ provider: 'discord', subject: 'd1' }])
assert.equal(await access.externalEligible(1, 7, 'matrix'), false)
})
test('a non-member is never eligible', async () => {
patch(identities, 'listForUser', async () => [{ provider: 'discord', subject: 'd1' }])
assert.equal(await access.externalEligible(1, 7, 'discord'), false)
})
// ── Path 2: leadership, and the staff override on top ──────────────────────
test('leadership follows the synced value when no override exists', async () => {
patch(teamsDb, 'activeByUser', async () => memberRow({ is_leader: 1 }))
assert.equal(await access.isLeaderByUser(1, 7), true)
})
test('a deny override outranks a synced leader', async () => {
patch(teamsDb, 'activeByUser', async () => memberRow({ is_leader: 1 }))
patch(accessDb, 'overrideFor', async () => ({ member_key: '0x1', effect: 'deny' }))
assert.equal(await access.isLeaderByUser(1, 7), false)
})
test('a grant override promotes someone the game does not call a leader', async () => {
patch(teamsDb, 'activeByUser', async () => memberRow({ is_leader: 0 }))
patch(accessDb, 'overrideFor', async () => ({ member_key: '0x1', effect: 'grant' }))
assert.equal(await access.isLeaderByUser(1, 7), true)
})
test('an override survives a resync, because it is never written into the projection', async () => {
// The projection keeps saying what the game says; the override keeps saying what
// staff decided. Applied at READ time, so a sync fifteen minutes later cannot
// undo it — which is the entire point of §2.5.1.
patch(accessDb, 'overridesForTeam', async () => [
{ member_key: '0x1', effect: 'deny', reason: 'harassment', actor_username: 'mod1', created_at: 'then' },
])
patch(teamsDb, 'membersByTeam', async () => [memberRow({ is_leader: 1 })])
const roster = await access.rosterWithOverrides(1)
assert.equal(roster[0].is_leader, false, 'the resolved answer is the override')
assert.equal(roster[0].is_leader_synced, true, 'what the game says is still visible')
assert.equal(roster[0].leader_override.reason, 'harassment')
assert.equal(roster[0].leader_override.by, 'mod1')
})
test('a member with no override carries no override field', async () => {
patch(teamsDb, 'membersByTeam', async () => [memberRow({ is_leader: 1 })])
const roster = await access.rosterWithOverrides(1)
assert.equal(roster[0].leader_override, null)
assert.equal(roster[0].is_leader, true)
})
test('leadership resolves through forumAccess too, override included', async () => {
patch(teamsDb, 'activeByUser', async () => memberRow({ is_leader: 0 }))
patch(accessDb, 'overrideFor', async () => ({ member_key: '0x1', effect: 'grant' }))
const result = await access.forumAccess(1, 7)
assert.equal(result.isLeader, true)
})
test('a granted non-member is never a leader', async () => {
// isLeader is path 2, which is a property of a MEMBER row. Someone with only a
// forum grant has no member row, so there is nothing to promote.
patch(accessDb, 'activeGrant', async () => grantRow())
patch(accessDb, 'overrideFor', async () => ({ member_key: '0x1', effect: 'grant' }))
const result = await access.forumAccess(1, 7)
assert.equal(result.allowed, true)
assert.equal(result.isLeader, false)
})