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:
Harlekin
2026-09-01 19:11:53 +02:00
parent c6401aeb4d
commit cb66839a05
36 changed files with 2339 additions and 98 deletions
+83 -9
View File
@@ -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()