Files
planer/src/server/umgebung.ts
T
Florian WildeandClaude Opus 5 7f8f505126 feat: Datenmodell und Umgebung fuer Integrationen
Ein Zustand je Quelle haelt fest, wie weit gelesen wurde und ob es
klappt. Der Merker ist bewusst ein freier Text: Zammad braucht darin
einen Zeitstempel, Telegram den Zaehler der zuletzt geholten Nachricht.

Dazu die gespiegelte Belegung ohne Titel, das Adressbuch der erlaubten
Telegram-Chats und der Einmalcode, mit dem sich ein Chat anmeldet.

Welche Integration eine Instanz darf, entscheidet weiterhin INSTANZ. Ein
Zammad-Token in der Instanz daheim bleibt wirkungslos — das steht in den
Faehigkeiten, nicht in einer Einstellung, die sich verstellen liesse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 23:03:32 +02:00

149 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'server-only'
import { z } from 'zod'
import { INSTANZEN, istErlaubt, 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(),
// --- Kalenderanbieter (M3) ---
// Graph gibt es in beiden Instanzen, Google nur daheim. Welche Werte
// tatsaechlich benutzt werden duerfen, entscheidet src/lib/instanz.ts.
GRAPH_CLIENT_ID: z.string().trim().default(''),
GRAPH_CLIENT_SECRET: z.string().trim().default(''),
/// Tenant-ID, oder "organizations" fuer jeden Arbeitsplatz-Tenant.
GRAPH_TENANT_ID: z.string().trim().default('organizations'),
GOOGLE_CLIENT_ID: z.string().trim().default(''),
GOOGLE_CLIENT_SECRET: z.string().trim().default(''),
// --- Benachrichtigungen (M5) ---
// VAPID-Schluessel fuer Web-Push. Erzeugen mit:
// npx web-push generate-vapid-keys
VAPID_OEFFENTLICH: z.string().trim().default(''),
VAPID_PRIVAT: z.string().trim().default(''),
/// Kontaktadresse fuer den Push-Dienst, als mailto:.
VAPID_KONTAKT: z.string().trim().default(''),
SMTP_HOST: z.string().trim().default(''),
SMTP_PORT: z.coerce.number().int().min(1).max(65535).default(587),
SMTP_BENUTZER: z.string().trim().default(''),
SMTP_PASSWORT: z.string().default(''),
SMTP_ABSENDER: z.string().trim().default(''),
/// Implizites TLS (Port 465). Sonst wird STARTTLS versucht.
SMTP_TLS: jaNein.default(false),
// --- Integrationen (M6) ---
// Zammad, nur in der Instanz "firma". Der Token gehoert einem Benutzer und
// sieht genau das, was dieser Benutzer sehen darf.
ZAMMAD_URL: z.string().trim().default(''),
ZAMMAD_TOKEN: z.string().trim().default(''),
/// Suchausdruck in Zammads Syntax. Was hier passt, wird zur Aufgabe.
ZAMMAD_ABFRAGE: z
.string()
.trim()
.default('owner_id:me AND (state.name:new OR state.name:open)'),
// Telegram, nur in der Instanz "daheim".
TELEGRAM_TOKEN: z.string().trim().default(''),
// Spiegel: firma sendet, daheim empfaengt. Dasselbe Geheimnis auf beiden
// Seiten; ohne gueltige Signatur nimmt der Empfaenger nichts an.
SPIEGEL_GEHEIMNIS: z.string().trim().default(''),
/// Nur auf der sendenden Seite: wohin geschickt wird.
SPIEGEL_ZIEL: z.string().trim().default(''),
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
/** Graph laesst sich verbinden, sobald Kennung und Geheimnis stehen. */
graphAktiv: boolean
googleAktiv: boolean
/** Web-Push ist erst mit vollstaendigem VAPID-Satz moeglich. */
pushAktiv: boolean
mailAktiv: boolean
/** Zammad braucht Adresse und Token – und die Instanz muss es erlauben. */
zammadAktiv: boolean
telegramAktiv: boolean
/** Senden geht nur mit Ziel, Empfangen schon mit dem Geheimnis allein. */
spiegelSendenAktiv: boolean
spiegelEmpfangenAktiv: 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,
),
graphAktiv: Boolean(werte.GRAPH_CLIENT_ID && werte.GRAPH_CLIENT_SECRET),
googleAktiv: Boolean(werte.GOOGLE_CLIENT_ID && werte.GOOGLE_CLIENT_SECRET),
pushAktiv: Boolean(
werte.VAPID_OEFFENTLICH && werte.VAPID_PRIVAT && werte.VAPID_KONTAKT,
),
mailAktiv: Boolean(werte.SMTP_HOST && werte.SMTP_ABSENDER),
// Die Instanz entscheidet mit: eine Integration, die hier nicht erlaubt
// ist, laesst sich auch mit vollstaendiger Konfiguration nicht anschalten.
zammadAktiv:
istErlaubt(werte.INSTANZ, 'integration:zammad') &&
Boolean(werte.ZAMMAD_URL && werte.ZAMMAD_TOKEN),
telegramAktiv:
istErlaubt(werte.INSTANZ, 'integration:telegram') && Boolean(werte.TELEGRAM_TOKEN),
spiegelSendenAktiv:
istErlaubt(werte.INSTANZ, 'spiegel:senden') &&
Boolean(werte.SPIEGEL_GEHEIMNIS && werte.SPIEGEL_ZIEL),
spiegelEmpfangenAktiv:
istErlaubt(werte.INSTANZ, 'spiegel:empfangen') && Boolean(werte.SPIEGEL_GEHEIMNIS),
}
}
/** 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
}