Phase 1: Anmeldung, Rechte und Rollenverwaltung
Auth.js übernimmt nur den OAuth-Ablauf mit state und PKCE, Scope identify. Das Zugangstoken des Nutzers wird danach verworfen: die Rollen holt der Bot mit seinem eigenen Token. Die Sitzung steht in der Datenbank, im Cookie liegt nur userId und sessionId. Ohne die Zeile in Session ist das Cookie wertlos, und nur so lässt sich eine Sitzung zurückziehen. Wer sich anmelden darf, steckt in lib/auth/signInPolicy.ts statt im Callback: das ist die Regel, an der alles hängt, und sie gehört einzeln prüfbar. Kein Guild-Mitglied, kein Zugang. Keine Rolle mit grantsMembership, kein Zugang. Notfall-Admins umgehen die Rolle, aber nicht die Guild-Mitgliedschaft. RoleMapping trägt jetzt grantsMembership, category und isPresent. Der Worker spiegelt jede Rolle der Guild hierher, damit im Adminbereich nichts von Hand eingetragen werden muss. Gelöschte Rollen werden nicht entfernt, sondern auf isPresent = false gesetzt: beim Umzug auf den echten Server bleiben sie als Vorlage stehen. Der Worker gleicht über Gateway-Ereignisse und zusätzlich alle fünf Minuten voll ab. Verliert jemand seine AfiS-Rolle, beendet er dessen Sitzungen sofort und setzt isActive auf false - der Datensatz bleibt, damit Autorenangaben in alten Akten gültig bleiben. Das passiert im Worker und nicht in der Web-App, weil es dort erst beim nächsten Request des Betroffenen griffe. Zwei Widersprüche der Dokumente aufgelöst, festgehalten in docs/13-entscheidungen.md: der Ausschluss schlägt auch den Admin (das Codebeispiel in 03-auth-und-rechte.md übergeht ihn, die Reihenfolge eine Zeile darüber nicht), und versiegelte Akten fallen im Where-Fragment heraus statt in einer Prüfung danach. 45 Tests, davon die Negativfälle aus docs/03 gegen eine echte Datenbank: Stufe 2 sieht die Stufe-4-Akte nicht und findet sie auch nicht in der Trefferzahl, ein Ausgeschlossener mit Stufe 5 kommt nicht heran, ein Rollenentzug beendet die Sitzungen, und ein Ausfall der Discord-API sieht nicht aus wie eine verweigerte Mitgliedschaft.
This commit is contained in:
+83
-9
@@ -1,15 +1,18 @@
|
||||
import { Client, GatewayIntentBits, Events } from 'discord.js'
|
||||
import { Client, Events, GatewayIntentBits, type Guild } from 'discord.js'
|
||||
import { createPrismaClient } from '@afis/db'
|
||||
import { loadEnv } from './env.js'
|
||||
import { writeHeartbeat } from './heartbeat.js'
|
||||
import { enforceMembership, handleDeparture, syncMember, syncRoles } from './sync.js'
|
||||
|
||||
/**
|
||||
* Rollen-Abgleich-Worker. Haengt am Discord-Gateway und haelt die
|
||||
* Guild-Mitgliedschaft in Postgres aktuell, damit die Web-App nie live bei
|
||||
* Discord anfragen muss. Siehe docs/02-architektur.md.
|
||||
* Rollen-Abgleich-Worker. Hängt am Discord-Gateway und hält Guild-Rollen und
|
||||
* Mitgliedschaft in Postgres aktuell, damit die Web-App nie live bei Discord
|
||||
* anfragen muss. Siehe docs/02-architektur.md.
|
||||
*
|
||||
* Phase 0 baut das Geruest: Verbindung, Lebenszeichen, sauberes Herunterfahren.
|
||||
* Der eigentliche Abgleich (Ereignisse, Voll-Abgleich, pg_notify) kommt in Phase 1.
|
||||
* Zwei Wege, absichtlich doppelt:
|
||||
* - Gateway-Ereignisse, damit eine Änderung in unter einer Sekunde ankommt
|
||||
* - Voll-Abgleich alle fünf Minuten als Sicherheitsnetz, falls ein Ereignis
|
||||
* verloren geht oder der Prozess eine Weile weg war
|
||||
*/
|
||||
async function main(): Promise<void> {
|
||||
const env = loadEnv()
|
||||
@@ -23,11 +26,73 @@ async function main(): Promise<void> {
|
||||
|
||||
let gatewayReady = false
|
||||
|
||||
async function fullSync(reason: string): Promise<void> {
|
||||
const guild = await client.guilds.fetch(env.discordGuildId)
|
||||
const roleCount = await syncRoles(db, await guild.fetch())
|
||||
const members = await guild.members.fetch()
|
||||
|
||||
let changed = 0
|
||||
for (const member of members.values()) {
|
||||
if (member.user.bot) continue
|
||||
if (await syncMember(db, member)) changed++
|
||||
await enforceMembership(db, member.id)
|
||||
}
|
||||
|
||||
// Wer nicht mehr auf dem Server ist, hat auch keine Sitzung mehr.
|
||||
const known = await db.user.findMany({ where: { isActive: true }, select: { id: true } })
|
||||
for (const user of known) {
|
||||
if (!members.has(user.id)) await handleDeparture(db, user.id)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[worker] Voll-Abgleich (${reason}): ${roleCount} Rollen, ${members.size} Mitglieder, ${changed} geändert`,
|
||||
)
|
||||
}
|
||||
|
||||
client.once(Events.ClientReady, (ready) => {
|
||||
gatewayReady = true
|
||||
console.log(`[worker] am Gateway als ${ready.user.tag}`)
|
||||
void fullSync('Start').catch((error: unknown) =>
|
||||
console.error('[worker] erster Abgleich fehlgeschlagen', error),
|
||||
)
|
||||
})
|
||||
|
||||
client.on(Events.GuildMemberAdd, (member) => {
|
||||
if (member.guild.id !== env.discordGuildId || member.user.bot) return
|
||||
void (async () => {
|
||||
await syncMember(db, member)
|
||||
await enforceMembership(db, member.id)
|
||||
})().catch((error: unknown) => console.error('[worker] guildMemberAdd', error))
|
||||
})
|
||||
|
||||
client.on(Events.GuildMemberUpdate, (_before, member) => {
|
||||
if (member.guild.id !== env.discordGuildId || member.user.bot) return
|
||||
void (async () => {
|
||||
await syncMember(db, member)
|
||||
await enforceMembership(db, member.id)
|
||||
})().catch((error: unknown) => console.error('[worker] guildMemberUpdate', error))
|
||||
})
|
||||
|
||||
client.on(Events.GuildMemberRemove, (member) => {
|
||||
if (member.guild.id !== env.discordGuildId) return
|
||||
void handleDeparture(db, member.id).catch((error: unknown) =>
|
||||
console.error('[worker] guildMemberRemove', error),
|
||||
)
|
||||
})
|
||||
|
||||
// Wird eine Rolle umbenannt, verschoben oder gelöscht, muss der Adminbereich
|
||||
// das sehen. Der Trigger auf RoleMapping löst danach pg_notify aus.
|
||||
const onRoleChange = (guild: Guild): void => {
|
||||
if (guild.id !== env.discordGuildId) return
|
||||
void syncRoles(db, guild).catch((error: unknown) =>
|
||||
console.error('[worker] Rollenabgleich', error),
|
||||
)
|
||||
}
|
||||
|
||||
client.on(Events.GuildRoleCreate, (role) => onRoleChange(role.guild))
|
||||
client.on(Events.GuildRoleUpdate, (_before, role) => onRoleChange(role.guild))
|
||||
client.on(Events.GuildRoleDelete, (role) => onRoleChange(role.guild))
|
||||
|
||||
client.on(Events.ShardDisconnect, (_event, shardId) => {
|
||||
gatewayReady = false
|
||||
console.warn(`[worker] Verbindung zu Shard ${shardId} verloren`)
|
||||
@@ -36,11 +101,12 @@ async function main(): Promise<void> {
|
||||
client.on(Events.ShardResume, () => {
|
||||
gatewayReady = true
|
||||
console.log('[worker] Verbindung wiederhergestellt')
|
||||
void fullSync('nach Wiederverbindung').catch((error: unknown) =>
|
||||
console.error('[worker] Abgleich nach Wiederverbindung fehlgeschlagen', error),
|
||||
)
|
||||
})
|
||||
|
||||
client.on(Events.Error, (error) => {
|
||||
console.error('[worker] Gateway-Fehler', error)
|
||||
})
|
||||
client.on(Events.Error, (error) => console.error('[worker] Gateway-Fehler', error))
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
void writeHeartbeat(
|
||||
@@ -50,9 +116,17 @@ async function main(): Promise<void> {
|
||||
).catch((error: unknown) => console.error('[worker] Lebenszeichen fehlgeschlagen', error))
|
||||
}, env.heartbeatIntervalMs)
|
||||
|
||||
const periodicSync = setInterval(() => {
|
||||
if (!gatewayReady) return
|
||||
void fullSync('planmäßig').catch((error: unknown) =>
|
||||
console.error('[worker] Voll-Abgleich fehlgeschlagen', error),
|
||||
)
|
||||
}, env.fullSyncIntervalMs)
|
||||
|
||||
async function shutdown(signal: string): Promise<void> {
|
||||
console.log(`[worker] ${signal}, fahre herunter`)
|
||||
clearInterval(heartbeat)
|
||||
clearInterval(periodicSync)
|
||||
await writeHeartbeat(db, 'down', 'planmäßig beendet').catch(() => undefined)
|
||||
await client.destroy()
|
||||
await db.$disconnect()
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { Guild, GuildMember, Role } from 'discord.js'
|
||||
import type { PrismaClient } from '@afis/db'
|
||||
|
||||
/**
|
||||
* Spiegelt Guild und Mitglieder nach Postgres.
|
||||
*
|
||||
* Die Web-App fragt nie live bei Discord an. Dadurch ist eine Rechteänderung
|
||||
* in Discord in unter einer Sekunde in der App wirksam, ohne dass ein
|
||||
* einziger Request auf die Discord-API wartet. Siehe docs/02-architektur.md.
|
||||
*/
|
||||
|
||||
/** Rollen, die Discord selbst verwaltet und die niemandem etwas nützen. */
|
||||
function isRelevantRole(role: Role): boolean {
|
||||
return !role.managed && role.id !== role.guild.id // @everyone hat die Guild-ID
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt für jede Rolle der Guild eine Zeile in `RoleMapping` an, damit im
|
||||
* Adminbereich nichts von Hand eingetragen werden muss. Vorhandene
|
||||
* Zuordnungen bleiben unangetastet: gespiegelt werden nur Name und Position.
|
||||
*
|
||||
* Rollen, die es nicht mehr gibt, werden nicht gelöscht, sondern auf
|
||||
* `isPresent = false` gesetzt. Beim Umzug auf einen anderen Discord-Server
|
||||
* bleiben sie damit als Vorlage stehen. Siehe docs/13-entscheidungen.md.
|
||||
*/
|
||||
export async function syncRoles(db: PrismaClient, guild: Guild): Promise<number> {
|
||||
const roles = [...guild.roles.cache.values()].filter(isRelevantRole)
|
||||
|
||||
for (const role of roles) {
|
||||
await db.roleMapping.upsert({
|
||||
where: { discordRoleId: role.id },
|
||||
create: {
|
||||
discordRoleId: role.id,
|
||||
label: role.name,
|
||||
sortOrder: role.position,
|
||||
isPresent: true,
|
||||
},
|
||||
update: {
|
||||
label: role.name,
|
||||
sortOrder: role.position,
|
||||
isPresent: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await db.roleMapping.updateMany({
|
||||
where: { discordRoleId: { notIn: roles.map((r) => r.id) }, isPresent: true },
|
||||
data: { isPresent: false },
|
||||
})
|
||||
|
||||
return roles.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt Rollen eines Mitglieds. Gibt zurück, ob sich etwas geändert hat,
|
||||
* damit nur dann `pg_notify` ausgelöst wird — der Voll-Abgleich läuft alle
|
||||
* fünf Minuten und soll nicht jedes Mal alle Caches leeren.
|
||||
*
|
||||
* `pg_notify` selbst kommt aus dem Trigger `user_role_notify` auf `UserRole`,
|
||||
* es muss hier nichts von Hand gesendet werden.
|
||||
*/
|
||||
export async function syncMember(db: PrismaClient, member: GuildMember): Promise<boolean> {
|
||||
const roleIds = [...member.roles.cache.keys()].filter((id) => id !== member.guild.id)
|
||||
const displayName = member.nickname ?? member.user.globalName ?? member.user.username
|
||||
|
||||
const [existingUser, existingRoles] = await Promise.all([
|
||||
db.user.findUnique({
|
||||
where: { id: member.id },
|
||||
select: { displayName: true, username: true, avatarHash: true, isActive: true },
|
||||
}),
|
||||
db.userRole.findMany({ where: { userId: member.id }, select: { discordRoleId: true } }),
|
||||
])
|
||||
|
||||
const before = new Set(existingRoles.map((r) => r.discordRoleId))
|
||||
const after = new Set(roleIds)
|
||||
const rolesChanged =
|
||||
before.size !== after.size || [...after].some((id) => !before.has(id))
|
||||
|
||||
const profileChanged =
|
||||
!existingUser ||
|
||||
existingUser.displayName !== displayName ||
|
||||
existingUser.username !== member.user.username ||
|
||||
existingUser.avatarHash !== member.user.avatar ||
|
||||
!existingUser.isActive
|
||||
|
||||
if (!rolesChanged && !profileChanged) return false
|
||||
|
||||
await db.$transaction([
|
||||
db.user.upsert({
|
||||
where: { id: member.id },
|
||||
create: {
|
||||
id: member.id,
|
||||
username: member.user.username,
|
||||
displayName,
|
||||
avatarHash: member.user.avatar,
|
||||
isActive: true,
|
||||
},
|
||||
update: {
|
||||
username: member.user.username,
|
||||
displayName,
|
||||
avatarHash: member.user.avatar,
|
||||
isActive: true,
|
||||
},
|
||||
}),
|
||||
db.userRole.deleteMany({ where: { userId: member.id, discordRoleId: { notIn: roleIds } } }),
|
||||
...roleIds.map((discordRoleId) =>
|
||||
db.userRole.upsert({
|
||||
where: { userId_discordRoleId: { userId: member.id, discordRoleId } },
|
||||
create: { userId: member.id, discordRoleId },
|
||||
update: {},
|
||||
}),
|
||||
),
|
||||
])
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Beendet alle Sitzungen eines Nutzers, wenn er keine Rolle mehr trägt, die
|
||||
* zur Anmeldung berechtigt. Der Datensatz bleibt stehen, damit Autorenangaben
|
||||
* in alten Akten gültig bleiben. Siehe docs/03-auth-und-rechte.md.
|
||||
*
|
||||
* Das passiert hier und nicht in der Web-App, weil es sonst erst beim nächsten
|
||||
* Request des Betroffenen griffe — und genau darauf kann man sich nicht
|
||||
* verlassen, wenn jemandem gerade der Zugriff entzogen wurde.
|
||||
*/
|
||||
export async function enforceMembership(db: PrismaClient, userId: string): Promise<boolean> {
|
||||
const roles = await db.userRole.findMany({
|
||||
where: { userId },
|
||||
select: { discordRoleId: true },
|
||||
})
|
||||
|
||||
const granting = await db.roleMapping.count({
|
||||
where: {
|
||||
discordRoleId: { in: roles.map((r) => r.discordRoleId) },
|
||||
grantsMembership: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (granting > 0) return false
|
||||
|
||||
const [{ count }] = await Promise.all([
|
||||
db.session.deleteMany({ where: { userId } }),
|
||||
db.user.update({ where: { id: userId }, data: { isActive: false } }).catch(() => undefined),
|
||||
])
|
||||
|
||||
if (count > 0) {
|
||||
console.log(`[worker] ${count} Sitzung(en) von ${userId} beendet, keine AfiS-Rolle mehr`)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Serveraustritt: Sitzungen beenden, Datensatz behalten. */
|
||||
export async function handleDeparture(db: PrismaClient, userId: string): Promise<void> {
|
||||
await db.$transaction([
|
||||
db.session.deleteMany({ where: { userId } }),
|
||||
db.userRole.deleteMany({ where: { userId } }),
|
||||
])
|
||||
await db.user.update({ where: { id: userId }, data: { isActive: false } }).catch(() => undefined)
|
||||
console.log(`[worker] ${userId} hat den Server verlassen, Sitzungen beendet`)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { createPrismaClient, type PrismaClient } from '@afis/db'
|
||||
import { enforceMembership, handleDeparture } from '../src/sync.js'
|
||||
|
||||
/**
|
||||
* „Rollenentzug in Discord beendet laufende Sitzungen" aus
|
||||
* docs/03-auth-und-rechte.md.
|
||||
*
|
||||
* Das Zeitbudget von fünf Sekunden ist im Betrieb die Summe aus
|
||||
* Gateway-Ereignis und diesem Schritt. Geprüft wird hier der Schritt, der in
|
||||
* unserer Hand liegt: die Sitzungen müssen weg sein, und der Datensatz muss
|
||||
* stehen bleiben, damit Autorenangaben in alten Akten gültig bleiben.
|
||||
*/
|
||||
const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
|
||||
const suite = url ? describe : describe.skip
|
||||
|
||||
let db: PrismaClient
|
||||
const marke = `worker-${Date.now()}`
|
||||
|
||||
const rolleAfis = `${marke}-afis`
|
||||
const rolleGast = `${marke}-gast`
|
||||
|
||||
suite('Sitzungen beim Rollenentzug', () => {
|
||||
beforeAll(async () => {
|
||||
db = createPrismaClient(url)
|
||||
await db.roleMapping.createMany({
|
||||
data: [
|
||||
{ discordRoleId: rolleAfis, label: 'AfiS', category: 'MEMBERSHIP', grantsMembership: true },
|
||||
{ discordRoleId: rolleGast, label: 'Gast', category: 'UNSORTED' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await db.$disconnect()
|
||||
})
|
||||
|
||||
async function nutzerMitSitzung(id: string, rollen: string[]): Promise<void> {
|
||||
await db.user.create({ data: { id, username: id, displayName: id } })
|
||||
if (rollen.length > 0) {
|
||||
await db.userRole.createMany({
|
||||
data: rollen.map((discordRoleId) => ({ userId: id, discordRoleId })),
|
||||
})
|
||||
}
|
||||
await db.session.create({
|
||||
data: { userId: id, expiresAt: new Date(Date.now() + 86_400_000) },
|
||||
})
|
||||
}
|
||||
|
||||
it('lässt eine Sitzung stehen, solange die AfiS-Rolle vorliegt', async () => {
|
||||
const id = `${marke}-bleibt`
|
||||
await nutzerMitSitzung(id, [rolleAfis])
|
||||
|
||||
const beendet = await enforceMembership(db, id)
|
||||
|
||||
expect(beendet).toBe(false)
|
||||
expect(await db.session.count({ where: { userId: id } })).toBe(1)
|
||||
})
|
||||
|
||||
it('beendet alle Sitzungen, sobald die AfiS-Rolle fehlt', async () => {
|
||||
const id = `${marke}-verliert`
|
||||
await nutzerMitSitzung(id, [rolleAfis, rolleGast])
|
||||
await db.session.create({
|
||||
data: { userId: id, expiresAt: new Date(Date.now() + 86_400_000) },
|
||||
})
|
||||
expect(await db.session.count({ where: { userId: id } })).toBe(2)
|
||||
|
||||
// Rollenentzug in Discord, hier direkt nachgestellt.
|
||||
await db.userRole.deleteMany({ where: { userId: id, discordRoleId: rolleAfis } })
|
||||
|
||||
const start = Date.now()
|
||||
const beendet = await enforceMembership(db, id)
|
||||
const dauer = Date.now() - start
|
||||
|
||||
expect(beendet).toBe(true)
|
||||
expect(await db.session.count({ where: { userId: id } })).toBe(0)
|
||||
expect(dauer).toBeLessThan(5000)
|
||||
|
||||
// Der Datensatz bleibt, nur inaktiv.
|
||||
const nutzer = await db.user.findUnique({ where: { id } })
|
||||
expect(nutzer).not.toBeNull()
|
||||
expect(nutzer?.isActive).toBe(false)
|
||||
})
|
||||
|
||||
it('beendet Sitzungen beim Serveraustritt und behält den Nutzer', async () => {
|
||||
const id = `${marke}-austritt`
|
||||
await nutzerMitSitzung(id, [rolleAfis])
|
||||
|
||||
await handleDeparture(db, id)
|
||||
|
||||
expect(await db.session.count({ where: { userId: id } })).toBe(0)
|
||||
expect(await db.userRole.count({ where: { userId: id } })).toBe(0)
|
||||
const nutzer = await db.user.findUnique({ where: { id } })
|
||||
expect(nutzer?.isActive).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import { loadEnvFromRepoRoot } from '@afis/db'
|
||||
|
||||
// Damit die Tests die lokale Entwicklungsdatenbank finden. In der CI stehen
|
||||
// die Werte bereits in der Umgebung.
|
||||
loadEnvFromRepoRoot()
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['tests/**/*.test.ts'],
|
||||
setupFiles: ['./tests/setup.ts'],
|
||||
fileParallelism: false,
|
||||
testTimeout: 20_000,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user