feat(instanz): Instanzsteuerung mit serverseitigem Riegel
INSTANZ entscheidet, welche Kalenderanbieter, Postfächer und Integrationen es gibt. Was eine Instanz nicht darf, liefert dort 404 statt eines Hinweises. Ein falsch geschriebener Wert bricht ab, statt still auf einen Standard zu fallen. Die Konfiguration wird mit zod geprüft; Fehlermeldungen nennen den Variablennamen, nie den Wert. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
748d032f70
commit
e2b00f4a57
@@ -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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<Record<Instanz, readonly Faehigkeit[]>> = {
|
||||||
|
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<Record<Instanz, string>> = {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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<typeof schema> & {
|
||||||
|
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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user