Skip to content

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.

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-uplink is 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.

UseHsts → UseHttpsRedirection → UseAuthentication → UseAuthorization → UseRateLimiter → Endpoints

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.

A typical authenticated sync request looks like this:

  1. Caddy terminates TLS and forwards to pia-server.
  2. The rate limiter applies the sync policy (per-user budget).
  3. UseAuthentication validates the JWT and populates HttpContext.User.
  4. SyncController.Push binds the SyncPushRequest body and makes one call, ISyncService.PushAsync — controllers stay thin and never touch EF Core or business logic directly.
  5. Inside PushAsync, QuotaService.CheckQuotaAsync rejects with 409 quota_exceeded if any cap would be violated.
  6. ConflictResolver reconciles per-record UpdatedAt timestamps.
  7. EF Core writes the changes inside a single transaction.
  8. The controller maps the returned Outcome<SyncPushResponse> to a 200 via ToActionResult, 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 is layered (appsettings.jsonappsettings.{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.

A few non-obvious hardening choices are worth knowing about so you don’t accidentally undo them in a refactor:

  • Email enumeration: /auth/login/local is 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-password always returns 200, regardless of whether the email is registered. Reset tokens are minted only for real users; the response is identical either way.
  • /auth/register intentionally returns 409 email_exists for 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.