ProjectPulse is a .NET 8 REST API for managing user-owned projects. It is a backend portfolio and learning project focused on practical API design, authentication, resource-level authorization, persistence, automated testing, and repeatable builds.
The project is intentionally compact. It demonstrates production-minded security and engineering practices without presenting itself as a production-ready enterprise platform.
- User registration and login with JWT bearer authentication.
- PBKDF2-SHA256 password hashing with per-password salts.
- Opaque refresh tokens stored only as SHA-256 hashes.
- Refresh-token rotation, expiry checks, revocation, and reuse protection.
- CRUD endpoints for projects with pagination, filtering, and sorting.
- Resource-level authorization: users can access only their own projects.
- Swagger/OpenAPI documentation.
- EF Core migrations with SQLite persistence.
- Startup validation for JWT and database configuration.
- Integration tests using
WebApplicationFactoryand isolated SQLite databases. - Reproducible SDK/package resolution and GitHub Actions CI.
| Area | Technology |
|---|---|
| Runtime | .NET 8 / ASP.NET Core Web API |
| Persistence | Entity Framework Core 8 and SQLite |
| Authentication | JWT bearer tokens and opaque refresh tokens |
| Validation | FluentValidation |
| API documentation | Swagger / Swashbuckle |
| Testing | xUnit and Microsoft.AspNetCore.Mvc.Testing |
| Build reproducibility | global.json and NuGet lock files |
| CI | GitHub Actions |
SQLite keeps local setup simple and makes the repository easy to evaluate. It is not intended here as a recommendation for a larger multi-instance production system.
ProjectPulse also has a companion React + TypeScript frontend:
The frontend provides authentication, persistent local sessions, and basic project CRUD using this API.
- JWT issuer, audience, and signing key are validated during startup.
- The signing key must be at least 32 UTF-8 bytes and cannot be a known placeholder or demo value.
- Critical configuration is validated before migrations or seed operations can modify the database.
- Raw refresh tokens are returned to clients but never stored; only their SHA-256 hashes are persisted.
- Refresh tokens are one-time credentials. Rotation revokes the old token, and reuse is rejected.
- Sensitive authentication endpoints use per-client fixed-window rate limiting.
- Project ownership comes from the authenticated JWT subject, never from client input.
- Cross-user project lookups return
404 Not Foundto avoid revealing resource existence.
Secrets must be supplied through environment variables, development secret storage, or a deployment secret manager. Do not commit real credentials or signing keys.
- .NET SDK 8.0.422, or a compatible patch selected by
global.json. - Optional:
dotnet-ef8.0.28 for manual migration commands.
Confirm the selected SDK:
dotnet --versionASP.NET Core maps double underscores in environment-variable names to configuration sections.
| Environment variable | Required | Purpose |
|---|---|---|
Jwt__Issuer |
Yes | Expected JWT issuer |
Jwt__Audience |
Yes | Expected JWT audience |
Jwt__Key |
Yes | Random signing key of at least 32 UTF-8 bytes |
ConnectionStrings__Default |
Outside Development | SQLite connection string |
EnableSwagger |
No | Enables Swagger outside Development when true |
SEED |
No | Enables optional development seed data when true |
SEED_ADMIN_PASSWORD |
When seeding | Password for the seeded development admin |
Development settings provide local issuer, audience, and SQLite defaults. A real development signing key must still be supplied because tracked placeholder values are rejected.
Restore locked dependencies:
dotnet restore --locked-modeGenerate a temporary development signing key and run the API.
PowerShell:
$bytes = New-Object byte[] 32
$rng = [Security.Cryptography.RandomNumberGenerator]::Create()
$rng.GetBytes($bytes)
$env:Jwt__Key = [Convert]::ToBase64String($bytes)
$rng.Dispose()
dotnet run --project .\ProjectPulse.Api\ProjectPulse.Api.csprojBash:
export Jwt__Key="$(openssl rand -base64 32)"
dotnet run --project ./ProjectPulse.Api/ProjectPulse.Api.csprojWith the default launch profile, the API listens on http://localhost:5241. Use the unauthenticated health endpoint for a simple liveness check:
http://localhost:5241/health
In Development, CORS allows the local Vite frontend from http://localhost:5173, http://127.0.0.1:5173, http://localhost:5174, and http://127.0.0.1:5174.
Swagger is available in Development at:
http://localhost:5241/swagger/index.html
For a non-Development environment, also provide explicit issuer, audience, and connection-string values:
Jwt__Issuer=<expected-issuer>
Jwt__Audience=<expected-audience>
Jwt__Key=<random-secret-at-least-32-bytes>
ConnectionStrings__Default=Data Source=<database-path>
The image uses a locked multi-stage .NET 8 build and runs the published API as a non-root user. Build it from the repository root so global.json and NuGet lock files are available:
docker build -t projectpulse-api -f ProjectPulse.Api/Dockerfile .Generate a signing key in the host shell before starting a container.
PowerShell:
$bytes = New-Object byte[] 32
$rng = [Security.Cryptography.RandomNumberGenerator]::Create()
$rng.GetBytes($bytes)
$env:PROJECTPULSE_JWT_KEY = [Convert]::ToBase64String($bytes)
$rng.Dispose()
$env:PROJECTPULSE_CONNECTION_STRING = "Data Source=/data/projectpulse.db"Bash:
export PROJECTPULSE_JWT_KEY="$(openssl rand -base64 32)"
export PROJECTPULSE_CONNECTION_STRING="Data Source=/data/projectpulse.db"Run the image with configuration supplied through environment variables and a named volume mounted at /data:
docker run --rm --name projectpulse-api -p 8080:8080 \
-e Jwt__Issuer=ProjectPulse \
-e Jwt__Audience=ProjectPulse \
-e Jwt__Key="$PROJECTPULSE_JWT_KEY" \
-e "ConnectionStrings__Default=Data Source=/data/projectpulse.db" \
-e EnableSwagger=true \
-v projectpulse-data:/data \
projectpulse-apiAlternatively, Compose uses the same host signing-key variable:
docker compose up --buildThe API is then available on http://localhost:8080; /health is the preferred unauthenticated liveness endpoint and Swagger is at /swagger/index.html for this local Compose configuration.
The SQLite database is stored at /data/projectpulse.db. Mount /data to retain it when containers are replaced. This container setup is intended for local/demo evaluation; SQLite and automatic startup migrations are not designed here for horizontally scaled deployment.
The application currently applies pending EF Core migrations during startup. For manual migration management, install the matching CLI tool if needed and run:
dotnet tool install --global dotnet-ef --version 8.0.28
dotnet ef database update \
--project ./ProjectPulse.Api/ProjectPulse.Api.csproj \
--startup-project ./ProjectPulse.Api/ProjectPulse.Api.csprojThe required JWT and database configuration must be available when invoking the startup project.
Important migration behavior:
- The ownership migration deletes legacy projects that have no owner. Those rows predate ownership and cannot be assigned safely to a real user.
- The refresh-token hashing migration deletes legacy raw refresh-token rows. Raw values cannot be safely converted while preserving active client sessions.
The integration suite starts the API with safe test configuration and uses isolated temporary SQLite databases.
dotnet restore --locked-mode
dotnet build ./ProjectPulse.Api/ProjectPulse.Api.csproj --configuration Release --no-restore
dotnet test --configuration Release --no-restoreCurrent coverage focuses on startup, project ownership boundaries, and refresh-token storage and rotation.
The .NET CI workflow runs on pushes and pull requests targeting master. It performs a locked restore, builds the API in Release mode, and runs the Release test suite with least-privilege repository permissions.
| Method | Route | Authentication | Purpose |
|---|---|---|---|
POST |
/auth/register |
No | Register a user |
POST |
/auth/login |
No | Issue access and refresh tokens |
POST |
/auth/refresh |
No | Rotate a refresh token |
POST |
/auth/logout |
No | Revoke a refresh token |
GET |
/health |
No | Basic liveness check |
GET |
/users/me |
JWT | Return the current user claims |
GET |
/projects |
JWT | List the current user's projects |
POST |
/projects |
JWT | Create an owned project |
GET |
/projects/{id} |
JWT | Get an owned project |
PUT |
/projects/{id} |
JWT | Update an owned project |
DELETE |
/projects/{id} |
JWT | Delete an owned project |
ProjectPulse/
├── .github/workflows/ # GitHub Actions CI
├── ProjectPulse.Api/
│ ├── Controllers/ # HTTP endpoints
│ ├── Domain/ # EF Core entities
│ ├── DTOs/ # Request/response contracts and validators
│ ├── Migrations/ # Database schema history
│ ├── Persistence/ # DbContext and startup seeding
│ ├── Security/ # Claims helpers
│ ├── Services/ # Password and token services
│ └── Program.cs # Dependency registration and HTTP pipeline
├── ProjectPulse.Api.Tests/ # Integration tests and test application factory
├── global.json # Pinned .NET SDK
└── ProjectPulse.sln
- SQLite and automatic startup migrations favor local development over multi-instance deployment.
- Authentication does not yet include email verification, password reset, MFA, or account lockout.
- Projects have a single owner; collaboration and project-specific roles are not implemented.
- Refresh-token state is database-backed and designed for this single-service scope.
- Test coverage is intentionally focused rather than exhaustive.
- Docker support is suitable for local/demo evaluation; production deployment would require additional infrastructure hardening.
Possible next steps include broader validation/error-path tests, structured observability, PostgreSQL deployment support, and collaborative project membership.
Christian Manrique Zanetti — LinkedIn