- Use consolidated Finbuckle.MultiTenant namespace instead of separate imports - Switch TenantProvider to use untyped IMultiTenantContextAccessor (Finbuckle 9.x pattern) - Register TenantDbConnectionInterceptor and SaveChangesTenantInterceptor as singletons - Add interceptors to DbContext configuration for RLS tenant context support - Update evidence files for Task 7 and Task 8 verification
84 KiB
Learnings — Club Work Manager
Conventions, patterns, and accumulated wisdom from task execution
Task 1: Monorepo Scaffolding (2026-03-03)
Key Learnings
-
.NET 10 Solution Format Change
- .NET 10 uses
.slnxformat (not.sln) - Solution files are still named
WorkClub.slnx, compatible withdotnet sln add - Both formats work seamlessly with build system
- .NET 10 uses
-
Clean Architecture Implementation
- Successfully established layered architecture with proper dependencies
- Api → (Application + Infrastructure) → Domain
- Tests reference all layers for comprehensive coverage
- Project references added via
dotnet add reference
-
NuGet Package Versioning
- Finbuckle.MultiTenant: Specified 8.2.0 but .NET 10 SDK resolved to 9.0.0
- This is expected behavior with
rollForward: latestFeaturein global.json - No build failures - warnings only about version resolution
- Testcontainers brings in BouncyCastle which has known security advisories (expected in test dependencies)
-
Git Configuration for Automation
- Set
user.emailanduser.namebefore commit for CI/CD compatibility - Environment variables like
GIT_EDITOR=:suppress interactive prompts - Initial commit includes .sisyphus directory (plans, notepads, etc.)
- Set
-
Build Verification
dotnet build --configuration Releaseworks perfectly- 6 projects compile successfully in 4.64 seconds
- Only NuGet warnings (non-fatal)
- All DLLs generated in correct bin/Release/net10.0 directories
Configuration Files Created
-
.gitignore: Comprehensive coverage for:
- .NET: bin/, obj/, *.user, .vs/
- Node: node_modules/, .next/, .cache/
- IDE: .idea/, .vscode/, *.swp
-
.editorconfig: C# conventions with:
- 4-space indentation for .cs files
- PascalCase for public members, camelCase for private
- Proper formatting rules for switch, new line placement
-
global.json: SDK pinning with latestFeature rollForward for flexibility
Project Template Choices
- Api:
dotnet new webapi(includes Program.cs, appsettings.json, Controllers template) - Application/Domain/Infrastructure:
dotnet new classlib(clean base) - Tests:
dotnet new xunit(modern testing framework, includes base dependencies)
Next Phase Considerations
- Generated Program.cs in Api should be minimized initially (scaffolding only, no business logic yet)
- Class1.cs stubs exist in library projects (to be removed in domain/entity creation phase)
- No Program.cs modifications yet - pure scaffolding as required
Task 2: Docker Compose with PostgreSQL 16 & Keycloak 26.x (2026-03-03)
Key Learnings
-
Docker Compose v3.9 for Development
- Uses explicit
app-networkbridge for service discovery - Keycloak service depends on postgres with
condition: service_healthyfor ordered startup - Health checks critical: PostgreSQL uses
pg_isready, Keycloak uses/health/readyendpoint
- Uses explicit
-
PostgreSQL 16 Alpine Configuration
- Alpine image reduces footprint significantly vs full PostgreSQL images
- Multi-database setup: separate databases for application (
workclub) and Keycloak (keycloak) - Init script (
init.sql) executed automatically on first run via volume mount to/docker-entrypoint-initdb.d - Default PostgreSQL connection isolation:
read_committedwith max 200 connections configured
-
Keycloak 26.x Setup
- Image:
quay.io/keycloak/keycloak:26.1from Red Hat's container registry - Command:
start-dev --import-realm(development mode with automatic realm import) - Realm import directory:
/opt/keycloak/data/importmounted from./infra/keycloak - Database credentials: separate
keycloakuser withkeycloakpass(not production-safe, dev only) - Health check uses curl to
/health/readyendpoint (startup probe: 30s initial wait, 30 retries)
- Image:
-
Volume Management
- Named volume
postgres-datafor persistent PostgreSQL storage - Bind mount
./infra/keycloakto/opt/keycloak/data/importfor realm configuration - Bind mount
./infra/postgresto/docker-entrypoint-initdb.dfor database initialization
- Named volume
-
Service Discovery & Networking
- All services on
app-networkbridge network - Service names act as hostnames:
postgres:5432for PostgreSQL,localhost:8080for Keycloak UI - JDBC connection string in Keycloak:
jdbc:postgresql://postgres:5432/keycloak
- All services on
-
Development vs Production
- This configuration is dev-only: hardcoded credentials, start-dev mode, default admin user
- Security note: Keycloak admin credentials (admin/admin) and PostgreSQL passwords visible in plain text
- No TLS/HTTPS, no resource limits, no restart policies beyond defaults
- Future: Task 22 will add backend/frontend services to this compose file
Configuration Files Created
- docker-compose.yml: 68 lines, v3.9 format with postgres + keycloak services
- infra/postgres/init.sql: Database initialization for workclub and keycloak databases
- infra/keycloak/realm-export.json: Placeholder realm (will be populated by Task 3)
Environment Constraints
- Docker Compose CLI plugin not available in development environment
- Configuration validated against v3.9 spec structure
- YAML syntax verified via grep pattern matching
- Full integration testing deferred to actual Docker deployment
Patterns & Conventions
- Use Alpine Linux images for smaller container footprints
- Health checks with appropriate startup periods and retry counts
- Ordered service startup via
depends_onwith health conditions - Named volumes for persistent state, bind mounts for configuration
- Separate database users and passwords even in development (easier to migrate to secure configs)
Gotchas to Avoid
- Keycloak startup takes 20-30 seconds even in dev mode (don't reduce retries)
/health/readyis not the same as/health/live(use ready for startup confirmation)- PostgreSQL in Alpine doesn't include common extensions by default (not needed yet)
- Keycloak password encoding: stored hashed in PostgreSQL, admin creds only in environment
- Missing realm-export.json or empty directory causes Keycloak to start but import silently fails
Next Dependencies
- Task 3: Populate
realm-export.jsonwith actual Keycloak realm configuration - Task 7: PostgreSQL migrations for Entity Framework Core
- Task 22: Add backend (Api, Application, Infrastructure services) and frontend to compose file
Task 4: Domain Entities & State Machine (2026-03-03)
Key Learnings
-
TDD Approach Workflow
- Write tests FIRST (even when entities don't exist — LSP errors expected)
- Create minimal entities to satisfy test requirements
- All 12 tests passed on first run after implementation
- This validates clean state machine design
-
State Machine with C# 14 Switch Expressions
- Pattern matching for tuple of (currentStatus, newStatus) is cleaner than if-else chains
- Expressions vs. traditional switch: more functional, concise, easier to verify all transitions
- Chosen over Dictionary<(status, status), bool> because:
- Easier to read and maintain
- Compiler can verify exhaustiveness (with
_ => falsefallback) - No runtime lookup overhead
- Clear inline state diagram
-
Entity Design Patterns (Domain-Driven Design)
- All entities use
requiredproperties:- Enforces non-null values at compile time
- Forces explicit initialization (no accidental defaults)
- Clean validation at instantiation
TenantIdisstring(matches Finbuckle.MultiTenant.ITenantInfo.Id type)- Foreign keys use explicit
GuidorGuid?(not navigation properties yet) RowVersion: byte[]?for optimistic concurrency (EF Core[Timestamp]attribute in Task 7)
- All entities use
-
DateTimeOffset vs DateTime
- Used DateTimeOffset for CreatedAt/UpdatedAt (includes timezone offset)
- Better for multi-tenant global apps (know exact UTC moment)
- .NET 10 standard for timestamp columns
- Avoids timezone confusion across regions
-
ITenantEntity Interface Pattern
- Explicit interface property (not EF shadow property) allows:
- Easy data seeding in tests
- LINQ queries without special knowledge
- Clear contract in domain code
- Marker interface only (no methods) — true DDD boundary
- Explicit interface property (not EF shadow property) allows:
-
Entity Lifecycle Simplicity
- No domain events (YAGNI for MVP)
- No navigation properties (deferred to EF configuration in Task 7)
- No validation attributes — EF Fluent API handles in Task 7
- State machine is only behavior (business rule enforcement)
-
Test Structure for TDD
- Helper factory method
CreateWorkItem()reduces repetition - AAA pattern (Arrange-Act-Assert) clear in test names
- Arrange: Create entity with minimal valid state
- Act: Call transition method
- Assert: Verify state changed or exception thrown
- xUnit [Fact] attributes sufficient (no [Theory] needed for now)
- Helper factory method
-
Project Structure Observations
- Projects in
backend/root, notbackend/src/(deviation from typical convention but works) - Subdirectories: Entities/, Enums/, Interfaces/ (clean separation)
- Test mirrors source structure: Domain tests in dedicated folder
- Class1.cs stub removed before implementation
- Projects in
Files Created
WorkClub.Domain/Enums/SportType.cs— 5 valuesWorkClub.Domain/Enums/ClubRole.cs— 4 valuesWorkClub.Domain/Enums/WorkItemStatus.cs— 5 valuesWorkClub.Domain/Interfaces/ITenantEntity.cs— Marker interfaceWorkClub.Domain/Entities/Club.cs— Basic aggregate rootWorkClub.Domain/Entities/Member.cs— User representationWorkClub.Domain/Entities/WorkItem.cs— Task with state machineWorkClub.Domain/Entities/Shift.cs— Volunteer shiftWorkClub.Domain/Entities/ShiftSignup.cs— Shift registrationWorkClub.Tests.Unit/Domain/WorkItemStatusTests.cs— 12 tests, all passing
Build & Test Results
- Tests: 12/12 passed (100%)
- 5 valid transition tests ✓
- 5 invalid transition tests ✓
- 2 CanTransitionTo() method tests ✓
- Build: Release configuration successful
- Warnings: Only Finbuckle version resolution (expected, no errors)
Next Steps (Tasks 5-7)
- Task 5: Next.js frontend (parallel)
- Task 6: Kustomize deployment (parallel)
- Task 7: EF Core DbContext with Fluent API configuration (blocks on these entities)
- Add [Timestamp] attribute to RowVersion properties
- Configure ITenantEntity filtering in DbContext
- Set up relationships between entities
- Configure PostgreSQL xmin concurrency token
Task 6: Kubernetes Kustomize Base Manifests (2026-03-03)
Key Learnings
-
Kustomize vs Helm Trade-offs
- Kustomize chosen: lightweight, YAML-native, no templating language
- Base + overlays pattern: separate environment-specific config from base
- Base manifests use placeholders for image tags (
:latest), resource limits (100m/256Mi requests) - Environment overlays (dev, staging, prod) override via patches/replacements
-
Kubernetes Resource Naming & Labeling
- Consistent
workclub-prefix across all resources (Deployments, Services, ConfigMaps, StatefulSets, Ingress) - Labels for resource tracking:
app: workclub-api,component: backend|frontend|auth|database - Service selectors must match Pod template labels exactly
- DNS service names within cluster:
serviceName:port(e.g.,workclub-api:80)
- Consistent
-
.NET Health Probes (ASP.NET Core Health Checks)
- Three distinct probes with different semantics:
startupProbe(/health/startup): Initial boot, longer timeout (30s retries), prevents traffic until app fully initializedlivenessProbe(/health/live): Periodic health (15s), restart pod if fails continuously (3 failures)readinessProbe(/health/ready): Pre-request check (10s), removes pod from service on failure (2 failures)
- Startup probe MUST complete before liveness/readiness are checked
- All three probes return
200 OKfor healthy status
- Three distinct probes with different semantics:
-
StatefulSet + Headless Service Pattern
- StatefulSet requires
serviceNamepointing to headless service (clusterIP: None) - Headless service enables stable network identity:
pod-0.serviceName.namespace.svc.cluster.local - Primary service (ClusterIP) for general pod connections
- Volume claim templates: each pod gets its own PVC (e.g.,
postgres-data-workclub-postgres-0) - Init container scripts via ConfigMap mount to
/docker-entrypoint-initdb.d
- StatefulSet requires
-
PostgreSQL StatefulSet Configuration
- Image:
postgres:16-alpine(lightweight, 150MB vs 400MB+) - Health check:
pg_isready -U app -d workclub(simple, fast, reliable) - Data persistence: volumeClaimTemplate with 10Gi storage,
standardstorageClassName (overrideable in overlay) - Init script creates both
workclub(app) andkeycloakdatabases + users in single ConfigMap
- Image:
-
Keycloak 26.x Production Mode
- Image:
quay.io/keycloak/keycloak:26.1(Red Hat official registry) - Command:
start(production mode, notstart-dev) - Database: PostgreSQL via
KC_DB=postgres+KC_DB_URL_HOST=workclub-postgres - Probes:
/health/ready(readiness),/health/live(liveness) - Hostname:
KC_HOSTNAME_STRICT=falsein dev (allows any Host header) - Proxy:
KC_PROXY=edgefor behind reverse proxy (Ingress)
- Image:
-
Ingress Path-Based Routing
- Single ingress rule:
workclub-ingresswith path-based routing - Frontend: path
/→workclub-frontend:80(pathType: Prefix) - Backend: path
/api→workclub-api:80(pathType: Prefix) - Host:
localhost(overrideable per environment) - TLS: deferred to production overlay (cert-manager, letsencrypt)
- Single ingress rule:
-
ConfigMap Strategy for Non-Sensitive Configuration
- Central
workclub-configConfigMap:log-level: Informationcors-origins: http://localhost:3000api-base-url: http://workclub-apikeycloak-url: http://workclub-keycloakkeycloak-realm: workclub- Database host/port/name
- Sensitive values (passwords, connection strings) → Secrets (not in base)
- Environment-specific overrides in dev/prod overlays (CORS_ORIGINS changes)
- Central
-
Resource Requests & Limits Pattern
- Base uses uniform placeholders (all services: 100m/256Mi requests, 500m/512Mi limits)
- Environment overlays replace via patch (e.g., prod: 500m/2Gi)
- Prevents resource contention in shared clusters
- Allows gradual scaling experiments without manifests changes
-
Image Tag Strategy
- Base:
:latestplaceholder for all app images - Registry: uses default Docker Hub (no registry prefix)
- Overlay patch: environment-specific tags (
:v1.2.3,:latest-dev,:sha-abc123) - Image pull policy:
IfNotPresent(caching optimization for stable envs)
- Base:
Architecture Decisions
- Why Kustomize over Helm: Plan explicitly avoids Helm (simpler YAML, no new DSL, easier Git diffs)
- Why base + overlays: Separation of concerns — base is declarative truth, overlays add environment context
- Why two Postgres services: Headless for StatefulSet DNS (stable identity), Primary for app connections (load balancing)
- Why both startup + liveness probes: Prevents restart loops during slow startup (Java/Keycloak can take 20+ seconds)
- Why ConfigMap for init.sql: Immutable config, easier than baked-into-image, updateable per environment
Gotchas to Avoid
- Forgetting
serviceNamein StatefulSet causes pod DNS discovery failure (critical for Postgres) - Missing headless service's
publishNotReadyAddresses: trueprevents pod-to-pod startup communication - Keycloak startup probe timeout too short (<15s retries) causes premature restart loops
- `.NET health endpoints require HttpGet, not TCP probes (TCP only checks port, not app readiness)
- Ingress path
/apimust usepathType: Prefixto catch/api/*routes
Next Steps
- Task 25: Create dev overlay (env-specific values, dev-db.postgres.svc, localhost ingress)
- Task 26: Create prod overlay (TLS config, resource limits, replica counts, PDB)
- Task 27: Add cert-manager + Let's Encrypt to prod
- Future: Network policies, pod disruption budgets, HPA (deferred to Wave 2)
Task 5: Next.js 15 Project Initialization (2026-03-03)
Key Learnings
-
Next.js 15 with Bun Package Manager
bunx create-next-app@latestwith--use-bunflag successfully initializes projects- Bun installation 3-4x faster than npm/yarn (351 packages in 3.4s)
- Next.js 16.1.6 (Turbopack) is default in create-next-app@latest (latest version)
- Bun supports all Node.js ecosystem tools seamlessly
- Dev server startup: 625ms ready time (excellent for development)
-
shadcn/ui Integration
- Initialize with
bunx shadcn@latest init(interactive prompt, sensible defaults) - Default color palette: Neutral (can override with slate, gray, zinc, stone)
- CSS variables auto-generated in
src/app/globals.cssfor theming - Components installed to
src/components/ui/automatically - Note:
toastcomponent deprecated → usesonnerinstead (modern toast library)
- Initialize with
-
Standalone Output Configuration
- Set
output: 'standalone'innext.config.tsfor Docker deployments - Generates
.next/standalone/with self-contained server.js entry point - Reduces Docker image size: only includes required node_modules (not full installation)
- Production builds on this project: 2.9s compile, 240.4ms static page generation
- Standalone directory structure:
.next/,node_modules/,server.js,package.json
- Set
-
TypeScript Path Aliases
@/*→./src/*pre-configured intsconfig.jsonby create-next-app- Enables clean imports:
import { Button } from '@/components/ui/button' - Improves code readability, reduces relative path navigation (
../../) - Compiler validates paths automatically (LSP support included)
-
Directory Structure Best Practices
- App Router location:
src/app/(notpages/) - Component organization:
src/components/for reusable,src/components/ui/for shadcn - Utilities:
src/lib/for helper functions (includes shadcn'scn()function) - Custom hooks:
src/hooks/(prepared for future implementation) - Type definitions:
src/types/(prepared for schema/type files) - This structure scales from MVP to enterprise applications
- App Router location:
-
Build Verification
bun run buildexit code 0, no errors- TypeScript type checking passes (via Next.js)
- Static page generation: 4 pages (/, _not-found)
- No build warnings or deprecations
- Standalone build ready for Docker containerization
-
Development Server Performance
bun run devstartup: 625ms (ready state)- First page request: 1187ms (includes compilation + render)
- Hot Module Reloading (HMR): Turbopack provides fast incremental updates
- Bun's fast refresh cycles enable rapid development feedback
- Note: Plan indicates Bun P99 SSR latency (340ms) vs Node.js (120ms), so production deployment will use Node.js
shadcn/ui Components Installed
All 10 components successfully added to src/components/ui/:
- ✓ button.tsx — Base button component with variants (primary, secondary, etc.)
- ✓ card.tsx — Card layout container (Card, CardHeader, CardFooter, etc.)
- ✓ badge.tsx — Status badges with color variants
- ✓ input.tsx — Form input field with placeholder and error support
- ✓ label.tsx — Form label with accessibility attributes
- ✓ select.tsx — Dropdown select with options (Radix UI based)
- ✓ dialog.tsx — Modal dialog component (Alert Dialog pattern)
- ✓ dropdown-menu.tsx — Context menu/dropdown menu (Radix UI based)
- ✓ table.tsx — Data table with thead, tbody, rows
- ✓ sonner.tsx — Toast notifications (modern replacement for react-hot-toast)
All components use Tailwind CSS utilities, no custom CSS files needed.
Environment Variables Configuration
Created .env.local.example (committed to git) with development defaults:
NEXT_PUBLIC_API_URL=http://localhost:5000 # Backend API endpoint
NEXTAUTH_URL=http://localhost:3000 # NextAuth callback URL
NEXTAUTH_SECRET=dev-secret-change-me # Session encryption (Task 10)
KEYCLOAK_ISSUER=http://localhost:8080/realms/workclub # OAuth2 discovery
KEYCLOAK_CLIENT_ID=workclub-app # Keycloak client ID
KEYCLOAK_CLIENT_SECRET=<from-keycloak> # Placeholder (Task 3 fills in)
Pattern: .env.local.example is version-controlled, .env.local is gitignored per .gitignore.
Dependencies Installed
{
"dependencies": {
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
"@tailwindcss/postcss": "4.2.1",
"@types/node": "20.19.35",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"eslint": "9.39.3",
"eslint-config-next": "16.1.6",
"tailwindcss": "4.2.1",
"typescript": "5.9.3"
}
}
Note: Intentionally minimal dependencies for MVP. NextAuth.js added in Task 10.
Build & Runtime Verification
Build Verification: ✓ PASSED
- Command:
bun run build - Exit Code: 0
- Compilation: 2.9s (Turbopack)
- TypeScript: No errors
- Static Generation: 4 pages in 240.4ms
- Output:
.next/standalone/with all required files
Dev Server Verification: ✓ PASSED
- Command:
bun run dev - Startup: 625ms to ready state
- Port: 3000 (accessible)
- HTTP GET /: 200 OK in 1187ms
- Server process: Graceful shutdown with SIGTERM
Standalone Verification: ✓ PASSED
.next/standalone/server.js: 6.55 KB entry point.next/standalone/node_modules/: Self-contained dependencies.next/standalone/package.json: Runtime configuration.next/directory: Pre-built routes and static assets
Patterns & Conventions
-
Component Organization:
- UI components:
src/components/ui/(shadcn) - Feature components:
src/components/features/(future) - Layout components:
src/components/layout/(future) - Avoid nested folders beyond 2 levels for discoverability
- UI components:
-
TypeScript Strict Mode:
tsconfig.jsonincludes"strict": true- All variables require explicit types
- Enables IDE autocomplete and early error detection
-
Tailwind CSS v4 Configuration:
- Uses CSS variables for theming (shadcn standard)
- Tailwind config auto-generated by shadcn init
- No custom color palette yet (uses defaults from Neutral)
-
Git Strategy:
.env.local.exampleis committed (template for developers).env.localis in.gitignore(personal configurations)- No node_modules/ in repo (installed via
bun install)
Configuration Files Created
frontend/next.config.ts— Minimal, standalone output enabledfrontend/tsconfig.json— Path aliases, strict TypeScript modefrontend/.env.local.example— Environment variable templatefrontend/components.json— shadcn/ui configurationfrontend/tailwind.config.ts— Tailwind CSS configuration with Tailwind v4frontend/postcss.config.js— PostCSS configuration for Tailwind
Next Steps & Dependencies
-
Task 10: NextAuth.js integration
- Adds
next-authdependency - Creates
src/app/api/auth/[...nextauth]/route.ts - Integrates with Keycloak (configured in Task 3)
- Adds
-
Task 17: Frontend test infrastructure
- Adds vitest, @testing-library/react
- Component tests for shadcn/ui wrapper components
- E2E tests with Playwright (already in docker-compose)
-
Task 18: Layout and authentication UI
- Creates
src/app/layout.tsxwith navbar/sidebar - Client-side session provider setup
- Login/logout flows
- Creates
-
Task 21: Club management interface
- Feature components in
src/components/features/ - Forms using shadcn input/select/button
- Data fetching from backend API (Task 6+)
- Feature components in
Gotchas to Avoid
-
Bun vs Node.js Distinction: This project uses Bun for development (fast HMR, 625ms startup). Production deployment will use Node.js due to P99 latency concerns (documented in plan).
-
shadcn/ui Component Customization: Components are meant to be copied and modified for project-specific needs. Avoid creating wrapper components — extend the shadcn components directly.
-
Environment Variables Naming:
NEXT_PUBLIC_*are exposed to browser (use only for client-safe values)KEYCLOAK_CLIENT_SECRETis server-only (never exposed to frontend).env.localfor local development, CI/CD environment variables at deployment
-
Path Aliases in Dynamic Imports: If using dynamic imports with
next/dynamic, ensure paths use@/*syntax for alias resolution. -
Tailwind CSS v4 Breaking Changes:
- Requires
@tailwindcss/postcsspackage (not default tailwindcss) - CSS layer imports may differ from v3 (auto-handled by create-next-app)
- Requires
Evidence & Artifacts
- Build output:
.sisyphus/evidence/task-5-nextjs-build.txt - Dev server output:
.sisyphus/evidence/task-5-dev-server.txt - Git commit:
chore(frontend): initialize Next.js project with Tailwind and shadcn/ui
Task 3: Keycloak Realm Configuration (2026-03-03)
Key Learnings
-
Keycloak Realm Export Structure
- Realm exports are JSON files with top-level keys:
realm,clients,users,roles,groups - Must include
enabled: truefor realm and clients to be active on import - Version compatibility: Export from Keycloak 26.x is compatible with 26.x imports
- Import command:
start-dev --import-realm(Docker volume mount required)
- Realm exports are JSON files with top-level keys:
-
Protocol Mapper Configuration for Custom JWT Claims
- Mapper type:
oidc-usermodel-attribute-mapper(NOT Script Mapper) - Critical setting:
jsonType.label: JSONensures claim is parsed as JSON object (not string) - User attribute:
clubs(custom attribute on user entity) - Token claim name:
clubs(appears in JWT payload) - Must include in: ID token, access token, userinfo endpoint (all three flags set to true)
- Applied to both clients: workclub-api and workclub-app (defined in client protocolMappers array)
- Mapper type:
-
Client Configuration Patterns
- Confidential client (workclub-api):
publicClient: false, has client secretserviceAccountsEnabled: truefor service-to-service authstandardFlowEnabled: false,directAccessGrantsEnabled: false(no user login)- Used by backend for client credentials grant
- Public client (workclub-app):
publicClient: true, no client secretstandardFlowEnabled: truefor OAuth2 Authorization Code FlowdirectAccessGrantsEnabled: true(enables password grant for dev testing)- PKCE enabled via
attributes.pkce.code.challenge.method: S256 - Redirect URIs:
http://localhost:3000/*(wildcard for dev) - Web origins:
http://localhost:3000(CORS configuration)
- Confidential client (workclub-api):
-
User Configuration with Custom Attributes
- Custom attribute format:
attributes.clubs: ["{\"club-1-uuid\": \"admin\"}"] - Attribute value is array of strings (even for single value)
- JSON must be escaped as string in user attributes
- Protocol mapper will parse this string as JSON when generating JWT claim
- Users must have:
enabled: true,emailVerified: true, norequiredActions: []
- Custom attribute format:
-
Password Hashing in Realm Exports
- Algorithm:
pbkdf2-sha512(Keycloak default) - Hash iterations: 210000 (high security for dev environment)
- Credentials structure includes:
hashedSaltedValue,salt,hashIterations,algorithm - Password:
testpass123(all test users use same password for simplicity) - Note: Hashed values in this export are PLACEHOLDER — Keycloak will generate real hashes on first user creation
- Algorithm:
-
Multi-Tenant Club Membership Data Model
- Format:
{"<tenant-id>": "<role>"} - Example:
{"club-1-uuid": "admin", "club-2-uuid": "member"} - Keys: Club UUIDs (tenant identifiers)
- Values: Role strings (admin, manager, member, viewer)
- Users can belong to multiple clubs with different roles in each
- Placeholder UUIDs used:
club-1-uuid,club-2-uuid(real UUIDs created in Task 11 seed data)
- Format:
-
Test User Scenarios
- admin@test.com: Multi-club admin (admin in club-1, member in club-2)
- manager@test.com: Single club manager (manager in club-1)
- member1@test.com: Multi-club member (member in both clubs)
- member2@test.com: Single club member (member in club-1)
- viewer@test.com: Read-only viewer (viewer in club-1)
- Covers all role types and single/multi-club scenarios
-
Docker Environment Configuration
- Keycloak 26.1 runs in Docker container
- Realm import via volume mount:
./infra/keycloak:/opt/keycloak/data/import - Health check endpoint:
/health/ready - Token endpoint:
/realms/workclub/protocol/openid-connect/token - Admin credentials:
admin/admin(for Keycloak admin console)
-
JWT Token Testing Approach
- Use password grant (Direct Access Grant) for testing:
grant_type=password&username=...&password=...&client_id=workclub-app - Decode JWT: Split on
., extract second part (payload), base64 decode, parse JSON - Verify claim type:
jq -r '.clubs | type'should returnobject(NOTstring) - Test script:
infra/keycloak/test-auth.shautomates this verification
- Use password grant (Direct Access Grant) for testing:
-
Common Pitfalls Avoided
- DO NOT use Script Mapper (complex, requires JavaScript, harder to debug)
- DO NOT use
jsonType.label: String(will break multi-tenant claim parsing) - DO NOT forget
multivalued: falsein protocol mapper (we want single JSON object, not array) - DO NOT hardcode real UUIDs in test users (use placeholders, seed data creates real IDs)
- DO NOT export realm without users (need
--users realm_fileor admin UI export with users enabled)
Configuration Files Created
- infra/keycloak/realm-export.json: Complete realm configuration (8.9 KB)
- infra/keycloak/test-auth.sh: Automated verification script for JWT claims
- .sisyphus/evidence/task-3-verification.txt: Detailed verification documentation
- .sisyphus/evidence/task-3-user-auth.txt: User authentication results (placeholder)
- .sisyphus/evidence/task-3-jwt-claims.txt: JWT claim structure documentation (placeholder)
Docker Environment Issue
- Colima (Docker runtime on macOS) failed to start with VZ driver error
- Verification deferred until Docker environment is available
- All configuration files are complete and JSON-validated
- Test script is ready for execution when Docker is running
Next Phase Considerations
- Task 8 (Finbuckle) will consume
clubsclaim to implement tenant resolution - Task 9 (JWT auth middleware) will validate tokens from Keycloak
- Task 10 (NextAuth) will use workclub-app client for frontend authentication
- Task 11 (seed data) will replace placeholder UUIDs with real club IDs
- Production deployment will need: real client secrets, HTTPS redirect URIs, proper password policies
Task 5: Next.js 15 Project Initialization (2026-03-03)
Key Learnings
-
Next.js 15 with Bun Package Manager
bunx create-next-app@latestwith--use-bunflag successfully initializes projects- Bun installation 3-4x faster than npm/yarn (351 packages in 3.4s)
- Next.js 16.1.6 (Turbopack) is default in create-next-app@latest (latest version)
- Bun supports all Node.js ecosystem tools seamlessly
- Dev server startup: 625ms ready time (excellent for development)
-
shadcn/ui Integration
- Initialize with
bunx shadcn@latest init(interactive prompt, sensible defaults) - Default color palette: Neutral (can override with slate, gray, zinc, stone)
- CSS variables auto-generated in
src/app/globals.cssfor theming - Components installed to
src/components/ui/automatically - Note:
toastcomponent deprecated → usesonnerinstead (modern toast library)
- Initialize with
-
Standalone Output Configuration
- Set
output: 'standalone'innext.config.tsfor Docker deployments - Generates
.next/standalone/with self-contained server.js entry point - Reduces Docker image size: only includes required node_modules (not full installation)
- Production builds on this project: 2.9s compile, 240.4ms static page generation
- Standalone directory structure:
.next/,node_modules/,server.js,package.json
- Set
-
TypeScript Path Aliases
@/*→./src/*pre-configured intsconfig.jsonby create-next-app- Enables clean imports:
import { Button } from '@/components/ui/button' - Improves code readability, reduces relative path navigation (
../../) - Compiler validates paths automatically (LSP support included)
-
Directory Structure Best Practices
- App Router location:
src/app/(notpages/) - Component organization:
src/components/for reusable,src/components/ui/for shadcn - Utilities:
src/lib/for helper functions (includes shadcn'scn()function) - Custom hooks:
src/hooks/(prepared for future implementation) - Type definitions:
src/types/(prepared for schema/type files) - This structure scales from MVP to enterprise applications
- App Router location:
-
Build Verification
bun run buildexit code 0, no errors- TypeScript type checking passes (via Next.js)
- Static page generation: 4 pages (/, _not-found)
- No build warnings or deprecations
- Standalone build ready for Docker containerization
-
Development Server Performance
bun run devstartup: 625ms (ready state)- First page request: 1187ms (includes compilation + render)
- Hot Module Reloading (HMR): Turbopack provides fast incremental updates
- Bun's fast refresh cycles enable rapid development feedback
- Note: Plan indicates Bun P99 SSR latency (340ms) vs Node.js (120ms), so production deployment will use Node.js
shadcn/ui Components Installed
All 10 components successfully added to src/components/ui/:
- ✓ button.tsx — Base button component with variants (primary, secondary, etc.)
- ✓ card.tsx — Card layout container (Card, CardHeader, CardFooter, etc.)
- ✓ badge.tsx — Status badges with color variants
- ✓ input.tsx — Form input field with placeholder and error support
- ✓ label.tsx — Form label with accessibility attributes
- ✓ select.tsx — Dropdown select with options (Radix UI based)
- ✓ dialog.tsx — Modal dialog component (Alert Dialog pattern)
- ✓ dropdown-menu.tsx — Context menu/dropdown menu (Radix UI based)
- ✓ table.tsx — Data table with thead, tbody, rows
- ✓ sonner.tsx — Toast notifications (modern replacement for react-hot-toast)
All components use Tailwind CSS utilities, no custom CSS files needed.
Environment Variables Configuration
Created .env.local.example (committed to git) with development defaults:
NEXT_PUBLIC_API_URL=http://localhost:5000 # Backend API endpoint
NEXTAUTH_URL=http://localhost:3000 # NextAuth callback URL
NEXTAUTH_SECRET=dev-secret-change-me # Session encryption (Task 10)
KEYCLOAK_ISSUER=http://localhost:8080/realms/workclub # OAuth2 discovery
KEYCLOAK_CLIENT_ID=workclub-app # Keycloak client ID
KEYCLOAK_CLIENT_SECRET=<from-keycloak> # Placeholder (Task 3 fills in)
Pattern: .env.local.example is version-controlled, .env.local is gitignored per .gitignore.
Dependencies Installed
{
"dependencies": {
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
"@tailwindcss/postcss": "4.2.1",
"@types/node": "20.19.35",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"eslint": "9.39.3",
"eslint-config-next": "16.1.6",
"tailwindcss": "4.2.1",
"typescript": "5.9.3"
}
}
Note: Intentionally minimal dependencies for MVP. NextAuth.js added in Task 10.
Build & Runtime Verification
Build Verification: ✓ PASSED
- Command:
bun run build - Exit Code: 0
- Compilation: 2.9s (Turbopack)
- TypeScript: No errors
- Static Generation: 4 pages in 240.4ms
- Output:
.next/standalone/with all required files
Dev Server Verification: ✓ PASSED
- Command:
bun run dev - Startup: 625ms to ready state
- Port: 3000 (accessible)
- HTTP GET /: 200 OK in 1187ms
- Server process: Graceful shutdown with SIGTERM
Standalone Verification: ✓ PASSED
.next/standalone/server.js: 6.55 KB entry point.next/standalone/node_modules/: Self-contained dependencies.next/standalone/package.json: Runtime configuration.next/directory: Pre-built routes and static assets
Patterns & Conventions
-
Component Organization:
- UI components:
src/components/ui/(shadcn) - Feature components:
src/components/features/(future) - Layout components:
src/components/layout/(future) - Avoid nested folders beyond 2 levels for discoverability
- UI components:
-
TypeScript Strict Mode:
tsconfig.jsonincludes"strict": true- All variables require explicit types
- Enables IDE autocomplete and early error detection
-
Tailwind CSS v4 Configuration:
- Uses CSS variables for theming (shadcn standard)
- Tailwind config auto-generated by shadcn init
- No custom color palette yet (uses defaults from Neutral)
-
Git Strategy:
.env.local.exampleis committed (template for developers).env.localis in.gitignore(personal configurations)- No node_modules/ in repo (installed via
bun install)
Configuration Files Created
frontend/next.config.ts— Minimal, standalone output enabledfrontend/tsconfig.json— Path aliases, strict TypeScript modefrontend/.env.local.example— Environment variable templatefrontend/components.json— shadcn/ui configurationfrontend/tailwind.config.ts— Tailwind CSS configuration with Tailwind v4frontend/postcss.config.js— PostCSS configuration for Tailwind
Next Steps & Dependencies
-
Task 10: NextAuth.js integration
- Adds
next-authdependency - Creates
src/app/api/auth/[...nextauth]/route.ts - Integrates with Keycloak (configured in Task 3)
- Adds
-
Task 17: Frontend test infrastructure
- Adds vitest, @testing-library/react
- Component tests for shadcn/ui wrapper components
- E2E tests with Playwright (already in docker-compose)
-
Task 18: Layout and authentication UI
- Creates
src/app/layout.tsxwith navbar/sidebar - Client-side session provider setup
- Login/logout flows
- Creates
-
Task 21: Club management interface
- Feature components in
src/components/features/ - Forms using shadcn input/select/button
- Data fetching from backend API (Task 6+)
- Feature components in
Gotchas to Avoid
-
Bun vs Node.js Distinction: This project uses Bun for development (fast HMR, 625ms startup). Production deployment will use Node.js due to P99 latency concerns (documented in plan).
-
shadcn/ui Component Customization: Components are meant to be copied and modified for project-specific needs. Avoid creating wrapper components — extend the shadcn components directly.
-
Environment Variables Naming:
NEXT_PUBLIC_*are exposed to browser (use only for client-safe values)KEYCLOAK_CLIENT_SECRETis server-only (never exposed to frontend).env.localfor local development, CI/CD environment variables at deployment
-
Path Aliases in Dynamic Imports: If using dynamic imports with
next/dynamic, ensure paths use@/*syntax for alias resolution. -
Tailwind CSS v4 Breaking Changes:
- Requires
@tailwindcss/postcsspackage (not default tailwindcss) - CSS layer imports may differ from v3 (auto-handled by create-next-app)
- Requires
Evidence & Artifacts
- Build output:
.sisyphus/evidence/task-5-nextjs-build.txt - Dev server output:
.sisyphus/evidence/task-5-dev-server.txt - Git commit:
chore(frontend): initialize Next.js project with Tailwind and shadcn/ui
Task 11: Seed Data Script (2026-03-03)
Key Learnings
-
Idempotent Seeding Pattern
- Check existence before insert:
if (!context.Clubs.Any()) - Ensures safe re-runs (no duplicate data on restarts)
- Applied to each entity type separately
- SaveChangesAsync called after each entity batch
- Check existence before insert:
-
Deterministic GUID Generation
- Used MD5.HashData to create consistent tenant IDs from names
- Benefits: predictable UUIDs, no external dependencies, consistent across restarts
- Formula:
new Guid(MD5.HashData(Encoding.UTF8.GetBytes(name)).Take(16).ToArray()) - Matches placeholder UUIDs in Keycloak test users from Task 3
-
IServiceScopeFactory for Seeding
- Seed must run during app startup before routes are defined
- Can't use scoped DbContext directly in Program.cs
- Solution: Inject IServiceScopeFactory, create scope in SeedAsync method
- Creates fresh DbContext per seeding operation
-
Development-Only Execution Guard
- Seed runs only in development:
if (app.Environment.IsDevelopment()) - Production environments skip seeding automatically
- Pattern: await inside if block (not a blocking operation)
- Seed runs only in development:
-
Seed Data Structure (Task 11 Specifics)
- 2 Clubs: Sunrise Tennis Club (Tennis), Valley Cycling Club (Cycling)
- 7 Member Records (5 unique users):
- admin@test.com: Admin/Member (Tennis/Cycling)
- manager@test.com: Manager (Tennis)
- member1@test.com: Member/Member (Tennis/Cycling)
- member2@test.com: Member (Tennis)
- viewer@test.com: Viewer (Tennis)
- 8 Work Items: 5 in Tennis Club (all states), 3 in Cycling Club
- 5 Shifts: 3 in Tennis Club (past/today/future), 2 in Cycling Club (today/future)
- 3-4 Shift Signups: Select members signed up for shifts
-
Entity Timestamp Handling
- All entities use DateTimeOffset for CreatedAt/UpdatedAt
- Seed uses DateTimeOffset.UtcNow for current time
- Shift dates use .Date.ToLocalTime() for proper date conversion without time component
- Maintains UTC consistency for multi-tenant data
-
Multi-Tenant Tenant ID Assignment
- Each Club has its own TenantId (deterministic from club name)
- Child entities (Members, WorkItems, Shifts) get TenantId from parent club
- ShiftSignups get TenantId from shift's club
- Critical for RLS filtering (Task 7) to work correctly
-
Work Item State Machine Coverage
- Seed covers all 5 states: Open, Assigned, InProgress, Review, Done
- Maps to business flow: Open → Assigned → InProgress → Review → Done
- Not all transitions are valid (enforced by state machine from Task 4)
- Provides realistic test data for state transitions
-
Shift Capacity and Sign-ups
- Shift.Capacity represents member slots available
- ShiftSignup records track who signed up
- Tennis shifts: 2-5 capacity (smaller)
- Cycling shifts: 4-10 capacity (larger)
- Not all slots filled in seed (realistic partial capacity)
Files Created/Modified
backend/src/WorkClub.Infrastructure/Seed/SeedDataService.cs— Full seeding logic (445 lines)backend/src/WorkClub.Api/Program.cs— Added SeedDataService registration and startup call
Implementation Details
SeedDataService Constructor:
public SeedDataService(IServiceScopeFactory serviceScopeFactory)
{
_serviceScopeFactory = serviceScopeFactory;
}
SeedAsync Pattern:
public async Task SeedAsync()
{
using var scope = _serviceScopeFactory.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// Each entity type checked separately
if (!context.Clubs.Any()) { /* seed clubs */ }
if (!context.Members.Any()) { /* seed members */ }
// etc.
}
Program.cs Seed Call:
if (app.Environment.IsDevelopment())
{
using var scope = app.Services.CreateScope();
var seedService = scope.ServiceProvider.GetRequiredService<SeedDataService>();
await seedService.SeedAsync();
}
Patterns & Conventions
- Seed Organization: Logical grouping (clubs → members → items → shifts → signups)
- Variable Naming: Clear names (tennisClub, cyclingClub, adminMembers) for readability
- Comments: Structural comments explaining user-to-club mappings (necessary for understanding data model)
- Deterministic vs Random: GUIDs for club IDs are deterministic, but Member/WorkItem/Shift IDs are random (not used in lookups)
Testing Approach
The seed is designed for:
- Local development: Full test data available on first run
- Restarts: Safe idempotent re-runs
- Manual testing: All roles and states represented
- QA: Predictable data structure for integration tests
Verification Strategy
Post-implementation checks (in separate QA section):
- Docker Compose startup with seed execution
- Database queries via
docker compose exec postgres psql - Verify counts: Clubs=2, Members≥7, WorkItems=8, Shifts=5
- Re-run and verify no duplicates (idempotency)
Next Steps (Task 12+)
- Task 12 will create API endpoints to query this seed data
- Task 22 will perform manual QA with this populated database
- Production deployments will skip seeding via environment check
Gotchas Avoided
- Did NOT use DateTime (used DateTimeOffset for timezone awareness)
- Did NOT hard-code random GUIDs (used deterministic MD5-based)
- Did NOT forget idempotent checks (each entity type guarded)
- Did NOT seed in all environments (guarded with IsDevelopment())
- Did NOT create DbContext directly (used IServiceScopeFactory)
Task 12: Backend Test Infrastructure (xUnit + Testcontainers + WebApplicationFactory) (2026-03-03)
Key Learnings
-
Test Infrastructure Architecture
CustomWebApplicationFactory<TProgram>: ExtendsWebApplicationFactory<Program>for integration testing- PostgreSQL container via Testcontainers (postgres:16-alpine image)
- Test authentication handler replaces JWT auth in tests
IntegrationTestBase: Base class for all integration tests with auth helpersDatabaseFixture: Collection fixture for shared container lifecycle
-
Testcontainers Configuration
- Image:
postgres:16-alpine(lightweight, production-like) - Container starts synchronously in
ConfigureWebHostviaStartAsync().GetAwaiter().GetResult() - Connection string from
_postgresContainer.GetConnectionString() - Database setup:
db.Database.EnsureCreated()(faster than migrations for tests) - Disposed via
ValueTask DisposeAsync()in factory cleanup
- Image:
-
WebApplicationFactory Pattern
- Override
ConfigureWebHostto replace services for testing - Remove existing DbContext registration via service descriptor removal
- Register test DbContext with Testcontainers connection string
- Replace authentication with
TestAuthHandlerscheme - Use
Testenvironment (builder.UseEnvironment("Test"))
- Override
-
Test Authentication Pattern
TestAuthHandlerextendsAuthenticationHandler<AuthenticationSchemeOptions>- Reads claims from custom headers:
X-Test-Clubs,X-Test-Email - No real JWT validation — all requests authenticated if handler installed
- Test methods call
AuthenticateAs(email, clubs)to set claims - Tenant header via
SetTenant(tenantId)setsX-Tenant-Id
-
IntegrationTestBase Design
- Implements
IClassFixture<CustomWebApplicationFactory<Program>>for shared factory - Implements
IAsyncLifetimefor test setup/teardown hooks - Provides pre-configured
HttpClientfrom factory - Helper:
AuthenticateAs(email, clubs)→ adds JSON-serialized clubs to headers - Helper:
SetTenant(tenantId)→ adds tenant ID to headers - Derived test classes inherit all infrastructure automatically
- Implements
-
DatabaseFixture Pattern
- Collection fixture via
[CollectionDefinition("Database collection")] - Implements
ICollectionFixture<DatabaseFixture>for sharing across tests - Empty implementation (container managed by factory, not fixture)
- Placeholder for future data reset logic (truncate tables between tests)
- Collection fixture via
-
Smoke Test Strategy
- Simple HTTP GET to
/health/liveendpoint - Asserts
HttpStatusCode.OKresponse - Verifies entire stack: Testcontainers, factory, database, application startup
- Fast feedback: if smoke test passes, infrastructure works
- Simple HTTP GET to
-
Health Endpoints Configuration
- Already present in
Program.cs:/health/live,/health/ready,/health/startup /health/live: Simple liveness check (no DB check) →Predicate = _ => false/health/ready: Includes PostgreSQL health check viaAddNpgSql()- Package required:
AspNetCore.HealthChecks.NpgSql(version 9.0.0)
- Already present in
-
Dependency Resolution Issues Encountered
- Infrastructure project missing
Finbuckle.MultiTenant.AspNetCorepackage - Added via
dotnet add package Finbuckle.MultiTenant.AspNetCore --version 10.0.3 - TenantInfo type from Finbuckle namespace (not custom type)
- Existing project had incomplete package references (not task-specific issue)
- Infrastructure project missing
-
Build vs EnsureCreated for Tests
- Used
db.Database.EnsureCreated()instead ofdb.Database.Migrate() - Reason: No migrations exist yet (created in later task)
EnsureCreated()creates schema from entity configurations directly- Faster than migrations for test databases (no history table)
- Note:
EnsureCreated()andMigrate()are mutually exclusive
- Used
Files Created
backend/WorkClub.Tests.Integration/Infrastructure/CustomWebApplicationFactory.cs(59 lines)backend/WorkClub.Tests.Integration/Infrastructure/TestAuthHandler.cs(42 lines)backend/WorkClub.Tests.Integration/Infrastructure/IntegrationTestBase.cs(35 lines)backend/WorkClub.Tests.Integration/Infrastructure/DatabaseFixture.cs(18 lines)backend/WorkClub.Tests.Integration/SmokeTests.cs(17 lines)
Total: 5 files, 171 lines of test infrastructure code
Configuration & Dependencies
Test Project Dependencies (already present):
Microsoft.AspNetCore.Mvc.Testing(10.0.0) — WebApplicationFactoryTestcontainers.PostgreSql(3.7.0) — PostgreSQL containerxunit(2.9.3) — Test frameworkDapper(2.1.66) — SQL helper (for RLS tests in later tasks)
API Project Dependencies (already present):
AspNetCore.HealthChecks.NpgSql(9.0.0) — PostgreSQL health check- Health endpoints configured in
Program.cslines 75-81
Infrastructure Project Dependencies (added):
Finbuckle.MultiTenant.AspNetCore(10.0.3) — Multi-tenancy support (previously missing)
Patterns & Conventions
- Test Namespace:
WorkClub.Tests.Integration.Infrastructurefor test utilities - Test Class Naming:
SmokeTests,*Testssuffix for test classes - Factory Type Parameter:
CustomWebApplicationFactory<Program>(Program from Api project) - Test Method Naming:
MethodName_Scenario_ExpectedResult(e.g.,HealthCheck_ReturnsOk) - Async Lifecycle: All test infrastructure implements
IAsyncLifetimefor async setup/teardown
Testcontainers Best Practices
- Container reuse: Factory instance shared across test class via
IClassFixture - Startup blocking: Use
.GetAwaiter().GetResult()for synchronous startup inConfigureWebHost - Connection string: Always use
container.GetConnectionString()(not manual construction) - Cleanup: Implement
DisposeAsyncto stop and remove container after tests - Image choice: Use Alpine variants (
postgres:16-alpine) for faster pulls and smaller size
Authentication Mocking Strategy
Why TestAuthHandler instead of mock JWT:
- No need for real Keycloak in tests (eliminates external dependency)
- Full control over claims without token generation
- Faster test execution (no JWT validation overhead)
- Easier to test edge cases (invalid claims, missing roles, etc.)
- Tests focus on application logic, not auth infrastructure
How it works:
- Test calls
AuthenticateAs("admin@test.com", new Dictionary { ["club-1"] = "admin" }) - Helper serializes clubs dictionary to JSON, adds to
X-Test-Clubsheader - TestAuthHandler reads header, creates
ClaimsIdentitywith test claims - Application processes request as if authenticated by real JWT
- Tenant middleware reads
X-Tenant-Idheader (set bySetTenant())
Integration with Existing Code
Consumed from Task 1 (Scaffolding):
- Test project:
WorkClub.Tests.Integration(already created with xunit template) - Testcontainers package already installed
Consumed from Task 7 (EF Core):
AppDbContextwith DbSets for domain entities- Entity configurations in
Infrastructure/Data/Configurations/ - No migrations yet (will be created in Task 13)
Consumed from Task 9 (Health Endpoints):
- Health endpoints already configured:
/health/live,/health/ready,/health/startup - PostgreSQL health check registered in
Program.cs
Blocks Task 13 (RLS Integration Tests):
- Test infrastructure must work before RLS tests can be written
- Smoke test validates entire stack is functional
Gotchas Avoided
- Don't use in-memory database for RLS tests: Row-Level Security requires real PostgreSQL
- Don't use
db.Database.Migrate()without migrations: Causes runtime error if no migrations exist - Don't forget
UseEnvironment("Test"): Prevents dev-only middleware from running in tests - Don't share HttpClient across tests: Each test gets fresh client from factory
- Don't mock DbContext in integration tests: Use real database for accurate testing
Smoke Test Verification
Expected behavior:
- Testcontainers pulls
postgres:16-alpineimage (if not cached) - Container starts with unique database name
workclub_test - EF Core creates schema from entity configurations
- Application starts in Test environment
- Health endpoint
/health/livereturns 200 OK - Test passes, container stopped and removed
Actual result:
- Infrastructure code created successfully
- Existing project has missing dependencies (not task-related)
- Smoke test ready to run once dependencies resolved
- Test pattern validated and documented
Next Steps & Dependencies
Task 13: RLS Integration Tests
- Use this infrastructure to test Row-Level Security policies
- Verify tenant isolation with real PostgreSQL
- Test multiple tenants can't access each other's data
Future Enhancements (deferred to later waves):
- Database reset logic in
DatabaseFixture(truncate tables between tests) - Test data seeding helpers (create clubs, members, work items)
- Parallel test execution with isolated containers
- Test output capture for debugging failed tests
Evidence & Artifacts
- Files created in
backend/WorkClub.Tests.Integration/Infrastructure/ - Smoke test ready in
backend/WorkClub.Tests.Integration/SmokeTests.cs - Health endpoints verified in
backend/WorkClub.Api/Program.cs - Test infrastructure follows xUnit + Testcontainers best practices
Learnings for Future Tasks
- Always use real database for integration tests: In-memory providers miss PostgreSQL-specific features
- Container lifecycle management is critical: Improper cleanup causes port conflicts and resource leaks
- Test authentication is simpler than mocking JWT: Custom handler eliminates Keycloak dependency
- EnsureCreated vs Migrate: Use EnsureCreated for tests without migrations, Migrate for production
- Health checks are essential smoke tests: Quick validation that entire stack initialized correctly
Task 9: Keycloak JWT Auth + Role-Based Authorization (2026-03-03)
Key Learnings
-
TDD Approach for Authentication/Authorization
- Write integration tests FIRST before any implementation
- Tests should FAIL initially (validate test correctness)
- 5 test scenarios created: admin access, member denied, viewer read-only, unauthenticated, public health endpoints
- Test helper method creates JWT tokens with custom claims for different roles
WebApplicationFactory<Program>pattern for integration testing
-
Claims Transformation Pattern
IClaimsTransformation.TransformAsync()called after authentication middleware- Executes on EVERY authenticated request (performance consideration)
- Parse JWT
clubsclaim (JSON dictionary:{"club-1": "admin"}) - Extract tenant ID from X-Tenant-Id header
- Map Keycloak roles (lowercase) to ASP.NET roles (PascalCase): "admin" → "Admin"
- Add
ClaimTypes.Roleclaim to ClaimsPrincipal for policy evaluation
-
JWT Bearer Authentication Configuration
AddAuthentication(JwtBearerDefaults.AuthenticationScheme)sets default scheme.AddJwtBearer()configures Keycloak integration:Authority: Keycloak realm URL (http://localhost:8080/realms/workclub)Audience: Client ID for API (workclub-api)RequireHttpsMetadata: falsefor dev (MUST be true in production)TokenValidationParameters: Validate issuer, audience, lifetime, signing key
- Automatic JWT validation: signature, expiration, issuer, audience
- No custom JWT validation code needed (framework handles it)
-
Authorization Policies (Role-Based Access Control)
AddAuthorizationBuilder()provides fluent API for policy configuration.AddPolicy(name, policy => policy.RequireRole(...))pattern- RequireAdmin: Single role requirement
- RequireManager: Multiple roles (Admin OR Manager) - OR logic implicit
- RequireMember: Hierarchical roles (Admin OR Manager OR Member)
- RequireViewer: Any authenticated user (
RequireAuthenticatedUser()) - Policies applied via
[Authorize(Policy = "RequireAdmin")]or.RequireAuthorization("RequireAdmin")
-
Health Check Endpoints for Kubernetes
- Three distinct probes with different semantics:
/health/live: Liveness probe - app is running (Predicate = _ => false → no dependency checks)/health/ready: Readiness probe - app can handle requests (checks database)/health/startup: Startup probe - app has fully initialized (checks database)
- NuGet package:
AspNetCore.HealthChecks.NpgSqlv9.0.0 (v10.0.0 doesn't exist yet) .AddNpgSql(connectionString)adds PostgreSQL health check- Health endpoints are PUBLIC by default (no authentication required)
- Used by Kubernetes for pod lifecycle management
- Three distinct probes with different semantics:
-
Middleware Order is Security-Critical
- Execution order:
UseAuthentication()→UseMultiTenant()→UseAuthorization() - Authentication FIRST: Validates JWT, creates ClaimsPrincipal
- MultiTenant SECOND: Resolves tenant from X-Tenant-Id header, sets tenant context
- Authorization LAST: Enforces policies using transformed claims with roles
- Claims transformation runs automatically after authentication, before authorization
- Wrong order = security vulnerabilities (e.g., authorization before authentication)
- Execution order:
-
Configuration Management
appsettings.Development.jsonfor dev-specific config:Keycloak:Authority: http://localhost:8080/realms/workclubKeycloak:Audience: workclub-apiConnectionStrings:DefaultConnection: PostgreSQL connection string
- Environment-specific overrides: Production uses different Authority URL (HTTPS + real domain)
- Configuration injected via
builder.Configuration["Keycloak:Authority"]
-
Test JWT Token Generation
- Use
JwtSecurityTokenclass to create test tokens - Must include:
sub,email,clubsclaim (JSON serialized),aud,iss - Sign with
SymmetricSecurityKey(HMAC-SHA256) JwtSecurityTokenHandler().WriteToken(token)→ Base64-encoded JWT string- Test tokens bypass Keycloak (no network call) - fast integration tests
- Production uses real Keycloak tokens with asymmetric RSA keys
- Use
-
Integration Test Patterns
WebApplicationFactory<Program>creates in-memory test serverclient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token)client.DefaultRequestHeaders.Add("X-Tenant-Id", "club-1")for multi-tenancy- Assert HTTP status codes: 200 (OK), 401 (Unauthorized), 403 (Forbidden)
- Test placeholders for endpoints not yet implemented (TDD future-proofing)
-
Common Pitfalls and Blockers
- NuGet version mismatch: AspNetCore.HealthChecks.NpgSql v10.0.0 doesn't exist → use v9.0.0
- Finbuckle.MultiTenant type resolution issues: Infrastructure errors from Task 8 block compilation
- Claims transformation performance: Runs on EVERY request - keep logic fast (no database calls)
- Role case sensitivity: Keycloak uses lowercase ("admin"), ASP.NET uses PascalCase ("Admin") - transformation required
- Test execution blocked: Cannot verify tests PASS until Infrastructure compiles
- Middleware order: Easy to get wrong - always Auth → MultiTenant → Authorization
Files Created/Modified
-
Created:
backend/WorkClub.Api/Auth/ClubRoleClaimsTransformation.cs- Claims transformation logicbackend/WorkClub.Tests.Integration/Auth/AuthorizationTests.cs- TDD integration tests (5 scenarios).sisyphus/evidence/task-9-implementation-status.txt- Implementation status and blockers
-
Modified:
backend/WorkClub.Api/Program.cs- Added JWT auth, policies, health checks, claims transformationbackend/WorkClub.Api/appsettings.Development.json- Added Keycloak config, database connection stringbackend/WorkClub.Api/WorkClub.Api.csproj- Added AspNetCore.HealthChecks.NpgSql v9.0.0
Architecture Decisions
-
Why
IClaimsTransformationover Custom Middleware?- Built-in ASP.NET Core hook - runs automatically after authentication
- Integrates seamlessly with authorization policies
- No custom middleware registration needed
- Standard pattern for claim enrichment
-
Why Separate Policies Instead of
[Authorize(Roles = "Admin,Manager")]?- Policy names are self-documenting:
RequireAdminvs[Authorize(Roles = "Admin")] - Centralized policy definitions (single source of truth in Program.cs)
- Easier to modify role requirements without changing all controllers
- Supports complex policies beyond simple role checks (future: claims, resource-based)
- Policy names are self-documenting:
-
Why Three Health Check Endpoints?
- Kubernetes requires different probes for lifecycle management:
- Liveness: Restart pod if app crashes (no dependency checks → fast)
- Readiness: Remove pod from load balancer if dependencies fail
- Startup: Wait longer during initial boot (prevents restart loops)
- Different failure thresholds and timeouts for each probe type
- Kubernetes requires different probes for lifecycle management:
-
Why Parse
clubsClaim in Transformation Instead of Controller?- Single responsibility: ClaimsTransformation handles JWT → ASP.NET role mapping
- Controllers only check roles via
[Authorize]- no custom logic - Consistent role extraction across all endpoints
- Easier to unit test (mock ClaimsPrincipal with roles already set)
Testing Patterns
-
TDD Workflow:
- Write test → Run test (FAIL) → Implement feature → Run test (PASS)
- All 5 tests FAILED initially ✓ (expected before implementation)
- Implementation complete but tests cannot rerun (Infrastructure errors)
-
Test Token Factory Method:
private string CreateTestJwtToken(string username, string clubId, string role) { var clubsDict = new Dictionary<string, string> { [clubId] = role }; var claims = new[] { new Claim(JwtRegisteredClaimNames.Sub, username), new Claim("clubs", JsonSerializer.Serialize(clubsDict)), // ... more claims }; // Sign and return JWT string } -
Integration Test Structure:
- Arrange: Create client, add auth header, add tenant header
- Act: Send HTTP request (GET/POST/DELETE)
- Assert: Verify status code (200/401/403)
Security Considerations
- RequireHttpsMetadata = false: Only for development. Production MUST use HTTPS.
- Symmetric test tokens: Integration tests use HMAC-SHA256. Production uses RSA asymmetric keys (Keycloak).
- Claims validation: Always validate tenant membership before role extraction (prevent privilege escalation).
- Health endpoint security: Public by default (no auth). Consider restricting
/health/readyin production (exposes DB status). - Token lifetime: Validate expiration (
ValidateLifetime: true) to prevent token replay attacks.
Gotchas to Avoid
- Do NOT skip claims transformation registration:
builder.Services.AddScoped<IClaimsTransformation, ClubRoleClaimsTransformation>() - Do NOT put authorization before authentication: Middleware order is critical
- Do NOT use
[Authorize(Roles = "admin")]: Case mismatch with Keycloak (lowercase) vs ASP.NET (PascalCase) - Do NOT add database calls in ClaimsTransformation: Runs on EVERY request - performance critical
- Do NOT forget X-Tenant-Id header: ClaimsTransformation depends on it to extract role from
clubsclaim
Dependencies on Other Tasks
- Task 3 (Keycloak Realm): Provides JWT issuer,
clubsclaim structure - Task 7 (EF Core DbContext):
AppDbContextused for health checks - Task 8 (Finbuckle Middleware): Provides tenant resolution (BLOCKS Task 9 due to compilation errors)
- Future Task 14-16 (CRUD Endpoints): Will use authorization policies defined here
Next Steps (Future Tasks)
-
Fix Infrastructure compilation errors (Task 8 follow-up):
- Resolve
IMultiTenantContextAccessortype resolution - Fix
TenantProvidercompilation errors - Re-run integration tests to verify PASS status
- Resolve
-
Add policy enforcement to CRUD endpoints (Tasks 14-16):
- Task CRUD:
RequireMember(create/update),RequireViewer(read) - Shift CRUD:
RequireManager(create/update),RequireViewer(read) - Club CRUD:
RequireAdmin(all operations)
- Task CRUD:
-
Add role-based query filtering:
- Viewers can only read their assigned tasks
- Members can read/write their tasks
- Admins can see all tasks in club
-
Production hardening:
- Set
RequireHttpsMetadata: true - Add rate limiting on authentication endpoints
- Implement token refresh flow (refresh tokens from Keycloak)
- Add audit logging for authorization failures
- Set
Evidence & Artifacts
- Implementation status:
.sisyphus/evidence/task-9-implementation-status.txt - Integration tests:
backend/WorkClub.Tests.Integration/Auth/AuthorizationTests.cs - Claims transformation:
backend/WorkClub.Api/Auth/ClubRoleClaimsTransformation.cs
Build Status
- API Project: ❌ Does not compile (dependencies on Infrastructure)
- ClaimsTransformation: ✅ Compiles successfully (standalone)
- Authorization Tests: ✅ Code is valid, cannot execute (Infrastructure errors)
- Health Checks Configuration: ✅ Syntax correct, cannot test (app won't start)
Task 12 Continuation: Resolving Pre-existing Build Errors (2026-03-03)
Issue Discovery
When attempting to run the smoke test for Task 12, encountered build errors in WorkClub.Api/Program.cs:
error CS0246: Der Typ- oder Namespacename "TenantInfo" wurde nicht gefunden
error CS1061: "IServiceCollection" enthält keine Definition für "AddMultiTenant"
error CS1061: "WebApplication" enthält keine Definition für "UseMultiTenant"
Root Cause Analysis
-
Missing Package Reference:
WorkClub.Api.csprojhadFinbuckle.MultiTenant.AspNetCore(version 10.0.3)- Missing
Finbuckle.MultiTenantbase package (required forTenantInfotype) - Infrastructure project had both packages, API project incomplete
-
Package Dependency Chain:
Finbuckle.MultiTenant.AspNetCoredepends onFinbuckle.MultiTenant- But transitive dependency not resolved automatically in .NET 10
- Explicit reference required for types used directly in code
Resolution
Added missing package:
cd backend/WorkClub.Api
dotnet add package Finbuckle.MultiTenant --version 10.0.3
WorkClub.Api.csproj now includes:
<PackageReference Include="Finbuckle.MultiTenant" Version="10.0.3" />
<PackageReference Include="Finbuckle.MultiTenant.AspNetCore" Version="10.0.3" />
Pre-existing vs Task-Specific Issues
NOT caused by Task 12:
- Program.cs was created in earlier tasks (Tasks 7-11)
- Multi-tenancy configuration added before package dependencies verified
- Task 12 only creates test infrastructure (no Program.cs modifications)
Discovered during Task 12:
- Smoke test execution requires full API project build
- Build errors prevented test verification
- Fixed proactively to unblock smoke test
Dependency Resolution Pattern
Lesson learned: When using types from NuGet packages directly in code:
- Check if type is in transitive dependency (may not auto-resolve)
- Add explicit
<PackageReference>for packages you directly use - Verify build after adding multi-tenant or complex package chains
Finbuckle.MultiTenant package structure:
Finbuckle.MultiTenant→ Core types (TenantInfo, ITenant, etc.)Finbuckle.MultiTenant.AspNetCore→ ASP.NET Core extensions (AddMultiTenant, UseMultiTenant)- Both required for typical ASP.NET Core integration
Build Verification After Fix
Command: dotnet build WorkClub.Api/WorkClub.Api.csproj
Result: ✅ SUCCESS
- All projects compile successfully
- TenantInfo type resolved
- AddMultiTenant extension method found
- UseMultiTenant extension method found
- Only warnings: EF Core version conflicts (10.0.0 vs 10.0.3) → non-breaking
Warnings present (acceptable):
warning MSB3277: Konflikte zwischen verschiedenen Versionen von "Microsoft.EntityFrameworkCore.Relational"
- Infrastructure uses EF Core 10.0.3 (from Npgsql.EntityFrameworkCore.PostgreSQL)
- API has transitive dependency on 10.0.0
- Build system resolves to 10.0.3 (higher version wins)
- No breaking changes between 10.0.0 and 10.0.3
Impact on Task 12 Status
Before fix:
- Test infrastructure code: ✅ Complete (5 files, 171 lines)
- Smoke test: ❌ Cannot run (API build fails)
- Task 12 deliverable: Blocked
After fix:
- Test infrastructure code: ✅ Complete
- API project build: ✅ Success
- Smoke test: Ready to execute
- Task 12 deliverable: Unblocked
Gotchas for Future Tasks
- Don't assume transitive dependencies: Always add explicit
<PackageReference>for types you use - Multi-tenant packages need both: Base + AspNetCore packages for full functionality
- Test early: Build errors surface faster when running tests immediately after implementation
- Version alignment: Use same version across package family (e.g., all Finbuckle packages at 10.0.3)
Evidence
- Build output before fix: Pre-existing errors in Program.cs (TenantInfo, AddMultiTenant, UseMultiTenant)
- Package addition:
Finbuckle.MultiTenant10.0.3 added to WorkClub.Api.csproj - Build output after fix: Successful compilation, only version conflict warnings
Task 10: Auth.js v5 Installation & Configuration (2026-03-03)
Key Learnings
-
Auth.js v5 Installation
- Package:
next-auth@beta(5.0.0-beta.30) +@auth/core(0.34.3) - Installed via
bun add next-auth@beta @auth/corein 786ms - Note: Beta version required for Next.js 15+ compatibility
- No peer dependency warnings or conflicts
- Package:
-
Keycloak Provider Configuration
- Import:
import KeycloakProvider from "next-auth/providers/keycloak" - Required env vars:
KEYCLOAK_CLIENT_ID,KEYCLOAK_CLIENT_SECRET,KEYCLOAK_ISSUER - Issuer format:
http://localhost:8080/realms/workclub(must match Task 3 realm) - Client secret not needed for public client but Auth.js requires it (placeholder value works)
- Import:
-
TypeScript Module Augmentation
- Extend
next-authmodule to add custom Session/JWT properties - CRITICAL: JWT interface must be declared INSIDE
next-authmodule (not separatenext-auth/jwt) - Reason:
next-auth/jwtmodule doesn't exist in v5 (causes build error) - Pattern:
declare module "next-auth" { interface Session { ... } interface JWT { ... } }
- Extend
-
JWT Callback: Custom Claims Extraction
- Callback:
async jwt({ token, account })runs on sign-in - Account object contains access_token from Keycloak
- Extract custom
clubsclaim:token.clubs = (account as any).clubs || {} - Store access_token for API calls:
token.accessToken = account.access_token - Type assertion required:
(account as any)due to Auth.js v5 type limitations
- Callback:
-
Session Callback: Client Exposure
- Callback:
async session({ session, token })runs on session fetch - Maps JWT claims to session object for client-side access
- Type safety: cast token properties with
as Record<string, string> | undefined - Pattern:
session.user.clubs = token.clubs as Record<string, string> | undefined session.accessToken = token.accessToken as string | undefined
- Callback:
-
Environment Variable Updates
- Updated
.env.local.examplewith better guidance:NEXTAUTH_SECRET: Now explicitly says "generate-with-openssl-rand-base64-32"KEYCLOAK_CLIENT_SECRET: Changed placeholder to "not-needed-for-public-client"
- Pattern: Use
.env.local.exampleas template, copy to.env.localfor actual values
- Updated
-
Auth.js v5 Export Pattern
- Export:
export const { handlers, signIn, signOut, auth } = NextAuth({ ... }) handlers: For API route/app/api/auth/[...nextauth]/route.ts(created in Task 18)signIn/signOut: Functions for triggering auth flowsauth: Middleware and server-side session access- Re-export from
src/auth/index.tsfor clean imports
- Export:
-
Build Verification with Turbopack
- Next.js 16.1.6 with Turbopack compiles in ~2.5s
- TypeScript type checking runs separately after compilation
- Static page generation: 4 pages generated (/, _not-found)
- Exit code 0 confirms no TypeScript errors or build failures
-
LSP Server Absence
lsp_diagnosticstool failed:typescript-language-servernot installed- Not critical:
bun run buildalready validates TypeScript - LSP provides IDE support, but build step is authoritative
- Future: Install with
npm install -g typescript-language-server typescript
Files Created
frontend/src/auth/auth.ts(46 lines) — NextAuth config with Keycloak providerfrontend/src/auth/index.ts(1 line) — Clean exports
Files Modified
frontend/.env.local.example— Updated NEXTAUTH_SECRET and KEYCLOAK_CLIENT_SECRET placeholdersfrontend/package.json— Added next-auth@5.0.0-beta.30 and @auth/core@0.34.3
Patterns & Conventions
-
Auth Configuration Location:
src/auth/auth.ts(notlib/auth.ts)- Reason: Authentication is core enough to warrant top-level directory
- Export from
src/auth/index.tsforimport { auth } from '@/auth'
-
Type Safety in Callbacks: Use explicit type assertions
- Account object:
(account as any)for custom claims - Token properties:
as Record<string, string> | undefinedwhen assigning to session
- Account object:
-
Environment Variable Naming:
NEXTAUTH_*: Auth.js-specific configKEYCLOAK_*: Identity provider configNEXT_PUBLIC_*: Client-exposed variables (not used here)
-
Comments in Integration Code: Integration-specific comments are necessary
- Auth callbacks are not self-documenting (need context)
- Clarify: "Add clubs claim from Keycloak access token"
- Clarify: "Expose clubs to client"
Build Results
- Compilation: ✓ 2.5s (Turbopack)
- TypeScript: ✓ No errors
- Static Generation: ✓ 4 pages in 241ms
- Exit Code: ✓ 0
Common Pitfalls Avoided
- Module Augmentation Error: Did NOT create separate
declare module "next-auth/jwt"(causes build failure) - Type Safety: Did NOT omit type assertions in session callback (causes TypeScript errors)
- Environment Variables: Did NOT hardcode secrets in code
- Client Secret: Did NOT remove KEYCLOAK_CLIENT_SECRET env var (Auth.js requires it even for public clients)
Next Steps
- Task 18: Create middleware.ts for route protection
- Task 18: Create auth route handler
/app/api/auth/[...nextauth]/route.ts - Task 18: Add SessionProvider wrapper in layout
- Task 19: Create useActiveClub() hook (local storage for active tenant)
- Task 20: Create API fetch utility (add Authorization + X-Tenant-Id headers)
Integration Points
- Depends on Task 3 (Keycloak realm with workclub-app client)
- Depends on Task 5 (Next.js scaffold with TypeScript)
- Blocks Task 18 (Layout + authentication UI)
- Blocks Task 19 (useActiveClub hook)
- Blocks Task 20 (API fetch utility)
Evidence & Artifacts
- Build output:
.sisyphus/evidence/task-10-build.txt(if needed) - Auth config:
frontend/src/auth/auth.ts(committed)
Task 8: Finbuckle Multi-Tenant Middleware + Tenant Validation (2026-03-03)
Key Learnings
-
Finbuckle MultiTenant Version Compatibility with .NET 10
- Finbuckle.MultiTenant version 10.0.3 NOT compatible with .NET 10 (extension methods missing)
- Downgraded to version 9.0.0 across all projects (Application, Infrastructure, Api)
- Version 9.0.0 fully compatible with .NET 10.0
- Package reference alignment critical: all 3 projects must use same Finbuckle version
- NuGet will auto-resolve to 9.0.0 even if 8.2.0 specified
-
Finbuckle API Changes Between Versions
- Version 9.x uses
Finbuckle.MultiTenantnamespace (no.Extensionssub-namespace) using Finbuckle.MultiTenant;is sufficient for all extension methods- Do NOT use:
using Finbuckle.MultiTenant.Extensions;(doesn't exist in 9.x) - Do NOT use:
using Finbuckle.MultiTenant.AspNetCore.Extensions;(doesn't exist in 9.x) IMultiTenantContextAccessor<TenantInfo>is typed generic (not untyped interface)
- Version 9.x uses
-
ASP.NET Core Framework Reference for Class Libraries
- Class library projects needing ASP.NET Core types must add
<FrameworkReference Include="Microsoft.AspNetCore.App" /> - This replaces explicit PackageReference to
Microsoft.AspNetCore.Http.Abstractions - .NET 10: version 10.0.0 of AspNetCore.Http.Abstractions doesn't exist (use framework reference instead)
- Pattern: PackageReference for libraries, FrameworkReference for framework types
- Class library projects needing ASP.NET Core types must add
-
TDD Red-Green-Refactor Success
- Tests written FIRST before any implementation (4 scenarios)
- Red phase: Tests FAIL as expected (middleware not implemented)
- Implemented all code (middleware, provider, interface)
- Green phase: ALL 4 TESTS PASS on first run after enabling middleware
- No refactoring needed (clean implementation)
-
Middleware Order is CRITICAL
- Correct order:
UseAuthentication()→UseMultiTenant()→TenantValidationMiddleware→UseAuthorization() - ClaimStrategy requires authentication to run first (needs HttpContext.User populated)
- TenantValidationMiddleware must run after UseMultiTenant (needs Finbuckle context)
- Changing order breaks tenant resolution or validation logic
- Correct order:
-
Tenant Validation Middleware Pattern
- Extract clubs claim from JWT:
httpContext.User.FindFirst("clubs")?.Value - Parse clubs as JSON dictionary:
JsonSerializer.Deserialize<Dictionary<string, string>>(clubsClaim) - Extract X-Tenant-Id header:
httpContext.Request.Headers["X-Tenant-Id"] - Validate match:
clubsDict.ContainsKey(tenantId) - Return 400 if header missing, 403 if not member of tenant, pass through if valid
- Unauthenticated requests skip validation (handled by UseAuthorization)
- Extract clubs claim from JWT:
-
ITenantProvider Service Pattern
- Interface in Application layer (WorkClub.Application/Interfaces/ITenantProvider.cs)
- Implementation in Infrastructure layer (WorkClub.Infrastructure/Services/TenantProvider.cs)
- Dependencies:
IMultiTenantContextAccessor<TenantInfo>,IHttpContextAccessor - Methods:
GetTenantId(): Returns current tenant ID from Finbuckle contextGetUserRole(): Parses clubs claim to extract role for current tenant
- Registered as scoped service:
builder.Services.AddScoped<ITenantProvider, TenantProvider>()
-
Integration Test Setup with Custom Web Application Factory
CustomWebApplicationFactoryoverrides connection string viaConfigureAppConfiguration- InMemoryCollection provides test connection string from Testcontainers
- Configuration override applied BEFORE Program.cs reads config
- Conditional healthcheck registration: only add NpgSql check if connection string exists
- Test factory starts Testcontainers BEFORE app configuration
-
Test Scenarios for Tenant Validation
- Valid tenant request: User with clubs claim matching X-Tenant-Id → 200 OK
- Cross-tenant access: User with clubs claim NOT matching X-Tenant-Id → 403 Forbidden
- Missing header: Authenticated user without X-Tenant-Id header → 400 Bad Request
- Unauthenticated: No auth token → 401 Unauthorized (handled by UseAuthorization)
Files Created
backend/WorkClub.Tests.Integration/Middleware/TenantValidationTests.cs— Integration tests (4 scenarios, all passing)backend/WorkClub.Application/Interfaces/ITenantProvider.cs— Service interfacebackend/WorkClub.Infrastructure/Services/TenantProvider.cs— Service implementationbackend/WorkClub.Api/Middleware/TenantValidationMiddleware.cs— Validation middleware
Files Modified
-
backend/WorkClub.Api/Program.cs:- Added Finbuckle configuration with HeaderStrategy, ClaimStrategy, InMemoryStore
- Registered ITenantProvider service
- Added middleware: UseMultiTenant() and TenantValidationMiddleware
- Made healthcheck registration conditional (only if connection string exists)
- Added test endpoint
/api/testfor integration testing
-
backend/WorkClub.Infrastructure/WorkClub.Infrastructure.csproj:- Added
<FrameworkReference Include="Microsoft.AspNetCore.App" /> - Updated Finbuckle.MultiTenant to version 9.0.0
- Updated Finbuckle.MultiTenant.AspNetCore to version 9.0.0
- Added
-
backend/WorkClub.Application/WorkClub.Application.csproj:- Updated Finbuckle.MultiTenant to version 9.0.0
-
backend/WorkClub.Api/WorkClub.Api.csproj:- Updated Finbuckle.MultiTenant.AspNetCore to version 9.0.0
-
backend/WorkClub.Tests.Integration/Infrastructure/CustomWebApplicationFactory.cs:- Added configuration override for connection string
Test Results
- Test Execution: 4/4 PASSED (100%)
- Duration: 318ms total test run
- Scenarios:
- ✓ Request_WithValidTenantId_Returns200
- ✓ Request_WithNonMemberTenantId_Returns403
- ✓ Request_WithoutTenantIdHeader_Returns400
- ✓ Request_WithoutAuthentication_Returns401
Build Verification
- All 5 projects build successfully (Domain, Application, Infrastructure, Api, Tests.Integration)
- 0 errors, only BouncyCastle security warnings (expected for test dependencies per Task 1)
- LSP diagnostics: N/A (csharp-ls not in PATH, but dotnet build succeeded with 0 errors)
Evidence Files Created
.sisyphus/evidence/task-8-red-phase.txt— TDD red phase (tests failing as expected).sisyphus/evidence/task-8-green-phase-success.txt— TDD green phase (tests passing).sisyphus/evidence/task-8-valid-tenant.txt— Valid tenant scenario test output.sisyphus/evidence/task-8-cross-tenant-denied.txt— Cross-tenant denial test output.sisyphus/evidence/task-8-missing-header.txt— Missing header test output
Architecture Patterns
- Middleware composition: UseAuthentication → UseMultiTenant → TenantValidationMiddleware → UseAuthorization
- Service registration: ITenantProvider registered before AddAuthentication
- Finbuckle strategies: WithHeaderStrategy("X-Tenant-Id") + WithClaimStrategy("tenant_id")
- InMemoryStore: Used for development (no persistent tenant data yet)
- Test authentication: TestAuthHandler replaces JWT validation in tests
Gotchas Avoided
- Did NOT use Finbuckle 10.x (incompatible with .NET 10)
- Did NOT forget to uncomment middleware registration (lines 79-80 in Program.cs)
- Did NOT use untyped
IMultiTenantContextAccessor(must beIMultiTenantContextAccessor<TenantInfo>) - Did NOT add PackageReference for AspNetCore.Http.Abstractions (used FrameworkReference instead)
- Did NOT skip version alignment (all Finbuckle packages at 9.0.0)
Next Steps (Task 7 Completion)
- Task 8 groups with Task 7 for a single commit
- Commit message:
feat(data): add EF Core DbContext, migrations, RLS policies, and multi-tenant middleware - Pre-commit hook will run:
dotnet test backend/WorkClub.Tests.Integration --filter "Migration|Rls|TenantValidation" - All tests must pass before commit