feat(backend): implement phases 0-6 (auth, kc, wahl, files, chat, sync)

Full NestJS backend for the KC-App platform:
- auth: Authentik OIDC resource-server strategy + guest invite-code JWT
  login, plus TokenVerificationService for the WS handshake path
- kc: Leitungsteam-only KC (event) creation/listing
- wahl: Wahl/Workshop admin, Force-Zuteilung overrides, ZuteilungService
  (port of the WP plugin's kc_run_zuteilung), CSV export
- files: LT-only upload with visibility tiers; list/download filtered by
  caller tier; StorageProvider abstraction (WebDAV/Nextcloud default, S3)
- chat: Gemeinde group / DM / LT-wide / broadcast channels; REST + raw ws
  gateway sharing ChatService access rules
- sync: append-only SyncLogEntry replication log + local<->cloud
  push/pull scheduler, shared-secret guarded
- common: Role enum, @Roles decorator, KC-scoped RolesGuard (LT global)
- serves client/web/ interim static web client under / (API under /api)

Typecheck, nest build and boot test pass; needs real Postgres/Authentik/
Nextcloud to run end to end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 16:23:45 +02:00
co-authored by Claude Sonnet 5
parent e49eed871c
commit 7aba87368d
47 changed files with 3003 additions and 115 deletions
@@ -0,0 +1,74 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import * as jwt from 'jsonwebtoken';
import * as jwksRsa from 'jwks-rsa';
import { PrismaClient } from '../prisma/prisma.module';
import { AuthenticatedUser } from './authenticated-request';
import { GuestJwtPayload } from './guest-auth.service';
/// Verifies raw bearer tokens outside the HTTP/passport pipeline, needed for
/// the WebSocket handshake where AuthGuard('authentik'|'guest') don't apply.
@Injectable()
export class TokenVerificationService {
private readonly issuerUrl: string;
private readonly jwks: jwksRsa.JwksClient;
constructor(
private readonly config: ConfigService,
private readonly prisma: PrismaClient,
private readonly guestJwt: JwtService,
) {
this.issuerUrl = config.getOrThrow<string>('AUTHENTIK_ISSUER_URL');
this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true });
}
async verifyAuthentik(token: string): Promise<AuthenticatedUser> {
const decoded = jwt.decode(token, { complete: true });
const kid = decoded?.header.kid;
if (!kid) {
throw new UnauthorizedException('Malformed Authentik token');
}
const key = await this.jwks.getSigningKey(kid);
const payload = jwt.verify(token, key.getPublicKey(), {
issuer: this.issuerUrl,
algorithms: ['RS256'],
}) as jwt.JwtPayload;
if (!payload.sub) {
throw new UnauthorizedException('Authentik token missing subject');
}
const user = await this.prisma.user.findUnique({
where: { authentikSub: payload.sub },
include: { memberships: true },
});
if (!user) {
throw new UnauthorizedException('User not provisioned locally yet');
}
return {
userId: user.id,
authentikSub: user.authentikSub,
email: user.email,
memberships: user.memberships.map((m) => ({
kcId: m.kcId,
gemeindeId: m.gemeindeId,
role: m.role,
})),
};
}
async verifyGuest(token: string): Promise<GuestJwtPayload> {
return this.guestJwt.verifyAsync<GuestJwtPayload>(token);
}
/// Tries Authentik first (team member), then falls back to a guest token.
async verifyEither(token: string): Promise<
{ kind: 'user'; user: AuthenticatedUser } | { kind: 'guest'; guest: GuestJwtPayload }
> {
try {
return { kind: 'user', user: await this.verifyAuthentik(token) };
} catch {
return { kind: 'guest', guest: await this.verifyGuest(token) };
}
}
}