Files
KC-APP-Server/README.md
T
linus 288628f20e
CybeDefend Security Scan / cybedefend_scan (push) Failing after 19s
CybeDefend Security Scan / cybedefend_scan (pull_request) Failing after 1s
feat(chat): free-form GRUPPE channels with mutable participants
- ChatChannelType.GRUPPE: created by Leitungsteam (any KC) or a Gemeinde
  Verantwortliche/r (own KC), mixing team users and guests/Konfis as
  explicit ChatParticipant rows (unlike GEMEINDE_GRUPPE, membership is
  not derived from Gemeinde)
- POST /chat/:kcId/gruppen to create, GET participant-candidates, and
  POST/DELETE /chat/gruppen/:channelId/participants to manage membership
  (creator, LT, or Verantwortliche/r of that KC)
- ChatGateway broadcasts chat:participants-changed on membership change
- PushService updated for nullable ChatParticipant.userId + new
  guestAccountId column
- SyncService now replicates ChatParticipant
- Prisma migration + 14 new unit tests (75/75 passing), tsc clean
- CI: add .gitea/workflows/cybedefend-scan.yml + .cybedefend project config
2026-09-12 13:25:48 +02:00

152 lines
9.2 KiB
Markdown

# KC-App Backend
NestJS API for the KC-App platform. Split out of the main KC-APP monorepo
(https://git.konfi-castle.com/linus/KC-APP); the Flutter clients live there.
## Setup
```bash
npm install
cp .env.example .env # DATABASE_URL / AUTHENTIK_ISSUER_URL / AUTHENTIK_LEITUNGSTEAM_GROUP /
# GUEST_JWT_SECRET / TEAM_JWT_SECRET / APP_BASE_URL (+ MAIL_* for real email)
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`), and `User.isLeitungsteam` is
reconciled on every login from the token's `groups` claim vs.
`AUTHENTIK_LEITUNGSTEAM_GROUP``toAuthenticatedUser` then synthesises a
virtual global `LEITUNGSTEAM` membership from that flag. Other roles come
from local `Membership` rows (only `status = ACTIVE` ones count).
Verantwortliche self-provision through the `onboarding/` approval flow;
a user with neither the LT flag nor a membership has no rights. 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. A personal
invite (with `email`) is mailed via `MailService`; the response carries
`emailSent`. Group-link invites (no `email`) are shared by hand.
- `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.
- `mail/` — global `MailProvider` abstraction (mirrors `files/storage/`):
default `log` provider only logs what it would send; `MAIL_PROVIDER=smtp`
uses a real `nodemailer` SMTP transport (`SMTP_*`, `MAIL_FROM`).
`MailService.sendTeamerInvite()` composes the personal-invite email with a
link built from `APP_BASE_URL`. Delivery is best-effort — failures are
logged and swallowed, never blocking the invite.
- `push/` — global `PushProvider` abstraction; default `log`, `PUSH_PROVIDER=fcm`
uses FCM HTTP v1 (service-account JWT → OAuth token, no extra dep;
`FCM_PROJECT_ID`, `GOOGLE_APPLICATION_CREDENTIALS`). `DeviceToken` rows
(bound to a `User` or `GuestAccount`) via `POST /push/register` +
`/unregister`. `PushService.notifyChannel()` resolves a channel's readable
audience → their tokens (minus the sender) → send, pruning invalid ones;
`ChatService.sendMessage()` fires it best-effort.
- `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, Broadcast (Konfis lesen nur), and free-form `GRUPPE`
chats. 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.
- `POST /chat/:kcId/gruppen` lets a Leitungsteam member (any KC) or a
Gemeinde Verantwortliche/r (their own KC — `RolesGuard`'s kcId scoping)
create a `GRUPPE` channel with any mix of team users and Konfis (guests)
from that KC as initial participants (`participantUserIds`,
`participantGuestIds`); the creator is always included. Unlike
`GEMEINDE_GRUPPE`, membership isn't derived from `Gemeinde` — every
participant is an explicit `ChatParticipant` row, so a Konfi (who always
belongs to exactly one Gemeinde) can be added regardless of which
Gemeinde the chat's creator manages.
- `POST` / `DELETE /chat/gruppen/:channelId/participants` (body
`{ userId }` or `{ guestId }`) add/remove a participant afterwards.
Allowed for the channel's creator, any Leitungsteam member, or a
Verantwortliche/r of that KC — not the participants themselves, and not
guests.
- `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 features are implemented (`prisma/migrations/` holds the
schema history). `npm test` runs Jest unit tests (`ZuteilungService`,
`TeamAuthService`, `TeamerService`, `OnboardingService`,
`resolveOrProvisionAuthentikUser` / `toAuthenticatedUser`; Prisma mocked).
Ops notes to go live: the Authentik provider must emit a `groups` claim for
the LT check; `MAIL_PROVIDER=smtp` + `SMTP_*` for invite emails;
`PUSH_PROVIDER=fcm` + a Firebase service-account JSON for push; and real
Nextcloud/S3 credentials for file storage.