QDG Knowledge Base Read-only viewer QWebHub
general

Architecture

Version 1 · Initial page: system-level architecture — component diagram, tech stack, request pipeline, DI service graph, background services, auth flow, deployment, and config/secrets notes.

Architecture

A system-level view of how Titan Command is put together. For the local database's own schema, see the Database Schema page; for every HTTP route, see the API Reference page; for how the recurring sync feature works, see the Auto Sync Feature page. This doc doesn't repeat those — it explains how the pieces connect.

System overview

                         ┌───────────────────────────┐
                         │   Browser (operator UI)    │
                         └──────────────┬─────────────┘
                                        │ HTTP (cookie-based JWT)
                         ┌──────────────▼─────────────┐
                         │      QDG.Migration.Web      │  ASP.NET Core 8 MVC
                         │  Controllers, Views, DI     │
                         └──┬──────────┬─────────────┬─┘
                            │          │             │
          ┌─────────────────▼──┐  ┌────▼──────────┐  │
          │ QDG.Migration.Core │  │QDG.Migration.  │  │
          │ Services (business │  │Data (EF Core)  │  │
          │ logic, no ASP.NET/ │  │Repositories +  │  │
          │ EF dependency)     │  │AppDbContext     │  │
          └──────────┬─────────┘  └───────┬─────────┘  │
                      │ MySqlConnector/    │ SQLite      │ HttpClient
                      │ Dapper             │             │
          ┌───────────▼──────────┐  ┌──────▼──────┐  ┌───▼────────────┐
          │  Source MySQL (QDB)   │  │titan_command│  │  QDBAuth        │
          │  Destination MySQL    │  │.db (local    │  │  (external      │
          │  (QDG) — config_tables│  │ app state)   │  │  identity/2FA)  │
          │  / config_id_mappings │  └─────────────┘  └────────────────┘
          └───────────────────────┘
  • QDG.Migration.Web — the ASP.NET Core MVC host. Controllers, Razor views, DI wiring (Program.cs), auth cookie handling, background services.
  • QDG.Migration.Core — domain models and all business logic (Services/). No dependency on ASP.NET or EF Core; talks to the remote MySQL source/destination directly via MySqlConnector/Dapper.
  • QDG.Migration.Data — EF Core AppDbContext (SQLite) and repositories that persist app state (sessions, mappings, sync jobs) — distinct from the business data being migrated.
  • Remote MySQL (×2) — the legacy QDB source and the redesigned QDG destination, reached over the network per DatabaseConfig, never touched by EF Core.
  • QDBAuth — an external identity service shared across the QDB ecosystem; this app has no local accounts, it's a thin client (ICentralAuthService) of QDBAuth's login/2FA/refresh API.

Tech stack

  • Backend: ASP.NET Core 8, MVC + Razor views (a handful of unused/unreachable Blazor .razor files also exist — see the API Reference page's "Dead code" note).
  • Frontend: Bootstrap 5, Bootstrap Icons, jQuery, DataTables (for the Table Selection checklist), Google "Inter" font. No SPA framework — server-rendered pages with targeted AJAX calls for live status/progress.
  • App-state storage: SQLite via EF Core (AppDbContext), created with EnsureCreated() and evolved mostly through hand-rolled schema patches (see the Database Schema page).
  • Migrated-data access: MySqlConnector/Dapper directly against source and destination MySQL — no ORM, no EF Core, for the actual business data.
  • Auth: JWT, read from a cookie (AuthToken), not an Authorization header.

Request pipeline

Middleware order, from Program.cs:

  1. UseExceptionHandler("/Home/Error") + UseHsts() — non-Development only.
  2. UseStaticFiles() — HTTPS redirection is explicitly disabled (UseHttpsRedirection() is commented out), presumably because TLS termination happens upstream (IIS/reverse proxy) rather than in-process.
  3. UseRouting()
  4. UseAuthentication()
  5. UseAuthorization()
  6. MapControllerRoute (conventional {controller=Home}/{action=Index}/{id?})
  7. MapRazorPages()

Two request-size limits are deliberately raised above ASP.NET's defaults, both because the wizard renders one form field per source table column (not per table) and some legacy tables have up to ~190 columns: FormOptions.ValueCountLimit and MVC's MaxModelBindingCollectionSize are both raised from 1,024 to 20,000, and Kestrel's MaxRequestHeadersTotalSize is raised to 128KB to leave headroom for the JWT + antiforgery + TempData cookies that stack up across a multi-step wizard session. The app requires authentication everywhere (no anonymous access), which is explicitly why the usual DoS-protection rationale for keeping those limits low doesn't apply here.

Service graph

DI registrations from Program.cs, grouped by role (all Scoped unless noted):

Group Interface → Implementation Lifetime
Migration pipeline IConfigurationService → ConfigurationService Scoped
ITableService → TableService Scoped
IRelationshipService → RelationshipService Scoped
IMigrationService → MigrationService Scoped
IDependencyResolver → DependencyResolver Scoped
IDatabaseAnalyzerService → DatabaseAnalyzerService Scoped
IExcelSchemaService → ExcelSchemaService Scoped
ISchemaValidationService → SchemaValidationService Scoped
Repositories IConfigRepository → ConfigRepository Scoped
ISessionRepository → SessionRepository Scoped
IDestinationMappingRepository → DestinationMappingRepository Scoped
Auth IAuthTokenService → AuthTokenService Scoped
ICentralAuthService → CentralAuthService Scoped, via typed AddHttpClient
TokenRefreshCoordinator Singleton
Progress/registries IMigrationProgressStore → MigrationProgressStore Singleton
IActiveMigrationRegistry → ActiveMigrationRegistry Singleton
ICopyJobRegistry → CopyJobRegistry Singleton
Specialized copy/repair IBarrierTrialsCopyService → BarrierTrialsCopyService Scoped
ISectionalCopyService → SectionalCopyService Scoped
IDisciplineRepairService → DisciplineRepairService Scoped
Sync ISyncService → SyncService (Web) Scoped
ICopySyncService → CopySyncService (Web) Scoped

Plus AppDbContext (SQLite, Scoped by default), AddMemoryCache(), AddRazorPages().

The three singletons in the "Progress/registries" group exist specifically so that a background Task.Run and the request that started it (or a later polling request) can share live state — progress snapshots and cancellation tokens don't survive in a per-request scope.

Background services

Exactly two IHostedServices run in this app, both polling every 1 minute:

  • SyncBackgroundService — checks SyncJob rows for due runs.
  • CopySyncBackgroundService — checks CopySyncConfig rows for due runs.

Both follow the same shape: query for IsEnabled && Status != Running && NextRunAt <= now, then fire each due item on its own Task.Run with a fresh DI scope (never the poller's own scope). See the Auto Sync Feature page for what happens inside a run.

Startup resilience

On every app start, alongside the schema-patching described in the Database Schema page, two idempotent recovery queries run:

UPDATE SyncJobs SET Status = 0, LastError = '...recovered...' WHERE Status = 1;
UPDATE CopySyncConfigs SET Status = 0 WHERE Status = 1;

This resets any job left stuck at Status = Running by a process that died mid-run (IIS recycle, crash) — without it, such a job would be permanently invisible to the poller (which deliberately skips anything already Running, to avoid double-triggering) and look like it's running forever while doing nothing. This is a whole-system resilience property, not something specific to one feature — it applies identically to Sync Jobs and Copy-Sync.

Auth architecture

QDBAuth is the identity source of truth for the whole QDB ecosystem; this app has no local accounts. The flow:

  1. AccountController calls ICentralAuthService.LoginAsync(email, password), then VerifyTwoFactorAsync(pendingToken, code) for the 6-digit TOTP step, then WhoAmIAsync to resolve identity and permissions.
  2. On success, IAuthTokenService.CreateSessionToken mints this app's own short-lived session JWT (not QDBAuth's token directly) into the AuthToken cookie, via AuthCookieHelper.
  3. SilentTokenRefreshFilter runs as an authorization filter on every authenticated request (except Account actions). If the embedded external access token is within 120s of its 900s (15 min) expiry, it silently calls ICentralAuthService.RefreshAsync and reissues the cookie — sliding the session forward as long as the user stays active, up to the refresh token's ~1-day ceiling.
  4. TokenRefreshCoordinator (the one non-registry singleton) serializes refresh calls per session, because QDBAuth's refresh tokens are single-use — two concurrent requests racing to refresh would otherwise leave one of them with an already-dead token.
  5. A dead/rotated refresh token, or a deactivated account, forces a silent logout (cookie cleared, redirect to /Account/Login?reason=expired).

Roles (Admin/Operator/Viewer) are derived from QDBAuth's permission string via AuthTokenService.MapPermissionToRole, using the CentralAuthApi:RoleMap config (external ADMIN/DEVELOPER/VIEWER → internal Admin/Operator/Viewer) with DefaultRole: Viewer as the fallback.

Deployment

.github/workflows/deploy.yml — triggered on push to master or manual workflow_dispatch, runs on a self-hosted Windows runner (labels Windows, x64, qdg-aus, qdg-main):

  1. Checkout, dotnet restore/build --configuration Release/publish --output ./publish.
  2. Deploy (PowerShell): Stop-WebAppPool + Stop-Website for the QDG-Migrator IIS site, wait 5 seconds, robocopy ./publish → C:\WebSites\QDG-Migrator with /E /PURGE (mirrors the publish output, deletes anything in the target that isn't in the new output).
  3. A finally block always restarts the app pool and website, even if robocopy failed — avoiding a deploy failure leaving the site down.
  4. Robocopy exit codes ≥ 8 are treated as failure and fail the step.

There is no staging environment and no blue/green — a push to master deploys straight to the production IIS site. The self-hosted runner implies the runner machine either is the IIS host or has direct WebAdministration access to it.

Configuration & secrets

Only appsettings.json and appsettings.Development.json exist — no appsettings.Production.json. Top-level sections in appsettings.json:

  • ConnectionStrings.AppDatabase — SQLite connection string for titan_command.db.
  • CentralAuthApi — BaseUrl, ClientSecret, SiteName, RoleMap, DefaultRole for the QDBAuth integration.
  • Logging, AllowedHosts — standard ASP.NET Core defaults.

Worth knowing, not assuming: the checked-in CentralAuthApi.ClientSecret value and the JWT signing key's in-code fallback both read as dev-only placeholders, and no appsettings.Production.json or other override file is visible in this repo. Don't assume production secrets are handled through a mechanism this repo doesn't show (environment variables, IIS-level config, a secrets store) without checking the actual production host — the repo alone doesn't prove it.

Updated by Claude on Aug. 12, 2026, 9:12 a.m. · Task: Create architecture documentation for Titan Command