Files
KC-APP-Server/README.md
T
linusandClaude Sonnet 5 f03b209e84 feat(backend): JIT-provision the local User on first Authentik login
AuthentikStrategy no longer rejects a valid token whose user has no local
row — it creates the User from the token claims (given_name/family_name/
email) via the new shared resolveOrProvisionAuthentikUser helper, which is
race-safe (P2002 -> re-read) and captures the User to the sync log. The WS
token path (TokenVerificationService.verifyAuthentik) and OnboardingService
now use the same helper, removing three copies of the lookup/create logic.

A provisioned user still has no Membership and therefore no rights: LT role
assignment from Authentik groups is the remaining gap; Verantwortliche go
through the onboarding approval flow.

Tests: provision-user.spec.ts (existing/new/race/rethrow); npm test green
at 51. Docs updated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 08:05:18 +02:00

6.8 KiB

KC-App Backend

NestJS API for the KC-App platform (see repo root README + plan for architecture context).

Setup

npm install
cp .env.example .env   # then fill in DATABASE_URL / AUTHENTIK_ISSUER_URL / GUEST_JWT_SECRET / TEAM_JWT_SECRET
npx prisma generate
npx prisma migrate dev --name init   # requires a running PostgreSQL instance
npm run start:dev

The API is served under /api (see app.setGlobalPrefix('api') in main.ts); everything else (/, /app.js, ...) is served statically from ../client/web via ServeStaticModule, so the backend doubles as the web client's host - no separate web server is needed.

Auth model

  • Leitungsteam and Gemeinde Verantwortliche sign in with Authentik (the "Konfi-Castle-ID"); this API acts as an OIDC resource server, verifying access tokens against Authentik's JWKS (AuthentikStrategy). The local User is provisioned just-in-time on first login from the token claims (resolveOrProvisionAuthentikUser); role + KC/Gemeinde scope then come from local Membership rows (only status = ACTIVE ones count). A freshly provisioned user has no membership and thus no rights until one is granted (LT: manually for now; Verantwortliche: the onboarding/ approval flow). Clients perform the Authorization Code + PKCE flow against Authentik directly.
  • Gemeinde Teamer are local accounts (no Authentik): a User row with a passwordHash and kcId set, authentikSub left null. A Gemeinde Verantwortliche/r creates them directly or via a TeamerInvite (shareable group link or per-email invite). Login is POST /auth/team-login (email + password) or POST /auth/teamer/register (redeem an invite token); both return a JWT signed with TEAM_JWT_SECRET and carrying typ: "team". TeamJwtStrategy (AuthGuard('team')) resolves it to the same shape as AuthentikStrategy, so guards/controllers treat both alike.
  • Guests/Konfis get a temporary local account (first/last name required, no Authentik) created via POST /auth/guest with a KC invite code, returning a JWT signed with GUEST_JWT_SECRET.

Modules implemented so far

  • prisma/ — shared PrismaClient provider.
  • auth/ — Authentik resource-server strategy (AuthGuard('authentik')), guest invite-code login (AuthGuard('guest')), and local Gemeinde Teamer auth (AuthGuard('team')): POST /auth/team-login and POST /auth/teamer/register (invite redemption), bcrypt hashes, tokens signed with TEAM_JWT_SECRET. TokenVerificationService (WS handshake) now accepts Authentik, team, or guest tokens.
  • kc/ — KC (event) creation/listing, Leitungsteam-only.
  • gemeinde/ — Gemeinde (congregation) CRUD per KC (POST /gemeinde, GET /gemeinde?kcId=, GET/PATCH/DELETE /gemeinde/:id), Leitungsteam-only. Gemeinde Verantwortliche/Teamer get their own Gemeinde from their Membership, not from this endpoint.
  • teamer/ — local Gemeinde Teamer accounts + invites, under /gemeinde/:gemeindeId/...: POST/GET teamer, DELETE teamer/:userId, POST/GET teamer-invites, DELETE teamer-invites/:inviteId. Callable by Leitungsteam (any Gemeinde) or a Verantwortliche/r for their own Gemeinde (enforced in TeamerService, since RolesGuard only scopes by kcId). Files/chat read endpoints accept 'team' tokens too, so Teamer see non-Konfi files and chat.
  • onboarding/ — self-registration for Gemeinde Verantwortliche. GET /onboarding/kc/:inviteCode (public) returns the KC name + its Gemeinden to pick from. POST /onboarding/verantwortliche takes the caller's raw Authentik bearer token (no local Membership needed yet), JIT-provisions the local User from the token claims, and creates a Membership with status = PENDING. Leitungsteam reviews via GET /onboarding/requests?kcId= and POST /onboarding/requests/:id/approve or .../reject. Auth strategies only load ACTIVE memberships, so a pending request grants nothing until approved.
  • wahl/ — Wahl/Workshop administration (Leitungsteam-only), guest Teilnehmer submission, Force-Zuteilung overrides, and ZuteilungService: a faithful port of the WP plugin's kc_run_zuteilung (force-assignments → up to 3 wish rounds → random fill → consolidation of workshops that stay below minTeilnehmer), plus CSV export (GET /wahl/:id/zuteilung/csv).
  • files/ — Leitungsteam-only upload (POST /files/:kcId, multipart) tagged with a FileVisibility tier; list/download (GET /files/:kcId, GET /files/download/:fileId) accept either an Authentik or a guest token and filter by the caller's allowed visibility tiers. Storage is behind a StorageProvider abstraction: defaults to Nextcloud via WebDAV (WEBDAV_* env vars), switchable to S3-compatible storage with STORAGE_PROVIDER=s3 (S3_* env vars).
  • chat/ — Gemeinde-Gruppenchat, 1:1 Direktnachrichten, Leitungsteam-über- greifende Kanäle und Broadcast (Konfis lesen nur). Channel administration and message history are plain REST (ChatController); real-time send/ receive is a raw ws gateway (ChatGateway, path /chat) since passport guards don't apply to WS upgrades — auth happens once via ?token= at connect time (TokenVerificationService tries Authentik JWKS, then falls back to a guest token). Access rules live in ChatService and are shared between the REST and WS entry points.
  • sync/ — replicates mutations between the local (on-site) and cloud server. SyncService.capture() is called by feature services right after a write, appending an entry to the append-only SyncLogEntry log tagged with this server's SERVER_ID. The local server (set SYNC_ENABLED=true, SYNC_PEER_URL) periodically pushes its new entries to the cloud's POST /sync/ingest and pulls the cloud's via GET /sync/export (SyncSchedulerService, every 30s), both guarded by SYNC_SHARED_SECRET (SyncSecretGuard) rather than user auth. No conflict resolution is implemented by design — the local server is the sole source of truth while an event is live. POST /sync/trigger lets a Leitungsteam member force an immediate push+pull. Known gap: only entity metadata is replicated; uploaded file bytes only resolve on both sides if local and cloud share the same Nextcloud/S3 backend.
  • common/Role enum, @Roles() decorator, RolesGuard (KC-scoped, Leitungsteam roles are global across all KCs).

All planned backend phases are implemented. npm test runs Jest unit tests (ZuteilungService, TeamAuthService, TeamerService, OnboardingService, resolveOrProvisionAuthentikUser; Prisma mocked). Remaining work: the Flutter clients (see repo root README), deriving the LT Membership from Authentik group claims (the User is provisioned, the role is not), invite email delivery, and the first real Prisma migration (only schema.prisma exists so far).