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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Harlekin
2026-09-01 19:11:53 +02:00
co-authored by Claude Opus 5
parent dbca57cedf
commit ea389c12fa
38 changed files with 2369 additions and 110 deletions
+62
View File
@@ -0,0 +1,62 @@
import type { Prisma } from '@afis/db'
import type { EffectivePermissions } from '@/lib/auth/permissions'
/**
* Where-Fragment für alle Akten, die ein Nutzer lesen darf.
*
* **Die Prüfung wird nie in JavaScript nachgelagert.** Trefferzahlen und
* Paginierung laufen über dieselbe Abfrage, damit sich aus Lücken nichts
* ableiten lässt. Siehe docs/03-auth-und-rechte.md.
*
* Reihenfolge nach dem Dokument, erste zutreffende Regel gewinnt:
* 1. Ausschluss → kein Zugriff, egal welche Stufe
* 2. Admin → Zugriff
* 3. Beteiligter → Zugriff
* 4. Stufe reicht → Zugriff
* 5. sonst kein Zugriff
*
* Der Ausschluss steht auch über dem Admin. Das Codebeispiel im Dokument gibt
* für Admins `{}` zurück und übergeht ihn damit — das widerspricht der
* Reihenfolge eine Zeile darüber. Hier gilt die Reihenfolge: der Ausschluss
* deckt gerade den Fall ab, dass gegen einen hochrangigen Mitarbeiter
* ermittelt wird, und der Administrator ist einer.
* Siehe docs/13-entscheidungen.md.
*/
export function visibleCaseWhere(p: EffectivePermissions): Prisma.CaseWhereInput {
const notExcluded: Prisma.CaseWhereInput = {
exclusions: { none: { userId: p.userId } },
}
if (p.isAdmin) return notExcluded
const conditions: Prisma.CaseWhereInput[] = [
notExcluded,
{
OR: [
{ participants: { some: { userId: p.userId } } },
{ classification: { lte: p.clearanceLevel } },
],
},
]
// Versiegelte Akten sind nur mit Recht 'admin' oder Stufe 5 lesbar.
// Siehe docs/05-akten-und-vorlagen.md, Lebenszyklus.
if (p.clearanceLevel < 5) {
conditions.push({ status: { not: 'SEALED' } })
}
return { AND: conditions }
}
/**
* Dasselbe für Verlaufseinträge: die Akte muss lesbar sein **und** die
* Feldstufe des Eintrags darf die Stufe des Lesers nicht übersteigen.
*/
export function visibleEntryWhere(p: EffectivePermissions): Prisma.CaseEntryWhereInput {
if (p.isAdmin) {
return { case: visibleCaseWhere(p) }
}
return {
AND: [{ case: visibleCaseWhere(p) }, { minClearance: { lte: p.clearanceLevel } }],
}
}
+51
View File
@@ -0,0 +1,51 @@
/**
* Prozesslokaler Cache mit Verfallszeit und Obergrenze.
*
* Die Rechteauflösung läuft einmal pro Request. Ohne Cache wären das zwei
* Abfragen auf jeder Seite, mit Cache praktisch keine. Die Verfallszeit ist
* nur das Sicherheitsnetz: der eigentliche Weg ist die sofortige Verwerfung
* über `pg_notify`, siehe lib/realtime/listener.ts und docs/03-auth-und-rechte.md.
*/
export class TtlCache<K, V> {
readonly #entries = new Map<K, { value: V; expiresAt: number }>()
constructor(
private readonly ttlMs: number,
private readonly maxEntries: number,
) {}
get(key: K): V | undefined {
const entry = this.#entries.get(key)
if (!entry) return undefined
if (entry.expiresAt <= Date.now()) {
this.#entries.delete(key)
return undefined
}
// Auffrischen, damit die Reihenfolge der Map die Nutzung widerspiegelt.
this.#entries.delete(key)
this.#entries.set(key, entry)
return entry.value
}
set(key: K, value: V): void {
this.#entries.delete(key)
this.#entries.set(key, { value, expiresAt: Date.now() + this.ttlMs })
while (this.#entries.size > this.maxEntries) {
const oldest = this.#entries.keys().next()
if (oldest.done) break
this.#entries.delete(oldest.value)
}
}
delete(key: K): void {
this.#entries.delete(key)
}
clear(): void {
this.#entries.clear()
}
get size(): number {
return this.#entries.size
}
}
+94
View File
@@ -0,0 +1,94 @@
import { notFound, redirect } from 'next/navigation'
import { auth } from '@/auth'
import { db } from '@/lib/db'
import { loadSession } from '@/lib/auth/session'
import { resolvePermissions } from '@/lib/auth/effective'
import { ensureListening } from '@/lib/realtime/listener'
import type { EffectivePermissions, Permission } from '@/lib/auth/permissions'
export type CurrentUser = {
id: string
displayName: string
username: string
avatarHash: string | null
sessionId: string
permissions: EffectivePermissions
}
/**
* Der angemeldete Nutzer für genau diesen Request, oder `null`.
*
* Drei Prüfungen, alle serverseitig:
* 1. Das Cookie trägt eine gültige Signatur (Auth.js)
* 2. Die Sitzungszeile existiert noch und ist nicht abgelaufen
* 3. Der Nutzer trägt weiterhin eine Rolle, die zur Anmeldung berechtigt
*
* Schritt 3 ist doppelt gemoppelt — der Worker beendet Sitzungen beim
* Rollenverlust bereits von sich aus — aber genau das ist der Sinn: fällt der
* Worker aus, verliert trotzdem niemand unbemerkt seinen Ausschluss.
*/
export async function getCurrentUser(): Promise<CurrentUser | null> {
const token = await auth()
const sessionId = (token as { sessionId?: string } | null)?.sessionId
if (!sessionId) return null
// Der LISTEN-Client wird beim ersten Request des Prozesses aufgebaut.
await ensureListening()
const session = await loadSession(sessionId)
if (!session) return null
const user = await db.user.findUnique({
where: { id: session.userId },
select: { id: true, displayName: true, username: true, avatarHash: true, isActive: true },
})
if (!user || !user.isActive) return null
const permissions = await resolvePermissions(session.userId)
if (!permissions.hasMembership && !permissions.isAdmin) return null
return {
id: user.id,
displayName: user.displayName,
username: user.username,
avatarHash: user.avatarHash,
sessionId: session.id,
permissions,
}
}
/** Für Seiten, die eine Anmeldung voraussetzen. */
export async function requireUser(): Promise<CurrentUser> {
const user = await getCurrentUser()
if (!user) redirect('/anmelden')
return user
}
/**
* Für Seiten und Aktionen, die ein Recht voraussetzen.
*
* Fehlt das Recht, gibt es 404 statt 403. Existenz ist auch eine Information:
* aus einem 403 liesse sich ablesen, dass es die Seite gibt.
* Siehe docs/03-auth-und-rechte.md.
*/
export async function requirePermission(permission: Permission): Promise<CurrentUser> {
const user = await requireUser()
if (!user.permissions.isAdmin && !user.permissions.permissions.has(permission)) {
notFound()
}
return user
}
/**
* Für Server Actions: wirft, statt umzuleiten. Jede Server Action prüft die
* Berechtigung erneut, egal was die Oberfläche vorher versteckt hat.
* Siehe docs/07-dateien-und-sicherheit.md.
*/
export async function requirePermissionForAction(permission: Permission): Promise<CurrentUser> {
const user = await getCurrentUser()
if (!user) throw new Error('Nicht angemeldet')
if (!user.permissions.isAdmin && !user.permissions.permissions.has(permission)) {
throw new Error('Keine Berechtigung')
}
return user
}
+40
View File
@@ -0,0 +1,40 @@
import { getEnv } from '@/lib/env'
const DISCORD_API = 'https://discord.com/api/v10'
export type GuildMember = {
userId: string
roleIds: string[]
nickname: string | null
}
/**
* Fragt die Mitgliedschaft **serverseitig mit dem Bot-Token** ab.
*
* Das Zugangstoken des Nutzers aus dem OAuth-Ablauf wird dafür bewusst nicht
* benutzt und nach dem Callback verworfen: OAuth klärt, wer jemand ist, der Bot
* klärt, was er darf. Siehe docs/03-auth-und-rechte.md.
*
* `null` bedeutet: kein Mitglied der Guild. Ein Fehler der Discord-API wird
* geworfen, damit ein Ausfall nicht wie eine abgelehnte Anmeldung aussieht.
*/
export async function fetchGuildMember(userId: string): Promise<GuildMember | null> {
const env = getEnv()
const response = await fetch(`${DISCORD_API}/guilds/${env.DISCORD_GUILD_ID}/members/${userId}`, {
headers: { authorization: `Bot ${env.DISCORD_BOT_TOKEN}` },
cache: 'no-store',
})
if (response.status === 404) return null
if (!response.ok) {
throw new Error(`Discord-API antwortete mit ${response.status}`)
}
const body = (await response.json()) as { roles?: string[]; nick?: string | null }
return {
userId,
roleIds: body.roles ?? [],
nickname: body.nick ?? null,
}
}
+101
View File
@@ -0,0 +1,101 @@
import { db } from '@/lib/db'
import { getEnv } from '@/lib/env'
import { TtlCache } from '@/lib/auth/cache'
import {
PERMISSIONS,
isPermission,
type EffectivePermissions,
type Permission,
} from '@/lib/auth/permissions'
/** Höchste Freigabestufe. */
const MAX_CLEARANCE = 5
/**
* 60 Sekunden Verfallszeit als Sicherheitsnetz. Der reguläre Weg ist die
* sofortige Verwerfung bei `member_changed` oder `role_mapping_changed`.
*/
const cache = new TtlCache<string, EffectivePermissions>(60_000, 200)
/** Wird vom LISTEN-Client aufgerufen, siehe lib/realtime/listener.ts. */
export function invalidateUser(userId: string): void {
cache.delete(userId)
}
/** Eine Änderung am Rollen-Mapping betrifft potenziell jeden. */
export function invalidateAll(): void {
cache.clear()
}
function bootstrapAdmin(userId: string): EffectivePermissions {
return {
userId,
clearanceLevel: MAX_CLEARANCE,
permissions: new Set<Permission>(PERMISSIONS),
isAdmin: true,
hasMembership: true,
}
}
/**
* Löst die Rechte eines Nutzers aus `UserRole` × `RoleMapping` auf:
* höchste Stufe aller Rollen, Vereinigung aller Rechte.
*
* `hasMembership` ist eine Ergänzung gegenüber docs/03-auth-und-rechte.md und
* bildet den Türsteher ab: auf dem AfiS-Discord sind auch Gäste, die keine
* Akte sehen dürfen. Siehe docs/13-entscheidungen.md.
*
* Notfall-Admins aus `BOOTSTRAP_ADMIN_IDS` bekommen immer Stufe 5 und alle
* Rechte, ohne Datenbankabfrage. Ohne das sperrt man sich beim ersten Fehler
* in der Rollenverwaltung selbst aus.
*/
export async function resolvePermissions(userId: string): Promise<EffectivePermissions> {
const cached = cache.get(userId)
if (cached) return cached
if (getEnv().BOOTSTRAP_ADMIN_IDS.includes(userId)) {
const resolved = bootstrapAdmin(userId)
cache.set(userId, resolved)
return resolved
}
// Eine Abfrage, kein Nachfiltern in JavaScript.
const rows = await db.userRole.findMany({
where: { userId },
select: {
discordRoleId: true,
},
})
const roleIds = rows.map((r) => r.discordRoleId)
const mappings =
roleIds.length === 0
? []
: await db.roleMapping.findMany({
where: { discordRoleId: { in: roleIds } },
select: { clearanceLevel: true, permissions: true, grantsMembership: true },
})
let clearanceLevel = 0
let hasMembership = false
const permissions = new Set<Permission>()
for (const mapping of mappings) {
if (mapping.clearanceLevel > clearanceLevel) clearanceLevel = mapping.clearanceLevel
if (mapping.grantsMembership) hasMembership = true
for (const value of mapping.permissions) {
if (isPermission(value)) permissions.add(value)
}
}
const resolved: EffectivePermissions = {
userId,
clearanceLevel: Math.min(clearanceLevel, MAX_CLEARANCE),
permissions,
isAdmin: permissions.has('admin'),
hasMembership,
}
cache.set(userId, resolved)
return resolved
}
+6
View File
@@ -50,6 +50,12 @@ export type EffectivePermissions = {
permissions: Set<Permission>
/** Aus BOOTSTRAP_ADMIN_IDS oder Recht 'admin'. */
isAdmin: boolean
/**
* Trägt der Nutzer eine Rolle, die zur Anmeldung berechtigt? Ergänzung
* gegenüber docs/03-auth-und-rechte.md: auf dem AfiS-Discord sind auch
* Gäste. Siehe docs/13-entscheidungen.md.
*/
hasMembership: boolean
}
export function can(p: EffectivePermissions, permission: Permission): boolean {
+79
View File
@@ -0,0 +1,79 @@
import { db } from '@/lib/db'
/** Laufzeit einer Sitzung, gleitend verlängert. */
export const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000
/**
* Erst nachschreiben, wenn eine Stunde vergangen ist. Sonst erzeugt jeder
* Seitenaufruf einen Schreibvorgang, nur um den Ablauf um Sekunden zu schieben.
*/
const SLIDE_THRESHOLD_MS = 60 * 60 * 1000
export type ActiveSession = {
id: string
userId: string
expiresAt: Date
}
export async function createSession(userId: string, userAgent?: string): Promise<string> {
const session = await db.session.create({
data: {
userId,
expiresAt: new Date(Date.now() + SESSION_MAX_AGE_MS),
userAgent: userAgent?.slice(0, 500) ?? null,
},
select: { id: true },
})
return session.id
}
/**
* Lädt eine Sitzung und verlängert sie gleitend. Gibt `null` zurück, wenn sie
* nicht mehr existiert oder abgelaufen ist.
*
* Die Zeile in der Datenbank ist der eigentliche Sitzungsnachweis: das Cookie
* allein lässt sich nicht zurückziehen. Verliert jemand seine Rolle, löscht der
* Worker die Zeile, und der nächste Request ist abgemeldet.
* Siehe docs/03-auth-und-rechte.md.
*/
export async function loadSession(sessionId: string): Promise<ActiveSession | null> {
const session = await db.session.findUnique({
where: { id: sessionId },
select: { id: true, userId: true, expiresAt: true, lastSeenAt: true },
})
if (!session) return null
if (session.expiresAt.getTime() <= Date.now()) {
await db.session.delete({ where: { id: sessionId } }).catch(() => undefined)
return null
}
if (Date.now() - session.lastSeenAt.getTime() > SLIDE_THRESHOLD_MS) {
const now = new Date()
await db.session
.update({
where: { id: sessionId },
data: { lastSeenAt: now, expiresAt: new Date(now.getTime() + SESSION_MAX_AGE_MS) },
})
.catch(() => undefined)
}
return { id: session.id, userId: session.userId, expiresAt: session.expiresAt }
}
export async function endSession(sessionId: string): Promise<void> {
await db.session.delete({ where: { id: sessionId } }).catch(() => undefined)
}
/** Wird beim Rollenverlust und beim Serveraustritt benutzt. */
export async function endAllSessionsFor(userId: string): Promise<number> {
const { count } = await db.session.deleteMany({ where: { userId } })
return count
}
/** Tägliche Aufräumaufgabe, siehe docs/11-betrieb.md. */
export async function deleteExpiredSessions(): Promise<number> {
const { count } = await db.session.deleteMany({ where: { expiresAt: { lte: new Date() } } })
return count
}
+36
View File
@@ -0,0 +1,36 @@
import type { GuildMember } from '@/lib/auth/discord'
export const SIGN_IN_ERRORS = {
notInGuild: 'keine-mitgliedschaft',
notAfis: 'keine-afis-rolle',
discordUnreachable: 'discord-nicht-erreichbar',
} as const
export type SignInReason = (typeof SIGN_IN_ERRORS)[keyof typeof SIGN_IN_ERRORS]
export type SignInDecision = { ok: true } | { ok: false; reason: SignInReason }
/**
* Wer darf sich anmelden.
*
* Bewusst als eigene Funktion und nicht im Auth.js-Callback vergraben: das ist
* die Regel, an der alles hängt, und sie gehört einzeln prüfbar.
*
* - Kein Mitglied der Guild → abgelehnt. Es wird kein Konto angelegt.
* - Mitglied ohne Rolle mit `grantsMembership` → abgelehnt. Auf dem
* AfiS-Discord sind auch Gäste, die keine Akte sehen dürfen.
* - Notfall-Admin → durchgelassen, aber nur solange er Mitglied der Guild
* ist. Sonst bliebe die Hintertür auch nach einem Rauswurf offen.
*
* Siehe docs/03-auth-und-rechte.md und docs/13-entscheidungen.md.
*/
export function decideSignIn(input: {
member: GuildMember | null
isBootstrapAdmin: boolean
grantingRoleCount: number
}): SignInDecision {
if (!input.member) return { ok: false, reason: SIGN_IN_ERRORS.notInGuild }
if (input.isBootstrapAdmin) return { ok: true }
if (input.grantingRoleCount === 0) return { ok: false, reason: SIGN_IN_ERRORS.notAfis }
return { ok: true }
}
+69 -4
View File
@@ -29,10 +29,22 @@ export const de = {
signIn: 'Mit Discord anmelden',
signOut: 'Abmelden',
signedInAs: 'Angemeldet als',
notInGuild: {
title: 'Du bist nicht auf dem AfiS-Discord',
body: 'Die Anmeldung setzt eine Mitgliedschaft auf dem Discord-Server voraus. Wende dich an die Abteilungsleitung.',
},
intro:
'Die Anmeldung läuft über Discord. Zugriff haben Mitglieder der Abteilung für innere Sicherheit.',
reasons: {
'keine-mitgliedschaft': {
title: 'Du bist nicht auf dem AfiS-Discord',
body: 'Die Anmeldung setzt eine Mitgliedschaft auf dem Discord-Server voraus. Wende dich an die Abteilungsleitung.',
},
'keine-afis-rolle': {
title: 'Keine Berechtigung für das Aktensystem',
body: 'Du bist auf dem Server, trägst aber keine Rolle der Abteilung. Wende dich an die Abteilungsleitung.',
},
'discord-nicht-erreichbar': {
title: 'Discord antwortet nicht',
body: 'Die Mitgliedschaft war nicht prüfbar. In einigen Minuten erneut versuchen.',
},
} as Record<string, { title: string; body: string }>,
sessionEnded: {
title: 'Sitzung beendet',
body: 'Deine Rollen haben sich geändert. Melde dich neu an.',
@@ -41,6 +53,59 @@ export const de = {
title: 'Anmeldung nicht möglich',
body: 'Der Anmeldevorgang wurde abgebrochen. Erneut versuchen.',
},
backToSignIn: 'Zurück zur Anmeldung',
},
overview: {
heading: 'Dein Zugang',
intro: 'Was das System über dich weiss und was daraus folgt.',
person: 'Person',
discordId: 'Discord-Kennung',
roles: 'Rollen auf dem Discord',
clearance: 'Errechnete Freigabestufe',
permissions: 'Rechte',
noPermissions: 'Keine Rechte. Die Abteilungsleitung vergibt sie in der Rollenverwaltung.',
noRoles: 'Keine Rollen.',
bootstrapAdmin: 'Notfallzugang aus der Konfiguration, unabhängig vom Rollen-Mapping',
unmapped: 'nicht zugeordnet',
},
roleAdmin: {
heading: 'Rollenverwaltung',
intro:
'Jede Rolle des Discord-Servers steht hier. Zugangsrollen bestimmen die Freigabestufe, Rangrollen die Rechte, und eine Zugehörigkeitsrolle entscheidet, wer sich überhaupt anmelden darf.',
empty:
'Noch keine Rollen eingelesen. Der Worker spiegelt sie, sobald er am Discord-Gateway hängt.',
role: 'Rolle',
category: 'Sorte',
clearance: 'Freigabestufe',
grantsMembership: 'Darf sich anmelden',
permissions: 'Rechte',
missingFromGuild: 'Auf dem Server nicht mehr vorhanden',
save: 'Zuordnung speichern',
invalidInput: 'Eingabe nicht gültig',
roleGone: 'Rolle nicht gefunden, sie wurde zwischenzeitlich gelöscht',
saving: 'Wird gespeichert',
saved: 'Zuordnung gespeichert',
confirm: 'Rollen-Mapping ändern? Das wirkt sofort für alle angemeldeten Personen.',
categories: {
MEMBERSHIP: 'Zugehörigkeit',
RANK: 'Rang',
CLEARANCE: 'Zugang',
UNSORTED: 'Nicht eingeordnet',
} as Record<string, string>,
optIn:
'Dieses Recht ist im Auslieferungszustand keiner Rolle zugeordnet. Vergib es bewusst.',
preview: {
heading: 'Vorschau',
intro: 'Was jemand mit dieser Stufe sieht, wenn er keine weiteren Rollen hat.',
level: 'Stufe',
sees: 'Sieht Akten bis einschliesslich',
hidden: 'Höhere Akten sind unsichtbar',
lockedOnly: 'Höhere Akten sind sichtbar, aber gesperrt',
nobody: 'Keine Rolle führt zu dieser Stufe',
viaRoles: 'Erreichbar über',
},
},
clearance: {
+76
View File
@@ -0,0 +1,76 @@
import { Client } from 'pg'
import { invalidateAll, invalidateUser } from '@/lib/auth/effective'
/**
* Ein einziger `pg`-Client hält `LISTEN` offen und verteilt im Prozess weiter.
* Kein WebSocket, kein Redis: Postgres `LISTEN/NOTIFY` reicht bei dieser
* Nutzerzahl vollständig. Siehe docs/06-chat-und-realtime.md.
*
* Phase 1 nutzt zwei Kanäle: `member_changed` verwirft die Rechte eines
* Nutzers, `role_mapping_changed` verwirft alles. Dadurch ist eine
* Rechteänderung in Discord in unter einer Sekunde wirksam, ohne dass ein
* einziger Request auf die Discord-API wartet.
*
* Die Verbindung läuft an pgbouncer vorbei: im Transaktionsmodus überlebt ein
* `LISTEN` den Pool nicht.
*/
const CHANNELS = ['member_changed', 'role_mapping_changed'] as const
type Listener = { client: Client; ready: Promise<void> }
const globalForListener = globalThis as unknown as { afisListener?: Listener }
function connect(): Listener {
const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
if (!url) throw new Error('Konfiguration unvollständig: DIRECT_DATABASE_URL fehlt')
const client = new Client({ connectionString: url })
client.on('notification', (message) => {
if (message.channel === 'member_changed' && message.payload) {
invalidateUser(message.payload)
return
}
if (message.channel === 'role_mapping_changed') {
invalidateAll()
}
})
client.on('error', (error: unknown) => {
console.error('[realtime] Verbindung verloren, baue neu auf', error)
globalForListener.afisListener = undefined
// Der nächste Aufruf von ensureListening baut neu auf. Bis dahin greift
// die Verfallszeit des Caches, es geht also nichts verloren, es dauert nur
// bis zu 60 Sekunden statt unter einer.
client.end().catch(() => undefined)
})
const ready = client
.connect()
.then(async () => {
for (const channel of CHANNELS) {
await client.query(`LISTEN ${channel}`)
}
console.log('[realtime] hört auf', CHANNELS.join(', '))
})
.catch((error: unknown) => {
globalForListener.afisListener = undefined
throw error
})
return { client, ready }
}
/**
* Stellt sicher, dass der Prozess auf Änderungen hört. Mehrfach aufrufbar.
* Schlägt der Aufbau fehl, ist das kein Grund, den Request scheitern zu lassen:
* der Cache verfällt dann eben nach 60 Sekunden von selbst.
*/
export async function ensureListening(): Promise<void> {
globalForListener.afisListener ??= connect()
try {
await globalForListener.afisListener.ready
} catch (error) {
console.error('[realtime] konnte nicht verbinden', error)
}
}