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:
Florian Wilde
2026-09-10 12:41:29 +02:00
co-authored by Claude Opus 5
parent 748d032f70
commit e2b00f4a57
4 changed files with 304 additions and 0 deletions
+91
View File
@@ -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'])
})
})
+96
View File
@@ -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
}