11 Commits

Author SHA1 Message Date
Claude
fb4747cfeb auth: Diagnose-Log bei abgelehntem Authentik-Login
Loggt bei AccessDenied die empfangenen Gruppen + erwartete Admin-Gruppe; bei
leerer Gruppenliste Hinweis auf fehlendes 'groups'-Scope-Mapping. Erleichtert
die Diagnose der Authentik-Gruppensteuerung im Betrieb.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:47:09 +02:00
Claude
0634d8c236 fix(csp): Inline-Skripte in Prod erlauben (Next.js-Hydration)
Prod-CSP hatte script-src 'self' ohne nonce/hash -> Next.js' Inline-Bootstrap-/
Hydration-Skripte wurden vom Browser blockiert (Login-Seite ohne JS, 'Connection
closed'). script-src um 'unsafe-inline' ergänzt (KEIN 'unsafe-eval' in Prod);
übrige CSP (default-src 'self', object-src none, frame-ancestors none, base-uri
self, form-action self) bleibt strikt. Stärkere nonce-basierte CSP via Middleware
als Hardening-Option offen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:42:13 +02:00
Claude
987b8c9c8f build: public/ anlegen (.gitkeep) — Dockerfile COPY /app/public
Das Projekt hatte kein public/-Verzeichnis; der Runner-Stage-COPY
(COPY /app/public ./public) brach mit '/app/public: not found' ab.
Leeres, getracktes public/ behebt das (Next.js bedient es problemlos).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:37:20 +02:00
Claude
5d4afb5936 build: Build-Zeit-Platzhalter-Env in builder-Stage (next build)
next build evaluiert beim 'Collecting page data' Server-Routen (u. a.
/api/auth/[...nextauth]); src/lib/env.ts validiert beim Import (Fail-Fast) und
brach ohne gesetzte Variablen ab. Platzhalter-Env (erfüllt das Zod-Schema) nur
für den Build ergänzt — Server-env wird nicht ins Bundle inlined, die builder-
Stage landet nicht im Runtime-Image; echte Werte kommen zur Laufzeit aus Compose.

Lokal verifiziert: next build läuft mit den Platzhaltern sauber durch (alle Routen).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:34:06 +02:00
Claude
4863eadcce build: öffentliche npm-Registry erzwingen (Fix für npm-ci/Apple-Mirror)
Wie im feuerwehr_dashboard:
- .npmrc mit registry=https://registry.npmjs.org/ (committet)
- Dockerfile deps-Stage: npm@11 pinnen + .npmrc kopieren; statt 'npm ci' nun
  'npm install' und den committeten Lockfile NICHT verwenden (er wurde gegen einen
  internen Mirror erzeugt -> apple-Artifactory-URLs -> auf dem Server nicht erreichbar).

Damit baut 'make up-core'/'make deploy' auf dem Server gegen die öffentliche Registry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:16:38 +02:00
Claude
f933ecc19e make: up-core/deploy-core — App + Postgres ohne Geo starten
Ermöglicht Deploy ohne OSRM/Nominatim (deren Preprocessing/Import viel RAM
braucht; Austria-Extrakt OOM-te beim osrm-extract). App läuft mit
Haversine-Fallback; Geo später via 'make data' + 'make up' nachziehen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:07:53 +02:00
Claude
38021cbc51 db: DATABASE_URL-Schema auf postgresql:// (wie feuerwehr_dashboard)
Dashboard nutzt das Format postgresql://USER:PASS@HOST:PORT/DB. Angeglichen in
docker-compose.yml, .env.example (+ Host-Hinweis lokal=localhost / Container=postgres),
vitest.setup.ts und env.test.ts. Funktional identisch (pg/Drizzle akzeptieren beide),
aber konsistent mit dem bestehenden Setup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 12:47:46 +02:00
Claude
f71cf51eb4 deploy: Traefik-Setup an feuerwehr_dashboard angleichen
Abgeglichen mit ~/work/feuerwehr_dashboard/docker-compose.yml:
- externes Traefik-Netz heißt 'frontend' (external: true), nicht 'traefik'
- explizite Router->Service-Bindung (routers.floriannetz.service=floriannetz)
- entrypoints=websecure, tls + certresolver=letsencrypt, port 3000
- traefik.docker.network -> frontend; AUTHENTIK_ADMIN_GROUP an App durchgereicht
- internes Netz als Bridge (statt internal:true): Postgres/Geo ohne Host-Ports,
  aber App hat Egress für Authentik-OIDC
- APP_HOST-Default florian.feuerwehr-rems.at; TRAEFIK_NETWORK-Default frontend
- Doku (deployment-traefik.md) + Makefile-Kommentare angepasst

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 12:39:42 +02:00
Claude
f2578cedab feat(auth): Admin-Zugang über Authentik-Gruppe steuern
Statt manuell gesetzter DB-Rolle erhalten Mitglieder der Authentik-Gruppe
AUTHENTIK_ADMIN_GROUP (Default floriannetz-admins) beim SSO-Login automatisch
platform_admin; Nicht-Mitglieder werden abgewiesen. Erstes Seeding entfällt.

- auth.config.ts: Scope 'openid email profile groups' anfordern
- lib/auth/authentik.ts: reine Helfer extractGroups/isAdminGroupMember (+ 7 Unit-Tests)
- auth.ts: signIn wertet groups-Claim aus, upsert (idempotent) als platform_admin
  mit stabiler users.id für Audit/FKs
- env.ts/.env.example: AUTHENTIK_ADMIN_GROUP
- docs/reference/authentik-setup.md: Provider-/Gruppen-/Scope-Setup

Verifiziert offline: tsc OK; lint sauber; vitest 240 passed / 7 skipped.
Wehr-Konten bleiben lokale Accounts (kein Authentik).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 09:39:13 +02:00
Claude
a8d07ba2ab Makefile: lokale Dev-/DB-Ziele (build, migrate, seed, setup) ergänzen
- docker-compose.dev.yml: veröffentlicht Postgres-Port 5432 für Host-läufige
  Migrationen/Seeds (Produktiv-Postgres bleibt app-intern).
- Makefile: help-Default + Ziele install/dev/build-app/lint/typecheck/test,
  db-up/db-wait/migrate/seed/seed-auth/seed-all/generate/db-check/studio/db-reset,
  one-shot 'setup', E2E-Ziele; bestehende Deploy-Ziele (build/up/deploy/data) erhalten.
  'make build-app migrate' bzw. 'make setup' decken den gewünschten Build+Migrate-Flow ab.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:18:13 +02:00
Matthias Hochmeister
2e56a92b70 fix(tests): Coverage-Pruefung lauffaehig machen und Drift-Allowlist haerten
BLOCKING-Befunde aus "Tests & Sicherheitshaertung":

1) Coverage-Pfad war nie ausfuehrbar: @vitest/coverage-v8 fehlte in den
   devDependencies, obwohl vitest.config coverage.provider "v8" setzt und
   test:coverage "vitest run --coverage" aufruft. Paket passend zu vitest
   ^3.2 ergaenzt und installiert. coverage.include zog ganze Verzeichnisse
   (src/lib/search, src/lib/geo) ein - inkl. DB-/HTTP-gebundener Wrapper
   (Drizzle gegen Postgres, Nominatim/OSRM), die offline nicht laufen und
   die globale Schwelle verwaesserten. Scope auf die REINE, offline
   testbare Logik beschraenkt (perFile-Schwellen), I/O-Wrapper und reine
   Typ-Module ausgenommen (per Integrations-/E2E-Tests abgesichert).
   Fehlende Branch-Abdeckung in geo/eintreffzeit.ts mit Tests fuer
   Einzel-Haversine-Fallback, reine Koordinaten-lose Liste und fehlende
   OSRM-distances-Zeile geschlossen. npm run test:coverage: Exit 0,
   Schwellen (Lines/Stmts/Funcs >=90, Branches >=80) erfuellt.

2) Driftschutz zu permissiv: isPublic() in tests/support/route-scan.ts
   stufte ueber `route.startsWith(p)` jeden reinen String-Praefix als
   oeffentlich ein (z. B. /loginhelp, /api/healthz, /api/authentication)
   und liess solche Routen dem Auth-Gating-Driftcheck entkommen. Die
   redundante dritte Bedingung entfernt; exakter Treffer und echtes
   Unterpfad-Segment (p + "/") sind korrekt und ausreichend. Negativ-
   Testfall ergaenzt.

Verifiziert (offline): tsc --noEmit (0), vitest run (233 passed,
7 DB-Tests deferred), test:coverage (Exit 0). DB-/Server-/Browser-
abhaengige Schritte deferred (kein Postgres/Server im Sandbox).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 14:42:25 +02:00
24 changed files with 1151 additions and 73 deletions

View File

@@ -4,7 +4,10 @@
NODE_ENV=development
# Datenbank (Postgres)
DATABASE_URL=postgres://floriannetz:floriannetz@localhost:5432/floriannetz
# Datenbank (Postgres). Format: postgresql://USER:PASSWORT@HOST:PORT/DB
# Lokal (Host -> Docker-Postgres via docker-compose.dev.yml): HOST=localhost.
# Im Container setzt docker-compose.yml HOST automatisch auf den Service "postgres".
DATABASE_URL=postgresql://floriannetz:floriannetz@localhost:5432/floriannetz
# Auth.js / NextAuth
# AUTH_SECRET muss >= 32 Zeichen sein (z. B. `openssl rand -base64 32`)
@@ -17,6 +20,10 @@ AUTH_TRUST_HOST=true
AUTHENTIK_ISSUER=http://localhost:9000/application/o/floriannetz/
AUTHENTIK_CLIENT_ID=floriannetz
AUTHENTIK_CLIENT_SECRET=bitte-setzen
# Mitglieder dieser Authentik-Gruppe erhalten beim Login automatisch
# platform_admin. Wer NICHT in der Gruppe ist, wird vom SSO-Login abgewiesen.
# Setup siehe docs/reference/authentik-setup.md.
AUTHENTIK_ADMIN_GROUP=floriannetz-admins
# Geo (interne Dienste; Defaults zeigen auf Docker-Compose-Hostnamen)
OSRM_URL=http://osrm:5000
@@ -27,11 +34,13 @@ HAVERSINE_KMH=50
# Deployment / externes Traefik
# APP_HOST ist der öffentliche Hostname (Traefik-Routing + AUTH_URL-Basis).
# In Produktion: AUTH_URL=https://${APP_HOST} und AUTH_TRUST_HOST=true setzen.
APP_HOST=floriannetz.example.at
APP_HOST=florian.feuerwehr-rems.at
# Traefik-Zertifikatsauflöser (muss in der externen Traefik-Instanz definiert sein).
TRAEFIK_CERTRESOLVER=letsencrypt
# Name des externen, von Traefik verwalteten Docker-Netzes.
TRAEFIK_NETWORK=traefik
# Name des externen, von Traefik verwalteten Docker-Netzes
# (im feuerwehr_dashboard heißt es "frontend"). Muss existieren:
# docker network create frontend
TRAEFIK_NETWORK=frontend
# Optionaler Katalog-Seed beim Container-Start (idempotent).
RUN_SEED=false
# Postgres-Zugangsdaten für den Compose-Postgres-Service.

3
.gitignore vendored
View File

@@ -9,6 +9,9 @@ tests/e2e/.auth/
next-env.d.ts
*.tsbuildinfo
# Coverage-Report (vitest --coverage), generiertes Artefakt.
coverage/
# Generiertes Artefakt: wird im Docker-builder aus src/db/seed gebündelt
# (scripts/build-seed-bundle.mjs), nicht eingecheckt.
docker/seed.mjs

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
registry=https://registry.npmjs.org/

View File

@@ -9,14 +9,33 @@ ARG NODE_VERSION=22
# --- deps: Produktions- und Build-Abhängigkeiten installieren -----------------
FROM node:${NODE_VERSION}-alpine AS deps
WORKDIR /app
# Nur Manifeste kopieren -> Layer-Cache bleibt stabil, solange sich Deps nicht ändern.
COPY package.json package-lock.json ./
RUN npm ci
# node:alpine bündelt npm 10, das bei plattformfremden optionalen Transitiv-Deps
# (z. B. @node-rs/argon2 -> *-wasm32-wasi / @emnapi) strenger ist. npm 11 wie im
# feuerwehr_dashboard verwenden.
RUN npm install -g npm@11
# .npmrc erzwingt das ÖFFENTLICHE npm-Registry. Der committete Lockfile wurde
# gegen einen internen Mirror erzeugt (resolved-URLs zeigen dorthin, daher der
# npm-ci-Fehler) und wird im Build bewusst NICHT verwendet — Auflösung frisch aus
# der öffentlichen Registry (gleiches Vorgehen wie feuerwehr_dashboard/frontend).
COPY package.json .npmrc ./
RUN npm install --no-audit --no-fund
# --- builder: Next.js im Standalone-Modus bauen -------------------------------
FROM node:${NODE_VERSION}-alpine AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
# Build-Zeit-Platzhalter: src/lib/env.ts validiert beim Import (Fail-Fast).
# `next build` evaluiert beim "Collecting page data" die Server-Routen (u. a.
# /api/auth/[...nextauth]) -> ohne gesetzte Variablen bricht der Import ab.
# Diese Werte sind NUR für den Build (erfüllen das Zod-Schema); Server-env wird
# NICHT ins Bundle inlined und die builder-Stage landet NICHT im Runtime-Image.
# Echte Werte kommen zur Laufzeit aus docker-compose.
ENV DATABASE_URL=postgresql://build:build@localhost:5432/build \
AUTH_SECRET=build_only_placeholder_secret_min_32_chars_long \
AUTH_URL=https://build.invalid \
AUTHENTIK_ISSUER=https://build.invalid/application/o/floriannetz/ \
AUTHENTIK_CLIENT_ID=build \
AUTHENTIK_CLIENT_SECRET=build
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# next.config.ts setzt output:"standalone" -> erzeugt .next/standalone/server.js.

155
Makefile
View File

@@ -1,38 +1,151 @@
# FlorianNetz — Deployment-Makefile (externes Traefik).
# FlorianNetz — Makefile
# Lokale Entwicklung, Datenbank (Migrationen/Seeds) und Deployment (externes Traefik).
#
# Ziele:
# make build - baut das App-Image (Next.js standalone, non-root)
# make up - startet den Stack (App + Postgres + Geo) hinter Traefik
# make down - stoppt den Stack
# make logs - folgt den App-Logs
# make deploy - build + up (Standard-Deploy)
# make data - bereitet die OSRM-Geodaten vor (Download + Preprocessing)
# make config - validiert die Compose-Konfiguration
# Schnellstart (lokal, Postgres via Docker):
# make setup # install + Postgres hoch + migrate + seed-all
# make dev # Dev-Server -> http://localhost:3000
#
# Hinweis: up/data/deploy benötigen Docker (+ Netzzugriff/RAM/Disk) und werden
# NICHT in CI/Sandbox ausgeführt. Das externe Traefik-Netz muss existieren:
# docker network create traefik
# Nur Build + Migrate (z. B. CI / vor Deploy):
# make build-app migrate
#
# Voll-Deploy hinter externem Traefik (Docker):
# docker network create frontend # einmalig (externes Traefik-Netz)
# make deploy
#
# `make help` listet alle Ziele.
COMPOSE = docker compose --env-file .env
SHELL := /bin/bash
COMPOSE = docker compose --env-file .env
# Lokale DB-Ziele binden den Dev-Override ein (veröffentlicht Postgres-Port 5432).
COMPOSE_DEV = docker compose --env-file .env -f docker-compose.yml -f docker-compose.dev.yml
.PHONY: build up down logs deploy data config
.DEFAULT_GOAL := help
build:
# ---------------------------------------------------------------------------
.PHONY: help
help: ## Diese Übersicht anzeigen
@grep -hE '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}'
# --- Umgebung -------------------------------------------------------------
.PHONY: env install
env: ## .env aus .env.example erzeugen (falls noch nicht vorhanden)
@if [ ! -f .env ]; then cp .env.example .env && echo "→ .env aus .env.example erstellt (Werte anpassen!)"; else echo "→ .env existiert bereits"; fi
install: ## npm-Abhängigkeiten installieren
npm install
# --- Lokale Entwicklung & Qualität ---------------------------------------
.PHONY: dev build-app lint typecheck test test-cov check
dev: ## Next.js Dev-Server (http://localhost:3000)
npm run dev
build-app: ## Next.js Production-Build (ohne Docker)
npm run build
lint: ## ESLint
npm run lint
typecheck: ## TypeScript prüfen (tsc --noEmit)
npm run typecheck
test: ## Unit-Tests (Vitest)
npm run test
test-cov: ## Unit-Tests mit Coverage
npm run test:coverage
check: lint typecheck test ## Lint + Typecheck + Unit-Tests (Offline-DoD)
# --- E2E (braucht laufenden Server + Browser) ----------------------------
.PHONY: e2e-install e2e e2e-gating
e2e-install: ## Playwright-Browser installieren (einmalig)
npx playwright install
e2e: ## Komplette Playwright-E2E-Suite
npm run test:e2e
e2e-gating: ## Nur die Default-deny-Gating-Suite
npm run test:e2e:gating
# --- Datenbank (lokal; Postgres via Docker, Port auf Host veröffentlicht) -
.PHONY: db-up db-down db-wait generate migrate seed-auth seed seed-all db-check studio db-reset
db-up: ## Nur Postgres starten (Docker, Port 5432 lokal)
$(COMPOSE_DEV) up -d postgres
db-down: ## Postgres-Container stoppen
$(COMPOSE_DEV) stop postgres
db-wait: ## Warten bis Postgres bereit ist (max ~60s)
@echo "→ Warte auf Postgres…"; \
for i in $$(seq 1 30); do \
if $(COMPOSE_DEV) exec -T postgres sh -c 'pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB"' >/dev/null 2>&1; then \
echo "→ Postgres bereit."; exit 0; \
fi; sleep 2; \
done; echo "✗ Postgres nicht bereit (Timeout)."; exit 1
generate: ## Drizzle-Migration aus dem Schema generieren
npm run db:generate
migrate: ## Migrationen anwenden (DATABASE_URL aus .env -> localhost:5432)
npm run db:migrate
seed-auth: ## Ersten Platform-Admin anlegen (idempotent)
npm run db:seed-auth
seed: ## NÖ-Katalog seeden: Merkmale/Vorlagen/Kategorien (idempotent)
npm run db:seed
seed-all: seed-auth seed ## Auth- + Katalog-Seed
db-check: ## Drizzle-Schema-/Migrationskonsistenz prüfen (offline)
npm run db:check
studio: ## Drizzle Studio öffnen (DB-Browser)
npm run db:studio
db-reset: ## ACHTUNG: Postgres-Volume löschen, neu migrieren + seeden
$(COMPOSE_DEV) rm -sf postgres
-docker volume rm florian-netz_postgres-data
$(MAKE) db-up db-wait migrate seed-all
# --- Erststart (lokal, von 0) --------------------------------------------
.PHONY: setup
setup: install env db-up db-wait migrate seed-all ## Komplettes lokales Setup von 0
@echo ""
@echo "✓ Setup fertig. Login-Admin via 'make seed-auth' angelegt. Weiter mit: make dev"
# --- Deployment (externes Traefik; braucht Docker) -----------------------
# Externes Netz muss existieren: docker network create frontend
.PHONY: build up up-core down logs ps deploy deploy-core migrate-stack data config
build: ## App-Image bauen (Next.js standalone, non-root)
$(COMPOSE) build app
up:
up: ## Stack starten (App + Postgres + Geo) hinter Traefik
$(COMPOSE) up -d
down:
up-core: ## Nur App + Postgres starten (OHNE Geo/OSRM/Nominatim) — wenig RAM nötig
$(COMPOSE) up -d --build app postgres
down: ## Stack stoppen
$(COMPOSE) down
logs:
logs: ## App-Logs folgen
$(COMPOSE) logs -f app
deploy: build up
ps: ## Status der Stack-Container
$(COMPOSE) ps
data:
deploy: build up ## build + up (voller Stack inkl. Geo; migrate via Entrypoint)
deploy-core: ## build + up-core (App + Postgres, ohne Geo; Geo später per 'make data' + 'make up')
$(MAKE) up-core
migrate-stack: ## Migrationen im laufenden App-Container ausführen (manuell)
$(COMPOSE) exec app node docker/migrate.mjs
data: ## OSRM-Geodaten vorbereiten (Download + Preprocessing; viel RAM/Disk)
./scripts/prepare-osm-data.sh
config:
config: ## Compose-Konfiguration validieren
$(COMPOSE) config --services

11
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,11 @@
# Lokale Entwicklung: veröffentlicht den Postgres-Port auf dem Host (5432),
# damit auf dem HOST laufende Befehle (`make migrate`, `make seed`, `npm run db:*`)
# die Datenbank über DATABASE_URL (…@localhost:5432/…) erreichen.
#
# Wird NUR von den lokalen DB-Zielen des Makefiles eingebunden
# (docker compose -f docker-compose.yml -f docker-compose.dev.yml …),
# NICHT vom Produktiv-Deploy — dort bleibt Postgres app-intern (kein offener Port).
services:
postgres:
ports:
- "5432:5432"

View File

@@ -1,18 +1,22 @@
# FlorianNetz — Basis-Compose hinter EXTERNEM Traefik.
#
# Ausgerichtet auf das bestehende Setup von feuerwehr_dashboard:
# - externes, von Traefik verwaltetes Netz heißt "frontend" (external: true)
# - Router: entrypoints=websecure, tls + certresolver=letsencrypt
# - explizite Router->Service-Bindung, loadbalancer.server.port=3000
# - traefik.docker.network = das externe "frontend"-Netz
#
# Es gibt bewusst KEINEN eigenen Proxy-/Traefik-Service: Routing/TLS übernimmt
# eine separat betriebene Traefik-Instanz, die am externen Netz "${TRAEFIK_NETWORK}"
# (Default: traefik) lauscht. Dieses Netz muss bereits existieren:
# docker network create traefik
# die separat betriebene Traefik-Instanz am Netz "${TRAEFIK_NETWORK}" (Default:
# frontend). Dieses Netz muss bereits existieren:
# docker network create frontend
#
# Geo-Dienste (osrm, nominatim) sind hier mit ihren Laufzeit-Verträgen definiert;
# das schwergewichtige Daten-Preprocessing/Volume kommt aus docker-compose.geo.yml
# (siehe scripts/prepare-osm-data.sh / infra/geo).
# Postgres/Geo liegen am internen Bridge-Netz (keine veröffentlichten Ports,
# also nicht öffentlich erreichbar) — der App-Container hat über dieses Netz
# zugleich Egress (z. B. für den Authentik-OIDC-Token-Austausch).
#
# Start:
# docker compose --env-file .env up -d
# Lokal ohne Traefik/TLS:
# docker compose -f docker-compose.yml -f docker-compose.override.yml up -d
# Start: docker compose --env-file .env up -d
# Lokal: docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
services:
app:
@@ -24,7 +28,7 @@ services:
condition: service_healthy
environment:
NODE_ENV: production
DATABASE_URL: postgres://${POSTGRES_USER:-floriannetz}:${POSTGRES_PASSWORD:-floriannetz}@postgres:5432/${POSTGRES_DB:-floriannetz}
DATABASE_URL: postgresql://${POSTGRES_USER:-floriannetz}:${POSTGRES_PASSWORD:-floriannetz}@postgres:5432/${POSTGRES_DB:-floriannetz}
# Forwarded-Header + sichere Cookies hinter Traefik.
AUTH_TRUST_HOST: "true"
AUTH_URL: https://${APP_HOST}
@@ -32,13 +36,14 @@ services:
AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER}
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID}
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET}
AUTHENTIK_ADMIN_GROUP: ${AUTHENTIK_ADMIN_GROUP:-floriannetz-admins}
OSRM_URL: http://osrm:5000
NOMINATIM_URL: http://nominatim:8080
GEO_HTTP_TIMEOUT_MS: ${GEO_HTTP_TIMEOUT_MS:-4000}
HAVERSINE_KMH: ${HAVERSINE_KMH:-50}
RUN_SEED: ${RUN_SEED:-false}
networks:
- traefik
- frontend
- internal
healthcheck:
test:
@@ -51,11 +56,12 @@ services:
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.docker.network=${TRAEFIK_NETWORK:-traefik}"
- "traefik.http.routers.floriannetz.rule=Host(`${APP_HOST}`)"
- "traefik.docker.network=${TRAEFIK_NETWORK:-frontend}"
- "traefik.http.routers.floriannetz.entrypoints=websecure"
- "traefik.http.routers.floriannetz.rule=Host(`${APP_HOST}`)"
- "traefik.http.routers.floriannetz.tls=true"
- "traefik.http.routers.floriannetz.tls.certresolver=${TRAEFIK_CERTRESOLVER:-letsencrypt}"
- "traefik.http.routers.floriannetz.service=floriannetz"
- "traefik.http.services.floriannetz.loadbalancer.server.port=3000"
# Security-Header-Middleware (zusätzlich zu next.config.ts; defense-in-depth).
- "traefik.http.routers.floriannetz.middlewares=floriannetz-sechdrs"
@@ -137,10 +143,12 @@ volumes:
nominatim-data:
networks:
# Externes, von der separaten Traefik-Instanz verwaltetes Netz.
traefik:
# Externes, von der separaten Traefik-Instanz verwaltetes Netz (wie im
# feuerwehr_dashboard "frontend"). Muss existieren: docker network create frontend
frontend:
external: true
name: ${TRAEFIK_NETWORK:-traefik}
# Internes Netz: Postgres/Geo sind nur app-intern erreichbar, nicht öffentlich.
name: ${TRAEFIK_NETWORK:-frontend}
# Internes Bridge-Netz: Postgres/Geo ohne veröffentlichte Ports (nicht
# öffentlich), zugleich Egress für den App-Container (Authentik-OIDC).
internal:
internal: true
driver: bridge

View File

@@ -0,0 +1,53 @@
# Authentik-Integration & Admin-Zugang (FlorianNetz)
FlorianNetz nutzt Authentik als **OIDC-Identitätsanbieter**. Der **Admin-Zugang
(platform_admin) wird zentral über eine Authentik-Gruppe** gesteuert — nicht über
manuell gesetzte DB-Rollen.
## Wie es funktioniert
- Anmeldung über Authentik (OIDC). FlorianNetz fordert die Scopes
`openid email profile groups` an.
- Im `signIn`-Callback (`src/auth.ts`) wird der `groups`-Claim ausgewertet:
- **Mitglied der Admin-Gruppe** (`AUTHENTIK_ADMIN_GROUP`, Standard
`floriannetz-admins`) → wird (idempotent) als `platform_admin` in `users`
angelegt/aktualisiert und eingeloggt.
- **Kein Mitglied** → Login wird **abgewiesen** (`return false`).
- Folge: Admins werden **in Authentik** verwaltet (Gruppenmitgliedschaft), nicht
per `seed-auth`. Ein erstes manuelles Seeding entfällt (kein Henne-Ei-Problem).
- **Wehr-Konten** (wehr_admin/wehr_read) bleiben **lokale** App-Konten
(E-Mail+Passwort), die Wehr-Admins selbst anlegen — sie nutzen NICHT Authentik.
## Einrichtung in Authentik
1. **Gruppe anlegen:** z. B. `floriannetz-admins`; gewünschte Admin-Benutzer
hinzufügen. (Muss exakt `AUTHENTIK_ADMIN_GROUP` entsprechen.)
2. **Provider anlegen:** OAuth2/OpenID Provider
- Redirect-URI: `https://<APP_HOST>/api/auth/callback/authentik`
- Signing Key wie üblich; Client-Typ „Confidential".
3. **Scopes/Property-Mappings:** dem Provider die Scope-Mappings
`openid`, `email`, `profile` **und** das Gruppen-Mapping zuweisen, das den
`groups`-Claim liefert (Authentik-Standard: „authentik default OAuth Mapping:
OpenID 'groups'"). Ohne dieses Mapping enthält das Token keine `groups` und
**niemand** erhält Admin-Zugang.
4. **Application anlegen** und mit dem Provider verknüpfen; Slug muss zum
`AUTHENTIK_ISSUER` passen (`…/application/o/<slug>/`).
5. **Client-ID/-Secret** aus dem Provider übernehmen.
## Umgebungsvariablen
```
AUTHENTIK_ISSUER=https://auth.example.at/application/o/floriannetz/
AUTHENTIK_CLIENT_ID=…
AUTHENTIK_CLIENT_SECRET=…
AUTHENTIK_ADMIN_GROUP=floriannetz-admins
```
## Prüfen
- Mitglied von `floriannetz-admins` meldet sich an → landet als Admin in
`/admin`; in `users` existiert eine Zeile `authTyp='authentik'`,
`rolle='platform_admin'`.
- Nicht-Mitglied meldet sich an → Login abgewiesen (zurück zu `/login`).
- `groups`-Claim fehlt (Mapping nicht zugewiesen) → alle SSO-Logins abgewiesen
(erwartetes Fail-safe-Verhalten: kein Claim ⇒ kein Admin).

View File

@@ -18,17 +18,20 @@ Der Stack besteht aus genau vier Services (kein Proxy):
Netze:
- **`traefik`** — externes, von Traefik verwaltetes Netz (`external: true`,
Name aus `TRAEFIK_NETWORK`, Default `traefik`). Nur `app` hängt daran.
- **`internal`** — internes Netz (`internal: true`); Postgres und die Geo-Dienste
sind ausschließlich für die App erreichbar, nie öffentlich.
- **`frontend`** — externes, von Traefik verwaltetes Netz (`external: true`,
Name aus `TRAEFIK_NETWORK`, Default `frontend` — wie im feuerwehr_dashboard).
Nur `app` hängt daran (Proxy↔App).
- **`internal`** — internes Bridge-Netz; Postgres und die Geo-Dienste haben
**keine veröffentlichten Ports** (nicht öffentlich erreichbar). Über dieses
Netz hat der App-Container zugleich **Egress** (z. B. für den
Authentik-OIDC-Token-Austausch).
## Voraussetzungen
Das externe Traefik-Netz muss existieren, bevor der Stack startet:
```bash
docker network create traefik
docker network create frontend
```
Die externe Traefik-Instanz muss:
@@ -36,7 +39,7 @@ Die externe Traefik-Instanz muss:
- einen Entrypoint `websecure` (Port 443) bereitstellen,
- einen Zertifikatsauflöser anbieten, dessen Name `TRAEFIK_CERTRESOLVER`
entspricht (Default `letsencrypt`),
- am Netz `traefik` lauschen (`providers.docker` mit `exposedByDefault=false`).
- am Netz `frontend` lauschen (`providers.docker` mit `exposedByDefault=false`).
Die App-Labels in `docker-compose.yml` setzen Router (`Host(\`${APP_HOST}\`)`,
`entrypoints=websecure`, `tls.certresolver`), Service-Port `3000` und eine
@@ -48,16 +51,17 @@ Vollständiger Vertrag in `.env.example`. Für den Betrieb hinter Traefik zwinge
| Variable | Beispiel / Hinweis |
| ------------------------- | ---------------------------------------------------- |
| `APP_HOST` | öffentlicher Hostname, z. B. `floriannetz.example.at` |
| `APP_HOST` | öffentlicher Hostname, z. B. `florian.feuerwehr-rems.at` |
| `AUTH_URL` | `https://${APP_HOST}` — Basis für Callback + Cookies |
| `AUTH_TRUST_HOST` | `true` — Auth.js vertraut den Forwarded-Headern |
| `AUTH_SECRET` | >= 32 Zeichen (`openssl rand -base64 32`) |
| `AUTHENTIK_ISSUER` | OIDC-Issuer-URL der Authentik-Anwendung |
| `AUTHENTIK_CLIENT_ID` | Client-ID der Authentik-Anwendung |
| `AUTHENTIK_CLIENT_SECRET` | Client-Secret der Authentik-Anwendung |
| `AUTHENTIK_ADMIN_GROUP` | Authentik-Gruppe → platform_admin (Default `floriannetz-admins`; s. authentik-setup.md) |
| `DATABASE_URL` | wird in Compose aus `POSTGRES_*` zusammengesetzt |
| `TRAEFIK_CERTRESOLVER` | Name des Traefik-Zertifikatsauflösers |
| `TRAEFIK_NETWORK` | Name des externen Traefik-Netzes (Default `traefik`) |
| `TRAEFIK_NETWORK` | Name des externen Traefik-Netzes (Default `frontend`) |
## Forwarded-Header & sichere Cookies
@@ -86,6 +90,10 @@ https://${APP_HOST}/api/auth/callback/authentik
Der Pfad `callback/authentik` entspricht dem NextAuth-Provider-Namen. Bei lokaler
Entwicklung zusätzlich `http://localhost:3000/api/auth/callback/authentik`.
**Admin-Zugang über Gruppe:** Dem Provider muss das `groups`-Scope-Mapping
zugewiesen sein, und es muss die Gruppe aus `AUTHENTIK_ADMIN_GROUP` existieren —
nur deren Mitglieder werden `platform_admin`. Details: `authentik-setup.md`.
## Health-Check & Middleware-Allowlist
`GET /api/health` ist **öffentlich** (anonym `200`, nur Liveness, keine
@@ -107,7 +115,7 @@ App-Healthcheck pingen `http://127.0.0.1:3000/api/health`.
```bash
cp .env.example .env # Werte setzen (APP_HOST, AUTH_*, AUTHENTIK_*, POSTGRES_*)
docker network create traefik # einmalig, falls nicht vorhanden
docker network create frontend # einmalig, falls nicht vorhanden
make data # einmalig: OSRM-Geodaten vorbereiten (groß, dauert)
make deploy # build + up
```

650
package-lock.json generated
View File

@@ -34,6 +34,7 @@
"@types/pg": "^8.11.11",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitest/coverage-v8": "^3.2.6",
"autoprefixer": "^10.4.20",
"drizzle-kit": "^0.30.4",
"eslint": "^9.21.0",
@@ -59,6 +60,20 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@ampproject/remapping": {
"version": "2.3.0",
"resolved": "https://npm.apple.com/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@auth/core": {
"version": "0.41.2",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@auth/core/-/core-0.41.2.tgz",
@@ -88,6 +103,64 @@
}
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.29.7",
"resolved": "https://npm.apple.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true,
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.7",
"resolved": "https://npm.apple.com/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.7"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.7",
"resolved": "https://npm.apple.com/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"dev": true,
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://npm.apple.com/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@drizzle-team/brocli": {
"version": "0.10.2",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
@@ -1687,6 +1760,33 @@
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://npm.apple.com/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"dev": true,
"dependencies": {
"string-width": "^5.1.2",
"string-width-cjs": "npm:string-width@^4.2.0",
"strip-ansi": "^7.0.1",
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
"wrap-ansi": "^8.1.0",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@istanbuljs/schema": {
"version": "0.1.6",
"resolved": "https://npm.apple.com/@istanbuljs/schema/-/schema-0.1.6.tgz",
"integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -2211,6 +2311,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://npm.apple.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"dev": true,
"optional": true,
"engines": {
"node": ">=14"
}
},
"node_modules/@playwright/test": {
"version": "1.60.0",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@playwright/test/-/test-1.60.0.tgz",
@@ -4011,6 +4121,39 @@
"win32"
]
},
"node_modules/@vitest/coverage-v8": {
"version": "3.2.6",
"resolved": "https://npm.apple.com/@vitest/coverage-v8/-/coverage-v8-3.2.6.tgz",
"integrity": "sha512-LsAdmUapA0qSN306d8+zOyawM0hFm2m2Hg9IwVNIKBm+qJV8cijiq2c+gxKZcB1HCfIWAy+0qEZDCUQA58A1cw==",
"dev": true,
"dependencies": {
"@ampproject/remapping": "^2.3.0",
"@bcoe/v8-coverage": "^1.0.2",
"ast-v8-to-istanbul": "^0.3.3",
"debug": "^4.4.1",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-lib-source-maps": "^5.0.6",
"istanbul-reports": "^3.1.7",
"magic-string": "^0.30.17",
"magicast": "^0.3.5",
"std-env": "^3.9.0",
"test-exclude": "^7.0.1",
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "3.2.6",
"vitest": "3.2.6"
},
"peerDependenciesMeta": {
"@vitest/browser": {
"optional": true
}
}
},
"node_modules/@vitest/expect": {
"version": "3.2.6",
"resolved": "https://npm.apple.com/@vitest/expect/-/expect-3.2.6.tgz",
@@ -4160,6 +4303,19 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://npm.apple.com/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://npm.apple.com/ansi-styles/-/ansi-styles-4.3.0.tgz",
@@ -4397,6 +4553,25 @@
"integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==",
"dev": true
},
"node_modules/ast-v8-to-istanbul": {
"version": "0.3.12",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz",
"integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.31",
"estree-walker": "^3.0.3",
"js-tokens": "^10.0.0"
}
},
"node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
"version": "10.0.0",
"resolved": "https://npm.apple.com/js-tokens/-/js-tokens-10.0.0.tgz",
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
"dev": true,
"license": "MIT"
},
"node_modules/async-function": {
"version": "1.0.0",
"resolved": "https://npm.apple.com/async-function/-/async-function-1.0.0.tgz",
@@ -5155,6 +5330,12 @@
"node": ">= 0.4"
}
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://npm.apple.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"dev": true
},
"node_modules/electron-to-chromium": {
"version": "1.5.368",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz",
@@ -5977,6 +6158,22 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://npm.apple.com/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"dev": true,
"dependencies": {
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/fraction.js": {
"version": "5.3.4",
"resolved": "https://npm.apple.com/fraction.js/-/fraction.js-5.3.4.tgz",
@@ -6176,6 +6373,27 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/glob": {
"version": "10.5.0",
"resolved": "https://npm.apple.com/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"dev": true,
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://npm.apple.com/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -6188,6 +6406,30 @@
"node": ">=10.13.0"
}
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "2.1.1",
"resolved": "https://npm.apple.com/brace-expansion/-/brace-expansion-2.1.1.tgz",
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
"dev": true,
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/glob/node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://npm.apple.com/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"dependencies": {
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/globals": {
"version": "14.0.0",
"resolved": "https://npm.apple.com/globals/-/globals-14.0.0.tgz",
@@ -6317,6 +6559,13 @@
"node": ">= 0.4"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://npm.apple.com/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://npm.apple.com/ignore/-/ignore-5.3.2.tgz",
@@ -6541,6 +6790,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://npm.apple.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/is-generator-function": {
"version": "1.1.2",
"resolved": "https://npm.apple.com/is-generator-function/-/is-generator-function-1.1.2.tgz",
@@ -6770,6 +7028,58 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://npm.apple.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://npm.apple.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-lib-source-maps": {
"version": "5.0.6",
"resolved": "https://npm.apple.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
"integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.23",
"debug": "^4.1.1",
"istanbul-lib-coverage": "^3.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/iterator.prototype": {
"version": "1.1.5",
"resolved": "https://npm.apple.com/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
@@ -6787,6 +7097,22 @@
"node": ">= 0.4"
}
},
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://npm.apple.com/jackspeak/-/jackspeak-3.4.3.tgz",
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
},
"optionalDependencies": {
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/jiti": {
"version": "1.21.7",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/jiti/-/jiti-1.21.7.tgz",
@@ -6979,6 +7305,12 @@
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
"dev": true
},
"node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://npm.apple.com/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/magic-string/-/magic-string-0.30.21.tgz",
@@ -6989,6 +7321,33 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/magicast": {
"version": "0.3.5",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/magicast/-/magicast-0.3.5.tgz",
"integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.25.4",
"@babel/types": "^7.25.4",
"source-map-js": "^1.2.0"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://npm.apple.com/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -7043,6 +7402,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://npm.apple.com/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"dev": true,
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://npm.apple.com/ms/-/ms-2.1.3.tgz",
@@ -7449,6 +7817,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://npm.apple.com/parent-module/-/parent-module-1.0.1.tgz",
@@ -7485,6 +7860,22 @@
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true
},
"node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://npm.apple.com/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"dev": true,
"dependencies": {
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
},
"engines": {
"node": ">=16 || 14 >=14.18"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://npm.apple.com/pathe/-/pathe-2.0.3.tgz",
@@ -8625,6 +9016,18 @@
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://npm.apple.com/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://npm.apple.com/source-map/-/source-map-0.6.1.tgz",
@@ -8697,6 +9100,68 @@
"node": ">= 0.4"
}
},
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://npm.apple.com/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"dev": true,
"dependencies": {
"eastasianwidth": "^0.2.0",
"emoji-regex": "^9.2.2",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/string-width-cjs": {
"name": "string-width",
"version": "4.2.3",
"resolved": "https://npm.apple.com/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string-width-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/string-width-cjs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/string-width-cjs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://npm.apple.com/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string.prototype.includes": {
"version": "2.0.1",
"resolved": "https://npm.apple.com/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
@@ -8805,6 +9270,46 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://npm.apple.com/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/strip-ansi-cjs": {
"name": "strip-ansi",
"version": "6.0.1",
"resolved": "https://npm.apple.com/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/strip-bom": {
"version": "3.0.0",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/strip-bom/-/strip-bom-3.0.0.tgz",
@@ -9012,6 +9517,57 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/test-exclude": {
"version": "7.0.2",
"resolved": "https://npm.apple.com/test-exclude/-/test-exclude-7.0.2.tgz",
"integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==",
"dev": true,
"dependencies": {
"@istanbuljs/schema": "^0.1.2",
"glob": "^10.4.1",
"minimatch": "^10.2.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/test-exclude/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://npm.apple.com/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/test-exclude/node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://npm.apple.com/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/test-exclude/node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://npm.apple.com/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://npm.apple.com/thenify/-/thenify-3.3.1.tgz",
@@ -10495,6 +11051,7 @@
"resolved": "https://npm.apple.com/vitest/-/vitest-3.2.6.tgz",
"integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==",
"dev": true,
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.6",
@@ -10702,6 +11259,99 @@
"node": ">=0.10.0"
}
},
"node_modules/wrap-ansi": {
"version": "8.1.0",
"resolved": "https://npm.apple.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.1.0",
"string-width": "^5.0.1",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs": {
"name": "wrap-ansi",
"version": "7.0.0",
"resolved": "https://npm.apple.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://npm.apple.com/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://npm.apple.com/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi/node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/xtend/-/xtend-4.0.2.tgz",

View File

@@ -50,6 +50,7 @@
"@types/pg": "^8.11.11",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitest/coverage-v8": "^3.2.6",
"autoprefixer": "^10.4.20",
"drizzle-kit": "^0.30.4",
"eslint": "^9.21.0",

3
public/.gitkeep Normal file
View File

@@ -0,0 +1,3 @@
# Platzhalter, damit das public/-Verzeichnis existiert und vom Docker-Build
# (COPY /app/public ./public) sowie von Next.js (statische Assets) genutzt werden
# kann. Statische Dateien (z. B. favicon.ico, robots.txt) hier ablegen.

View File

@@ -30,6 +30,9 @@ export const authConfig = {
issuer: process.env.AUTHENTIK_ISSUER!,
clientId: process.env.AUTHENTIK_CLIENT_ID!,
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET!,
// `groups`-Claim anfordern, damit der Admin-Zugang über die
// Authentik-Gruppenmitgliedschaft gesteuert werden kann (signIn-Callback).
authorization: { params: { scope: "openid email profile groups" } },
}),
],
callbacks: {

View File

@@ -7,12 +7,47 @@ import { users } from "@/db/schema";
import { authConfig } from "./auth.config";
import { verifyPassword } from "@/lib/auth/password";
import { checkRateLimit, recordAttempt } from "@/lib/auth/rate-limit";
import { extractGroups, isAdminGroupMember } from "@/lib/auth/authentik";
import { ROLES } from "@/lib/auth/roles";
import { env } from "@/lib/env";
const credSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
/**
* Stellt sicher, dass ein Authentik-Admin (Mitglied der Admin-Gruppe) als
* platform_admin in `users` existiert — für eine stabile id (Audit/FKs).
* Idempotent über die eindeutige E-Mail; benötigt KEIN vorheriges Seeding.
*/
async function upsertAuthentikAdmin(email: string, name: string | null) {
const normalized = email.toLowerCase();
const rows = await db
.insert(users)
.values({
email: normalized,
name: name ?? normalized,
rolle: ROLES.PLATFORM_ADMIN,
authTyp: "authentik",
aktiv: true,
brigadeId: null,
})
.onConflictDoUpdate({
target: users.email,
set: {
rolle: ROLES.PLATFORM_ADMIN,
authTyp: "authentik",
aktiv: true,
...(name ? { name } : {}),
},
})
.returning();
const row = rows[0];
if (!row) throw new Error("Authentik-Admin-Upsert lieferte keine Zeile");
return row;
}
export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig,
providers: [
@@ -50,17 +85,32 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
],
callbacks: {
...authConfig.callbacks,
// Authentik-Login-Gate: nur vorgemerkte, aktive authentik-Konten zulassen.
async signIn({ user, account }) {
// Authentik-Login = Admin-Zugang, gesteuert über die Authentik-GRUPPE:
// Nur Mitglieder von AUTHENTIK_ADMIN_GROUP dürfen rein und werden
// (idempotent) als platform_admin angelegt. Alle anderen werden abgewiesen.
async signIn({ user, account, profile }) {
if (account?.provider === "authentik") {
const email = user.email;
if (!email) return false;
const u = await db.query.users.findFirst({
where: eq(users.email, email),
});
if (!u || !u.aktiv || u.authTyp !== "authentik") return false;
if (!email) {
console.warn("[auth] Authentik-Login ohne E-Mail abgelehnt.");
return false;
}
const groups = extractGroups(profile);
if (!isAdminGroupMember(groups, env.AUTHENTIK_ADMIN_GROUP)) {
console.warn(
`[auth] Authentik-Login abgelehnt: "${email}" ist nicht in Gruppe ` +
`"${env.AUTHENTIK_ADMIN_GROUP}". Erhaltene Gruppen: ${JSON.stringify(groups)}` +
(groups.length
? ""
: " — leer: vermutlich fehlt das 'groups'-Scope-Mapping im " +
"Authentik-Provider (oder der 'groups'-Scope wird nicht angefragt)."),
);
return false;
}
const u = await upsertAuthentikAdmin(email, user.name ?? null);
user.id = u.id;
user.role = u.rolle;
user.brigadeId = u.brigadeId ?? null;
user.brigadeId = null;
}
return true;
},

View File

@@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const VALID_ENV = {
NODE_ENV: "test",
DATABASE_URL: "postgres://user:pass@localhost:5432/floriannetz",
DATABASE_URL: "postgresql://user:pass@localhost:5432/floriannetz",
AUTH_SECRET: "x".repeat(32),
AUTH_URL: "http://localhost:3000",
AUTHENTIK_ISSUER: "http://localhost:9000/application/o/floriannetz/",

View File

@@ -0,0 +1,38 @@
import { describe, it, expect } from "vitest";
import { extractGroups, isAdminGroupMember } from "@/lib/auth/authentik";
describe("isAdminGroupMember", () => {
it("true, wenn die Admin-Gruppe enthalten ist", () => {
expect(
isAdminGroupMember(["wehr-x", "floriannetz-admins"], "floriannetz-admins"),
).toBe(true);
});
it("false, wenn die Admin-Gruppe fehlt", () => {
expect(isAdminGroupMember(["wehr-x"], "floriannetz-admins")).toBe(false);
});
it("false bei leerer Gruppenliste", () => {
expect(isAdminGroupMember([], "floriannetz-admins")).toBe(false);
});
});
describe("extractGroups", () => {
it("liest den groups-Claim", () => {
expect(extractGroups({ sub: "1", groups: ["a", "b"] })).toEqual(["a", "b"]);
});
it("leeres Array, wenn kein groups-Claim vorhanden ist", () => {
expect(extractGroups({ sub: "1" })).toEqual([]);
});
it("leeres Array bei undefined/null", () => {
expect(extractGroups(undefined)).toEqual([]);
expect(extractGroups(null)).toEqual([]);
});
it("defensiv: ignoriert nicht-string-Arrays", () => {
expect(extractGroups({ groups: "kein-array" })).toEqual([]);
expect(extractGroups({ groups: [1, 2, 3] })).toEqual([]);
});
});

25
src/lib/auth/authentik.ts Normal file
View File

@@ -0,0 +1,25 @@
import { z } from "zod";
/**
* Reine Helfer für die Authentik-Gruppensteuerung des Admin-Zugangs.
* Bewusst OHNE DB-/Node-Importe, damit sie isoliert unit-testbar sind und
* auch im Edge-Pfad unbedenklich wären.
*/
const profileWithGroups = z
.object({ groups: z.array(z.string()).optional() })
.passthrough();
/** Extrahiert den `groups`-Claim aus dem Authentik-OIDC-Profil (defensiv). */
export function extractGroups(profile: unknown): string[] {
const parsed = profileWithGroups.safeParse(profile);
return parsed.success && parsed.data.groups ? parsed.data.groups : [];
}
/** Entscheidung: Ist der Benutzer Mitglied der konfigurierten Admin-Gruppe? */
export function isAdminGroupMember(
groups: readonly string[],
adminGroup: string,
): boolean {
return groups.includes(adminGroup);
}

View File

@@ -10,6 +10,8 @@ const serverSchema = z.object({
AUTHENTIK_ISSUER: z.string().url(),
AUTHENTIK_CLIENT_ID: z.string().min(1),
AUTHENTIK_CLIENT_SECRET: z.string().min(1),
// Authentik-Gruppe, deren Mitglieder automatisch platform_admin werden.
AUTHENTIK_ADMIN_GROUP: z.string().min(1).default("floriannetz-admins"),
// Geo:
OSRM_URL: z.string().url().default("http://osrm:5000"),
NOMINATIM_URL: z.string().url().default("http://nominatim:8080"),

View File

@@ -61,4 +61,46 @@ describe("orderByEintreffzeit", () => {
const result = await orderByEintreffzeit(origin, [a, b], etaTable);
expect(result.map((r) => r.id)).toEqual(["a", "b"]);
});
it("(f) OSRM ohne Route fuer EINEN Kandidaten: einzelner Haversine-Fallback", async () => {
// OSRM liefert eine Tabelle, kennt aber fuer 'fern' keine Route (null).
// -> nur dieser Treffer faellt auf Haversine zurueck, 'nah' bleibt osrm.
const etaTable = vi.fn().mockResolvedValue({
durations: [[0, 120, null]], // origin -> [nah=120s, fern=keine Route]
distances: [[0, 3000, null]],
});
const result = await orderByEintreffzeit(origin, [nah, fern], etaTable);
const nahRes = result.find((r) => r.id === "nah")!;
const fernRes = result.find((r) => r.id === "fern")!;
expect(nahRes.eta.mode).toBe("osrm");
expect(nahRes.eta.isFallback).toBe(false);
expect(nahRes.eta.durationSec).toBe(120);
// 'fern' ohne OSRM-Route -> Haversine-Fallback mit gesetzter Dauer.
expect(fernRes.eta.mode).toBe("haversine");
expect(fernRes.eta.isFallback).toBe(true);
expect(fernRes.eta.durationSec).not.toBeNull();
});
it("(g) nur Kandidaten ohne Koordinaten: OSRM wird nicht gerufen, alle ans Ende", async () => {
// coords.length === 0 -> computeEtas ruft etaTable gar nicht auf.
const ohne2: Hit = { id: "ohne2", brigadeCoords: null };
const etaTable = vi.fn().mockResolvedValue({ durations: [[0]], distances: [[0]] });
const result = await orderByEintreffzeit(origin, [ohne, ohne2], etaTable);
expect(etaTable).not.toHaveBeenCalled();
expect(result.map((r) => r.id)).toEqual(["ohne", "ohne2"]);
expect(result.every((r) => r.eta.durationSec === null)).toBe(true);
});
it("(h) OSRM-Tabelle ohne distances-Zeile: distanceMeters faellt auf null", async () => {
// distances fehlt komplett -> `?? []` greift, distanceMeters wird null,
// durationSec aus durations bleibt jedoch erhalten (mode=osrm).
const etaTable = vi.fn().mockResolvedValue({
durations: [[0, 90]],
// kein distances-Feld
});
const result = await orderByEintreffzeit(origin, [nah], etaTable);
expect(result[0]!.eta.mode).toBe("osrm");
expect(result[0]!.eta.durationSec).toBe(90);
expect(result[0]!.eta.distanceMeters).toBeNull();
});
});

View File

@@ -2,17 +2,19 @@
* Sicherheits-Header, eingehängt in next.config.ts.
*
* Content-Security-Policy ist der zentrale Querschnitts-Schutz (Implementierungs-
* plan Z.1314): in Produktion strikt mit default-src 'self', frame-ancestors 'none'
* und form-action 'self'. Im Dev-Modus benötigt Next.js (HMR/React-Refresh) eine
* gelockerte script-src/connect-src-Variante ('unsafe-eval' + ws: für den Dev-Socket).
* plan Z.1314): default-src 'self', frame-ancestors 'none', form-action 'self',
* object-src 'none'. script-src erlaubt 'unsafe-inline' (KEIN 'unsafe-eval' in
* Prod), da Next.js (App Router) Inline-Bootstrap-/Hydration-Skripte ohne Nonce
* ausliefert — eine strikte nonce-basierte CSP ginge nur über die Middleware
* (Hardening-Option). Im Dev zusätzlich 'unsafe-eval' + ws: (HMR/React-Refresh).
*/
const isProd = process.env.NODE_ENV === "production";
const CSP = [
"default-src 'self'",
// Dev braucht eval (React Refresh) + inline; Prod bleibt strikt.
// Next.js braucht Inline-Skripte (Bootstrap/Hydration, ohne Nonce); Dev zusätzlich eval.
isProd
? "script-src 'self'"
? "script-src 'self' 'unsafe-inline'"
: "script-src 'self' 'unsafe-eval' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",

View File

@@ -40,8 +40,11 @@ export function filePathToRoute(filePath: string): string {
}
function isPublic(route: string): boolean {
// Nur exakter Treffer oder echtes Unterpfad-Segment zählt als öffentlich.
// KEIN reiner String-Präfix: sonst wären z. B. "/loginhelp" oder
// "/api/healthz" fälschlich anonym erreichbar und entkämen dem Drift-Check.
return PUBLIC_ALLOWLIST.some(
(p) => route === p || route.startsWith(p + "/") || route.startsWith(p),
(p) => route === p || route.startsWith(p + "/"),
);
}

View File

@@ -60,6 +60,18 @@ describe("findUndeclaredRoutes", () => {
expect(findUndeclaredRoutes(discovered, declared)).toEqual([]);
});
it("flaggt Routen mit reinem String-Präfix als ungetestet (kein Pfadsegment)", () => {
const declared = new Set<string>();
// "/loginhelp" beginnt zwar mit "/login" und "/api/healthz" mit
// "/api/health", sind aber KEINE Unterpfade -> müssen gegated werden.
const discovered = ["/loginhelp", "/api/healthz", "/api/authentication"];
expect(findUndeclaredRoutes(discovered, declared)).toEqual([
"/loginhelp",
"/api/healthz",
"/api/authentication",
]);
});
it("PUBLIC_ALLOWLIST enthält /api/health und /login", () => {
expect(PUBLIC_ALLOWLIST).toContain("/api/health");
expect(PUBLIC_ALLOWLIST).toContain("/login");

View File

@@ -19,9 +19,31 @@ export default defineConfig({
coverage: {
provider: "v8",
// Querschnitt-Kern muss hoch abgedeckt sein (Definition of Done #7):
// src/lib/search und src/lib/geo >= 90 %.
// die REINE, offline testbare Logik in src/lib/search und src/lib/geo
// >= 90 %. Reine I/O-Wrapper (DB-Queries via Drizzle, HTTP zu
// Nominatim/OSRM) sowie reine Typ-Module sind hier ausgenommen: sie
// brauchen ein laufendes Postgres bzw. erreichbare Dienste und werden
// über Integrations-/E2E-Tests abgesichert (im Sandbox deferred, da
// kein Postgres/Server verfügbar).
include: ["src/lib/search/**", "src/lib/geo/**"],
exclude: [
// Reine Typdefinitionen (keine ausführbaren Zeilen).
"src/lib/search/types.ts",
"src/lib/geo/types.ts",
// DB-gebundene Query-Builder (Drizzle gegen Postgres).
"src/lib/search/facets.ts",
"src/lib/search/query-brigades.ts",
"src/lib/search/query-equipment.ts",
"src/lib/search/query-vehicles.ts",
"src/lib/geo/candidates.ts",
// Externe HTTP-Dienste (Nominatim-Geocoding, OSRM-Routing).
"src/lib/geo/nominatim.ts",
"src/lib/geo/osrm.ts",
],
// Per-Datei statt global: jede eingeschlossene reine-Logik-Datei muss
// die Schwelle selbst erreichen (kein Verwässern über den Durchschnitt).
thresholds: {
perFile: true,
lines: 90,
functions: 90,
statements: 90,

View File

@@ -3,7 +3,7 @@
// Es wird KEINE echte DB-Verbindung geöffnet (Pool ist lazy bis zur Query).
const TEST_ENV: Record<string, string> = {
NODE_ENV: "test",
DATABASE_URL: "postgres://test:test@localhost:5432/test",
DATABASE_URL: "postgresql://test:test@localhost:5432/test",
AUTH_SECRET: "test-secret-mindestens-32-zeichen-lang-xxxx",
AUTH_URL: "http://localhost:3000",
AUTH_TRUST_HOST: "true",