diff --git a/src/lib/instanz.test.ts b/src/lib/instanz.test.ts new file mode 100644 index 0000000..0261a21 --- /dev/null +++ b/src/lib/instanz.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { + FAEHIGKEITEN, + INSTANZEN, + faehigkeitenVon, + istErlaubt, + istInstanz, + kalenderAnbieterVon, + leseInstanz, +} from './instanz' + +describe('leseInstanz', () => { + it('nimmt die erlaubten Werte an', () => { + expect(leseInstanz('firma')).toBe('firma') + expect(leseInstanz('daheim')).toBe('daheim') + }) + + it('ist unempfindlich gegen Gross- und Kleinschreibung und Leerzeichen', () => { + expect(leseInstanz(' FIRMA ')).toBe('firma') + }) + + it('bricht ab, wenn nichts gesetzt ist', () => { + expect(() => leseInstanz(undefined)).toThrow(/nicht gesetzt/) + expect(() => leseInstanz(' ')).toThrow(/nicht gesetzt/) + }) + + it('faellt bei einem Tippfehler nicht still auf einen Standard zurueck', () => { + expect(() => leseInstanz('fimra')).toThrow(/unbekannten Wert/) + }) +}) + +describe('istInstanz', () => { + it('erkennt gueltige Werte', () => { + expect(istInstanz('firma')).toBe(true) + expect(istInstanz('daheim')).toBe(true) + }) + + it('weist alles andere ab', () => { + expect(istInstanz('privat')).toBe(false) + expect(istInstanz(undefined)).toBe(false) + expect(istInstanz(42)).toBe(false) + }) +}) + +describe('istErlaubt', () => { + it('trennt die Kalenderanbieter der beiden Instanzen', () => { + expect(istErlaubt('firma', 'kalender:graph')).toBe(true) + expect(istErlaubt('firma', 'kalender:google')).toBe(false) + expect(istErlaubt('daheim', 'kalender:google')).toBe(true) + }) + + it('haelt die Integrationen auseinander', () => { + expect(istErlaubt('firma', 'integration:zammad')).toBe(true) + expect(istErlaubt('daheim', 'integration:zammad')).toBe(false) + expect(istErlaubt('daheim', 'integration:telegram')).toBe(true) + expect(istErlaubt('firma', 'integration:telegram')).toBe(false) + }) + + it('richtet den Spiegel in genau eine Richtung ein', () => { + expect(istErlaubt('firma', 'spiegel:senden')).toBe(true) + expect(istErlaubt('firma', 'spiegel:empfangen')).toBe(false) + expect(istErlaubt('daheim', 'spiegel:empfangen')).toBe(true) + expect(istErlaubt('daheim', 'spiegel:senden')).toBe(false) + }) +}) + +describe('faehigkeitenVon', () => { + it('liefert nur bekannte Faehigkeiten', () => { + for (const instanz of INSTANZEN) { + for (const faehigkeit of faehigkeitenVon(instanz)) { + expect(FAEHIGKEITEN).toContain(faehigkeit) + } + } + }) + + it('gibt keiner Instanz alles', () => { + for (const instanz of INSTANZEN) { + expect(faehigkeitenVon(instanz).length).toBeLessThan(FAEHIGKEITEN.length) + } + }) +}) + +describe('kalenderAnbieterVon', () => { + it('nennt fuer die Firma nur Graph', () => { + expect(kalenderAnbieterVon('firma')).toEqual(['GRAPH']) + }) + + it('nennt daheim beide', () => { + expect(kalenderAnbieterVon('daheim')).toEqual(['GRAPH', 'GOOGLE']) + }) +}) diff --git a/src/lib/instanz.ts b/src/lib/instanz.ts new file mode 100644 index 0000000..6726ae3 --- /dev/null +++ b/src/lib/instanz.ts @@ -0,0 +1,96 @@ +/** + * Instanzsteuerung. + * + * Ein Image, zwei Deployments. Welche Provider, Integrationen und Routen + * erlaubt sind, haengt allein an INSTANZ und wird serverseitig erzwungen – + * nicht in der Oberflaeche versteckt. + * + * Diese Datei ist reine Logik: kein Zugriff auf process.env, keine Systemzeit. + */ + +export const INSTANZEN = ['firma', 'daheim'] as const +export type Instanz = (typeof INSTANZEN)[number] + +/** + * Alles, was je nach Instanz an- oder abgeschaltet ist. Eine Faehigkeit, die + * hier nicht steht, gibt es ueberall. + */ +export const FAEHIGKEITEN = [ + // Kalender- und Mailanbindung + 'kalender:graph', + 'kalender:google', + 'mail:graph', + 'mail:gmail', + // Integrationen + 'integration:zammad', + 'integration:wartungsplaner', + 'integration:telegram', + 'spiegel:senden', + 'spiegel:empfangen', +] as const +export type Faehigkeit = (typeof FAEHIGKEITEN)[number] + +const ERLAUBT: Readonly> = { + firma: [ + 'kalender:graph', + 'mail:graph', + 'integration:zammad', + 'integration:wartungsplaner', + 'spiegel:senden', + ], + daheim: [ + 'kalender:graph', + 'kalender:google', + 'mail:graph', + 'mail:gmail', + 'integration:telegram', + 'spiegel:empfangen', + ], +} + +/** Anzeigename der Instanz fuer die Oberflaeche. */ +export const INSTANZ_NAME: Readonly> = { + firma: 'Firma', + daheim: 'Daheim', +} + +export function istInstanz(wert: unknown): wert is Instanz { + return typeof wert === 'string' && (INSTANZEN as readonly string[]).includes(wert) +} + +/** + * Liest den Konfigurationswert und bricht bei Unsinn ab. Ein falsch + * geschriebenes INSTANZ darf nicht stillschweigend zu "daheim" werden – + * sonst laufen in der Firma Provider an, die dort nichts zu suchen haben. + */ +export function leseInstanz(wert: string | undefined): Instanz { + const bereinigt = wert?.trim().toLowerCase() + if (!bereinigt) { + throw new Error('INSTANZ ist nicht gesetzt. Erlaubt: ' + INSTANZEN.join(', ')) + } + if (!istInstanz(bereinigt)) { + throw new Error( + `INSTANZ hat den unbekannten Wert "${wert ?? ''}". Erlaubt: ` + + INSTANZEN.join(', '), + ) + } + return bereinigt +} + +/** Ist diese Faehigkeit in dieser Instanz erlaubt? */ +export function istErlaubt(instanz: Instanz, faehigkeit: Faehigkeit): boolean { + return ERLAUBT[instanz].includes(faehigkeit) +} + +/** Alle in dieser Instanz erlaubten Faehigkeiten, in fester Reihenfolge. */ +export function faehigkeitenVon(instanz: Instanz): readonly Faehigkeit[] { + return FAEHIGKEITEN.filter((f) => istErlaubt(instanz, f)) +} + +/** Welche Kalenderanbieter darf diese Instanz verbinden? */ +export function kalenderAnbieterVon(instanz: Instanz): readonly ('GRAPH' | 'GOOGLE')[] { + const anbieter: ('GRAPH' | 'GOOGLE')[] = [] + if (istErlaubt(instanz, 'kalender:graph')) anbieter.push('GRAPH') + if (istErlaubt(instanz, 'kalender:google')) anbieter.push('GOOGLE') + return anbieter +} diff --git a/src/server/instanz.ts b/src/server/instanz.ts new file mode 100644 index 0000000..aaefe84 --- /dev/null +++ b/src/server/instanz.ts @@ -0,0 +1,46 @@ +import 'server-only' +import { notFound } from 'next/navigation' +import { + type Faehigkeit, + type Instanz, + istErlaubt, + kalenderAnbieterVon, +} from '@/lib/instanz' +import { umgebung } from '@/server/umgebung' + +/** Welche Instanz laeuft hier? */ +export function aktuelleInstanz(): Instanz { + return umgebung().instanz +} + +/** Ist die Faehigkeit in dieser Instanz erlaubt? */ +export function kann(faehigkeit: Faehigkeit): boolean { + return istErlaubt(aktuelleInstanz(), faehigkeit) +} + +/** + * Riegel fuer Seiten und Route Handler: was diese Instanz nicht darf, + * existiert hier auch nicht. Kein Hinweis, kein 403 – schlicht 404. + */ +export function erzwingeFaehigkeit(faehigkeit: Faehigkeit): void { + if (!kann(faehigkeit)) { + notFound() + } +} + +/** + * Riegel fuer Server Actions und alles ausserhalb des Renderns, wo notFound() + * nicht greift. + */ +export function pruefeFaehigkeit(faehigkeit: Faehigkeit): void { + if (!kann(faehigkeit)) { + throw new Error( + `Die Faehigkeit "${faehigkeit}" ist in der Instanz "${aktuelleInstanz()}" nicht erlaubt.`, + ) + } +} + +/** Kalenderanbieter, die in dieser Instanz verbunden werden duerfen. */ +export function erlaubteKalenderAnbieter(): readonly ('GRAPH' | 'GOOGLE')[] { + return kalenderAnbieterVon(aktuelleInstanz()) +} diff --git a/src/server/umgebung.ts b/src/server/umgebung.ts new file mode 100644 index 0000000..5900dfb --- /dev/null +++ b/src/server/umgebung.ts @@ -0,0 +1,71 @@ +import 'server-only' +import { z } from 'zod' +import { INSTANZEN, type Instanz } from '@/lib/instanz' + +/** + * Jede Einstellung von aussen wird geprueft, bevor sie benutzt wird. Fehlt + * etwas Pflichtiges, faellt das beim ersten Zugriff auf – mit einer Meldung, + * die den Variablennamen nennt, nie deren Wert. + */ + +const jaNein = z + .enum(['true', 'false', '1', '0', 'ja', 'nein']) + .transform((w) => w === 'true' || w === '1' || w === 'ja') + +const schema = z.object({ + INSTANZ: z.enum(INSTANZEN), + APP_URL: z.url().transform((u) => u.replace(/\/+$/, '')), + DATABASE_URL: z.string().min(1), + + AUTH_SECRET: z.string().min(16, 'AUTH_SECRET ist zu kurz, mindestens 16 Zeichen.'), + AUTH_TRUST_HOST: jaNein.default(false), + ANMELDUNG_PASSWORT: jaNein.default(false), + + OIDC_NAME: z.string().trim().default(''), + OIDC_ISSUER: z.string().trim().default(''), + OIDC_CLIENT_ID: z.string().trim().default(''), + OIDC_CLIENT_SECRET: z.string().trim().default(''), + + WEBAUTHN_RP_ID: z.string().min(1), + WEBAUTHN_RP_NAME: z.string().min(1).default('Planer'), + WEBAUTHN_ORIGIN: z.url(), + + DEMO_MODUS: jaNein.default(false), +}) + +export type Umgebung = z.infer & { + instanz: Instanz + /** OIDC ist nur eingerichtet, wenn alle vier Werte gesetzt sind. */ + oidcAktiv: boolean +} + +let zwischengespeichert: Umgebung | null = null + +function lesen(): Umgebung { + const ergebnis = schema.safeParse(process.env) + if (!ergebnis.success) { + const felder = ergebnis.error.issues + .map((f) => `${f.path.join('.')}: ${f.message}`) + .join('\n ') + throw new Error(`Die Konfiguration ist unvollstaendig:\n ${felder}`) + } + const werte = ergebnis.data + return { + ...werte, + instanz: werte.INSTANZ, + oidcAktiv: Boolean( + werte.OIDC_ISSUER && werte.OIDC_CLIENT_ID && werte.OIDC_CLIENT_SECRET, + ), + } +} + +/** Die geprueften Einstellungen. Erst beim ersten Aufruf ausgewertet. */ +export function umgebung(): Umgebung { + zwischengespeichert ??= lesen() + return zwischengespeichert +} + +/** Nur fuer Tests: erzwingt beim naechsten Aufruf ein erneutes Einlesen. */ +export function umgebungVergessen(): void { + zwischengespeichert = null +}