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>
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 localUseris provisioned just-in-time on first login from the token claims (resolveOrProvisionAuthentikUser); role + KC/Gemeinde scope then come from localMembershiprows (onlystatus = ACTIVEones count). A freshly provisioned user has no membership and thus no rights until one is granted (LT: manually for now; Verantwortliche: theonboarding/approval flow). Clients perform the Authorization Code + PKCE flow against Authentik directly. - Gemeinde Teamer are local accounts (no Authentik): a
Userrow with apasswordHashandkcIdset,authentikSubleft null. A Gemeinde Verantwortliche/r creates them directly or via aTeamerInvite(shareable group link or per-email invite). Login isPOST /auth/team-login(email + password) orPOST /auth/teamer/register(redeem an invite token); both return a JWT signed withTEAM_JWT_SECRETand carryingtyp: "team".TeamJwtStrategy(AuthGuard('team')) resolves it to the same shape asAuthentikStrategy, so guards/controllers treat both alike. - Guests/Konfis get a temporary local account (first/last name required, no
Authentik) created via
POST /auth/guestwith a KC invite code, returning a JWT signed withGUEST_JWT_SECRET.
Modules implemented so far
prisma/— sharedPrismaClientprovider.auth/— Authentik resource-server strategy (AuthGuard('authentik')), guest invite-code login (AuthGuard('guest')), and local Gemeinde Teamer auth (AuthGuard('team')):POST /auth/team-loginandPOST /auth/teamer/register(invite redemption), bcrypt hashes, tokens signed withTEAM_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 theirMembership, 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 inTeamerService, sinceRolesGuardonly scopes bykcId). 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/verantwortlichetakes the caller's raw Authentik bearer token (no localMembershipneeded yet), JIT-provisions the localUserfrom the token claims, and creates aMembershipwithstatus = PENDING. Leitungsteam reviews viaGET /onboarding/requests?kcId=andPOST /onboarding/requests/:id/approveor.../reject. Auth strategies only loadACTIVEmemberships, so a pending request grants nothing until approved.wahl/— Wahl/Workshop administration (Leitungsteam-only), guest Teilnehmer submission, Force-Zuteilung overrides, andZuteilungService: a faithful port of the WP plugin'skc_run_zuteilung(force-assignments → up to 3 wish rounds → random fill → consolidation of workshops that stay belowminTeilnehmer), plus CSV export (GET /wahl/:id/zuteilung/csv).files/— Leitungsteam-only upload (POST /files/:kcId, multipart) tagged with aFileVisibilitytier; 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 aStorageProviderabstraction: defaults to Nextcloud via WebDAV (WEBDAV_*env vars), switchable to S3-compatible storage withSTORAGE_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 rawwsgateway (ChatGateway, path/chat) since passport guards don't apply to WS upgrades — auth happens once via?token=at connect time (TokenVerificationServicetries Authentik JWKS, then falls back to a guest token). Access rules live inChatServiceand 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-onlySyncLogEntrylog tagged with this server'sSERVER_ID. The local server (setSYNC_ENABLED=true,SYNC_PEER_URL) periodically pushes its new entries to the cloud'sPOST /sync/ingestand pulls the cloud's viaGET /sync/export(SyncSchedulerService, every 30s), both guarded bySYNC_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/triggerlets 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/—Roleenum,@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).