Architecture overview
Pia Server is an ASP.NET Core 10 service. HTTP endpoints are MVC controllers, grouped by area under Controllers/. The Blazor Server admin UI and one SignalR hub are mounted in the same process.
Module map
Section titled “Module map”| Area | Controllers | Purpose |
|---|---|---|
| Auth | Controllers/Auth/ |
OAuth login, local login, refresh, logout, /auth/me, MFA, WebAuthn, sessions |
| Sync | Controllers/Sync/ |
Cursor-based pull, conflict-aware push, status, audit, quota, plugin assets |
| AI proxy | Controllers/AiProxy/ |
Forward client requests to upstream AI providers, with guardrails and the tool loop |
| E2EE | Controllers/E2EE/ |
Register / approve / revoke devices, exchange wrapped UMK, recovery |
| Knowledge | Controllers/Knowledge/ |
Knowledge-base document ingest and management |
| Operators | Controllers/Operators/ |
Pia Mesh assignments — enqueue, poll, cancel |
| Admin | Controllers/Admin/ |
Admin-only APIs behind AdminPolicy, including the assignment roll-up |
| Licensing | Controllers/Licensing/ |
Licence status |
Two things sit outside the controller model, both deliberately:
- The admin UI is Blazor Server components mounted at
/admin. /hubs/pod-uplinkis a SignalR hub — the single exception to the controllers-only rule, because a persistent bidirectional connection is what the Mesh uplink is. It is also the tool plane’s only inbound route. See Pod uplink and connector credentials.
Health check at GET /health. It deliberately does not depend on Temporal or on any pod: an unreachable Mesh dependency must never read as an unhealthy server.
Middleware order
Section titled “Middleware order”UseHsts → UseHttpsRedirection → UseAuthentication → UseAuthorization → UseRateLimiter → EndpointsService registration
Section titled “Service registration”Program.cs itself registers only cross-cutting infrastructure (the DB context factory, Data
Protection, authentication, MVC/Razor Components, rate limiting). Everything else is composed from
per-area extension methods — AddAuthServices, AddAiProxyServices, AddE2EEServices,
AddSyncServices, AddAdminSurfaceServices, AddBackgroundServices, AddConnectorServices /
AddRemoteConnectorServices (must run after AddKnowledgeServices so the knowledge-base connector
keeps first-registered-wins tool-name precedence over any Mesh pod), and AddKnowledgeServices when a
vector-DB connection string is configured. Call order in Program.cs is registration order, and a
golden-file test (Snapshots/services.golden.txt) fails on an unreviewed reordering.
A representative slice of what those methods register:
| Service | Lifetime | Notes |
|---|---|---|
PiaDbContext |
Scoped | EF Core, provider chosen by config |
JwtService |
Singleton | HS256, issuer pia-server, audience pia-client |
UserService |
Scoped | User CRUD, find-or-create on OAuth callback |
EncryptionService |
Singleton | AES-256-GCM with per-user keys via HKDF |
AiProxyService |
Scoped | Routes per-mode AI requests to upstream providers |
ConflictResolver |
Scoped | Last-write-wins sync conflict resolution |
QuotaService |
Scoped | Enforces per-user object caps before push |
SyncService |
Scoped | Sync orchestration |
OnboardingSessionService |
Singleton | In-memory E2EE pairing sessions (10-min expiry) |
RefreshTokenCleanupService |
Hosted | Background prune of expired refresh tokens |
IConnectorRegistry |
Scoped | Resolves the tool set for a chat request across all connectors |
IPodPresenceRegistry |
Singleton | In-memory pod presence; deliberately does not survive a restart |
IMcpSessionManager |
Singleton | Pooled MCP sessions to remote pods, with connect backoff and a liveness probe |
OperatorWorker |
Hosted | Temporal worker for assignments; idles when Operators:Enabled is off |
AssignmentRetentionService |
Hosted | Five-minute sweep for expired assignments and stuck Queued rows |
OnboardingSessionService is in-memory by design — pairing sessions are short-lived and don’t need to survive a restart. If you run the server in a load-balanced deployment, route all /api/e2ee/* calls for a given device pairing to the same node, or replace the implementation with a Redis-backed one.
Request flow at a glance
Section titled “Request flow at a glance”A typical authenticated sync request looks like this:
- Caddy terminates TLS and forwards to
pia-server. - The rate limiter applies the
syncpolicy (per-user budget). UseAuthenticationvalidates the JWT and populatesHttpContext.User.SyncController.Pushbinds theSyncPushRequestbody and makes one call,ISyncService.PushAsync— controllers stay thin and never touch EF Core or business logic directly.- Inside
PushAsync,QuotaService.CheckQuotaAsyncrejects with409 quota_exceededif any cap would be violated. ConflictResolverreconciles per-recordUpdatedAttimestamps.- EF Core writes the changes inside a single transaction.
- The controller maps the returned
Outcome<SyncPushResponse>to a200viaToActionResult, with server-assigned IDs and any conflict metadata.
E2EE traffic adds a parallel side-channel: the server stores wrapped UMK material via E2EEDevicesController/E2EERecoveryController, but never sees plaintext keys.
Configuration
Section titled “Configuration”Configuration is layered (appsettings.json → appsettings.{Environment}.json → environment variables → command-line args). The key sections are:
| Section | Purpose |
|---|---|
Database |
Provider (sqlite / postgresql) and connection string |
Jwt |
Issuer, audience, signing key, lifetimes |
OAuth |
Per-provider client IDs and secrets |
Ai |
Default provider + per-mode overrides |
Encryption |
Master key (must come from env in production) |
Operators, Temporal |
The Mesh operator runtime — off by default |
License |
Licence file path |
See Configuration for the full reference.
Hardening notes
Section titled “Hardening notes”A few non-obvious hardening choices are worth knowing about so you don’t accidentally undo them in a refactor:
- Email enumeration:
/auth/login/localis timing-padded (300 ms floor) and runs a dummy hash on the unknown-email branch. The error body is identical for “unknown email” and “wrong password”. Don’t introduce branches that return earlier than the floor. /auth/forgot-passwordalways returns 200, regardless of whether the email is registered. Reset tokens are minted only for real users; the response is identical either way./auth/registerintentionally returns409 email_existsfor duplicates (UX trade-off accepted), with rate limiting in front.- OAuth callback collisions: when an OAuth provider brings an email that already exists under a different provider, the server returns
409 email_exists— the same response as/auth/register. This leaks existence but cannot be probed anonymously.