diff --git a/docs/admin/00-New-Website-output.md b/docs/plans/admin/00-New-Website-output.md
similarity index 100%
rename from docs/admin/00-New-Website-output.md
rename to docs/plans/admin/00-New-Website-output.md
diff --git a/docs/admin/00-New-Website.md b/docs/plans/admin/00-New-Website.md
similarity index 100%
rename from docs/admin/00-New-Website.md
rename to docs/plans/admin/00-New-Website.md
diff --git a/docs/admin/01-admin-site_plan.md b/docs/plans/admin/01-admin-site_plan.md
similarity index 100%
rename from docs/admin/01-admin-site_plan.md
rename to docs/plans/admin/01-admin-site_plan.md
diff --git a/docs/api/00 - new-aspnet-project.md b/docs/plans/api/00 - new-aspnet-project.md
similarity index 100%
rename from docs/api/00 - new-aspnet-project.md
rename to docs/plans/api/00 - new-aspnet-project.md
diff --git a/docs/api/00-output.md b/docs/plans/api/00-output.md
similarity index 100%
rename from docs/api/00-output.md
rename to docs/plans/api/00-output.md
diff --git a/docs/api/01_project_setup.md b/docs/plans/api/01_project_setup.md
similarity index 100%
rename from docs/api/01_project_setup.md
rename to docs/plans/api/01_project_setup.md
diff --git a/docs/api/01_project_setup_output.md b/docs/plans/api/01_project_setup_output.md
similarity index 100%
rename from docs/api/01_project_setup_output.md
rename to docs/plans/api/01_project_setup_output.md
diff --git a/docs/api/02_core_domain_models.md b/docs/plans/api/02_core_domain_models.md
similarity index 100%
rename from docs/api/02_core_domain_models.md
rename to docs/plans/api/02_core_domain_models.md
diff --git a/docs/api/02_core_domain_models_output.md b/docs/plans/api/02_core_domain_models_output.md
similarity index 100%
rename from docs/api/02_core_domain_models_output.md
rename to docs/plans/api/02_core_domain_models_output.md
diff --git a/docs/api/03_database_and_repositories.md b/docs/plans/api/03_database_and_repositories.md
similarity index 100%
rename from docs/api/03_database_and_repositories.md
rename to docs/plans/api/03_database_and_repositories.md
diff --git a/docs/api/03_database_and_repositories_output.md b/docs/plans/api/03_database_and_repositories_output.md
similarity index 100%
rename from docs/api/03_database_and_repositories_output.md
rename to docs/plans/api/03_database_and_repositories_output.md
diff --git a/docs/api/04_authentication_and_authorization.md b/docs/plans/api/04_authentication_and_authorization.md
similarity index 100%
rename from docs/api/04_authentication_and_authorization.md
rename to docs/plans/api/04_authentication_and_authorization.md
diff --git a/docs/api/04_authentication_and_authorization_output.md b/docs/plans/api/04_authentication_and_authorization_output.md
similarity index 100%
rename from docs/api/04_authentication_and_authorization_output.md
rename to docs/plans/api/04_authentication_and_authorization_output.md
diff --git a/docs/api/05_flags_api.md b/docs/plans/api/05_flags_api.md
similarity index 100%
rename from docs/api/05_flags_api.md
rename to docs/plans/api/05_flags_api.md
diff --git a/docs/api/05_flags_api_output.md b/docs/plans/api/05_flags_api_output.md
similarity index 100%
rename from docs/api/05_flags_api_output.md
rename to docs/plans/api/05_flags_api_output.md
diff --git a/docs/api/06_admin_api.md b/docs/plans/api/06_admin_api.md
similarity index 100%
rename from docs/api/06_admin_api.md
rename to docs/plans/api/06_admin_api.md
diff --git a/docs/api/06_admin_api_output.md b/docs/plans/api/06_admin_api_output.md
similarity index 100%
rename from docs/api/06_admin_api_output.md
rename to docs/plans/api/06_admin_api_output.md
diff --git a/docs/api/07_audit_and_webhooks.md b/docs/plans/api/07_audit_and_webhooks.md
similarity index 100%
rename from docs/api/07_audit_and_webhooks.md
rename to docs/plans/api/07_audit_and_webhooks.md
diff --git a/docs/api/07_audit_and_webhooks_output.md b/docs/plans/api/07_audit_and_webhooks_output.md
similarity index 100%
rename from docs/api/07_audit_and_webhooks_output.md
rename to docs/plans/api/07_audit_and_webhooks_output.md
diff --git a/docs/api/08_testing_strategy.md b/docs/plans/api/08_testing_strategy.md
similarity index 100%
rename from docs/api/08_testing_strategy.md
rename to docs/plans/api/08_testing_strategy.md
diff --git a/docs/system-design.md b/docs/system-design.md
new file mode 100644
index 0000000..970ea73
--- /dev/null
+++ b/docs/system-design.md
@@ -0,0 +1,59 @@
+# System Design — QA Physical Architecture
+
+Physical deployment topology for the QA environment. Source: `deploy/qa/docker-compose.qa.yml`, `scripts/ci/*.sh`, `.github/workflows/ci.yml`.
+
+```mermaid
+flowchart TB
+ subgraph CI["Self-hosted CI runner (qa)"]
+ Pipeline["deploy-qa.sh / smoke-qa.sh"]
+ end
+
+ subgraph Registry["Gitea Container Registry"]
+ ApiImage["miccheck-api:qa"]
+ AdminImage["miccheck-admin:qa"]
+ end
+
+ subgraph QAHost["QA Docker host — network: miccheck-qa-net (bridge)"]
+ subgraph AdminC["admin container
nginx:1.27-alpine"]
+ Nginx["nginx
serves Vue SPA
proxies /api/, /health"]
+ end
+
+ subgraph ApiC["api container
aspnet:10.0"]
+ Api["MicCheck.Api
ASPNETCORE_URLS=http://+:8080"]
+ end
+
+ subgraph DbC["db container
postgres:16-alpine"]
+ Db[("miccheck DB")]
+ end
+ end
+
+ Browser["Browser / QA tester"]
+
+ Pipeline -->|docker build/push| Registry
+ Registry -->|pull :qa| AdminC
+ Registry -->|pull :qa| ApiC
+
+ Browser -->|":3001 (QA_ADMIN_PORT)"| Nginx
+ Nginx -->|"http://api:8080 (internal)"| Api
+ Api -->|"Host=db;5432 (internal)"| Db
+
+ Pipeline -.->|"127.0.0.1:55432 (bridge-gateway bound)"| Db
+```
+
+## Components
+
+| Component | Image | Exposure | Notes |
+|---|---|---|---|
+| `admin` | `nginx:1.27-alpine` (built from `src/admin/Dockerfile.ci`, `node:22-alpine` build stage) | `${QA_ADMIN_PORT:-3001}:80` published on host | Serves Vue/Vite SPA; nginx (`src/admin/nginx.conf`) reverse-proxies `/api/` and `/health` to `api:8080` |
+| `api` | `mcr.microsoft.com/dotnet/aspnet:10.0` (built from `src/api/MicCheck.Api/Dockerfile.ci`, `sdk:10.0` build stage) | no published host port — internal only, reached via `admin`'s nginx proxy | `ASPNETCORE_URLS=http://+:8080`; JWT config (`Jwt__SecretKey`/`Issuer=MicCheck`/`Audience=MicCheck`) from `JWT_SECRET_KEY` secret; healthcheck `curl localhost:8080/health` |
+| `db` | `postgres:16-alpine` | `${DB_BIND_HOST:-127.0.0.1}:55432 → 5432`, bound to docker bridge gateway IP (not `0.0.0.0`) — reachable only from sibling CI containers, not off-box | DB `miccheck`, user `miccheck`, password from `POSTGRES_PASSWORD` secret; volume `miccheck-qa-pgdata` |
+
+All three services run on an isolated bridge network `miccheck-qa-net` (project `miccheck-qa`), separate from the local dev Aspire stack.
+
+## Deploy flow
+
+1. `build-and-push` job builds `api` and `admin` images, tags with git SHA and `qa`, pushes to Gitea registry as `$REGISTRY/$REGISTRY_OWNER/miccheck-api` / `miccheck-admin`.
+2. `deploy-qa` job (self-hosted runner, `main` branch only) pulls `:qa` images and runs `docker-compose.qa.yml` via `deploy-qa.sh`.
+3. `smoke-qa` job hits the deployed stack (`smoke-qa.sh`) to verify health.
+
+Secrets used: `REGISTRY`, `REGISTRY_OWNER`, `REGISTRY_USER`, `REGISTRY_TOKEN`, `JWT_SECRET_KEY`, `POSTGRES_PASSWORD`. Vars: `QA_ADMIN_PORT`.
diff --git a/src/api/MicCheck.Api/Environments/EnvironmentsController.cs b/src/api/MicCheck.Api/Environments/EnvironmentsController.cs
index fb8c2b6..b9e5eeb 100755
--- a/src/api/MicCheck.Api/Environments/EnvironmentsController.cs
+++ b/src/api/MicCheck.Api/Environments/EnvironmentsController.cs
@@ -1,5 +1,8 @@
using MicCheck.Api.Common.Security.Authorization;
using MicCheck.Api.Common;
+using MicCheck.Api.Features;
+using MicCheck.Api.Identities;
+using MicCheck.Api.Segments;
using MicCheck.Api.Webhooks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -146,9 +149,9 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
}
[HttpGet("api/v1/environment/{apiKey}/identities")]
- public async Task>> ListIdentities(
+ public async Task>> ListIdentities(
string apiKey,
- [FromServices] Identities.AdminIdentityService adminIdentityService,
+ [FromServices] AdminIdentityService adminIdentityService,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken ct = default)
@@ -158,15 +161,15 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
if (environment is null) return NotFound();
var result = await adminIdentityService.ListAsync(environment.Id, page, pageSize, ct);
- var results = result.Items.Select(Identities.AdminIdentityResponse.From).ToList();
- return Ok(new PaginatedResponse(result.Total, null, null, results));
+ var results = result.Items.Select(AdminIdentityResponse.From).ToList();
+ return Ok(new PaginatedResponse(result.Total, null, null, results));
}
[HttpPost("api/v1/environment/{apiKey}/identities")]
- public async Task> CreateIdentity(
+ public async Task> CreateIdentity(
string apiKey,
- [FromBody] Identities.CreateIdentityRequest request,
- [FromServices] Identities.AdminIdentityService adminIdentityService,
+ [FromBody] CreateIdentityRequest request,
+ [FromServices] AdminIdentityService adminIdentityService,
CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -177,13 +180,13 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
return Conflict(new { error = "An identity with this identifier already exists in the environment." });
return CreatedAtAction(nameof(GetIdentity), new { apiKey, id = identity.Id },
- Identities.AdminIdentityResponse.From(identity));
+ AdminIdentityResponse.From(identity));
}
[HttpGet("api/v1/environment/{apiKey}/identity/{id}")]
- public async Task> GetIdentity(
+ public async Task> GetIdentity(
string apiKey, int id,
- [FromServices] Identities.AdminIdentityService adminIdentityService,
+ [FromServices] AdminIdentityService adminIdentityService,
CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -191,13 +194,13 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
var identity = await adminIdentityService.FindByIdAsync(id, environment.Id, ct);
if (identity is null) return NotFound();
- return Ok(Identities.AdminIdentityResponse.From(identity));
+ return Ok(AdminIdentityResponse.From(identity));
}
[HttpDelete("api/v1/environment/{apiKey}/identity/{id}")]
public async Task DeleteIdentity(
string apiKey, int id,
- [FromServices] Identities.AdminIdentityService adminIdentityService,
+ [FromServices] AdminIdentityService adminIdentityService,
CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -211,10 +214,10 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
}
[HttpPut("api/v1/environment/{apiKey}/identity/{id}/trait/{key}")]
- public async Task> UpsertIdentityTrait(
+ public async Task> UpsertIdentityTrait(
string apiKey, int id, string key,
- [FromBody] Identities.UpsertTraitRequest request,
- [FromServices] Identities.AdminIdentityService adminIdentityService,
+ [FromBody] UpsertTraitRequest request,
+ [FromServices] AdminIdentityService adminIdentityService,
CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -228,7 +231,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
[HttpDelete("api/v1/environment/{apiKey}/identity/{id}/trait/{key}")]
public async Task DeleteIdentityTrait(
string apiKey, int id, string key,
- [FromServices] Identities.AdminIdentityService adminIdentityService,
+ [FromServices] AdminIdentityService adminIdentityService,
CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -240,9 +243,9 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
}
[HttpGet("api/v1/environment/{apiKey}/identity/{id}/featurestates")]
- public async Task>> GetIdentityFeatureStates(
+ public async Task>> GetIdentityFeatureStates(
string apiKey, int id,
- [FromServices] Identities.AdminIdentityService adminIdentityService,
+ [FromServices] AdminIdentityService adminIdentityService,
CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -252,14 +255,14 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
if (identity is null) return NotFound();
var states = await adminIdentityService.GetFeatureStatesAsync(id, environment.Id, ct);
- return Ok(states.Select(Features.FeatureStateResponse.From).ToList());
+ return Ok(states.Select(FeatureStateResponse.From).ToList());
}
[HttpPut("api/v1/environment/{apiKey}/identity/{id}/featurestate/{featureId}")]
- public async Task> SetIdentityFeatureState(
+ public async Task> SetIdentityFeatureState(
string apiKey, int id, int featureId,
- Features.UpdateFeatureStateRequest request,
- [FromServices] Identities.AdminIdentityService adminIdentityService,
+ UpdateFeatureStateRequest request,
+ [FromServices] AdminIdentityService adminIdentityService,
CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -269,13 +272,13 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
if (identity is null) return NotFound();
var state = await adminIdentityService.SetFeatureStateAsync(id, environment.Id, featureId, request.Enabled, request.Value, ct);
- return Ok(Features.FeatureStateResponse.From(state));
+ return Ok(FeatureStateResponse.From(state));
}
[HttpDelete("api/v1/environment/{apiKey}/identity/{id}/featurestate/{featureId}")]
public async Task DeleteIdentityFeatureState(
string apiKey, int id, int featureId,
- [FromServices] Identities.AdminIdentityService adminIdentityService,
+ [FromServices] AdminIdentityService adminIdentityService,
CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -289,22 +292,22 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
}
[HttpGet("api/v1/environment/{apiKey}/featurestates")]
- public async Task>> ListFeatureStates(
+ public async Task>> ListFeatureStates(
string apiKey,
- [FromServices] Features.FeatureStateService featureStateService,
+ [FromServices] FeatureStateService featureStateService,
CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound();
var states = await featureStateService.ListByEnvironmentAsync(environment.Id, ct);
- return Ok(states.Select(Features.FeatureStateResponse.From).ToList());
+ return Ok(states.Select(FeatureStateResponse.From).ToList());
}
[HttpGet("api/v1/environment/{apiKey}/featurestate/{id}")]
- public async Task> GetFeatureState(
+ public async Task> GetFeatureState(
string apiKey, int id,
- [FromServices] Features.FeatureStateService featureStateService,
+ [FromServices] FeatureStateService featureStateService,
CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -312,14 +315,14 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
var state = await featureStateService.FindByIdAsync(id, environment.Id, ct);
if (state is null) return NotFound();
- return Ok(Features.FeatureStateResponse.From(state));
+ return Ok(FeatureStateResponse.From(state));
}
[HttpPut("api/v1/environment/{apiKey}/featurestate/{id}")]
- public async Task> UpdateFeatureState(
+ public async Task> UpdateFeatureState(
string apiKey, int id,
- Features.UpdateFeatureStateRequest request,
- [FromServices] Features.FeatureStateService featureStateService,
+ UpdateFeatureStateRequest request,
+ [FromServices] FeatureStateService featureStateService,
CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -329,14 +332,14 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
if (state is null) return NotFound();
var updated = await featureStateService.UpdateAsync(id, request.Enabled, request.Value, ct);
- return Ok(Features.FeatureStateResponse.From(updated));
+ return Ok(FeatureStateResponse.From(updated));
}
[HttpPatch("api/v1/environment/{apiKey}/featurestate/{id}")]
- public async Task> PatchFeatureState(
+ public async Task> PatchFeatureState(
string apiKey, int id,
- Features.PatchFeatureStateRequest request,
- [FromServices] Features.FeatureStateService featureStateService,
+ PatchFeatureStateRequest request,
+ [FromServices] FeatureStateService featureStateService,
CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -346,15 +349,15 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
if (state is null) return NotFound();
var updated = await featureStateService.PatchAsync(id, request.Enabled, request.Value, ct);
- return Ok(Features.FeatureStateResponse.From(updated));
+ return Ok(FeatureStateResponse.From(updated));
}
// ─── Feature Segments ────────────────────────────────────────────────────
[HttpGet("api/v1/environment/{apiKey}/feature/{featureId}/segments")]
- public async Task>> ListFeatureSegments(
+ public async Task>> ListFeatureSegments(
string apiKey, int featureId,
- [FromServices] Features.FeatureSegmentService featureSegmentService,
+ [FromServices] FeatureSegmentService featureSegmentService,
CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -365,10 +368,10 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
}
[HttpPost("api/v1/environment/{apiKey}/feature/{featureId}/segments")]
- public async Task> CreateFeatureSegment(
+ public async Task> CreateFeatureSegment(
string apiKey, int featureId,
- Features.CreateFeatureSegmentRequest request,
- [FromServices] Features.FeatureSegmentService featureSegmentService,
+ CreateFeatureSegmentRequest request,
+ [FromServices] FeatureSegmentService featureSegmentService,
CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -388,10 +391,10 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
}
[HttpPut("api/v1/environment/{apiKey}/feature/{featureId}/segment/{id}")]
- public async Task> UpdateFeatureSegment(
+ public async Task> UpdateFeatureSegment(
string apiKey, int featureId, int id,
- Features.UpdateFeatureSegmentRequest request,
- [FromServices] Features.FeatureSegmentService featureSegmentService,
+ UpdateFeatureSegmentRequest request,
+ [FromServices] FeatureSegmentService featureSegmentService,
CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -405,7 +408,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
[HttpDelete("api/v1/environment/{apiKey}/feature/{featureId}/segment/{id}")]
public async Task DeleteFeatureSegment(
string apiKey, int featureId, int id,
- [FromServices] Features.FeatureSegmentService featureSegmentService,
+ [FromServices] FeatureSegmentService featureSegmentService,
CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -418,11 +421,11 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
// ─── Identity Segments ───────────────────────────────────────────────────
[HttpGet("api/v1/environment/{apiKey}/identity/{id}/segments")]
- public async Task>> GetIdentitySegments(
+ public async Task>> GetIdentitySegments(
string apiKey, int id,
- [FromServices] Identities.AdminIdentityService adminIdentityService,
- [FromServices] Segments.SegmentService segmentService,
- [FromServices] Segments.SegmentEvaluator segmentEvaluator,
+ [FromServices] AdminIdentityService adminIdentityService,
+ [FromServices] SegmentService segmentService,
+ [FromServices] SegmentEvaluator segmentEvaluator,
CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -434,7 +437,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
var segments = await segmentService.ListByProjectAsync(environment.ProjectId, ct);
var matching = segments
.Where(s => segmentEvaluator.Evaluate(s, identity.Traits.ToList(), identity.Identifier))
- .Select(s => new Segments.SegmentSummaryResponse(s.Id, s.Name))
+ .Select(s => new SegmentSummaryResponse(s.Id, s.Name))
.ToList();
return Ok(matching);