From 42383bdc039cf6ca8f7a4665cf772ecde0160542 Mon Sep 17 00:00:00 2001 From: amadzarak Date: Sat, 25 Apr 2026 17:26:42 -0400 Subject: [PATCH] OPC # 0001: Extract OPC into standalone repo --- .dockerignore | 12 + .gitignore | 366 ++ .../Consumers/ProvisioningProgressConsumer.cs | 14 + ControlPlane.Api/ControlPlane.Api.csproj | 30 + ControlPlane.Api/Data/seed_opc.sql | 108 + ControlPlane.Api/Data/seed_opc_2.sql | 119 + ControlPlane.Api/Endpoints/GitEndpoints.cs | 203 + ControlPlane.Api/Endpoints/GiteaEndpoints.cs | 79 + .../Endpoints/ImageBuildEndpoints.cs | 75 + ControlPlane.Api/Endpoints/InfraEndpoints.cs | 232 ++ ControlPlane.Api/Endpoints/OpcEndpoints.cs | 244 ++ .../Endpoints/ProjectBuildEndpoints.cs | 65 + .../Endpoints/PromotionEndpoints.cs | 73 + .../Endpoints/ProvisioningEndpoints.cs | 106 + .../Endpoints/ReleaseEndpoints.cs | 62 + .../Endpoints/TenantLogEndpoints.cs | 108 + ControlPlane.Api/Program.cs | 131 + .../Properties/launchSettings.json | 12 + ControlPlane.Api/Services/GiteaService.cs | 216 + .../Services/ImageBuildService.cs | 144 + ControlPlane.Api/Services/OpcService.cs | 297 ++ .../Services/ProjectBuildService.cs | 127 + ControlPlane.Api/Services/PromotionService.cs | 283 ++ ControlPlane.Api/Services/ReleaseService.cs | 191 + ControlPlane.Api/Services/SseEventBus.cs | 38 + ControlPlane.Api/Services/TarHelper.cs | 84 + ControlPlane.Api/appsettings.json | 21 + ControlPlane.AppHost/AppHost.cs | 160 + .../ControlPlane.AppHost.csproj | 38 + .../DnsmasqConfig/dnsmasq.conf | 17 + .../KeycloakConfig/realm-export.json | 2753 +++++++++++++ .../NginxConfig/clarity.test.crt | 19 + .../NginxConfig/clarity.test.key | 28 + .../NginxConfig/conf.d/.gitkeep | 2 + .../conf.d/fdev-app-clarity-01000000.conf | 19 + .../conf.d/fdev-app-clarity-02000000.conf | 19 + .../conf.d/fdev-app-clarity-03000000.conf | 19 + .../conf.d/fdev-app-clarity-04000000.conf | 19 + .../NginxConfig/conf.d/gitea.conf | 21 + .../NginxConfig/conf.d/keycloak.conf | 16 + ControlPlane.AppHost/NginxConfig/nginx.conf | 27 + .../Properties/launchSettings.json | 31 + .../VaultConfig/entrypoint.sh | 48 + ControlPlane.AppHost/VaultConfig/vault.hcl | 13 + ControlPlane.AppHost/certs/localtest.me.crt | 19 + ControlPlane.AppHost/certs/localtest.me.key | 28 + ControlPlane.AppHost/certs/san.cnf | 16 + .../Config/ClarityInfraOptions.cs | 51 + ControlPlane.Core/ControlPlane.Core.csproj | 14 + ControlPlane.Core/Interfaces/ISagaStep.cs | 8 + ControlPlane.Core/Interfaces/SagaContext.cs | 30 + .../Messages/ProvisioningMessages.cs | 38 + ControlPlane.Core/Models/BuildRecord.cs | 24 + ControlPlane.Core/Models/ComponentMode.cs | 26 + ControlPlane.Core/Models/GitCommit.cs | 10 + ControlPlane.Core/Models/GiteaModels.cs | 63 + ControlPlane.Core/Models/OpcModels.cs | 73 + ControlPlane.Core/Models/PromotionRequest.cs | 22 + ControlPlane.Core/Models/ProvisioningJob.cs | 47 + .../Models/ProvisioningRequest.cs | 18 + ControlPlane.Core/Models/ReleaseRecord.cs | 27 + ControlPlane.Core/Models/StackConfig.cs | 51 + ControlPlane.Core/Models/TenantRecord.cs | 135 + ControlPlane.Core/Models/TenantTier.cs | 21 + .../Services/BuildHistoryService.cs | 138 + .../Services/TenantRegistryService.cs | 77 + .../ControlPlane.Worker.csproj | 33 + ControlPlane.Worker/Dockerfile | 30 + ControlPlane.Worker/Program.cs | 64 + ControlPlane.Worker/ProvisioningWorker.cs | 167 + .../Services/ClarityContainerService.cs | 358 ++ .../Services/KeycloakAdminClient.cs | 194 + ControlPlane.Worker/Steps/HandoffStep.cs | 27 + ControlPlane.Worker/Steps/KeycloakStep.cs | 90 + ControlPlane.Worker/Steps/LaunchStep.cs | 76 + ControlPlane.Worker/Steps/MigrationStep.cs | 119 + ControlPlane.Worker/Steps/VaultStep.cs | 45 + ControlPlane.slnx | 15 + Directory.Packages.props | 53 + clarity.controlplane/.env | 2 + clarity.controlplane/.gitignore | 24 + clarity.controlplane/CHANGELOG.md | 12 + clarity.controlplane/README.md | 73 + .../clarity.controlplane.esproj | 11 + clarity.controlplane/eslint.config.js | 23 + clarity.controlplane/index.html | 13 + clarity.controlplane/package-lock.json | 3537 +++++++++++++++++ clarity.controlplane/package.json | 34 + clarity.controlplane/public/favicon.svg | 1 + clarity.controlplane/public/icons.svg | 24 + clarity.controlplane/src/App.css | 1 + clarity.controlplane/src/App.tsx | 76 + clarity.controlplane/src/api/infraApi.ts | 44 + clarity.controlplane/src/api/opcApi.ts | 313 ++ .../src/api/provisioningApi.ts | 290 ++ clarity.controlplane/src/assets/hero.png | Bin 0 -> 13057 bytes clarity.controlplane/src/assets/react.svg | 1 + clarity.controlplane/src/assets/vite.svg | 1 + .../src/components/GitCommitDrawer.tsx | 117 + .../src/components/ImageBuildPanel.tsx | 132 + .../components/wizard/ClientDetailsStep.tsx | 102 + .../src/components/wizard/DeployWizard.tsx | 124 + .../wizard/DeploymentConfigStep.tsx | 82 + .../components/wizard/DeploymentLiveStep.tsx | 166 + .../src/components/wizard/ReviewStep.tsx | 38 + clarity.controlplane/src/config.ts | 11 + clarity.controlplane/src/index.css | 928 +++++ clarity.controlplane/src/main.tsx | 10 + clarity.controlplane/src/opc/OpcPage.tsx | 813 ++++ clarity.controlplane/src/pages/BranchPage.tsx | 381 ++ .../src/pages/BuildMonitorPage.tsx | 303 ++ .../src/pages/DashboardPage.tsx | 156 + .../src/pages/ImageBuildPage.tsx | 231 ++ clarity.controlplane/src/pages/InfraPage.tsx | 284 ++ .../src/pages/PipelinesPage.tsx | 260 ++ clarity.controlplane/src/types/opc.ts | 67 + .../src/types/provisioning.ts | 50 + clarity.controlplane/tsconfig.app.json | 25 + clarity.controlplane/tsconfig.json | 7 + clarity.controlplane/tsconfig.node.json | 24 + clarity.controlplane/vite.config.ts | 35 + infra/docker-compose.yml | 172 + infra/keycloak/realm-export.json | 2937 ++++++++++++++ infra/postgres/init.sql | 9 + infra/vault/config/vault.hcl | 13 + infra/vault/data/core/_audit | 1 + infra/vault/data/core/_auth | 1 + infra/vault/data/core/_index-header-hmac-key | 1 + infra/vault/data/core/_keyring | 1 + infra/vault/data/core/_local-audit | 1 + infra/vault/data/core/_local-auth | 1 + infra/vault/data/core/_local-mounts | 1 + infra/vault/data/core/_master | 1 + infra/vault/data/core/_mounts | 1 + infra/vault/data/core/_seal-config | 1 + infra/vault/data/core/_shamir-kek | 1 + infra/vault/data/core/cluster/_feature-flags | 1 + infra/vault/data/core/cluster/local/_info | 1 + infra/vault/data/core/cluster/local/_name | 1 + .../vault/data/core/hsm/_barrier-unseal-keys | 1 + infra/vault/data/core/versions/_2.0.0 | 1 + infra/vault/data/core/wrapping/_jwtkey | 1 + infra/vault/data/init.json | 15 + .../archive/_master-key | 1 + .../policy/_master-key | 1 + .../oidc_provider/assignment/_allow_all | 1 + .../oidc_provider/provider/_default | 1 + .../oidc_tokens/named_keys/_default | 1 + .../_729473c5-5b37-bd8e-dccf-e49b4b9966ab | 1 + .../_c8853eae-0ff1-2a4f-a911-1072fbc93a97 | 1 + .../sys/billing/local/2026/04/_maxKvCounts | 1 + .../sys/billing/local/2026/04/_maxRoleCounts | 1 + .../sys/billing/local/2026/04/_maxTotpCounts | 1 + .../local/2026/04/_metricsLastUpdatedAt | 1 + .../local/2026/04/_thirdPartyPluginCounts | 1 + .../2026/04/_transitDataProtectionCallCounts | 1 + .../billing/replicated/2026/04/_maxKvCounts | 1 + .../billing/replicated/2026/04/_maxRoleCounts | 1 + .../billing/replicated/2026/04/_maxTotpCounts | 1 + .../sys/counters/activity/_acme-regeneration | 1 + infra/vault/data/sys/policy/_control-group | 1 + infra/vault/data/sys/policy/_default | 1 + .../vault/data/sys/policy/_response-wrapping | 1 + infra/vault/data/sys/token/_salt | 1 + .../_00e202da9f2d9e267cb2812fb8b96004773c1380 | 1 + ...f7779cc7e3b5065ac315186207f0268ffc40af3567 | 1 + infra/vault/entrypoint.sh | 48 + opc_export.sql | 253 ++ scripts/clean-slate.ps1 | 102 + scripts/opc-schema.sql | 54 + 170 files changed, 21365 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 ControlPlane.Api/Consumers/ProvisioningProgressConsumer.cs create mode 100644 ControlPlane.Api/ControlPlane.Api.csproj create mode 100644 ControlPlane.Api/Data/seed_opc.sql create mode 100644 ControlPlane.Api/Data/seed_opc_2.sql create mode 100644 ControlPlane.Api/Endpoints/GitEndpoints.cs create mode 100644 ControlPlane.Api/Endpoints/GiteaEndpoints.cs create mode 100644 ControlPlane.Api/Endpoints/ImageBuildEndpoints.cs create mode 100644 ControlPlane.Api/Endpoints/InfraEndpoints.cs create mode 100644 ControlPlane.Api/Endpoints/OpcEndpoints.cs create mode 100644 ControlPlane.Api/Endpoints/ProjectBuildEndpoints.cs create mode 100644 ControlPlane.Api/Endpoints/PromotionEndpoints.cs create mode 100644 ControlPlane.Api/Endpoints/ProvisioningEndpoints.cs create mode 100644 ControlPlane.Api/Endpoints/ReleaseEndpoints.cs create mode 100644 ControlPlane.Api/Endpoints/TenantLogEndpoints.cs create mode 100644 ControlPlane.Api/Program.cs create mode 100644 ControlPlane.Api/Properties/launchSettings.json create mode 100644 ControlPlane.Api/Services/GiteaService.cs create mode 100644 ControlPlane.Api/Services/ImageBuildService.cs create mode 100644 ControlPlane.Api/Services/OpcService.cs create mode 100644 ControlPlane.Api/Services/ProjectBuildService.cs create mode 100644 ControlPlane.Api/Services/PromotionService.cs create mode 100644 ControlPlane.Api/Services/ReleaseService.cs create mode 100644 ControlPlane.Api/Services/SseEventBus.cs create mode 100644 ControlPlane.Api/Services/TarHelper.cs create mode 100644 ControlPlane.Api/appsettings.json create mode 100644 ControlPlane.AppHost/AppHost.cs create mode 100644 ControlPlane.AppHost/ControlPlane.AppHost.csproj create mode 100644 ControlPlane.AppHost/DnsmasqConfig/dnsmasq.conf create mode 100644 ControlPlane.AppHost/KeycloakConfig/realm-export.json create mode 100644 ControlPlane.AppHost/NginxConfig/clarity.test.crt create mode 100644 ControlPlane.AppHost/NginxConfig/clarity.test.key create mode 100644 ControlPlane.AppHost/NginxConfig/conf.d/.gitkeep create mode 100644 ControlPlane.AppHost/NginxConfig/conf.d/fdev-app-clarity-01000000.conf create mode 100644 ControlPlane.AppHost/NginxConfig/conf.d/fdev-app-clarity-02000000.conf create mode 100644 ControlPlane.AppHost/NginxConfig/conf.d/fdev-app-clarity-03000000.conf create mode 100644 ControlPlane.AppHost/NginxConfig/conf.d/fdev-app-clarity-04000000.conf create mode 100644 ControlPlane.AppHost/NginxConfig/conf.d/gitea.conf create mode 100644 ControlPlane.AppHost/NginxConfig/conf.d/keycloak.conf create mode 100644 ControlPlane.AppHost/NginxConfig/nginx.conf create mode 100644 ControlPlane.AppHost/Properties/launchSettings.json create mode 100644 ControlPlane.AppHost/VaultConfig/entrypoint.sh create mode 100644 ControlPlane.AppHost/VaultConfig/vault.hcl create mode 100644 ControlPlane.AppHost/certs/localtest.me.crt create mode 100644 ControlPlane.AppHost/certs/localtest.me.key create mode 100644 ControlPlane.AppHost/certs/san.cnf create mode 100644 ControlPlane.Core/Config/ClarityInfraOptions.cs create mode 100644 ControlPlane.Core/ControlPlane.Core.csproj create mode 100644 ControlPlane.Core/Interfaces/ISagaStep.cs create mode 100644 ControlPlane.Core/Interfaces/SagaContext.cs create mode 100644 ControlPlane.Core/Messages/ProvisioningMessages.cs create mode 100644 ControlPlane.Core/Models/BuildRecord.cs create mode 100644 ControlPlane.Core/Models/ComponentMode.cs create mode 100644 ControlPlane.Core/Models/GitCommit.cs create mode 100644 ControlPlane.Core/Models/GiteaModels.cs create mode 100644 ControlPlane.Core/Models/OpcModels.cs create mode 100644 ControlPlane.Core/Models/PromotionRequest.cs create mode 100644 ControlPlane.Core/Models/ProvisioningJob.cs create mode 100644 ControlPlane.Core/Models/ProvisioningRequest.cs create mode 100644 ControlPlane.Core/Models/ReleaseRecord.cs create mode 100644 ControlPlane.Core/Models/StackConfig.cs create mode 100644 ControlPlane.Core/Models/TenantRecord.cs create mode 100644 ControlPlane.Core/Models/TenantTier.cs create mode 100644 ControlPlane.Core/Services/BuildHistoryService.cs create mode 100644 ControlPlane.Core/Services/TenantRegistryService.cs create mode 100644 ControlPlane.Worker/ControlPlane.Worker.csproj create mode 100644 ControlPlane.Worker/Dockerfile create mode 100644 ControlPlane.Worker/Program.cs create mode 100644 ControlPlane.Worker/ProvisioningWorker.cs create mode 100644 ControlPlane.Worker/Services/ClarityContainerService.cs create mode 100644 ControlPlane.Worker/Services/KeycloakAdminClient.cs create mode 100644 ControlPlane.Worker/Steps/HandoffStep.cs create mode 100644 ControlPlane.Worker/Steps/KeycloakStep.cs create mode 100644 ControlPlane.Worker/Steps/LaunchStep.cs create mode 100644 ControlPlane.Worker/Steps/MigrationStep.cs create mode 100644 ControlPlane.Worker/Steps/VaultStep.cs create mode 100644 ControlPlane.slnx create mode 100644 Directory.Packages.props create mode 100644 clarity.controlplane/.env create mode 100644 clarity.controlplane/.gitignore create mode 100644 clarity.controlplane/CHANGELOG.md create mode 100644 clarity.controlplane/README.md create mode 100644 clarity.controlplane/clarity.controlplane.esproj create mode 100644 clarity.controlplane/eslint.config.js create mode 100644 clarity.controlplane/index.html create mode 100644 clarity.controlplane/package-lock.json create mode 100644 clarity.controlplane/package.json create mode 100644 clarity.controlplane/public/favicon.svg create mode 100644 clarity.controlplane/public/icons.svg create mode 100644 clarity.controlplane/src/App.css create mode 100644 clarity.controlplane/src/App.tsx create mode 100644 clarity.controlplane/src/api/infraApi.ts create mode 100644 clarity.controlplane/src/api/opcApi.ts create mode 100644 clarity.controlplane/src/api/provisioningApi.ts create mode 100644 clarity.controlplane/src/assets/hero.png create mode 100644 clarity.controlplane/src/assets/react.svg create mode 100644 clarity.controlplane/src/assets/vite.svg create mode 100644 clarity.controlplane/src/components/GitCommitDrawer.tsx create mode 100644 clarity.controlplane/src/components/ImageBuildPanel.tsx create mode 100644 clarity.controlplane/src/components/wizard/ClientDetailsStep.tsx create mode 100644 clarity.controlplane/src/components/wizard/DeployWizard.tsx create mode 100644 clarity.controlplane/src/components/wizard/DeploymentConfigStep.tsx create mode 100644 clarity.controlplane/src/components/wizard/DeploymentLiveStep.tsx create mode 100644 clarity.controlplane/src/components/wizard/ReviewStep.tsx create mode 100644 clarity.controlplane/src/config.ts create mode 100644 clarity.controlplane/src/index.css create mode 100644 clarity.controlplane/src/main.tsx create mode 100644 clarity.controlplane/src/opc/OpcPage.tsx create mode 100644 clarity.controlplane/src/pages/BranchPage.tsx create mode 100644 clarity.controlplane/src/pages/BuildMonitorPage.tsx create mode 100644 clarity.controlplane/src/pages/DashboardPage.tsx create mode 100644 clarity.controlplane/src/pages/ImageBuildPage.tsx create mode 100644 clarity.controlplane/src/pages/InfraPage.tsx create mode 100644 clarity.controlplane/src/pages/PipelinesPage.tsx create mode 100644 clarity.controlplane/src/types/opc.ts create mode 100644 clarity.controlplane/src/types/provisioning.ts create mode 100644 clarity.controlplane/tsconfig.app.json create mode 100644 clarity.controlplane/tsconfig.json create mode 100644 clarity.controlplane/tsconfig.node.json create mode 100644 clarity.controlplane/vite.config.ts create mode 100644 infra/docker-compose.yml create mode 100644 infra/keycloak/realm-export.json create mode 100644 infra/postgres/init.sql create mode 100644 infra/vault/config/vault.hcl create mode 100644 infra/vault/data/core/_audit create mode 100644 infra/vault/data/core/_auth create mode 100644 infra/vault/data/core/_index-header-hmac-key create mode 100644 infra/vault/data/core/_keyring create mode 100644 infra/vault/data/core/_local-audit create mode 100644 infra/vault/data/core/_local-auth create mode 100644 infra/vault/data/core/_local-mounts create mode 100644 infra/vault/data/core/_master create mode 100644 infra/vault/data/core/_mounts create mode 100644 infra/vault/data/core/_seal-config create mode 100644 infra/vault/data/core/_shamir-kek create mode 100644 infra/vault/data/core/cluster/_feature-flags create mode 100644 infra/vault/data/core/cluster/local/_info create mode 100644 infra/vault/data/core/cluster/local/_name create mode 100644 infra/vault/data/core/hsm/_barrier-unseal-keys create mode 100644 infra/vault/data/core/versions/_2.0.0 create mode 100644 infra/vault/data/core/wrapping/_jwtkey create mode 100644 infra/vault/data/init.json create mode 100644 infra/vault/data/logical/8bf897ec-a55a-8ed8-6a23-cfb07e8b61db/archive/_master-key create mode 100644 infra/vault/data/logical/8bf897ec-a55a-8ed8-6a23-cfb07e8b61db/policy/_master-key create mode 100644 infra/vault/data/logical/a9bdcf4b-d131-0d98-466b-913abeebbba5/oidc_provider/assignment/_allow_all create mode 100644 infra/vault/data/logical/a9bdcf4b-d131-0d98-466b-913abeebbba5/oidc_provider/provider/_default create mode 100644 infra/vault/data/logical/a9bdcf4b-d131-0d98-466b-913abeebbba5/oidc_tokens/named_keys/_default create mode 100644 infra/vault/data/logical/a9bdcf4b-d131-0d98-466b-913abeebbba5/oidc_tokens/public_keys/_729473c5-5b37-bd8e-dccf-e49b4b9966ab create mode 100644 infra/vault/data/logical/a9bdcf4b-d131-0d98-466b-913abeebbba5/oidc_tokens/public_keys/_c8853eae-0ff1-2a4f-a911-1072fbc93a97 create mode 100644 infra/vault/data/sys/billing/local/2026/04/_maxKvCounts create mode 100644 infra/vault/data/sys/billing/local/2026/04/_maxRoleCounts create mode 100644 infra/vault/data/sys/billing/local/2026/04/_maxTotpCounts create mode 100644 infra/vault/data/sys/billing/local/2026/04/_metricsLastUpdatedAt create mode 100644 infra/vault/data/sys/billing/local/2026/04/_thirdPartyPluginCounts create mode 100644 infra/vault/data/sys/billing/local/2026/04/_transitDataProtectionCallCounts create mode 100644 infra/vault/data/sys/billing/replicated/2026/04/_maxKvCounts create mode 100644 infra/vault/data/sys/billing/replicated/2026/04/_maxRoleCounts create mode 100644 infra/vault/data/sys/billing/replicated/2026/04/_maxTotpCounts create mode 100644 infra/vault/data/sys/counters/activity/_acme-regeneration create mode 100644 infra/vault/data/sys/policy/_control-group create mode 100644 infra/vault/data/sys/policy/_default create mode 100644 infra/vault/data/sys/policy/_response-wrapping create mode 100644 infra/vault/data/sys/token/_salt create mode 100644 infra/vault/data/sys/token/accessor/_00e202da9f2d9e267cb2812fb8b96004773c1380 create mode 100644 infra/vault/data/sys/token/id/_h5c687a901662c3a91e7496f7779cc7e3b5065ac315186207f0268ffc40af3567 create mode 100644 infra/vault/entrypoint.sh create mode 100644 opc_export.sql create mode 100644 scripts/clean-slate.ps1 create mode 100644 scripts/opc-schema.sql diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4ae3bef --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +**/.vs +**/.git +**/.idea +**/bin +**/obj +**/node_modules +**/.env +**/npm-debug.log +**/.dockerignore +**/Dockerfile* +**/docker-compose* +ClientAssets/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..226a408 --- /dev/null +++ b/.gitignore @@ -0,0 +1,366 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Oo]ut/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +VaultData/ +ClientAssets/ \ No newline at end of file diff --git a/ControlPlane.Api/Consumers/ProvisioningProgressConsumer.cs b/ControlPlane.Api/Consumers/ProvisioningProgressConsumer.cs new file mode 100644 index 0000000..1833d17 --- /dev/null +++ b/ControlPlane.Api/Consumers/ProvisioningProgressConsumer.cs @@ -0,0 +1,14 @@ +using ControlPlane.Api.Services; +using ControlPlane.Core.Messages; +using MassTransit; + +namespace ControlPlane.Api.Consumers; + +public sealed class ProvisioningProgressConsumer(SseEventBus bus) : IConsumer +{ + public Task Consume(ConsumeContext context) + { + bus.Publish(context.Message); + return Task.CompletedTask; + } +} diff --git a/ControlPlane.Api/ControlPlane.Api.csproj b/ControlPlane.Api/ControlPlane.Api.csproj new file mode 100644 index 0000000..3f91fda --- /dev/null +++ b/ControlPlane.Api/ControlPlane.Api.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ControlPlane.Api/Data/seed_opc.sql b/ControlPlane.Api/Data/seed_opc.sql new file mode 100644 index 0000000..a157eec --- /dev/null +++ b/ControlPlane.Api/Data/seed_opc.sql @@ -0,0 +1,108 @@ +-- ============================================================================= +-- OPC Seed Script – seeded from TODO.md backlog +-- Run against the ControlPlane database. +-- OPC # 0001 is already live; this starts at 0002. +-- ============================================================================= + +INSERT INTO opc (id, number, title, description, type, status, priority, assignee, created_at, updated_at) +VALUES + +-- ── Keycloak / Auth ─────────────────────────────────────────────────────────── +( + gen_random_uuid(), + 'OPC # 0002', + 'Fix KeycloakStep 401 on realm provisioning', + 'KeycloakStep is the current blocker in the provisioning saga. The step returns 401 when attempting to create the tenant realm. Investigate the admin-client credentials, token scope, and the endpoint URL used inside the Docker network.', + 'Bug', + 'In Progress', + 'High', + 'amadzarak', + NOW(), NOW() +), +( + gen_random_uuid(), + 'OPC # 0003', + 'KeycloakStep: full realm + user provisioning flow', + 'After the 401 is resolved, implement the full flow: create realm {subdomain}.clarity.io, create the admin role, create the day-zero admin user from AdminEmail, assign the admin role, and trigger execute-actions-email (verify email + set password).', + 'Feature', + 'New', + 'High', + 'amadzarak', + NOW(), NOW() +), +( + gen_random_uuid(), + 'OPC # 0004', + 'Keycloak JWT backchannel issuer cleanup', + 'Keycloak advertises its issuer based on the incoming request URL. When the backchannel hits http://keycloak:8080 directly it returns http://keycloak.clarity.test:8080 as the issuer, forcing layered workarounds in ValidIssuers and the rewrite handler. Clean fix: boot Keycloak with KC_HOSTNAME_URL=https://keycloak.clarity.test, verify via /.well-known/openid-configuration, then simplify ValidIssuers back to two entries. Deferred until next planned maintenance window (requires nuke to apply env var).', + 'Tech Debt', + 'New', + 'Medium', + 'amadzarak', + NOW(), NOW() +), + +-- ── VaultStep ───────────────────────────────────────────────────────────────── +( + gen_random_uuid(), + 'OPC # 0005', + 'VaultStep: read root token and write initial secrets', + 'Read the root token from /vault/file/init.json, enable KV-v2 secrets engine at {subdomain}/, then write the initial secrets: DB connection string and Keycloak client secret.', + 'Feature', + 'New', + 'High', + 'amadzarak', + NOW(), NOW() +), + +-- ── MigrationStep ───────────────────────────────────────────────────────────── +( + gen_random_uuid(), + 'OPC # 0006', + 'MigrationStep: run EF Core migrations per provisioning mode', + 'Wire up EF Core migrations inside MigrationStep for all three provisioning modes. Shared: run against the shared DB scoped to the tenant schema. Isolated: run against the dedicated Postgres container registered in SagaContext. Dedicated: run against the full dedicated Postgres instance.', + 'Feature', + 'New', + 'Medium', + 'amadzarak', + NOW(), NOW() +), + +-- ── HandoffStep ─────────────────────────────────────────────────────────────── +( + gen_random_uuid(), + 'OPC # 0007', + 'HandoffStep: send magic-link email and mark saga complete', + 'Send a magic-link / welcome email to AdminEmail via SMTP or SendGrid, then mark CompletedSteps.HandoffSent on the provisioning job. Blocked until SMTP is wired (currently SendRequiredActionsEmailAsync is commented out in KeycloakStep.cs).', + 'Feature', + 'New', + 'Medium', + 'amadzarak', + NOW(), NOW() +), + +-- ── Observability ───────────────────────────────────────────────────────────── +( + gen_random_uuid(), + 'OPC # 0008', + 'Stream tenant container logs into Aspire dashboard', + 'Use the Docker SDK to tail fdev-app-clarity-* container logs and forward them to Aspire''s structured log stream. Currently these logs are only visible via docker logs on the host.', + 'Feature', + 'New', + 'Low', + 'amadzarak', + NOW(), NOW() +), + +-- ── Kubernetes (backburner) ─────────────────────────────────────────────────── +( + gen_random_uuid(), + 'OPC # 0009', + 'Kubernetes migration path evaluation', + 'Currently managing containers directly via Docker.DotNet. Evaluate k8s when: scheduling across multiple nodes is needed, rolling deploys are required, or client count exceeds single-host capacity. Options: k3s (self-hosted), AKS/EKS (cloud), or keep Docker Compose per host for mid-scale. ClarityContainerService abstraction is intentional – swap Docker.DotNet for a k8s client without changing the saga.', + 'General', + 'New', + 'Low', + 'amadzarak', + NOW(), NOW() +); diff --git a/ControlPlane.Api/Data/seed_opc_2.sql b/ControlPlane.Api/Data/seed_opc_2.sql new file mode 100644 index 0000000..77ec65f --- /dev/null +++ b/ControlPlane.Api/Data/seed_opc_2.sql @@ -0,0 +1,119 @@ +-- ============================================================================= +-- OPC Seed Script 2 – completed work from TODO.md +-- Run against the ControlPlane database. +-- Picks up numbering at 0010 (0001–0009 covered in seed_opc.sql). +-- ============================================================================= + +INSERT INTO opc (id, number, title, description, type, status, priority, assignee, created_at, updated_at) +VALUES + +( + gen_random_uuid(), + 'OPC # 0010', + 'Aspire AppHost wired: Vault, MinIO, RabbitMQ, Postgres, Keycloak, Worker, API, UI', + 'Full Aspire AppHost configuration completed. All infrastructure services (Vault, MinIO, RabbitMQ, Postgres, Keycloak) and application services (Worker, API, UI) are registered and wired in the AppHost project.', + 'Feature', + 'Done', + 'High', + 'amadzarak', + NOW(), NOW() +), +( + gen_random_uuid(), + 'OPC # 0011', + 'Fix CRLF → LF on entrypoint.sh (was breaking Vault container)', + 'entrypoint.sh had Windows-style CRLF line endings which caused the Vault container to fail on startup. Fixed by enforcing LF via .gitattributes.', + 'Bug', + 'Done', + 'High', + 'amadzarak', + NOW(), NOW() +), +( + gen_random_uuid(), + 'OPC # 0012', + 'Vault initialises and unseals on first run', + 'Vault container now correctly initialises (generates root token + unseal keys) and auto-unseals on first run. Init output is written to /vault/file/init.json.', + 'Feature', + 'Done', + 'High', + 'amadzarak', + NOW(), NOW() +), +( + gen_random_uuid(), + 'OPC # 0013', + 'Pin Keycloak bootstrap password (fix persistent container password drift)', + 'Keycloak was experiencing password drift between container restarts due to the bootstrap admin credentials not being pinned. Fixed by explicitly setting the admin password so it persists across restarts.', + 'Bug', + 'Done', + 'High', + 'amadzarak', + NOW(), NOW() +), +( + gen_random_uuid(), + 'OPC # 0014', + 'Fix Keycloak endpoint name: tcp → http', + 'Keycloak Aspire resource was registered with a tcp endpoint name instead of http, causing service discovery failures. Renamed to http to align with the rest of the stack.', + 'Bug', + 'Done', + 'Medium', + 'amadzarak', + NOW(), NOW() +), +( + gen_random_uuid(), + 'OPC # 0015', + 'Worker starts and correctly waits for all dependencies', + 'The Worker service was starting before infrastructure dependencies were healthy. Implemented proper wait/health-check logic so the Worker blocks until Postgres, Keycloak, Vault, RabbitMQ, and MinIO are all ready.', + 'Bug', + 'Done', + 'High', + 'amadzarak', + NOW(), NOW() +), +( + gen_random_uuid(), + 'OPC # 0016', + 'MassTransit saga pipeline with compensation', + 'Implemented the full MassTransit-based provisioning saga with forward steps and compensating transactions. Each step registers its rollback so a mid-saga failure cleanly tears down any already-provisioned resources.', + 'Feature', + 'Done', + 'High', + 'amadzarak', + NOW(), NOW() +), +( + gen_random_uuid(), + 'OPC # 0017', + 'SSE progress stream: Worker → RabbitMQ → API → browser', + 'Implemented a real-time Server-Sent Events pipeline. The Worker publishes step progress to RabbitMQ, the API consumes and streams events via SSE, and the browser receives live updates without polling.', + 'Feature', + 'Done', + 'High', + 'amadzarak', + NOW(), NOW() +), +( + gen_random_uuid(), + 'OPC # 0018', + 'Frontend Diagnostics tab with full stack traces from worker', + 'Added a Diagnostics tab to the frontend that displays structured error messages and full stack traces forwarded from the Worker service, making provisioning failures debuggable directly in the UI.', + 'Feature', + 'Done', + 'Medium', + 'amadzarak', + NOW(), NOW() +), +( + gen_random_uuid(), + 'OPC # 0019', + 'Enforce LF line endings for *.sh and *.hcl via .gitattributes', + 'Added .gitattributes rules to enforce LF line endings for all *.sh and *.hcl files. Prevents CRLF issues from reappearing when contributors commit from Windows machines.', + 'Tech Debt', + 'Done', + 'Low', + 'amadzarak', + NOW(), NOW() +); diff --git a/ControlPlane.Api/Endpoints/GitEndpoints.cs b/ControlPlane.Api/Endpoints/GitEndpoints.cs new file mode 100644 index 0000000..b526964 --- /dev/null +++ b/ControlPlane.Api/Endpoints/GitEndpoints.cs @@ -0,0 +1,203 @@ +using ControlPlane.Core.Models; +using LibGit2Sharp; + +namespace ControlPlane.Api.Endpoints; + +public static class GitEndpoints +{ + public static IEndpointRouteBuilder MapGitEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/api/git/log", GetLog); + app.MapGet("/api/git/commits/{hash}", GetCommit); + app.MapGet("/api/git/branches", GetBranches); + app.MapGet("/api/git/branch-coverage", GetBranchCoverage); + return app; + } + + // GET /api/git/log?grep=OPC+%23+0001&limit=50 + private static IResult GetLog( + IConfiguration config, + string? grep = null, + int limit = 50) + { + var repoPath = ResolveRepo(config); + if (repoPath is null) + return Results.Problem("Could not locate a git repository. Set Git:RepoRoot in appsettings."); + + using var repo = new Repository(repoPath); + + var tips = repo.Branches + .Where(b => b.Tip != null) + .Select(b => (GitObject)b.Tip) + .ToList(); + + var filter = new CommitFilter + { + SortBy = CommitSortStrategies.Topological | CommitSortStrategies.Time, + IncludeReachableFrom = tips.Count > 0 ? tips : (object)repo.Head, + }; + + IEnumerable query = repo.Commits.QueryBy(filter); + + if (!string.IsNullOrWhiteSpace(grep)) + query = query.Where(c => c.Message.Contains(grep, StringComparison.OrdinalIgnoreCase)); + + var commits = query + .Take(limit) + .Select(c => ToGitCommit(repo, c)) + .ToList(); + + return Results.Ok(commits); + } + + // GET /api/git/commits/{hash} + private static IResult GetCommit(string hash, IConfiguration config) + { + var repoPath = ResolveRepo(config); + if (repoPath is null) + return Results.Problem("Could not locate a git repository."); + + using var repo = new Repository(repoPath); + var commit = repo.Lookup(hash); + if (commit is null) return Results.NotFound(); + + var parentTree = commit.Parents.FirstOrDefault()?.Tree; + var changes = repo.Diff.Compare(parentTree, commit.Tree); + var patch = repo.Diff.Compare(parentTree, commit.Tree); + + var files = changes.Select(c => new + { + path = c.Path, + oldPath = c.OldPath, + status = c.Status.ToString(), + additions = patch[c.Path]?.LinesAdded ?? 0, + deletions = patch[c.Path]?.LinesDeleted ?? 0, + patch = patch[c.Path]?.Patch ?? string.Empty, + }).ToList(); + + return Results.Ok(new + { + hash = commit.Sha, + shortHash = commit.Sha[..7], + author = commit.Author.Name, + email = commit.Author.Email, + date = commit.Author.When.ToString("yyyy-MM-dd HH:mm:ss zzz"), + subject = commit.MessageShort, + body = commit.Message, + files, + }); + } + + // GET /api/git/branches + private static IResult GetBranches(IConfiguration config) + { + var repoPath = ResolveRepo(config); + if (repoPath is null) + return Results.Problem("Could not locate a git repository."); + + using var repo = new Repository(repoPath); + var branches = repo.Branches + .Where(b => !b.IsRemote && b.Tip != null) + .Select(b => new + { + name = b.FriendlyName, + hash = b.Tip.Sha, + shortHash = b.Tip.Sha[..7], + subject = b.Tip.MessageShort, + author = b.Tip.Author.Name, + date = b.Tip.Author.When.ToString("yyyy-MM-dd HH:mm:ss zzz"), + isHead = b.IsCurrentRepositoryHead, + }) + .OrderBy(b => b.name) + .ToList(); + + return Results.Ok(branches); + } + + // GET /api/git/branch-coverage?commits=hash1,hash2,hash3 + // Returns each local branch and whether it contains ALL of the given commits. + private static IResult GetBranchCoverage(IConfiguration config, string? commits = null) + { + if (string.IsNullOrWhiteSpace(commits)) return Results.Ok(Array.Empty()); + + var hashes = commits.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (hashes.Length == 0) return Results.Ok(Array.Empty()); + + var repoPath = ResolveRepo(config); + if (repoPath is null) + return Results.Problem("Could not locate a git repository."); + + using var repo = new Repository(repoPath); + + var targetCommits = hashes + .Select(h => repo.Lookup(h)) + .Where(c => c is not null) + .ToList(); + + if (targetCommits.Count == 0) return Results.Ok(Array.Empty()); + + var result = repo.Branches + .Where(b => !b.IsRemote && b.Tip != null) + .Select(b => + { + var contains = targetCommits.All(tc => + { + // If merge base of branch tip and target == target, then target is an ancestor + var mergeBase = repo.ObjectDatabase.FindMergeBase(b.Tip, tc!); + return mergeBase?.Sha == tc!.Sha; + }); + return new + { + branch = b.FriendlyName, + contains, + tipHash = b.Tip.Sha[..7], + isHead = b.IsCurrentRepositoryHead, + }; + }) + .OrderBy(b => b.branch) + .ToList(); + + return Results.Ok(result); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + /// Resolves the repo root: explicit config overrides, otherwise auto-discover + /// from the running assembly directory upward via LibGit2Sharp. + private static string? ResolveRepo(IConfiguration config) + { + var configured = config["Git:RepoRoot"] ?? config["Docker:RepoRoot"]; + if (!string.IsNullOrWhiteSpace(configured) && Directory.Exists(configured)) + return configured; + + // Auto-discover: walk up from the app's own directory + var startPath = AppContext.BaseDirectory; + var discovered = Repository.Discover(startPath); + if (discovered is null) return null; + + // Repository.Discover returns the .git directory path; get the working dir + using var probe = new Repository(discovered); + return probe.Info.WorkingDirectory; + } + + private static GitCommit ToGitCommit(Repository repo, Commit c) + { + string[] files; + try + { + var parentTree = c.Parents.FirstOrDefault()?.Tree; + var changes = repo.Diff.Compare(parentTree, c.Tree); + files = changes.Select(ch => ch.Path).ToArray(); + } + catch { files = []; } + + return new GitCommit( + Hash: c.Sha, + ShortHash: c.Sha[..7], + Author: c.Author.Name, + Date: c.Author.When.ToString("yyyy-MM-dd HH:mm:ss zzz"), + Subject: c.MessageShort, + Files: files + ); + } +} diff --git a/ControlPlane.Api/Endpoints/GiteaEndpoints.cs b/ControlPlane.Api/Endpoints/GiteaEndpoints.cs new file mode 100644 index 0000000..718f2a7 --- /dev/null +++ b/ControlPlane.Api/Endpoints/GiteaEndpoints.cs @@ -0,0 +1,79 @@ +using ControlPlane.Api.Services; +using ControlPlane.Core.Models; + +namespace ControlPlane.Api.Endpoints; + +public static class GiteaEndpoints +{ + public static IEndpointRouteBuilder MapGiteaEndpoints(this IEndpointRouteBuilder app) + { + var g = app.MapGroup("/api/gitea").WithTags("Gitea"); + + g.MapGet ("/repo", GetRepo); + g.MapGet ("/branches", ListBranches); + g.MapPost("/branches", CreateBranch); + g.MapGet ("/pulls", ListPulls); + g.MapGet ("/pulls/{number:long}", GetPull); + g.MapPost("/pulls", CreatePull); + g.MapGet ("/tags", ListTags); + g.MapPost("/tags", CreateTag); + g.MapGet ("/webhooks", ListWebhooks); + g.MapPost("/webhooks", RegisterWebhook); + + return app; + } + + private static async Task GetRepo(GiteaService svc, CancellationToken ct) + { + var repo = await svc.GetRepoAsync(ct); + return repo is null ? Results.StatusCode(503) : Results.Ok(repo); + } + + private static async Task ListBranches(GiteaService svc, CancellationToken ct) => + Results.Ok(await svc.ListBranchesAsync(ct)); + + private static async Task CreateBranch( + CreateBranchRequest req, GiteaService svc, CancellationToken ct) + { + var branch = await svc.CreateBranchAsync(req, ct); + return branch is null ? Results.BadRequest("Failed to create branch in Gitea.") : Results.Ok(branch); + } + + private static async Task ListPulls( + GiteaService svc, string state = "open", CancellationToken ct = default) => + Results.Ok(await svc.ListPullRequestsAsync(state, ct)); + + private static async Task GetPull( + long number, GiteaService svc, CancellationToken ct) + { + var pr = await svc.GetPullRequestAsync(number, ct); + return pr is null ? Results.NotFound() : Results.Ok(pr); + } + + private static async Task CreatePull( + CreatePullRequestRequest req, GiteaService svc, CancellationToken ct) + { + var pr = await svc.CreatePullRequestAsync(req, ct); + return pr is null ? Results.BadRequest("Failed to create PR in Gitea.") : Results.Ok(pr); + } + + private static async Task ListTags(GiteaService svc, CancellationToken ct) => + Results.Ok(await svc.ListTagsAsync(ct)); + + private static async Task CreateTag( + CreateTagRequest req, GiteaService svc, CancellationToken ct) + { + var tag = await svc.CreateTagAsync(req, ct); + return tag is null ? Results.BadRequest("Failed to create tag in Gitea.") : Results.Ok(tag); + } + + private static async Task ListWebhooks(GiteaService svc, CancellationToken ct) => + Results.Ok(await svc.ListWebhooksAsync(ct)); + + private static async Task RegisterWebhook( + CreateWebhookRequest req, GiteaService svc, CancellationToken ct) + { + var hook = await svc.RegisterWebhookAsync(req, ct); + return hook is null ? Results.BadRequest("Failed to register webhook in Gitea.") : Results.Ok(hook); + } +} diff --git a/ControlPlane.Api/Endpoints/ImageBuildEndpoints.cs b/ControlPlane.Api/Endpoints/ImageBuildEndpoints.cs new file mode 100644 index 0000000..9c334f9 --- /dev/null +++ b/ControlPlane.Api/Endpoints/ImageBuildEndpoints.cs @@ -0,0 +1,75 @@ +using ControlPlane.Api.Services; +using System.Text.Json; + +namespace ControlPlane.Api.Endpoints; + +public static class ImageBuildEndpoints +{ + private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); + + public static IEndpointRouteBuilder MapImageBuildEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/image").WithTags("Image"); + + group.MapGet("/status", GetStatus); + group.MapPost("/build", TriggerBuild); + + return app; + } + + /// Returns the last known build status without triggering a new build. + private static async Task GetStatus(ImageBuildService svc) => + Results.Ok(await svc.GetStatusAsync()); + + /// + /// Triggers a docker build and streams the output line-by-line as SSE. + /// The build context is the repo root, which must be configured via + /// Docker:RepoRoot in appsettings / environment. + /// + private static async Task TriggerBuild( + HttpContext ctx, + ImageBuildService svc, + IConfiguration config, + CancellationToken ct) + { + var repoRoot = config["Docker:RepoRoot"]; + if (string.IsNullOrWhiteSpace(repoRoot) || !Directory.Exists(repoRoot)) + { + ctx.Response.StatusCode = 400; + await ctx.Response.WriteAsJsonAsync(new + { + error = "Docker:RepoRoot is not configured or does not exist.", + hint = "Add Docker__RepoRoot to the worker environment pointing at the repo root directory.", + }, ct); + return; + } + + ctx.Response.Headers.ContentType = "text/event-stream"; + ctx.Response.Headers.CacheControl = "no-cache"; + ctx.Response.Headers.Connection = "keep-alive"; + + // Use a Channel so the Progress callback (sync) can safely hand lines + // to the async SSE writer without blocking the Docker build thread. + var channel = System.Threading.Channels.Channel.CreateUnbounded( + new System.Threading.Channels.UnboundedChannelOptions { SingleWriter = true, SingleReader = true }); + + void OnLine(string line) => channel.Writer.TryWrite(line); + + // Run the build on a background thread so we can drain the channel here + var buildTask = Task.Run(() => svc.BuildAsync(repoRoot, OnLine, ct), ct) + .ContinueWith(_ => channel.Writer.TryComplete(), TaskScheduler.Default); + + await foreach (var line in channel.Reader.ReadAllAsync(ct)) + { + var json = JsonSerializer.Serialize(new { line }, JsonOpts); + await ctx.Response.WriteAsync($"data: {json}\n\n", ct); + await ctx.Response.Body.FlushAsync(ct); + } + + await buildTask; // ensure build is fully done + + // Signal stream end + await ctx.Response.WriteAsync("data: {\"done\":true}\n\n", ct); + await ctx.Response.Body.FlushAsync(ct); + } +} diff --git a/ControlPlane.Api/Endpoints/InfraEndpoints.cs b/ControlPlane.Api/Endpoints/InfraEndpoints.cs new file mode 100644 index 0000000..9bd12e4 --- /dev/null +++ b/ControlPlane.Api/Endpoints/InfraEndpoints.cs @@ -0,0 +1,232 @@ +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ControlPlane.Api.Endpoints; + +public static class InfraEndpoints +{ + public static IEndpointRouteBuilder MapInfraEndpoints(this IEndpointRouteBuilder app) + { + var g = app.MapGroup("/api/infra").WithTags("Infrastructure"); + + g.MapGet ("/status", GetStatus); + g.MapPost("/{container}/start", (string container) => ServiceAction(container, "start")); + g.MapPost("/{container}/stop", (string container) => ServiceAction(container, "stop")); + g.MapPost("/{container}/restart",(string container) => ServiceAction(container, "restart")); + g.MapGet ("/compose/up/stream", ComposeUpStream); + g.MapGet ("/compose/down/stream", ComposeDownStream); + + return app; + } + + // ── Known platform services ─────────────────────────────────────────────── + + private static readonly string[] PlatformContainers = + [ + "clarity-postgres", + "clarity-keycloak", + "clarity-vault", + "clarity-minio", + "clarity-gitea", + "clarity-nginx", + "clarity-dnsmasq", + ]; + + // ── Handlers ───────────────────────────────────────────────────────────── + + private static async Task GetStatus() + { + var services = new List(); + + foreach (var container in PlatformContainers) + { + var (code, output) = await DockerAsync( + $"inspect --format={{{{json .}}}} {container}"); + + if (code != 0 || string.IsNullOrWhiteSpace(output)) + { + services.Add(new InfraService(container, container, "stopped", [], null)); + continue; + } + + try + { + using var doc = JsonDocument.Parse(output.Trim()); + var root = doc.RootElement; + var state = root.GetProperty("State").GetProperty("Status").GetString() ?? "unknown"; + var health = root.GetProperty("State").TryGetProperty("Health", out var h) + ? h.GetProperty("Status").GetString() + : null; + + var status = (state, health) switch + { + ("running", "unhealthy") => "unhealthy", + ("running", _) => "running", + ("exited", _) => "stopped", + _ => state + }; + + // Ports + var ports = new List(); + if (root.TryGetProperty("NetworkSettings", out var ns) && + ns.TryGetProperty("Ports", out var portsEl)) + { + foreach (var port in portsEl.EnumerateObject()) + { + if (port.Value.ValueKind != JsonValueKind.Null) + ports.Add(port.Name.Split('/')[0]); + } + } + + // Uptime + string? uptime = null; + if (root.GetProperty("State").TryGetProperty("StartedAt", out var startedAt)) + { + if (DateTime.TryParse(startedAt.GetString(), out var started) && state == "running") + { + var elapsed = DateTime.UtcNow - started.ToUniversalTime(); + uptime = elapsed.TotalDays >= 1 + ? $"{(int)elapsed.TotalDays}d {elapsed.Hours}h" + : elapsed.TotalHours >= 1 + ? $"{(int)elapsed.TotalHours}h {elapsed.Minutes}m" + : $"{elapsed.Minutes}m"; + } + } + + // Friendly name + var name = root.TryGetProperty("Name", out var n) + ? n.GetString()?.TrimStart('/') ?? container + : container; + + services.Add(new InfraService(name, container, status, ports, uptime)); + } + catch + { + services.Add(new InfraService(container, container, "unknown", [], null)); + } + } + + return Results.Ok(new InfraStatusResponse(services, DateTimeOffset.UtcNow)); + } + + private static async Task ServiceAction(string container, string action) + { + if (!PlatformContainers.Contains(container)) + return Results.BadRequest($"Unknown platform container: {container}"); + + var (code, output) = await DockerAsync($"{action} {container}"); + return code == 0 + ? Results.Ok() + : Results.Problem(output ?? "Docker command failed", statusCode: 500); + } + + private static Task ComposeUpStream(HttpContext ctx, IConfiguration config, CancellationToken ct) => + StreamComposeOutput(ctx, config, "up --pull missing", ct); + + private static Task ComposeDownStream(HttpContext ctx, IConfiguration config, CancellationToken ct) => + StreamComposeOutput(ctx, config, "down", ct); + + private static async Task StreamComposeOutput( + HttpContext ctx, IConfiguration config, string composeArgs, CancellationToken ct) + { + var infraDir = ResolveInfraPath(config); + + ctx.Response.Headers.ContentType = "text/event-stream"; + ctx.Response.Headers.CacheControl = "no-cache"; + ctx.Response.Headers.Connection = "keep-alive"; + + var channel = System.Threading.Channels.Channel.CreateUnbounded( + new System.Threading.Channels.UnboundedChannelOptions { SingleWriter = false, SingleReader = true }); + + var psi = new ProcessStartInfo("docker", + $"compose -f \"{Path.Combine(infraDir, "docker-compose.yml")}\" {composeArgs}") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = infraDir, + }; + + var proc = Process.Start(psi)!; + + // Read stdout + stderr concurrently into the channel + var stdoutTask = Task.Run(async () => + { + while (await proc.StandardOutput.ReadLineAsync(ct) is { } line) + channel.Writer.TryWrite(line); + }, ct); + + var stderrTask = Task.Run(async () => + { + while (await proc.StandardError.ReadLineAsync(ct) is { } line) + channel.Writer.TryWrite(line); + }, ct); + + _ = Task.WhenAll(stdoutTask, stderrTask) + .ContinueWith(_ => channel.Writer.TryComplete(), TaskScheduler.Default); + + // Stream lines to client as SSE + await foreach (var line in channel.Reader.ReadAllAsync(ct)) + { + if (line is null) continue; + await ctx.Response.WriteAsync($"data: {line}\n\n", ct); + await ctx.Response.Body.FlushAsync(ct); + } + + await proc.WaitForExitAsync(ct); + var exitLine = proc.ExitCode == 0 ? "data: ✔ Done." : $"data: ✖ Exited with code {proc.ExitCode}"; + await ctx.Response.WriteAsync($"{exitLine}\n\n", ct); + await ctx.Response.Body.FlushAsync(ct); + proc.Dispose(); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static string ResolveInfraPath(IConfiguration config) + { + var repoRoot = config["Docker:RepoRoot"] + ?? Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..")); + return Path.GetFullPath(Path.Combine(repoRoot, "infra")); + } + + private static Task<(int Code, string? Output)> DockerAsync(string args) => + RunAsync("docker", args, null); + + private static async Task<(int Code, string? Output)> ComposeAsync(string args, string infraDir)=> + await RunAsync("docker", $"compose -f \"{Path.Combine(infraDir, "docker-compose.yml")}\" {args}", infraDir); + + private static async Task<(int Code, string? Output)> RunAsync( + string exe, string args, string? workingDir) + { + var psi = new ProcessStartInfo(exe, args) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + if (workingDir is not null) psi.WorkingDirectory = workingDir; + + using var proc = Process.Start(psi); + if (proc is null) return (-1, null); + var output = await proc.StandardOutput.ReadToEndAsync(); + var err = await proc.StandardError.ReadToEndAsync(); + await proc.WaitForExitAsync(); + return (proc.ExitCode, string.IsNullOrWhiteSpace(output) ? err : output); + } + + // ── Response models ─────────────────────────────────────────────────────── + + public record InfraService( + string Name, + string Container, + string Status, + List Ports, + string? Uptime); + + public record InfraStatusResponse( + List Services, + DateTimeOffset CheckedAt); +} diff --git a/ControlPlane.Api/Endpoints/OpcEndpoints.cs b/ControlPlane.Api/Endpoints/OpcEndpoints.cs new file mode 100644 index 0000000..645e471 --- /dev/null +++ b/ControlPlane.Api/Endpoints/OpcEndpoints.cs @@ -0,0 +1,244 @@ +using ControlPlane.Api.Services; +using ControlPlane.Core.Models; +using LibGit2Sharp; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; + +namespace ControlPlane.Api.Endpoints; + +public static class OpcEndpoints +{ + private static readonly JsonSerializerOptions JsonOpts = + new(JsonSerializerDefaults.Web) { WriteIndented = false }; + + public static IEndpointRouteBuilder MapOpcEndpoints(this IEndpointRouteBuilder app) + { + var g = app.MapGroup("/api/opc").WithTags("OPC"); + + // ── OPC CRUD ────────────────────────────────────────────────────────── + g.MapGet ("", ListOpcs); + g.MapGet ("/next-number", GetNextNumber); + g.MapPost ("", CreateOpc); + g.MapGet ("/{id:guid}", GetOpc); + g.MapPatch ("/{id:guid}", UpdateOpc); + g.MapDelete("/{id:guid}", DeleteOpc); + + // ── Notes ───────────────────────────────────────────────────────────── + g.MapGet ("/{id:guid}/notes", ListNotes); + g.MapPost ("/{id:guid}/notes", AddNote); + + // ── Artifacts ───────────────────────────────────────────────────────── + g.MapGet ("/{id:guid}/artifacts", ListArtifacts); + g.MapPost ("/{id:guid}/artifacts", CreateArtifact); + g.MapPatch ("/artifacts/{artifactId:guid}", UpdateArtifact); + g.MapDelete("/artifacts/{artifactId:guid}", DeleteArtifact); + + // ── Pinned commits ──────────────────────────────────────────────────── + g.MapGet ("/{id:guid}/pinned-commits", ListPinnedCommits); + g.MapPost ("/{id:guid}/pinned-commits", PinCommit); + g.MapDelete("/{id:guid}/pinned-commits/{hash}", UnpinCommit); + + // ── AI assist (proxies to OpenRouter, key stays on server) ──────────── + g.MapPost ("/ai-assist", AiAssist); + + return app; + } + + // ── OPC handlers ────────────────────────────────────────────────────────── + + private static async Task ListOpcs( + OpcService svc, + string? type = null, string? status = null, + CancellationToken ct = default) + { + var list = await svc.ListAsync(type, status, ct); + return Results.Ok(list); + } + + private static async Task GetNextNumber( + OpcService svc, CancellationToken ct) + { + var number = await svc.NextNumberAsync(ct); + return Results.Ok(new { number }); + } + + private static async Task CreateOpc( + CreateOpcRequest req, OpcService svc, CancellationToken ct) + { + var opc = await svc.CreateAsync(req, ct); + return Results.Created($"/api/opc/{opc.Id}", opc); + } + + private static async Task GetOpc( + Guid id, OpcService svc, CancellationToken ct) + { + var opc = await svc.GetAsync(id, ct); + return opc is null ? Results.NotFound() : Results.Ok(opc); + } + + private static async Task UpdateOpc( + Guid id, UpdateOpcRequest req, OpcService svc, CancellationToken ct) + { + var opc = await svc.UpdateAsync(id, req, ct); + return opc is null ? Results.NotFound() : Results.Ok(opc); + } + + private static async Task DeleteOpc( + Guid id, OpcService svc, CancellationToken ct) + { + return await svc.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound(); + } + + // ── Note handlers ───────────────────────────────────────────────────────── + + private static async Task ListNotes( + Guid id, OpcService svc, CancellationToken ct) + { + var notes = await svc.ListNotesAsync(id, ct); + return Results.Ok(notes); + } + + private static async Task AddNote( + Guid id, AddNoteRequest req, OpcService svc, CancellationToken ct) + { + var note = await svc.AddNoteAsync(id, req, ct); + return Results.Created($"/api/opc/{id}/notes/{note.Id}", note); + } + + // ── Artifact handlers ───────────────────────────────────────────────────── + + private static async Task ListArtifacts( + Guid id, OpcService svc, + string? type = null, CancellationToken ct = default) + { + var artifacts = await svc.ListArtifactsAsync(id, type, ct); + return Results.Ok(artifacts); + } + + private static async Task CreateArtifact( + Guid id, UpsertArtifactRequest req, OpcService svc, CancellationToken ct) + { + var artifact = await svc.UpsertArtifactAsync(id, req, ct); + return Results.Created($"/api/opc/{id}/artifacts/{artifact.Id}", artifact); + } + + private static async Task UpdateArtifact( + Guid artifactId, UpsertArtifactRequest req, OpcService svc, CancellationToken ct) + { + var artifact = await svc.UpdateArtifactAsync(artifactId, req, ct); + return artifact is null ? Results.NotFound() : Results.Ok(artifact); + } + + private static async Task DeleteArtifact( + Guid artifactId, OpcService svc, CancellationToken ct) + { + return await svc.DeleteArtifactAsync(artifactId, ct) + ? Results.NoContent() + : Results.NotFound(); + } + + // ── Pinned commit handlers ──────────────────────────────────────────────── + + private static async Task ListPinnedCommits( + Guid id, OpcService svc, CancellationToken ct) + { + var commits = await svc.ListPinnedCommitsAsync(id, ct); + return Results.Ok(commits); + } + + private static async Task PinCommit( + Guid id, PinCommitRequest req, OpcService svc, IConfiguration config, CancellationToken ct) + { + var repoPath = config["Docker:RepoRoot"]; + string fullHash = req.Hash; + string shortHash = req.Hash.Length >= 7 ? req.Hash[..7] : req.Hash; + string subject = string.Empty; + string author = string.Empty; + + if (!string.IsNullOrWhiteSpace(repoPath) && Directory.Exists(repoPath)) + { + using var repo = new Repository(repoPath); + var commit = repo.Lookup(req.Hash); + if (commit is null) return Results.NotFound("Commit not found in repository."); + fullHash = commit.Sha; + shortHash = commit.Sha[..7]; + subject = commit.MessageShort; + author = commit.Author.Name; + } + + var pinned = await svc.PinCommitAsync(id, fullHash, shortHash, subject, author, req.PinnedBy, ct); + return pinned is null + ? Results.NotFound() + : Results.Created($"/api/opc/{id}/pinned-commits/{fullHash}", pinned); + } + + private static async Task UnpinCommit( + Guid id, string hash, OpcService svc, CancellationToken ct) + { + return await svc.UnpinCommitAsync(id, hash, ct) ? Results.NoContent() : Results.NotFound(); + } + + // ── AI assist ───────────────────────────────────────────────────────────── + + private static async Task AiAssist( + AiAssistRequest req, + IConfiguration config, + IHttpClientFactory http, + CancellationToken ct) + { + var apiKey = config["OpenRouter:ApiKey"]; + if (string.IsNullOrWhiteSpace(apiKey)) + return Results.Problem("OpenRouter API key not configured. Add OpenRouter:ApiKey to appsettings."); + + var systemPrompt = + "You are an assistant helping a software engineering team write clear, concise " + + "OPC (Online Project Communication) content — requirements, change descriptions, " + + "QA test paths, and specifications. Be direct, structured, and professional. " + + "Respond with plain text only (no markdown wrapping)."; + + var messages = new List + { + new { role = "system", content = systemPrompt }, + }; + + if (!string.IsNullOrWhiteSpace(req.Context)) + messages.Add(new { role = "user", content = $"Context:\n{req.Context}" }); + + messages.Add(new { role = "user", content = req.Prompt }); + + var body = new + { + model = "anthropic/claude-3.5-haiku", + messages, + max_tokens = 1024, + }; + + var client = http.CreateClient("openrouter"); + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", apiKey); + client.DefaultRequestHeaders.Add("HTTP-Referer", "https://controlplane.clarity.internal"); + client.DefaultRequestHeaders.Add("X-Title", "Clarity ControlPlane OPC"); + + var response = await client.PostAsync( + "https://openrouter.ai/api/v1/chat/completions", + new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"), + ct); + + if (!response.IsSuccessStatusCode) + { + var err = await response.Content.ReadAsStringAsync(ct); + return Results.Problem($"OpenRouter error {response.StatusCode}: {err}"); + } + + var json = await response.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(json); + var text = doc.RootElement + .GetProperty("choices")[0] + .GetProperty("message") + .GetProperty("content") + .GetString() ?? string.Empty; + + return Results.Ok(new { text }); + } +} diff --git a/ControlPlane.Api/Endpoints/ProjectBuildEndpoints.cs b/ControlPlane.Api/Endpoints/ProjectBuildEndpoints.cs new file mode 100644 index 0000000..c4fac2f --- /dev/null +++ b/ControlPlane.Api/Endpoints/ProjectBuildEndpoints.cs @@ -0,0 +1,65 @@ +using ControlPlane.Api.Services; +using ControlPlane.Core.Services; +using System.Text.Json; + +namespace ControlPlane.Api.Endpoints; + +public static class ProjectBuildEndpoints +{ + private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web) + { + Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }, + }; + + public static IEndpointRouteBuilder MapProjectBuildEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/builds").WithTags("Builds"); + group.MapGet("/projects", GetProjects); + group.MapGet("/history", GetHistory); + group.MapPost("/{projectName}", TriggerProjectBuild); + return app; + } + + /// Returns the list of known projects the build monitor can track. + private static IResult GetProjects(ProjectBuildService svc) => + Results.Ok(svc.GetProjects()); + + private static async Task GetHistory(BuildHistoryService history) => + Results.Ok(await history.GetBuildsAsync()); + + /// + /// Triggers a build for a named project and streams SSE output. + /// projectName must match one of the names returned by GET /api/builds/projects. + /// + private static async Task TriggerProjectBuild( + string projectName, + HttpContext ctx, + ProjectBuildService svc, + CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(svc.RepoRoot)) + { + ctx.Response.StatusCode = 503; + await ctx.Response.WriteAsJsonAsync( + new { error = "Docker:RepoRoot is not configured on the server." }, ct); + return; + } + + ctx.Response.Headers.ContentType = "text/event-stream"; + ctx.Response.Headers.CacheControl = "no-cache"; + ctx.Response.Headers.Connection = "keep-alive"; + + async Task Send(object payload) + { + var json = JsonSerializer.Serialize(payload, JsonOpts); + await ctx.Response.WriteAsync($"data: {json}\n\n", ct); + await ctx.Response.Body.FlushAsync(ct); + } + + void OnLine(string line) => Send(new { line }).GetAwaiter().GetResult(); + + var record = await svc.BuildProjectAsync(projectName, OnLine, ct); + + await Send(new { done = true, build = record }); + } +} diff --git a/ControlPlane.Api/Endpoints/PromotionEndpoints.cs b/ControlPlane.Api/Endpoints/PromotionEndpoints.cs new file mode 100644 index 0000000..68b64c6 --- /dev/null +++ b/ControlPlane.Api/Endpoints/PromotionEndpoints.cs @@ -0,0 +1,73 @@ +using ControlPlane.Api.Services; +using ControlPlane.Core.Models; +using System.Text.Json; + +namespace ControlPlane.Api.Endpoints; + +public static class PromotionEndpoints +{ + private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); + + public static IEndpointRouteBuilder MapPromotionEndpoints(this IEndpointRouteBuilder app) + { + var g = app.MapGroup("/api/promotions").WithTags("Promotions"); + + // GET /api/promotions/ladder — branch status for all 4 ladder branches + g.MapGet("/ladder", async (PromotionService svc, CancellationToken ct) => + Results.Ok(await svc.GetLadderStatusAsync(ct))); + + // GET /api/promotions/history + g.MapGet("/history", async (PromotionService svc) => + Results.Ok(await svc.GetHistoryAsync())); + + // POST /api/promotions/promote — body: { from, to, requestedBy, note } + // Streams SSE log lines then sends {done, promotion} when complete + g.MapPost("/promote", async ( + HttpContext ctx, + PromotionService svc, + PromoteRequest req, + CancellationToken ct) => + { + // Validate ladder step + var ladder = PromotionService.Ladder; + var fi = Array.IndexOf(ladder, req.From); + var ti = Array.IndexOf(ladder, req.To); + if (fi < 0 || ti < 0 || ti != fi + 1) + { + ctx.Response.StatusCode = 400; + await ctx.Response.WriteAsJsonAsync( + new { error = $"Invalid promotion step: {req.From} → {req.To}. Must be adjacent in ladder." }, ct); + return; + } + + ctx.Response.Headers.ContentType = "text/event-stream"; + ctx.Response.Headers.CacheControl = "no-cache"; + ctx.Response.Headers.Connection = "keep-alive"; + + var channel = System.Threading.Channels.Channel.CreateUnbounded( + new System.Threading.Channels.UnboundedChannelOptions { SingleWriter = true, SingleReader = true }); + + void OnLine(string line) => channel.Writer.TryWrite(line); + + var promoteTask = Task.Run(() => + svc.PromoteAsync(req.From, req.To, req.RequestedBy ?? "system", req.Note, OnLine, ct), ct) + .ContinueWith(t => channel.Writer.TryComplete(t.Exception), TaskScheduler.Default); + + await foreach (var line in channel.Reader.ReadAllAsync(ct)) + { + var json = JsonSerializer.Serialize(new { line }, JsonOpts); + await ctx.Response.WriteAsync($"data: {json}\n\n", ct); + await ctx.Response.Body.FlushAsync(ct); + } + + var promotion = await promoteTask; + var doneJson = JsonSerializer.Serialize(new { done = true, promotion }, JsonOpts); + await ctx.Response.WriteAsync($"data: {doneJson}\n\n", ct); + await ctx.Response.Body.FlushAsync(ct); + }); + + return app; + } +} + +public record PromoteRequest(string From, string To, string? RequestedBy, string? Note); diff --git a/ControlPlane.Api/Endpoints/ProvisioningEndpoints.cs b/ControlPlane.Api/Endpoints/ProvisioningEndpoints.cs new file mode 100644 index 0000000..b19cfe1 --- /dev/null +++ b/ControlPlane.Api/Endpoints/ProvisioningEndpoints.cs @@ -0,0 +1,106 @@ +using ControlPlane.Api.Services; +using ControlPlane.Core.Messages; +using ControlPlane.Core.Models; +using ControlPlane.Core.Services; +using MassTransit; +using System.Text.Json; + +namespace ControlPlane.Api.Endpoints; + +public static class ProvisioningEndpoints +{ + private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); + + public static IEndpointRouteBuilder MapProvisioningEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/provision").WithTags("Provisioning"); + + group.MapPost("/", QueueProvisioningJob); + group.MapGet("/{id:guid}", GetJobStatus); + group.MapGet("/{id:guid}/stream", StreamJobEvents); + + app.MapGet("/api/tenants", GetTenants).WithTags("Tenants"); + + return app; + } + + private static async Task QueueProvisioningJob( + ProvisioningRequest request, + Dictionary jobs, + IPublishEndpoint bus) + { + var job = new ProvisioningJob + { + ClientName = request.ClientName, + StateCode = request.StateCode.ToUpperInvariant(), + Subdomain = request.Subdomain, + AdminEmail = request.AdminEmail, + SiteCode = request.SiteCode, + Environment = request.Environment, + Tier = request.Tier, + Status = ProvisioningStatus.Pending + }; + + jobs[job.Id] = job; + + await bus.Publish(new ProvisionClientCommand + { + JobId = job.Id, + ClientName = job.ClientName, + StateCode = job.StateCode, + Subdomain = job.Subdomain, + AdminEmail = job.AdminEmail, + SiteCode = job.SiteCode, + Environment = job.Environment, + Tier = job.Tier + }); + + return Results.Accepted($"/api/provision/{job.Id}", new { job.Id, job.Status }); + } + + private static IResult GetJobStatus(Guid id, Dictionary jobs) => + jobs.TryGetValue(id, out var job) ? Results.Ok(job) : Results.NotFound(); + + private static IResult GetTenants(TenantRegistryService registry) => + Results.Ok(registry.GetAll()); + + private static async Task StreamJobEvents( + Guid id, + SseEventBus bus, + Dictionary jobs, + HttpContext ctx, + CancellationToken cancellationToken) + { + if (!jobs.ContainsKey(id)) + { + ctx.Response.StatusCode = 404; + return; + } + + ctx.Response.Headers.ContentType = "text/event-stream"; + ctx.Response.Headers.CacheControl = "no-cache"; + ctx.Response.Headers.Connection = "keep-alive"; + + var channel = bus.Subscribe(id); + try + { + await foreach (var evt in channel.Reader.ReadAllAsync(cancellationToken)) + { + var json = JsonSerializer.Serialize(evt, JsonOpts); + await ctx.Response.WriteAsync($"data: {json}\n\n", cancellationToken); + await ctx.Response.Body.FlushAsync(cancellationToken); + + if (evt.Type is "job_complete" or "job_failed") break; + } + } + catch (OperationCanceledException) + { + // Client disconnected (e.g. browser refresh) — not an error. + } + finally + { + bus.Unsubscribe(id, channel); + } + } +} + diff --git a/ControlPlane.Api/Endpoints/ReleaseEndpoints.cs b/ControlPlane.Api/Endpoints/ReleaseEndpoints.cs new file mode 100644 index 0000000..5862f3a --- /dev/null +++ b/ControlPlane.Api/Endpoints/ReleaseEndpoints.cs @@ -0,0 +1,62 @@ +using ControlPlane.Api.Services; +using ControlPlane.Core.Services; +using System.Text.Json; + +namespace ControlPlane.Api.Endpoints; + +public static class ReleaseEndpoints +{ + private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web) + { + Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }, + }; + + public static IEndpointRouteBuilder MapReleaseEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/release").WithTags("Release"); + group.MapGet("/history", GetHistory); + group.MapPost("/{env}", TriggerRelease); + return app; + } + + private static async Task GetHistory(BuildHistoryService history) => + Results.Ok(await history.GetReleasesAsync()); + + /// + /// Triggers a rolling redeploy of all managed containers in the target env. + /// Streams SSE lines until release is complete. + /// Valid env values: fdev | uat | prod | all + /// + private static async Task TriggerRelease( + string env, + HttpContext ctx, + ReleaseService releases, + CancellationToken ct) + { + var valid = new[] { "fdev", "uat", "prod", "all" }; + if (!valid.Contains(env, StringComparer.OrdinalIgnoreCase)) + { + ctx.Response.StatusCode = 400; + await ctx.Response.WriteAsJsonAsync( + new { error = $"Invalid environment '{env}'. Valid: fdev, uat, prod, all." }, ct); + return; + } + + ctx.Response.Headers.ContentType = "text/event-stream"; + ctx.Response.Headers.CacheControl = "no-cache"; + ctx.Response.Headers.Connection = "keep-alive"; + + async Task Send(object payload) + { + var json = JsonSerializer.Serialize(payload, JsonOpts); + await ctx.Response.WriteAsync($"data: {json}\n\n", ct); + await ctx.Response.Body.FlushAsync(ct); + } + + void OnLine(string line) => Send(new { line }).GetAwaiter().GetResult(); + + var record = await releases.ReleaseAsync(env, OnLine, ct); + + await Send(new { done = true, release = record }); + } +} diff --git a/ControlPlane.Api/Endpoints/TenantLogEndpoints.cs b/ControlPlane.Api/Endpoints/TenantLogEndpoints.cs new file mode 100644 index 0000000..bc7e951 --- /dev/null +++ b/ControlPlane.Api/Endpoints/TenantLogEndpoints.cs @@ -0,0 +1,108 @@ +using Docker.DotNet; +using Docker.DotNet.Models; +using ControlPlane.Core.Services; + +namespace ControlPlane.Api.Endpoints; + +public static class TenantLogEndpoints +{ + public static IEndpointRouteBuilder MapTenantLogEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/api/tenants/{subdomain}/logs", StreamTenantLogs).WithTags("Tenants"); + return app; + } + + private static async Task StreamTenantLogs( + string subdomain, + IConfiguration config, + TenantRegistryService registry, + HttpContext ctx, + CancellationToken cancellationToken) + { + var tenant = registry.GetAll().FirstOrDefault(t => t.Subdomain == subdomain); + if (tenant is null) + { + ctx.Response.StatusCode = 404; + return; + } + + var containerName = tenant.ContainerName; + if (string.IsNullOrWhiteSpace(containerName)) + { + ctx.Response.StatusCode = 404; + return; + } + + ctx.Response.Headers.ContentType = "text/event-stream"; + ctx.Response.Headers.CacheControl = "no-cache"; + ctx.Response.Headers.Connection = "keep-alive"; + + var socketUri = config["Docker:Socket"] ?? "npipe://./pipe/docker_engine"; + using var docker = new DockerClientConfiguration(new Uri(socketUri)).CreateClient(); + + var logParams = new ContainerLogsParameters + { + ShowStdout = true, + ShowStderr = true, + Follow = true, + Tail = "200", + Timestamps = true, + }; + + try + { + using var stream = await docker.Containers.GetContainerLogsAsync( + containerName, tty: false, logParams, cancellationToken); + + // MultiplexedStream exposes CopyOutputToAsync which separates stdout/stderr + var stdoutBuf = new System.IO.MemoryStream(); + var stderrBuf = new System.IO.MemoryStream(); + + // Stream with Follow=true won't complete until cancelled — use a pipe instead + var stdoutPipe = new System.IO.Pipelines.Pipe(); + var stderrPipe = new System.IO.Pipelines.Pipe(); + + _ = Task.Run(async () => + { + try + { + await stream.CopyOutputToAsync( + System.IO.Stream.Null, + stdoutPipe.Writer.AsStream(), + stderrPipe.Writer.AsStream(), + cancellationToken); + } + finally + { + stdoutPipe.Writer.Complete(); + stderrPipe.Writer.Complete(); + } + }, cancellationToken); + + // Merge both pipes into SSE — read stdout line by line + var stdoutReader = new System.IO.StreamReader(stdoutPipe.Reader.AsStream()); + var stderrReader = new System.IO.StreamReader(stderrPipe.Reader.AsStream()); + + var stdoutTask = ReadLinesAsync(stdoutReader, ctx, cancellationToken); + var stderrTask = ReadLinesAsync(stderrReader, ctx, cancellationToken); + await Task.WhenAll(stdoutTask, stderrTask); + } + catch (OperationCanceledException) { /* client disconnected — normal */ } + } + + private static async Task ReadLinesAsync( + System.IO.StreamReader reader, + HttpContext ctx, + CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(ct); + if (line is null) break; + if (string.IsNullOrWhiteSpace(line)) continue; + + await ctx.Response.WriteAsync($"data: {line}\n\n", ct); + await ctx.Response.Body.FlushAsync(ct); + } + } +} diff --git a/ControlPlane.Api/Program.cs b/ControlPlane.Api/Program.cs new file mode 100644 index 0000000..12dc195 --- /dev/null +++ b/ControlPlane.Api/Program.cs @@ -0,0 +1,131 @@ +using ControlPlane.Api.Consumers; +using ControlPlane.Api.Endpoints; +using ControlPlane.Api.Services; +using ControlPlane.Core.Models; +using ControlPlane.Core.Services; +using MassTransit; +using Npgsql; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); +builder.Services.AddOpenApi(); +builder.Services.ConfigureHttpJsonOptions(o => + o.SerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter())); + +// In-memory job store - swap for EF Core post-MVP +builder.Services.AddSingleton>(); + +// Tenant registry - reads ClientAssets/{subdomain}.xml files +builder.Services.AddSingleton(); + +// SSE event bus - ProgressConsumer writes here, SSE endpoint reads +builder.Services.AddSingleton(); + +// Build + release pipeline services +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// OPC persistence (raw Npgsql) +var opcConnStr = builder.Configuration.GetConnectionString("opcdb"); +if (!string.IsNullOrWhiteSpace(opcConnStr)) + builder.Services.AddSingleton(NpgsqlDataSource.Create(opcConnStr)); +else + builder.Services.AddSingleton(NpgsqlDataSource.Create("Host=localhost;Database=opcdb;Username=postgres;Password=controlplane-dev")); +builder.Services.AddScoped(); + +// Named HttpClient for OpenRouter AI assist proxy +builder.Services.AddHttpClient("openrouter"); + +// Gitea integration +builder.Services.AddHttpClient("gitea").ConfigurePrimaryHttpMessageHandler(() => + new HttpClientHandler { ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator }); +builder.Services.AddScoped(); + +builder.Services.AddMassTransit(x => +{ + x.SetKebabCaseEndpointNameFormatter(); + + // Receives ProvisioningProgressEvent from Worker and pushes to SSE + x.AddConsumer(); + + x.UsingRabbitMq((ctx, cfg) => + { + var connStr = builder.Configuration.GetConnectionString("rabbitmq"); + if (!string.IsNullOrWhiteSpace(connStr)) + cfg.Host(new Uri(connStr)); + cfg.ConfigureEndpoints(ctx); + }); +}); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +if (app.Environment.IsDevelopment()) + app.MapOpenApi(); + +app.MapProvisioningEndpoints(); +app.MapTenantLogEndpoints(); +app.MapImageBuildEndpoints(); +app.MapReleaseEndpoints(); +app.MapProjectBuildEndpoints(); +app.MapGitEndpoints(); +app.MapPromotionEndpoints(); +app.MapOpcEndpoints(); +app.MapGiteaEndpoints(); +app.MapInfraEndpoints(); + +// Ensure OPC tables exist (idempotent — IF NOT EXISTS) +var ds = app.Services.GetRequiredService(); +await using (var cmd = ds.CreateCommand(""" + CREATE TABLE IF NOT EXISTS opc ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + number VARCHAR(20) NOT NULL UNIQUE, + title VARCHAR(500) NOT NULL, + description TEXT NOT NULL DEFAULT '', + type VARCHAR(50) NOT NULL DEFAULT 'General', + status VARCHAR(50) NOT NULL DEFAULT 'New', + priority VARCHAR(20) NOT NULL DEFAULT 'Medium', + assignee VARCHAR(200) NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE TABLE IF NOT EXISTS opc_note ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + opc_id UUID NOT NULL REFERENCES opc(id) ON DELETE CASCADE, + author VARCHAR(200) NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE TABLE IF NOT EXISTS opc_artifact ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + opc_id UUID NOT NULL REFERENCES opc(id) ON DELETE CASCADE, + artifact_type VARCHAR(50) NOT NULL, + title VARCHAR(500) NOT NULL DEFAULT '', + content TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE TABLE IF NOT EXISTS opc_pinned_commit ( + opc_id UUID NOT NULL REFERENCES opc(id) ON DELETE CASCADE, + hash VARCHAR(40) NOT NULL, + short_hash VARCHAR(10) NOT NULL DEFAULT '', + subject VARCHAR(1000) NOT NULL DEFAULT '', + author VARCHAR(200) NOT NULL DEFAULT '', + pinned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + pinned_by VARCHAR(200) NOT NULL DEFAULT '', + PRIMARY KEY (opc_id, hash) + ); + CREATE INDEX IF NOT EXISTS ix_opc_number ON opc(number); + CREATE INDEX IF NOT EXISTS ix_opc_note_opc_id ON opc_note(opc_id); + CREATE INDEX IF NOT EXISTS ix_opc_artifact_opc_id ON opc_artifact(opc_id); + CREATE INDEX IF NOT EXISTS ix_opc_artifact_type ON opc_artifact(opc_id, artifact_type); + CREATE INDEX IF NOT EXISTS ix_opc_pinned_commit_opc_id ON opc_pinned_commit(opc_id); + """)) + await cmd.ExecuteNonQueryAsync(); + +app.Run(); diff --git a/ControlPlane.Api/Properties/launchSettings.json b/ControlPlane.Api/Properties/launchSettings.json new file mode 100644 index 0000000..7f1e062 --- /dev/null +++ b/ControlPlane.Api/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "ControlPlane.Api": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:7280;http://localhost:5280" + } + } +} \ No newline at end of file diff --git a/ControlPlane.Api/Services/GiteaService.cs b/ControlPlane.Api/Services/GiteaService.cs new file mode 100644 index 0000000..b9282c2 --- /dev/null +++ b/ControlPlane.Api/Services/GiteaService.cs @@ -0,0 +1,216 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using ControlPlane.Core.Models; + +namespace ControlPlane.Api.Services; + +/// +/// Thin wrapper around the Gitea REST API v1. +/// Configured via Gitea__BaseUrl, Gitea__Owner, and Gitea__Token in appsettings. +/// +public class GiteaService +{ + private readonly HttpClient _http; + private readonly string _owner; + private readonly string _repo; + private readonly ILogger _log; + + private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); + + public GiteaService(IHttpClientFactory factory, IConfiguration cfg, ILogger log) + { + _log = log; + _owner = cfg["Gitea:Owner"] ?? "Clarity"; + _repo = cfg["Gitea:Repo"] ?? "Clarity"; + + var baseUrl = cfg["Gitea:BaseUrl"] ?? "https://opc.clarity.test"; + var token = cfg["Gitea:Token"] ?? string.Empty; + + _http = factory.CreateClient("gitea"); + _http.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/api/v1/"); + _http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + if (!string.IsNullOrWhiteSpace(token)) + _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("token", token); + } + + // ── Repos ───────────────────────────────────────────────────────────────── + + public async Task GetRepoAsync(CancellationToken ct = default) + { + try + { + return await _http.GetFromJsonAsync($"repos/{_owner}/{_repo}", JsonOpts, ct); + } + catch (Exception ex) { _log.LogWarning(ex, "Gitea GetRepo failed"); return null; } + } + + // ── Branches ────────────────────────────────────────────────────────────── + + public async Task> ListBranchesAsync(CancellationToken ct = default) + { + try + { + return await _http.GetFromJsonAsync>( + $"repos/{_owner}/{_repo}/branches?limit=50", JsonOpts, ct) ?? []; + } + catch (Exception ex) { _log.LogWarning(ex, "Gitea ListBranches failed"); return []; } + } + + public async Task CreateBranchAsync(CreateBranchRequest req, CancellationToken ct = default) + { + // Slugify: "OPC # 0032" + title → "feature/OPC-0032-git-workflow-integration" + var slug = SlugifyTitle(req.OpcTitle); + var num = req.OpcNumber.Replace("OPC # ", "OPC-").Replace(" ", ""); + var branchName = $"feature/{num}-{slug}"; + + var body = JsonSerializer.Serialize(new + { + new_branch_name = branchName, + old_branch_name = req.From, + }, JsonOpts); + + var res = await _http.PostAsync( + $"repos/{_owner}/{_repo}/branches", + new StringContent(body, Encoding.UTF8, "application/json"), ct); + + if (!res.IsSuccessStatusCode) + { + var err = await res.Content.ReadAsStringAsync(ct); + _log.LogWarning("Gitea CreateBranch failed {Status}: {Error}", res.StatusCode, err); + return null; + } + + return await res.Content.ReadFromJsonAsync(JsonOpts, ct); + } + + // ── Pull Requests ───────────────────────────────────────────────────────── + + public async Task> ListPullRequestsAsync( + string state = "open", CancellationToken ct = default) + { + try + { + return await _http.GetFromJsonAsync>( + $"repos/{_owner}/{_repo}/pulls?state={state}&limit=50", JsonOpts, ct) ?? []; + } + catch (Exception ex) { _log.LogWarning(ex, "Gitea ListPRs failed"); return []; } + } + + public async Task GetPullRequestAsync(long number, CancellationToken ct = default) + { + try + { + return await _http.GetFromJsonAsync( + $"repos/{_owner}/{_repo}/pulls/{number}", JsonOpts, ct); + } + catch (Exception ex) { _log.LogWarning(ex, "Gitea GetPR failed"); return null; } + } + + public async Task CreatePullRequestAsync( + CreatePullRequestRequest req, CancellationToken ct = default) + { + var body = JsonSerializer.Serialize(new + { + title = req.Title, + head = req.Head, + @base = req.Base, + body = req.Body, + }, JsonOpts); + + var res = await _http.PostAsync( + $"repos/{_owner}/{_repo}/pulls", + new StringContent(body, Encoding.UTF8, "application/json"), ct); + + if (!res.IsSuccessStatusCode) + { + var err = await res.Content.ReadAsStringAsync(ct); + _log.LogWarning("Gitea CreatePR failed {Status}: {Error}", res.StatusCode, err); + return null; + } + + return await res.Content.ReadFromJsonAsync(JsonOpts, ct); + } + + // ── Tags ────────────────────────────────────────────────────────────────── + + public async Task> ListTagsAsync(CancellationToken ct = default) + { + try + { + return await _http.GetFromJsonAsync>( + $"repos/{_owner}/{_repo}/tags?limit=20", JsonOpts, ct) ?? []; + } + catch (Exception ex) { _log.LogWarning(ex, "Gitea ListTags failed"); return []; } + } + + public async Task CreateTagAsync(CreateTagRequest req, CancellationToken ct = default) + { + var body = JsonSerializer.Serialize(new + { + tag_name = req.TagName, + message = req.Message, + target = req.CommitSha, + }, JsonOpts); + + var res = await _http.PostAsync( + $"repos/{_owner}/{_repo}/tags", + new StringContent(body, Encoding.UTF8, "application/json"), ct); + + if (!res.IsSuccessStatusCode) + { + var err = await res.Content.ReadAsStringAsync(ct); + _log.LogWarning("Gitea CreateTag failed {Status}: {Error}", res.StatusCode, err); + return null; + } + + return await res.Content.ReadFromJsonAsync(JsonOpts, ct); + } + + // ── Webhooks ────────────────────────────────────────────────────────────── + + public async Task> ListWebhooksAsync(CancellationToken ct = default) + { + try + { + return await _http.GetFromJsonAsync>( + $"repos/{_owner}/{_repo}/hooks", JsonOpts, ct) ?? []; + } + catch (Exception ex) { _log.LogWarning(ex, "Gitea ListWebhooks failed"); return []; } + } + + public async Task RegisterWebhookAsync( + CreateWebhookRequest req, CancellationToken ct = default) + { + var body = JsonSerializer.Serialize(new + { + type = "gitea", + active = true, + config = new { url = req.TargetUrl, content_type = "json" }, + events = req.Events, + }, JsonOpts); + + var res = await _http.PostAsync( + $"repos/{_owner}/{_repo}/hooks", + new StringContent(body, Encoding.UTF8, "application/json"), ct); + + if (!res.IsSuccessStatusCode) + { + var err = await res.Content.ReadAsStringAsync(ct); + _log.LogWarning("Gitea RegisterWebhook failed {Status}: {Error}", res.StatusCode, err); + return null; + } + + return await res.Content.ReadFromJsonAsync(JsonOpts, ct); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static string SlugifyTitle(string title) => + System.Text.RegularExpressions.Regex + .Replace(title.ToLowerInvariant(), @"[^a-z0-9]+", "-") + .Trim('-')[..Math.Min(40, title.Length)]; +} diff --git a/ControlPlane.Api/Services/ImageBuildService.cs b/ControlPlane.Api/Services/ImageBuildService.cs new file mode 100644 index 0000000..90a6275 --- /dev/null +++ b/ControlPlane.Api/Services/ImageBuildService.cs @@ -0,0 +1,144 @@ +using ControlPlane.Core.Models; +using ControlPlane.Core.Services; +using Docker.DotNet; +using Docker.DotNet.Models; + +namespace ControlPlane.Api.Services; + +/// +/// Drives `docker build` for the clarity-server image via the Docker SDK. +/// Streams each build log line to the provided callback so the API endpoint +/// can forward it as SSE to the control plane UI in real time. +/// Persists build history via BuildHistoryService. +/// +public class ImageBuildService( + IConfiguration config, + BuildHistoryService history, + ILogger logger) +{ + private static readonly SemaphoreSlim _lock = new(1, 1); + + public bool IsBuilding => _lock.CurrentCount == 0; + public string ImageName => config["Docker:ClarityServerImage"] ?? "clarity-server:latest"; + + public async Task GetStatusAsync() + { + var builds = await history.GetBuildsAsync(); + var last = builds.FirstOrDefault(b => b.Kind == BuildKind.DockerImage); + return new ImageBuildStatus( + last?.Target, + last?.FinishedAt, + last?.Status.ToString() ?? "Never built", + IsBuilding); + } + + /// + /// Runs docker build and streams each log line to . + /// Returns true on success, false if the build failed or was already running. + /// + public async Task BuildAsync( + string repoRoot, + Action onLine, + CancellationToken ct) + { + if (!await _lock.WaitAsync(TimeSpan.Zero, ct)) + { + onLine("⚠️ A build is already in progress."); + return false; + } + + var record = await history.CreateBuildAsync(BuildKind.DockerImage, ImageName); + + try + { + var socketUri = config["Docker:Socket"] ?? "npipe://./pipe/docker_engine"; + using var docker = new DockerClientConfiguration(new Uri(socketUri)).CreateClient(); + + var (repo, tag) = SplitImageTag(ImageName); + var dockerfilePath = "Clarity.Server/Dockerfile"; + + void Log(string line) { onLine(line); record.Log.Add(line); } + + Log($"▶ Building {ImageName} from {repoRoot}"); + Log($" Dockerfile: {dockerfilePath}"); + Log("──────────────────────────────────────"); + + var buildParams = new ImageBuildParameters + { + Dockerfile = dockerfilePath, + Tags = [$"{repo}:{tag}"], + Remove = true, + ForceRemove = true, + }; + + bool success = true; + string? errorDetail = null; + + await docker.Images.BuildImageFromDockerfileAsync( + buildParams, + await CreateTarballAsync(repoRoot, ct), + authConfigs: null, + headers: null, + new Progress(msg => + { + if (!string.IsNullOrWhiteSpace(msg.Stream)) + Log(msg.Stream.TrimEnd('\n')); + + if (msg.Error is not null) + { + success = false; + errorDetail = msg.Error.Message; + Log($"✖ {msg.Error.Message}"); + } + }), + ct); + + Log("──────────────────────────────────────"); + if (success) Log($"✔ {ImageName} built successfully at {DateTimeOffset.UtcNow:u}"); + else Log($"✖ Build failed: {errorDetail}"); + + await history.CompleteBuildAsync(record, success ? BuildStatus.Succeeded : BuildStatus.Failed); + logger.LogInformation("Image build {Result} for {Image}", success ? "succeeded" : "failed", ImageName); + return success; + } + catch (Exception ex) + { + record.Log.Add($"Exception: {ex.Message}"); + await history.CompleteBuildAsync(record, BuildStatus.Failed); + onLine($"✖ Exception during build: {ex.Message}"); + logger.LogError(ex, "Image build threw an exception."); + return false; + } + finally + { + _lock.Release(); + } + } + + /// + /// Packs the entire repo root into a tar stream for the Docker build context. + /// Respects .dockerignore if present. + /// + private static async Task CreateTarballAsync(string repoRoot, CancellationToken ct) + { + // Use docker's own CLI to create the tarball via stdin — avoids reimplementing + // .dockerignore parsing. Fall back to a pure managed tar if CLI isn't available. + // For simplicity we use a managed approach: stream the directory as a tar. + var ms = new MemoryStream(); + await Task.Run(() => TarHelper.Pack(repoRoot, ms), ct); + ms.Position = 0; + return ms; + } + + private static (string repo, string tag) SplitImageTag(string image) + { + var colon = image.LastIndexOf(':'); + return colon < 0 ? (image, "latest") : (image[..colon], image[(colon + 1)..]); + } +} + +public record ImageBuildStatus( + string? ImageName, + DateTimeOffset? BuiltAt, + string LastMessage, + bool IsBuilding); diff --git a/ControlPlane.Api/Services/OpcService.cs b/ControlPlane.Api/Services/OpcService.cs new file mode 100644 index 0000000..bce9ea1 --- /dev/null +++ b/ControlPlane.Api/Services/OpcService.cs @@ -0,0 +1,297 @@ +using ControlPlane.Core.Models; +using Npgsql; + +namespace ControlPlane.Api.Services; + +public class OpcService(NpgsqlDataSource db) +{ + // ── Helpers ────────────────────────────────────────────────────────────── + + private static OpcRecord ReadOpc(NpgsqlDataReader r) => new( + r.GetGuid(0), + r.GetString(1), + r.GetString(2), + r.GetString(3), + r.GetString(4), + r.GetString(5), + r.GetString(6), + r.GetString(7), + r.GetDateTime(8), + r.GetDateTime(9) + ); + + private static OpcNote ReadNote(NpgsqlDataReader r) => new( + r.GetGuid(0), + r.GetGuid(1), + r.GetString(2), + r.GetString(3), + r.GetDateTime(4) + ); + + private static OpcArtifact ReadArtifact(NpgsqlDataReader r) => new( + r.GetGuid(0), + r.GetGuid(1), + r.GetString(2), + r.GetString(3), + r.GetString(4), + r.GetDateTime(5), + r.GetDateTime(6) + ); + + // ── Next OPC number ─────────────────────────────────────────────────────── + + public async Task NextNumberAsync(CancellationToken ct = default) + { + await using var cmd = db.CreateCommand( + "SELECT number FROM opc ORDER BY CAST(TRIM(SUBSTRING(number FROM 7)) AS INTEGER) DESC LIMIT 1"); + var last = await cmd.ExecuteScalarAsync(ct) as string; + if (last is null) return "OPC # 0001"; + if (int.TryParse(last[6..], out var n)) + return $"OPC # {n + 1:D4}"; + return "OPC # 0001"; + } + + // ── OPC CRUD ────────────────────────────────────────────────────────────── + + public async Task> ListAsync( + string? typeFilter = null, string? statusFilter = null, + CancellationToken ct = default) + { + var sql = """ + SELECT id, number, title, description, type, status, priority, assignee, + created_at, updated_at + FROM opc + WHERE ($1::text IS NULL OR type = $1) + AND ($2::text IS NULL OR status = $2) + ORDER BY created_at DESC + """; + await using var cmd = db.CreateCommand(sql); + cmd.Parameters.AddWithValue(typeFilter ?? (object)DBNull.Value); + cmd.Parameters.AddWithValue(statusFilter ?? (object)DBNull.Value); + await using var r = await cmd.ExecuteReaderAsync(ct); + var list = new List(); + while (await r.ReadAsync(ct)) list.Add(ReadOpc(r)); + return list; + } + + public async Task GetAsync(Guid id, CancellationToken ct = default) + { + await using var cmd = db.CreateCommand( + "SELECT id, number, title, description, type, status, priority, assignee, created_at, updated_at FROM opc WHERE id = $1"); + cmd.Parameters.AddWithValue(id); + await using var r = await cmd.ExecuteReaderAsync(ct); + return await r.ReadAsync(ct) ? ReadOpc(r) : null; + } + + public async Task CreateAsync(CreateOpcRequest req, CancellationToken ct = default) + { + var number = await NextNumberAsync(ct); + var sql = """ + INSERT INTO opc (number, title, description, type, status, priority, assignee) + VALUES ($1, $2, $3, $4, 'New', $5, $6) + RETURNING id, number, title, description, type, status, priority, assignee, + created_at, updated_at + """; + await using var cmd = db.CreateCommand(sql); + cmd.Parameters.AddWithValue(number); + cmd.Parameters.AddWithValue(req.Title); + cmd.Parameters.AddWithValue(req.Description); + cmd.Parameters.AddWithValue(req.Type); + cmd.Parameters.AddWithValue(req.Priority); + cmd.Parameters.AddWithValue(req.Assignee); + await using var r = await cmd.ExecuteReaderAsync(ct); + await r.ReadAsync(ct); + return ReadOpc(r); + } + + public async Task UpdateAsync(Guid id, UpdateOpcRequest req, CancellationToken ct = default) + { + var sql = """ + UPDATE opc SET + title = COALESCE($2, title), + description = COALESCE($3, description), + type = COALESCE($4, type), + status = COALESCE($5, status), + priority = COALESCE($6, priority), + assignee = COALESCE($7, assignee), + updated_at = NOW() + WHERE id = $1 + RETURNING id, number, title, description, type, status, priority, assignee, + created_at, updated_at + """; + await using var cmd = db.CreateCommand(sql); + cmd.Parameters.AddWithValue(id); + cmd.Parameters.AddWithValue(req.Title ?? (object)DBNull.Value); + cmd.Parameters.AddWithValue(req.Description ?? (object)DBNull.Value); + cmd.Parameters.AddWithValue(req.Type ?? (object)DBNull.Value); + cmd.Parameters.AddWithValue(req.Status ?? (object)DBNull.Value); + cmd.Parameters.AddWithValue(req.Priority ?? (object)DBNull.Value); + cmd.Parameters.AddWithValue(req.Assignee ?? (object)DBNull.Value); + await using var r = await cmd.ExecuteReaderAsync(ct); + return await r.ReadAsync(ct) ? ReadOpc(r) : null; + } + + public async Task DeleteAsync(Guid id, CancellationToken ct = default) + { + await using var cmd = db.CreateCommand("DELETE FROM opc WHERE id = $1"); + cmd.Parameters.AddWithValue(id); + return await cmd.ExecuteNonQueryAsync(ct) > 0; + } + + // ── Notes ────────────────────────────────────────────────────────────────── + + public async Task> ListNotesAsync(Guid opcId, CancellationToken ct = default) + { + await using var cmd = db.CreateCommand( + "SELECT id, opc_id, author, content, created_at FROM opc_note WHERE opc_id = $1 ORDER BY created_at ASC"); + cmd.Parameters.AddWithValue(opcId); + await using var r = await cmd.ExecuteReaderAsync(ct); + var list = new List(); + while (await r.ReadAsync(ct)) list.Add(ReadNote(r)); + return list; + } + + public async Task AddNoteAsync(Guid opcId, AddNoteRequest req, CancellationToken ct = default) + { + var sql = """ + INSERT INTO opc_note (opc_id, author, content) + VALUES ($1, $2, $3) + RETURNING id, opc_id, author, content, created_at + """; + await using var cmd = db.CreateCommand(sql); + cmd.Parameters.AddWithValue(opcId); + cmd.Parameters.AddWithValue(req.Author); + cmd.Parameters.AddWithValue(req.Content); + await using var r = await cmd.ExecuteReaderAsync(ct); + await r.ReadAsync(ct); + return ReadNote(r); + } + + // ── Artifacts ───────────────────────────────────────────────────────────── + + public async Task> ListArtifactsAsync(Guid opcId, string? artifactType = null, CancellationToken ct = default) + { + var sql = """ + SELECT id, opc_id, artifact_type, title, content, created_at, updated_at + FROM opc_artifact + WHERE opc_id = $1 + AND ($2::text IS NULL OR artifact_type = $2) + ORDER BY created_at ASC + """; + await using var cmd = db.CreateCommand(sql); + cmd.Parameters.AddWithValue(opcId); + cmd.Parameters.AddWithValue(artifactType ?? (object)DBNull.Value); + await using var r = await cmd.ExecuteReaderAsync(ct); + var list = new List(); + while (await r.ReadAsync(ct)) list.Add(ReadArtifact(r)); + return list; + } + + public async Task UpsertArtifactAsync(Guid opcId, UpsertArtifactRequest req, CancellationToken ct = default) + { + var sql = """ + INSERT INTO opc_artifact (opc_id, artifact_type, title, content) + VALUES ($1, $2, $3, $4) + ON CONFLICT DO NOTHING + RETURNING id, opc_id, artifact_type, title, content, created_at, updated_at + """; + // Simple insert; for updates use artifact id endpoint + await using var cmd = db.CreateCommand(sql); + cmd.Parameters.AddWithValue(opcId); + cmd.Parameters.AddWithValue(req.ArtifactType); + cmd.Parameters.AddWithValue(req.Title); + cmd.Parameters.AddWithValue(req.Content); + await using var r = await cmd.ExecuteReaderAsync(ct); + await r.ReadAsync(ct); + return ReadArtifact(r); + } + + public async Task UpdateArtifactAsync(Guid artifactId, UpsertArtifactRequest req, CancellationToken ct = default) + { + var sql = """ + UPDATE opc_artifact SET + title = $2, + content = $3, + updated_at = NOW() + WHERE id = $1 + RETURNING id, opc_id, artifact_type, title, content, created_at, updated_at + """; + await using var cmd = db.CreateCommand(sql); + cmd.Parameters.AddWithValue(artifactId); + cmd.Parameters.AddWithValue(req.Title); + cmd.Parameters.AddWithValue(req.Content); + await using var r = await cmd.ExecuteReaderAsync(ct); + return await r.ReadAsync(ct) ? ReadArtifact(r) : null; + } + + public async Task DeleteArtifactAsync(Guid artifactId, CancellationToken ct = default) + { + await using var cmd = db.CreateCommand("DELETE FROM opc_artifact WHERE id = $1"); + cmd.Parameters.AddWithValue(artifactId); + return await cmd.ExecuteNonQueryAsync(ct) > 0; + } + + // ── Pinned commits ──────────────────────────────────────────────────────── + + private static OpcPinnedCommit ReadPinnedCommit(NpgsqlDataReader r) => new( + r.GetGuid(0), + r.GetString(1), + r.GetString(2), + r.GetString(3), + r.GetString(4), + r.GetDateTime(5), + r.GetString(6) + ); + + public async Task> ListPinnedCommitsAsync(Guid opcId, CancellationToken ct = default) + { + await using var cmd = db.CreateCommand( + "SELECT opc_id, hash, short_hash, subject, author, pinned_at, pinned_by FROM opc_pinned_commit WHERE opc_id = $1 ORDER BY pinned_at DESC"); + cmd.Parameters.AddWithValue(opcId); + await using var r = await cmd.ExecuteReaderAsync(ct); + var list = new List(); + while (await r.ReadAsync(ct)) list.Add(ReadPinnedCommit(r)); + return list; + } + + public async Task PinCommitAsync( + Guid opcId, string hash, string shortHash, string subject, string author, string pinnedBy, + CancellationToken ct = default) + { + // Verify the OPC exists + await using var existsCmd = db.CreateCommand("SELECT 1 FROM opc WHERE id = $1"); + existsCmd.Parameters.AddWithValue(opcId); + var exists = await existsCmd.ExecuteScalarAsync(ct); + if (exists is null) return null; + + var sql = """ + INSERT INTO opc_pinned_commit (opc_id, hash, short_hash, subject, author, pinned_by) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (opc_id, hash) DO UPDATE SET + short_hash = EXCLUDED.short_hash, + subject = EXCLUDED.subject, + author = EXCLUDED.author, + pinned_by = EXCLUDED.pinned_by, + pinned_at = NOW() + RETURNING opc_id, hash, short_hash, subject, author, pinned_at, pinned_by + """; + await using var cmd = db.CreateCommand(sql); + cmd.Parameters.AddWithValue(opcId); + cmd.Parameters.AddWithValue(hash); + cmd.Parameters.AddWithValue(shortHash); + cmd.Parameters.AddWithValue(subject); + cmd.Parameters.AddWithValue(author); + cmd.Parameters.AddWithValue(pinnedBy); + await using var r = await cmd.ExecuteReaderAsync(ct); + return await r.ReadAsync(ct) ? ReadPinnedCommit(r) : null; + } + + public async Task UnpinCommitAsync(Guid opcId, string hash, CancellationToken ct = default) + { + await using var cmd = db.CreateCommand( + "DELETE FROM opc_pinned_commit WHERE opc_id = $1 AND hash = $2"); + cmd.Parameters.AddWithValue(opcId); + cmd.Parameters.AddWithValue(hash); + return await cmd.ExecuteNonQueryAsync(ct) > 0; + } +} diff --git a/ControlPlane.Api/Services/ProjectBuildService.cs b/ControlPlane.Api/Services/ProjectBuildService.cs new file mode 100644 index 0000000..34449ac --- /dev/null +++ b/ControlPlane.Api/Services/ProjectBuildService.cs @@ -0,0 +1,127 @@ +using ControlPlane.Core.Models; +using ControlPlane.Core.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using System.Diagnostics; + +namespace ControlPlane.Api.Services; + +/// +/// Runs dotnet build or npm run build for individual projects in the repo. +/// Used by the Build Monitor tab in the control plane UI. +/// +public class ProjectBuildService( + IConfiguration config, + BuildHistoryService history, + ILogger logger) +{ + public string RepoRoot => config["Docker:RepoRoot"] ?? string.Empty; + + /// Known projects in the solution, returned to the UI for the build monitor grid. + public IReadOnlyList GetProjects() + { + if (string.IsNullOrWhiteSpace(RepoRoot)) return []; + + return + [ + new("Clarity.Server", BuildKind.DotnetProject, "Clarity.Server/Clarity.Server.csproj"), + new("Clarity.ServiceDefaults", BuildKind.DotnetProject, "Clarity.ServiceDefaults/Clarity.ServiceDefaults.csproj"), + new("frontend (Clarity.Server)", BuildKind.NpmProject, "frontend"), + ]; + } + + /// + /// Builds a single project and streams output to . + /// + public async Task BuildProjectAsync( + string projectName, + Action onLine, + CancellationToken ct) + { + var projects = GetProjects(); + var def = projects.FirstOrDefault(p => p.Name == projectName); + if (def is null) + { + var err = new BuildRecord { Kind = BuildKind.DotnetProject, Target = projectName, Status = BuildStatus.Failed }; + err.Log.Add($"Unknown project: {projectName}"); + return err; + } + + var record = await history.CreateBuildAsync(def.Kind, def.RelativePath); + record.Log.Add($"▶ Building {def.Name} [{def.Kind}]"); + record.Log.Add($" Path: {def.RelativePath}"); + record.Log.Add("──────────────────────────────────────"); + onLine($"▶ Building {def.Name}"); + + try + { + var (exe, args, workDir) = def.Kind == BuildKind.NpmProject + ? BuildNpmCommand(def.RelativePath) + : BuildDotnetCommand(def.RelativePath); + + var psi = new ProcessStartInfo(exe, args) + { + WorkingDirectory = workDir, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + using var proc = new Process { StartInfo = psi, EnableRaisingEvents = true }; + + void HandleLine(string? line) + { + if (line is null) return; + record.Log.Add(line); + onLine(line); + // Non-blocking fire-and-forget flush + _ = history.AppendBuildLogAsync(record, line); + } + + proc.OutputDataReceived += (_, e) => HandleLine(e.Data); + proc.ErrorDataReceived += (_, e) => HandleLine(e.Data); + + proc.Start(); + proc.BeginOutputReadLine(); + proc.BeginErrorReadLine(); + + await proc.WaitForExitAsync(ct); + + var status = proc.ExitCode == 0 ? BuildStatus.Succeeded : BuildStatus.Failed; + var summary = proc.ExitCode == 0 ? "✔ Build succeeded." : $"✖ Build failed (exit {proc.ExitCode})."; + onLine("──────────────────────────────────────"); + onLine(summary); + record.Log.Add(summary); + + await history.CompleteBuildAsync(record, status); + logger.LogInformation("Project build [{Name}] {Status}", def.Name, status); + return record; + } + catch (Exception ex) + { + onLine($"✖ Exception: {ex.Message}"); + record.Log.Add($"Exception: {ex.Message}"); + await history.CompleteBuildAsync(record, BuildStatus.Failed); + logger.LogError(ex, "Project build [{Name}] threw.", def.Name); + return record; + } + } + + private (string exe, string args, string workDir) BuildDotnetCommand(string relativePath) + { + var fullPath = Path.Combine(RepoRoot, relativePath.Replace('/', Path.DirectorySeparatorChar)); + return ("dotnet", $"build \"{fullPath}\" --configuration Release --nologo", RepoRoot); + } + + private (string exe, string args, string workDir) BuildNpmCommand(string relativePath) + { + var workDir = Path.Combine(RepoRoot, relativePath.Replace('/', Path.DirectorySeparatorChar)); + // npm on Windows needs cmd /c + return (OperatingSystem.IsWindows() ? "cmd" : "sh", + OperatingSystem.IsWindows() ? "/c npm run build" : "-c \"npm run build\"", + workDir); + } +} + +public record ProjectDefinition(string Name, BuildKind Kind, string RelativePath); diff --git a/ControlPlane.Api/Services/PromotionService.cs b/ControlPlane.Api/Services/PromotionService.cs new file mode 100644 index 0000000..e790f4c --- /dev/null +++ b/ControlPlane.Api/Services/PromotionService.cs @@ -0,0 +1,283 @@ +using ControlPlane.Core.Models; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using System.Diagnostics; +using System.Text.Json; + +namespace ControlPlane.Api.Services; + +/// +/// Handles all git operations for the promotion workflow: +/// branch status, diff summaries, merge + push, and promotion history persistence. +/// All git commands run against the repo root configured in Docker:RepoRoot. +/// +public class PromotionService(IConfiguration config, ILogger logger) +{ + // The ordered promotion ladder — each step is a valid promotion. + public static readonly string[] Ladder = ["develop", "staging", "uat", "master"]; + + private string RepoRoot => config["Docker:RepoRoot"] ?? string.Empty; + + private static readonly SemaphoreSlim _lock = new(1, 1); + private static readonly JsonSerializerOptions JsonOpts = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }, + }; + + // ── Branch status ──────────────────────────────────────────────────────── + + /// + /// Returns status for all ladder branches: last commit info + ahead/behind counts vs next branch. + /// + public async Task> GetLadderStatusAsync(CancellationToken ct = default) + { + var result = new List(); + + // Fetch to get up-to-date remote state, but don't fail if we're offline + await RunGitAsync("fetch --all --quiet", ct, swallowErrors: true); + + foreach (var branch in Ladder) + { + var exists = await BranchExistsAsync(branch, ct); + if (!exists) + { + result.Add(new BranchStatus(branch, false, null, null, 0, 0, [])); + continue; + } + + // Last commit on this branch + var lastCommit = await GitOutputAsync($"log {branch} -1 --format=%h|%an|%ad|%s --date=short", ct); + string? shortHash = null, author = null, date = null, subject = null; + if (!string.IsNullOrWhiteSpace(lastCommit)) + { + var p = lastCommit.Trim().Split('|', 4); + if (p.Length == 4) (shortHash, author, date, subject) = (p[0], p[1], p[2], p[3]); + } + + // Ahead/behind vs the NEXT branch in the ladder + int ahead = 0, behind = 0; + var nextIdx = Array.IndexOf(Ladder, branch) + 1; + if (nextIdx < Ladder.Length) + { + var next = Ladder[nextIdx]; + if (await BranchExistsAsync(next, ct)) + { + var counts = await GitOutputAsync($"rev-list --left-right --count {next}...{branch}", ct); + if (!string.IsNullOrWhiteSpace(counts)) + { + var parts = counts.Trim().Split('\t'); + if (parts.Length == 2) + { + int.TryParse(parts[0], out behind); + int.TryParse(parts[1], out ahead); + } + } + } + } + + // Unreleased commit summaries (commits in this branch not yet in next) + string[] unreleasedLines = []; + if (ahead > 0 && nextIdx < Ladder.Length && await BranchExistsAsync(Ladder[nextIdx], ct)) + { + var log = await GitOutputAsync($"log {Ladder[nextIdx]}..{branch} --oneline --no-decorate", ct); + unreleasedLines = log.Split('\n', StringSplitOptions.RemoveEmptyEntries); + } + + result.Add(new BranchStatus(branch, true, shortHash, $"{author} · {date} · {subject}", + ahead, behind, unreleasedLines)); + } + + return result; + } + + // ── Promotion ──────────────────────────────────────────────────────────── + + /// + /// Merges into with a no-fast-forward merge commit, + /// then pushes. Streams progress lines to . + /// + public async Task PromoteAsync( + string from, + string to, + string requestedBy, + string? note, + Action onLine, + CancellationToken ct) + { + if (!await _lock.WaitAsync(TimeSpan.Zero, ct)) + { + var busy = new PromotionRequest { FromBranch = from, ToBranch = to, Status = PromotionStatus.Failed }; + busy.Log.Add("⚠️ Another promotion is already in progress."); + return busy; + } + + var req = new PromotionRequest + { + FromBranch = from, + ToBranch = to, + RequestedBy = requestedBy, + Note = note, + Status = PromotionStatus.Running, + }; + + void Log(string line) { req.Log.Add(line); onLine(line); } + + try + { + Log($"▶ Promoting {from} → {to}"); + if (!string.IsNullOrWhiteSpace(note)) Log($" Note: {note}"); + Log("──────────────────────────────────────"); + + // 1. Fetch latest + Log(" git fetch --all"); + await RunGitAsync("fetch --all --quiet", ct); + + // 2. Checkout target branch + Log($" git checkout {to}"); + await RunGitAsync($"checkout {to}", ct); + + // 3. Pull target to latest + Log($" git pull origin {to}"); + await RunGitAsync($"pull origin {to} --quiet", ct); + + // 4. Count commits being promoted + var logOutput = await GitOutputAsync($"log {to}..{from} --oneline --no-decorate", ct); + var commitLines = logOutput.Split('\n', StringSplitOptions.RemoveEmptyEntries); + req.CommitCount = commitLines.Length; + req.CommitLines = commitLines; + Log($" Merging {commitLines.Length} commit(s) from {from}:"); + foreach (var cl in commitLines) Log($" {cl}"); + + // 5. Merge with --no-ff for a clean promotion commit + var mergeMsg = $"chore: promote {from} → {to}" + (note != null ? $" — {note}" : ""); + Log($" git merge --no-ff {from}"); + await RunGitAsync($"merge --no-ff {from} -m \"{mergeMsg}\"", ct); + + // 6. Push + Log($" git push origin {to}"); + await RunGitAsync($"push origin {to}", ct); + + // 7. Return to develop so the working tree stays clean + await RunGitAsync("checkout develop", ct, swallowErrors: true); + + Log("──────────────────────────────────────"); + Log($"✔ {from} → {to} promoted successfully at {DateTimeOffset.UtcNow:u}"); + req.Status = PromotionStatus.Succeeded; + req.CompletedAt = DateTimeOffset.UtcNow; + } + catch (Exception ex) + { + Log($"✖ Promotion failed: {ex.Message}"); + req.Status = PromotionStatus.Failed; + req.CompletedAt = DateTimeOffset.UtcNow; + + // Try to abort any broken merge state + await RunGitAsync("merge --abort", ct, swallowErrors: true); + await RunGitAsync("checkout develop", ct, swallowErrors: true); + + logger.LogError(ex, "Promotion {From}→{To} failed", from, to); + } + finally + { + await SaveAsync(req); + _lock.Release(); + } + + return req; + } + + // ── History persistence ────────────────────────────────────────────────── + + private string HistoryPath + { + get + { + var folder = config["ClientAssets__Folder"] ?? config["ClientAssets:Folder"] + ?? Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "ClientAssets")); + Directory.CreateDirectory(folder); + return Path.Combine(folder, "promotions.json"); + } + } + + private static readonly SemaphoreSlim _fileLock = new(1, 1); + + private async Task SaveAsync(PromotionRequest req) + { + await _fileLock.WaitAsync(); + try + { + var all = LoadHistory(); + var idx = all.FindIndex(r => r.Id == req.Id); + if (idx >= 0) all[idx] = req; else all.Insert(0, req); + if (all.Count > 100) all = all[..100]; + await File.WriteAllTextAsync(HistoryPath, JsonSerializer.Serialize(all, JsonOpts)); + } + finally { _fileLock.Release(); } + } + + public async Task> GetHistoryAsync() + { + await _fileLock.WaitAsync(); + try { return LoadHistory(); } + finally { _fileLock.Release(); } + } + + private List LoadHistory() + { + if (!File.Exists(HistoryPath)) return []; + try { return JsonSerializer.Deserialize>(File.ReadAllText(HistoryPath), JsonOpts) ?? []; } + catch { return []; } + } + + // ── Git helpers ────────────────────────────────────────────────────────── + + private async Task BranchExistsAsync(string branch, CancellationToken ct) + { + var output = await GitOutputAsync($"branch --list {branch}", ct); + return !string.IsNullOrWhiteSpace(output); + } + + private async Task GitOutputAsync(string args, CancellationToken ct) + { + var psi = MakePsi(args); + using var proc = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start git"); + var output = await proc.StandardOutput.ReadToEndAsync(ct); + await proc.WaitForExitAsync(ct); + return output; + } + + private async Task RunGitAsync(string args, CancellationToken ct, bool swallowErrors = false) + { + var psi = MakePsi(args); + using var proc = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start git"); + var stderr = await proc.StandardError.ReadToEndAsync(ct); + await proc.WaitForExitAsync(ct); + + if (!swallowErrors && proc.ExitCode != 0) + throw new InvalidOperationException($"git {args} exited {proc.ExitCode}: {stderr.Trim()}"); + + logger.LogDebug("git {Args} → exit {Code}", args, proc.ExitCode); + } + + private ProcessStartInfo MakePsi(string args) => new("git", args) + { + WorkingDirectory = RepoRoot, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; +} + +/// Current status of a single branch in the promotion ladder. +public record BranchStatus( + string Branch, + bool Exists, + string? ShortHash, + string? LastCommitSummary, + int AheadOfNext, // commits this branch has that the next doesn't + int BehindNext, // commits next has that this branch doesn't (shouldn't happen in clean flow) + string[] UnreleasedLines // oneline log of the ahead commits +); diff --git a/ControlPlane.Api/Services/ReleaseService.cs b/ControlPlane.Api/Services/ReleaseService.cs new file mode 100644 index 0000000..93a5314 --- /dev/null +++ b/ControlPlane.Api/Services/ReleaseService.cs @@ -0,0 +1,191 @@ +using ControlPlane.Core.Models; +using ControlPlane.Core.Services; +using Docker.DotNet; +using Docker.DotNet.Models; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace ControlPlane.Api.Services; + +/// +/// Orchestrates a release: finds all managed tenant containers matching the target +/// environment, removes each one, and restarts it from the latest clarity-server image. +/// Does NOT re-run Keycloak/Vault/DB steps — the container env vars are preserved from +/// the original provisioning and re-injected from the XML registry. +/// +public class ReleaseService( + IConfiguration config, + TenantRegistryService registry, + BuildHistoryService history, + ILogger logger) +{ + private static readonly SemaphoreSlim _lock = new(1, 1); + + public bool IsReleasing => _lock.CurrentCount == 0; + public string ImageName => config["Docker:ClarityServerImage"] ?? "clarity-server:latest"; + + /// + /// Runs a release for the given environment and streams status lines to . + /// + public async Task ReleaseAsync( + string targetEnv, + Action onLine, + CancellationToken ct) + { + if (!await _lock.WaitAsync(TimeSpan.Zero, ct)) + { + onLine("⚠️ A release is already in progress."); + var blocked = new ReleaseRecord + { + Environment = targetEnv, + ImageName = ImageName, + Status = ReleaseStatus.Failed, + FinishedAt = DateTimeOffset.UtcNow, + }; + blocked.Tenants.Add(new TenantReleaseResult + { + Subdomain = "*", ContainerName = "*", + Success = false, Error = "Release already in progress.", + }); + return blocked; + } + + var record = await history.CreateReleaseAsync(targetEnv, ImageName); + + try + { + onLine($"▶ Release to [{targetEnv}] using {ImageName}"); + onLine("──────────────────────────────────────"); + + var socketUri = config["Docker:Socket"] ?? "npipe://./pipe/docker_engine"; + using var docker = new DockerClientConfiguration(new Uri(socketUri)).CreateClient(); + + // Find all managed tenant containers for this environment + var filterEnv = targetEnv == "all" + ? new Dictionary> + { + ["label"] = new Dictionary { ["clarity.managed=true"] = true }, + } + : new Dictionary> + { + ["label"] = new Dictionary + { + ["clarity.managed=true"] = true, + [$"clarity.env={targetEnv}"] = true, + }, + }; + + var containers = await docker.Containers.ListContainersAsync( + new ContainersListParameters { All = true, Filters = filterEnv }, ct); + + if (containers.Count == 0) + { + onLine($" No managed containers found for environment [{targetEnv}]."); + record.Status = ReleaseStatus.Succeeded; + record.FinishedAt = DateTimeOffset.UtcNow; + await history.UpdateReleaseAsync(record); + return record; + } + + onLine($" Found {containers.Count} container(s) to redeploy."); + onLine(""); + + int succeeded = 0, failed = 0; + + foreach (var container in containers) + { + var name = container.Names.FirstOrDefault()?.TrimStart('/') ?? container.ID[..12]; + var tenantResult = new TenantReleaseResult + { + ContainerName = name, + Subdomain = container.Labels.TryGetValue("clarity.subdomain", out var sub) ? sub : name, + }; + record.Tenants.Add(tenantResult); + + try + { + onLine($" → {name}"); + + // Read env vars from existing container — preserve Keycloak/Vault/DB config + var inspect = await docker.Containers.InspectContainerAsync(container.ID, ct); + var env = inspect.Config.Env; + var labels = inspect.Config.Labels; + var network = inspect.HostConfig.NetworkMode; + + // Stop and remove old container + onLine($" Stopping..."); + try + { + await docker.Containers.StopContainerAsync( + container.ID, new ContainerStopParameters { WaitBeforeKillSeconds = 8 }, ct); + await docker.Containers.RemoveContainerAsync( + container.ID, new ContainerRemoveParameters { Force = true }, ct); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Stop/remove failed for {Name}, forcing removal.", name); + await docker.Containers.RemoveContainerAsync( + container.ID, new ContainerRemoveParameters { Force = true }, ct); + } + + // Create fresh container from latest image, preserving all env vars and labels + onLine($" Creating from {ImageName}..."); + var created = await docker.Containers.CreateContainerAsync( + new CreateContainerParameters + { + Name = name, + Image = ImageName, + Env = env, + Labels = labels, + HostConfig = new HostConfig + { + NetworkMode = network, + RestartPolicy = new RestartPolicy { Name = RestartPolicyKind.UnlessStopped }, + }, + }, ct); + + // Start it + var started = await docker.Containers.StartContainerAsync(created.ID, null, ct); + if (!started) throw new InvalidOperationException("Docker returned false for start."); + + onLine($" ✔ {name} redeployed."); + tenantResult.Success = true; + succeeded++; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to redeploy {Name}.", name); + onLine($" ✖ {name} failed: {ex.Message}"); + tenantResult.Success = false; + tenantResult.Error = ex.Message; + failed++; + } + + await history.UpdateReleaseAsync(record); + } + + record.Status = failed == 0 ? ReleaseStatus.Succeeded + : succeeded == 0 ? ReleaseStatus.Failed + : ReleaseStatus.PartialFailure; + record.FinishedAt = DateTimeOffset.UtcNow; + + onLine(""); + onLine("──────────────────────────────────────"); + onLine($"{(record.Status == ReleaseStatus.Succeeded ? "✔" : "⚠")} Release complete — {succeeded} succeeded, {failed} failed."); + } + catch (Exception ex) + { + logger.LogError(ex, "Release to [{Env}] threw an unhandled exception.", targetEnv); + record.Status = ReleaseStatus.Failed; + record.FinishedAt = DateTimeOffset.UtcNow; + onLine($"✖ Release aborted: {ex.Message}"); + } + finally + { + await history.UpdateReleaseAsync(record); + _lock.Release(); + } + + return record; + } +} diff --git a/ControlPlane.Api/Services/SseEventBus.cs b/ControlPlane.Api/Services/SseEventBus.cs new file mode 100644 index 0000000..23fa946 --- /dev/null +++ b/ControlPlane.Api/Services/SseEventBus.cs @@ -0,0 +1,38 @@ +using ControlPlane.Core.Messages; +using System.Collections.Concurrent; +using System.Threading.Channels; + +namespace ControlPlane.Api.Services; + +/// +/// Thin in-process pub/sub for SSE. MassTransit consumer writes here; +/// the SSE endpoint reads and streams to the browser. +/// +public sealed class SseEventBus +{ + private readonly ConcurrentDictionary>> _subs = new(); + + public void Publish(ProvisioningProgressEvent evt) + { + if (!_subs.TryGetValue(evt.JobId, out var channels)) return; + lock (channels) + foreach (var ch in channels) + ch.Writer.TryWrite(evt); + } + + public Channel Subscribe(Guid jobId) + { + var ch = Channel.CreateUnbounded(); + _subs.GetOrAdd(jobId, _ => []).Add(ch); + return ch; + } + + public void Unsubscribe(Guid jobId, Channel channel) + { + if (_subs.TryGetValue(jobId, out var channels)) + { + lock (channels) channels.Remove(channel); + channel.Writer.TryComplete(); + } + } +} diff --git a/ControlPlane.Api/Services/TarHelper.cs b/ControlPlane.Api/Services/TarHelper.cs new file mode 100644 index 0000000..4639ac4 --- /dev/null +++ b/ControlPlane.Api/Services/TarHelper.cs @@ -0,0 +1,84 @@ +using System.Formats.Tar; +using System.IO.Compression; + +namespace ControlPlane.Api.Services; + +/// +/// Creates a gzipped tar stream from a directory, respecting .dockerignore rules. +/// Used to supply the Docker build context to the Docker SDK. +/// +internal static class TarHelper +{ + private static readonly string[] DefaultIgnore = + [ + ".git", ".vs", ".vscode", "node_modules", "bin", "obj", + "VaultData", "*.user", "*.suo", + ]; + + public static void Pack(string root, Stream destination) + { + var ignorePatterns = LoadDockerIgnore(root); + + using var gz = new GZipStream(destination, CompressionLevel.Fastest, leaveOpen: true); + using var tar = new TarWriter(gz, TarEntryFormat.Gnu, leaveOpen: false); + + foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)) + { + var relative = Path.GetRelativePath(root, file).Replace('\\', '/'); + + if (ShouldIgnore(relative, ignorePatterns)) + continue; + + var entry = new GnuTarEntry(TarEntryType.RegularFile, relative) + { + DataStream = File.OpenRead(file), + }; + tar.WriteEntry(entry); + } + } + + private static List LoadDockerIgnore(string root) + { + var path = Path.Combine(root, ".dockerignore"); + var patterns = new List(DefaultIgnore); + if (!File.Exists(path)) return patterns; + + foreach (var line in File.ReadAllLines(path)) + { + var trimmed = line.Trim(); + if (!string.IsNullOrEmpty(trimmed) && !trimmed.StartsWith('#')) + patterns.Add(trimmed); + } + return patterns; + } + + private static bool ShouldIgnore(string relativePath, List patterns) + { + var segments = relativePath.Split('/'); + + foreach (var pattern in patterns) + { + var p = pattern.TrimStart('/').TrimEnd('/'); + + // Glob suffix match (e.g. *.user) + if (p.StartsWith('*')) + { + if (relativePath.EndsWith(p[1..], StringComparison.OrdinalIgnoreCase)) + return true; + continue; + } + + // Exact full-path match or root-anchored prefix (e.g. .git, .vs) + if (relativePath.Equals(p, StringComparison.OrdinalIgnoreCase)) + return true; + if (relativePath.StartsWith(p + "/", StringComparison.OrdinalIgnoreCase)) + return true; + + // Match any path segment so that nested bin/, obj/, node_modules/ etc. are caught + // regardless of which project subdirectory they live in. + if (segments.Any(seg => seg.Equals(p, StringComparison.OrdinalIgnoreCase))) + return true; + } + return false; + } +} diff --git a/ControlPlane.Api/appsettings.json b/ControlPlane.Api/appsettings.json new file mode 100644 index 0000000..588532d --- /dev/null +++ b/ControlPlane.Api/appsettings.json @@ -0,0 +1,21 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "OpenRouter": { + "ApiKey": "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" + }, + "Git": { + "RepoRoot": "C:\\Users\\amadzarak\\source\\repos\\Clarity" + }, + "Gitea": { + "BaseUrl": "https://opc.clarity.test", + "Owner": "Clarity", + "Repo": "Clarity", + "Token": "2ef325f682915c5959bf6a0dc73cec7034fcd2a2" + } +} \ No newline at end of file diff --git a/ControlPlane.AppHost/AppHost.cs b/ControlPlane.AppHost/AppHost.cs new file mode 100644 index 0000000..655908a --- /dev/null +++ b/ControlPlane.AppHost/AppHost.cs @@ -0,0 +1,160 @@ +using Scalar.Aspire; + +var builder = DistributedApplication.CreateBuilder(args); + +// ───────────────────────────────────────────────────────────────────────────── +// Platform infrastructure (Keycloak, Vault, MinIO, Nginx, Dnsmasq) is +// managed by infra/docker-compose.yml — NOT Aspire. +// Run `docker compose up -d` from the infra/ folder before starting this host. +// +// Fixed dev URLs (hardcoded to match infra/docker-compose.yml): +// Keycloak → http://localhost:8080 +// Vault → http://localhost:8200 +// MinIO → http://localhost:9000 +// +// ControlPlane owns: opc-postgres (opcdb + giteadb), RabbitMQ, Gitea. +// ───────────────────────────────────────────────────────────────────────────── + +// Shared paths +var clientAssetsPath = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", "ClientAssets")); +var nginxConfDPath = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", "infra", "nginx", "conf.d")); +var vaultKeysFile = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", "infra", "vault", "data", "init.json")); + +#region CONTROLPLANE POSTGRES +// ControlPlane owns this — isolated from platform infra postgres. +// Override via: dotnet user-secrets set "Parameters:cp-postgres-password" "yourpassword" +var cpPostgresPassword = builder.AddParameter("cp-postgres-password", "controlplane-dev", secret: true); +var cpPostgres = builder.AddPostgres("opc-postgres", password: cpPostgresPassword) + .WithLifetime(ContainerLifetime.Persistent) + .WithDataVolume("opc-postgres-data") + .WithHostPort(5433) + .WithPgAdmin(); + +var controlPlaneDb = cpPostgres.AddDatabase("opcdb"); +var giteaDb = cpPostgres.AddDatabase("giteadb"); +#endregion + +#region GITEA +// Gitea is ControlPlane's code management component — owns its own DB on opc-postgres. +var gitea = builder.AddContainer("gitea", "gitea/gitea", "latest") + .WithHttpEndpoint(port: 3000, targetPort: 3000, name: "http") + .WithEndpoint(port: 2222, targetPort: 22, name: "ssh") + .WithVolume("clarity-gitea-data", "/data") + .WithEnvironment("GITEA__database__DB_TYPE", "postgres") + .WithEnvironment("GITEA__database__HOST", "host.docker.internal:5433") + .WithEnvironment("GITEA__database__NAME", "giteadb") + .WithEnvironment("GITEA__database__USER", "postgres") + .WithEnvironment("GITEA__database__PASSWD", "controlplane-dev") + .WithEnvironment("GITEA__server__DOMAIN", "opc.clarity.test") + .WithEnvironment("GITEA__server__ROOT_URL", "http://opc.clarity.test") + .WithEnvironment("GITEA__server__SSH_DOMAIN", "opc.clarity.test") + .WithEnvironment("GITEA__server__SSH_PORT", "2222") + .WithEnvironment("GITEA__service__DISABLE_REGISTRATION", "true") + .WaitFor(giteaDb) + .WithLifetime(ContainerLifetime.Persistent); +#endregion + +#region RABBITMQ +var rabbitPassword = builder.AddParameter("rabbitmq-password", "clarity-rabbit", secret: true); +var rabbit = builder.AddRabbitMQ("rabbitmq", password: rabbitPassword) + .WithLifetime(ContainerLifetime.Persistent) + .WithManagementPlugin(); +#endregion + +#region CONTROLPLANE API +var api = builder.AddProject("controlplane-api") + .WithReference(rabbit) + .WaitFor(rabbit) + .WithReference(controlPlaneDb) + .WaitFor(controlPlaneDb) + .WithEnvironment("Gitea__BaseUrl", gitea.GetEndpoint("http")) + .WithEnvironment("ClientAssets__Folder", clientAssetsPath) + .WithEnvironment("Docker__RepoRoot", builder.AppHostDirectory.Replace("ControlPlane.AppHost", "").TrimEnd('\\', '/')) + .WithExternalHttpEndpoints(); +#endregion + +#region PROVISIONING WORKER +builder.AddProject("controlplane-worker") + .WithReference(rabbit) + .WaitFor(rabbit) + // Vault — fixed dev address from infra/docker-compose.yml + .WithEnvironment("Vault__Address", "http://localhost:8200") + .WithEnvironment("Vault__ContainerAddress", "http://vault:8200") + .WithEnvironment("Vault__KeysFile", vaultKeysFile) + // Keycloak — fixed dev address from infra/docker-compose.yml + .WithEnvironment("Keycloak__AuthServerUrl", "http://localhost:8080") + .WithEnvironment("Keycloak__ContainerUrl", "https://keycloak.clarity.test") + .WithEnvironment("Keycloak__Realm", "master") + .WithEnvironment("Keycloak__Resource", "admin-cli") + .WithEnvironment("Keycloak__AdminUser", "admin") + .WithEnvironment("Keycloak__AdminPassword", "Admin1234!") + // Gateway + .WithEnvironment("Gateway__TenantBaseUrl", "https://{subdomain}.clarity.test") + // ClarityInfraOptions + .WithEnvironment("Clarity__Domain", "clarity.test") + .WithEnvironment("Clarity__Network", "clarity-net") + .WithEnvironment("Clarity__KeycloakPublicUrl", "https://keycloak.clarity.test") + .WithEnvironment("Clarity__KeycloakInternalUrl", "http://keycloak:8080") + .WithEnvironment("Clarity__VaultInternalUrl", "http://vault:8200") + .WithEnvironment("Clarity__NginxCertPath", "/etc/nginx/certs/clarity.test.crt") + .WithEnvironment("Clarity__NginxCertKeyPath", "/etc/nginx/certs/clarity.test.key") + // Nginx conf.d — points to infra/nginx/conf.d so platform nginx picks up tenant configs + .WithEnvironment("Nginx__ConfDPath", nginxConfDPath) + .WithEnvironment("ClientAssets__Folder", clientAssetsPath) + // Platform Postgres connection string for tenant database provisioning (infra/docker-compose.yml) + .WithEnvironment("ConnectionStrings__platformdb", + "Host=localhost;Port=5432;Username=postgres;Password=postgres") + .WithReference(controlPlaneDb) + .WaitFor(controlPlaneDb); +#endregion + +#region CONTROLPLANE UI +builder.AddViteApp("controlplane-ui", "../clarity.controlplane") + .WithReference((IResourceBuilder)api) + .WaitFor(api); +#endregion + +#region CLARITY-NET — connect RabbitMQ to platform network +// Ensures RabbitMQ (the one container Aspire owns) is reachable from tenant containers +// on clarity-net. All other platform containers are already on clarity-net via docker-compose. +builder.Eventing.Subscribe(async (@event, ct) => +{ + const string network = "clarity-net"; + + await Task.Delay(TimeSpan.FromSeconds(4), ct); + + var (inspectCode, _) = await DockerOutputAsync($"network inspect {network}", ct); + if (inspectCode != 0) + await DockerOutputAsync($"network create {network}", ct); + + var (idCode, idOut) = await DockerOutputAsync("ps --filter name=rabbitmq --format {{.ID}}", ct); + if (idCode == 0 && !string.IsNullOrWhiteSpace(idOut)) + { + var containerId = idOut.Trim().Split('\n')[0].Trim(); + await DockerOutputAsync($"network connect --alias rabbitmq {network} {containerId}", ct); + } +}); +#endregion + +#region SCALAR API DOCS +var scalar = builder.AddScalarApiReference(); +scalar.WithApiReference(api); +#endregion + +builder.Build().Run(); + +static async Task<(int ExitCode, string Output)> DockerOutputAsync(string args, CancellationToken ct) +{ + var psi = new System.Diagnostics.ProcessStartInfo("docker", args) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + using var proc = System.Diagnostics.Process.Start(psi)!; + var output = await proc.StandardOutput.ReadToEndAsync(ct); + await proc.WaitForExitAsync(ct); + return (proc.ExitCode, output); +} + + diff --git a/ControlPlane.AppHost/ControlPlane.AppHost.csproj b/ControlPlane.AppHost/ControlPlane.AppHost.csproj new file mode 100644 index 0000000..ccb5065 --- /dev/null +++ b/ControlPlane.AppHost/ControlPlane.AppHost.csproj @@ -0,0 +1,38 @@ + + + + Exe + net10.0 + enable + enable + controlplane-apphost-$(MSBuildProjectName) + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/ControlPlane.AppHost/DnsmasqConfig/dnsmasq.conf b/ControlPlane.AppHost/DnsmasqConfig/dnsmasq.conf new file mode 100644 index 0000000..0a42dfd --- /dev/null +++ b/ControlPlane.AppHost/DnsmasqConfig/dnsmasq.conf @@ -0,0 +1,17 @@ +# Resolve all *.clarity.local subdomains to the loopback address. +# nginx (bound to port 80 on the host) then routes by subdomain to the correct tenant container. +address=/.clarity.test/127.0.0.1 + +# Don't read /etc/resolv.conf or /etc/hosts from the container — we are the resolver +no-resolv +no-hosts + +# Forward everything that isn't clarity.local to Cloudflare DNS +server=1.1.1.1 +server=8.8.8.8 + +# Listen on all interfaces inside the container +listen-address=0.0.0.0 + +# Log queries — useful during initial setup, can be removed later +log-queries diff --git a/ControlPlane.AppHost/KeycloakConfig/realm-export.json b/ControlPlane.AppHost/KeycloakConfig/realm-export.json new file mode 100644 index 0000000..acb217f --- /dev/null +++ b/ControlPlane.AppHost/KeycloakConfig/realm-export.json @@ -0,0 +1,2753 @@ +{ + "id": "8c485c8e-7e4a-4f8b-8e89-8dc74b112637", + "realm": "clarity", + "notBefore": 0, + "defaultSignatureAlgorithm": "RS256", + "revokeRefreshToken": false, + "refreshTokenMaxReuse": 0, + "accessTokenLifespan": 300, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "ssoSessionIdleTimeoutRememberMe": 0, + "ssoSessionMaxLifespanRememberMe": 0, + "offlineSessionIdleTimeout": 2592000, + "offlineSessionMaxLifespanEnabled": false, + "offlineSessionMaxLifespan": 5184000, + "clientSessionIdleTimeout": 0, + "clientSessionMaxLifespan": 0, + "clientOfflineSessionIdleTimeout": 0, + "clientOfflineSessionMaxLifespan": 0, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "oauth2DeviceCodeLifespan": 600, + "oauth2DevicePollingInterval": 5, + "enabled": true, + "sslRequired": "external", + "registrationAllowed": true, + "registrationEmailAsUsername": false, + "rememberMe": true, + "verifyEmail": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": true, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "permanentLockout": false, + "maxTemporaryLockouts": 0, + "bruteForceStrategy": "MULTIPLE", + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "roles": { + "realm": [ + { + "id": "1bddf68c-e62e-48ed-81d6-85f886fe4806", + "name": "uma_authorization", + "description": "${role_uma_authorization}", + "composite": false, + "clientRole": false, + "containerId": "8c485c8e-7e4a-4f8b-8e89-8dc74b112637", + "attributes": {} + }, + { + "id": "a5c10a52-665a-407c-a515-81f01d37253e", + "name": "offline_access", + "description": "${role_offline-access}", + "composite": false, + "clientRole": false, + "containerId": "8c485c8e-7e4a-4f8b-8e89-8dc74b112637", + "attributes": {} + }, + { + "id": "6a82b3ab-3414-4887-b604-58a1932f926d", + "name": "default-roles-clarity", + "description": "${role_default-roles}", + "composite": true, + "composites": { + "realm": [ + "offline_access", + "uma_authorization" + ], + "client": { + "account": [ + "view-profile", + "manage-account" + ] + } + }, + "clientRole": false, + "containerId": "8c485c8e-7e4a-4f8b-8e89-8dc74b112637", + "attributes": {} + } + ], + "client": { + "clarity-rest-api": [ + { + "id": "8f219d57-3f56-4ded-8f4a-37704b4768dd", + "name": "uma_protection", + "composite": false, + "clientRole": true, + "containerId": "b000e95a-0942-457a-a5d8-c2ca43538e7e", + "attributes": {} + } + ], + "realm-management": [ + { + "id": "eb3c7e4e-fdda-41d6-94ef-b416249a5ec4", + "name": "manage-users", + "description": "${role_manage-users}", + "composite": false, + "clientRole": true, + "containerId": "cc852506-d301-412c-9715-7a78dfe9e7e1", + "attributes": {} + }, + { + "id": "c9d2c02d-9d37-491a-b9d9-cacc3e6baab0", + "name": "manage-authorization", + "description": "${role_manage-authorization}", + "composite": false, + "clientRole": true, + "containerId": "cc852506-d301-412c-9715-7a78dfe9e7e1", + "attributes": {} + }, + { + "id": "0918b2fc-d886-4979-b4a4-c5d9b4d7b279", + "name": "manage-identity-providers", + "description": "${role_manage-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "cc852506-d301-412c-9715-7a78dfe9e7e1", + "attributes": {} + }, + { + "id": "9a5c3a93-b19f-4e03-81c3-69f2c2a0104b", + "name": "query-users", + "description": "${role_query-users}", + "composite": false, + "clientRole": true, + "containerId": "cc852506-d301-412c-9715-7a78dfe9e7e1", + "attributes": {} + }, + { + "id": "7073eb99-a7fe-431e-9766-c34983217a1f", + "name": "manage-events", + "description": "${role_manage-events}", + "composite": false, + "clientRole": true, + "containerId": "cc852506-d301-412c-9715-7a78dfe9e7e1", + "attributes": {} + }, + { + "id": "84644fe1-3f26-4a83-bf13-03e161846675", + "name": "view-users", + "description": "${role_view-users}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-users", + "query-groups" + ] + } + }, + "clientRole": true, + "containerId": "cc852506-d301-412c-9715-7a78dfe9e7e1", + "attributes": {} + }, + { + "id": "03f0d98f-8b18-471c-ba47-2aeaaecd4921", + "name": "manage-realm", + "description": "${role_manage-realm}", + "composite": false, + "clientRole": true, + "containerId": "cc852506-d301-412c-9715-7a78dfe9e7e1", + "attributes": {} + }, + { + "id": "d64c6e50-e62f-4867-9b3e-6cee237cf7e8", + "name": "query-groups", + "description": "${role_query-groups}", + "composite": false, + "clientRole": true, + "containerId": "cc852506-d301-412c-9715-7a78dfe9e7e1", + "attributes": {} + }, + { + "id": "10bec3e3-3100-4a25-b0bf-4c68522a96d3", + "name": "impersonation", + "description": "${role_impersonation}", + "composite": false, + "clientRole": true, + "containerId": "cc852506-d301-412c-9715-7a78dfe9e7e1", + "attributes": {} + }, + { + "id": "facc82f1-dac0-4ac5-b98c-414a135c8e07", + "name": "view-realm", + "description": "${role_view-realm}", + "composite": false, + "clientRole": true, + "containerId": "cc852506-d301-412c-9715-7a78dfe9e7e1", + "attributes": {} + }, + { + "id": "211d0741-6948-408d-b051-1d80b68c1129", + "name": "create-client", + "description": "${role_create-client}", + "composite": false, + "clientRole": true, + "containerId": "cc852506-d301-412c-9715-7a78dfe9e7e1", + "attributes": {} + }, + { + "id": "92b417f4-5b7e-443a-854b-24a5a74fac95", + "name": "realm-admin", + "description": "${role_realm-admin}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "manage-users", + "manage-identity-providers", + "query-users", + "manage-authorization", + "manage-events", + "view-users", + "manage-realm", + "query-groups", + "impersonation", + "view-realm", + "view-authorization", + "create-client", + "view-clients", + "view-events", + "query-realms", + "manage-clients", + "query-clients", + "view-identity-providers" + ] + } + }, + "clientRole": true, + "containerId": "cc852506-d301-412c-9715-7a78dfe9e7e1", + "attributes": {} + }, + { + "id": "11f65781-2669-4218-a746-9f0291e9c574", + "name": "view-authorization", + "description": "${role_view-authorization}", + "composite": false, + "clientRole": true, + "containerId": "cc852506-d301-412c-9715-7a78dfe9e7e1", + "attributes": {} + }, + { + "id": "6503446a-fa80-4d86-8648-3b4cd0dcb70e", + "name": "view-clients", + "description": "${role_view-clients}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-clients" + ] + } + }, + "clientRole": true, + "containerId": "cc852506-d301-412c-9715-7a78dfe9e7e1", + "attributes": {} + }, + { + "id": "f3bbad45-f52a-4439-8d69-b0b9afed6054", + "name": "view-events", + "description": "${role_view-events}", + "composite": false, + "clientRole": true, + "containerId": "cc852506-d301-412c-9715-7a78dfe9e7e1", + "attributes": {} + }, + { + "id": "abe22e50-b2a3-4cf8-b64b-c152d089c37a", + "name": "manage-clients", + "description": "${role_manage-clients}", + "composite": false, + "clientRole": true, + "containerId": "cc852506-d301-412c-9715-7a78dfe9e7e1", + "attributes": {} + }, + { + "id": "4a9f686e-1c7d-409e-81c2-d5814ba24a54", + "name": "query-realms", + "description": "${role_query-realms}", + "composite": false, + "clientRole": true, + "containerId": "cc852506-d301-412c-9715-7a78dfe9e7e1", + "attributes": {} + }, + { + "id": "de12dcec-6a35-45eb-b873-c72d9cc03139", + "name": "query-clients", + "description": "${role_query-clients}", + "composite": false, + "clientRole": true, + "containerId": "cc852506-d301-412c-9715-7a78dfe9e7e1", + "attributes": {} + }, + { + "id": "0f3f0e4f-1428-4cb0-a4f0-7256bf80c97c", + "name": "view-identity-providers", + "description": "${role_view-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "cc852506-d301-412c-9715-7a78dfe9e7e1", + "attributes": {} + } + ], + "security-admin-console": [], + "clarity-web-app": [], + "admin-cli": [], + "account-console": [], + "broker": [ + { + "id": "61abaeb6-30de-413e-85ad-05d4f9f2b6e7", + "name": "read-token", + "description": "${role_read-token}", + "composite": false, + "clientRole": true, + "containerId": "189c04d1-0b77-45f4-8f9a-f0f0aa112edc", + "attributes": {} + } + ], + "account": [ + { + "id": "e343bf4a-108e-49f7-b799-8bbf531913ab", + "name": "view-applications", + "description": "${role_view-applications}", + "composite": false, + "clientRole": true, + "containerId": "ec622d3d-be6a-4c15-83d4-40ecaab719a3", + "attributes": {} + }, + { + "id": "ada0e9b0-6b9c-4d31-b01d-82351977e4c3", + "name": "delete-account", + "description": "${role_delete-account}", + "composite": false, + "clientRole": true, + "containerId": "ec622d3d-be6a-4c15-83d4-40ecaab719a3", + "attributes": {} + }, + { + "id": "0208ca97-318d-4bba-805d-6fa386f94b89", + "name": "view-profile", + "description": "${role_view-profile}", + "composite": false, + "clientRole": true, + "containerId": "ec622d3d-be6a-4c15-83d4-40ecaab719a3", + "attributes": {} + }, + { + "id": "bbece1cf-aa64-44d4-9d7a-81c219323197", + "name": "manage-account", + "description": "${role_manage-account}", + "composite": true, + "composites": { + "client": { + "account": [ + "manage-account-links" + ] + } + }, + "clientRole": true, + "containerId": "ec622d3d-be6a-4c15-83d4-40ecaab719a3", + "attributes": {} + }, + { + "id": "c22297f3-f0dd-424d-9bb2-a002363a5db6", + "name": "view-groups", + "description": "${role_view-groups}", + "composite": false, + "clientRole": true, + "containerId": "ec622d3d-be6a-4c15-83d4-40ecaab719a3", + "attributes": {} + }, + { + "id": "576943c3-b750-40c8-8d82-ab4db51cb9da", + "name": "manage-consent", + "description": "${role_manage-consent}", + "composite": true, + "composites": { + "client": { + "account": [ + "view-consent" + ] + } + }, + "clientRole": true, + "containerId": "ec622d3d-be6a-4c15-83d4-40ecaab719a3", + "attributes": {} + }, + { + "id": "98162afc-58cd-4204-805d-5ddaa80449db", + "name": "manage-account-links", + "description": "${role_manage-account-links}", + "composite": false, + "clientRole": true, + "containerId": "ec622d3d-be6a-4c15-83d4-40ecaab719a3", + "attributes": {} + }, + { + "id": "4cba4628-9e17-4ab1-8b77-8bb01c55d5f5", + "name": "view-consent", + "description": "${role_view-consent}", + "composite": false, + "clientRole": true, + "containerId": "ec622d3d-be6a-4c15-83d4-40ecaab719a3", + "attributes": {} + } + ] + } + }, + "groups": [], + "defaultRole": { + "id": "6a82b3ab-3414-4887-b604-58a1932f926d", + "name": "default-roles-clarity", + "description": "${role_default-roles}", + "composite": true, + "clientRole": false, + "containerId": "8c485c8e-7e4a-4f8b-8e89-8dc74b112637" + }, + "requiredCredentials": [ + "password" + ], + "otpPolicyType": "totp", + "otpPolicyAlgorithm": "HmacSHA1", + "otpPolicyInitialCounter": 0, + "otpPolicyDigits": 6, + "otpPolicyLookAheadWindow": 1, + "otpPolicyPeriod": 30, + "otpPolicyCodeReusable": false, + "otpSupportedApplications": [ + "totpAppFreeOTPName", + "totpAppGoogleName", + "totpAppMicrosoftAuthenticatorName" + ], + "localizationTexts": {}, + "webAuthnPolicyRpEntityName": "keycloak", + "webAuthnPolicySignatureAlgorithms": [ + "ES256", + "RS256" + ], + "webAuthnPolicyRpId": "", + "webAuthnPolicyAttestationConveyancePreference": "not specified", + "webAuthnPolicyAuthenticatorAttachment": "not specified", + "webAuthnPolicyRequireResidentKey": "not specified", + "webAuthnPolicyUserVerificationRequirement": "not specified", + "webAuthnPolicyCreateTimeout": 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyAcceptableAaguids": [], + "webAuthnPolicyExtraOrigins": [], + "webAuthnPolicyPasswordlessRpEntityName": "keycloak", + "webAuthnPolicyPasswordlessSignatureAlgorithms": [ + "ES256", + "RS256" + ], + "webAuthnPolicyPasswordlessRpId": "", + "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified", + "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified", + "webAuthnPolicyPasswordlessRequireResidentKey": "Yes", + "webAuthnPolicyPasswordlessUserVerificationRequirement": "required", + "webAuthnPolicyPasswordlessCreateTimeout": 0, + "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyPasswordlessAcceptableAaguids": [], + "webAuthnPolicyPasswordlessExtraOrigins": [], + "users": [ + { + "id": "b6abfb59-bc98-4770-807a-f126717754cf", + "username": "service-account-clarity-rest-api", + "emailVerified": false, + "enabled": true, + "createdTimestamp": 1776548890416, + "totp": false, + "serviceAccountClientId": "clarity-rest-api", + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": [ + "default-roles-clarity" + ], + "clientRoles": { + "clarity-rest-api": [ + "uma_protection" + ] + }, + "notBefore": 0, + "groups": [] + } + ], + "scopeMappings": [ + { + "clientScope": "offline_access", + "roles": [ + "offline_access" + ] + } + ], + "clientScopeMappings": { + "account": [ + { + "client": "account-console", + "roles": [ + "manage-account", + "view-groups" + ] + } + ] + }, + "clients": [ + { + "id": "ec622d3d-be6a-4c15-83d4-40ecaab719a3", + "clientId": "account", + "name": "${client_account}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/clarity/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/realms/clarity/account/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "organization", + "microprofile-jwt" + ] + }, + { + "id": "1d4253e0-e521-4eab-a23f-71caf387b5cf", + "clientId": "account-console", + "name": "${client_account-console}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/clarity/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/realms/clarity/account/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "7e4ac639-0315-4ea6-bc95-58f716a244a2", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "organization", + "microprofile-jwt" + ] + }, + { + "id": "4693f940-945b-4699-bd4c-2584e3ce575e", + "clientId": "admin-cli", + "name": "${client_admin-cli}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "client.use.lightweight.access.token.enabled": "true" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "organization", + "microprofile-jwt" + ] + }, + { + "id": "189c04d1-0b77-45f4-8f9a-f0f0aa112edc", + "clientId": "broker", + "name": "${client_broker}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "true" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "organization", + "microprofile-jwt" + ] + }, + { + "id": "b000e95a-0942-457a-a5d8-c2ca43538e7e", + "clientId": "clarity-rest-api", + "name": "", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [ + "/*" + ], + "webOrigins": [ + "/*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": true, + "authorizationServicesEnabled": true, + "publicClient": false, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "oidc.ciba.grant.enabled": "false", + "client.secret.creation.time": "1776548890", + "backchannel.logout.session.required": "true", + "standard.token.exchange.enabled": "false", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "dpop.bound.access.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "web-origins", + "service_account", + "acr", + "roles", + "profile", + "basic", + "clarity_backend.all", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "organization", + "microprofile-jwt" + ], + "authorizationSettings": { + "allowRemoteResourceManagement": true, + "policyEnforcementMode": "ENFORCING", + "resources": [], + "policies": [], + "scopes": [], + "decisionStrategy": "UNANIMOUS" + } + }, + { + "id": "da5e8861-8ea8-4970-91b9-2ad907c4e373", + "clientId": "clarity-web-app", + "name": "", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "*" + ], + "webOrigins": [ + "*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "oidc.ciba.grant.enabled": "false", + "backchannel.logout.session.required": "true", + "standard.token.exchange.enabled": "false", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "dpop.bound.access.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "basic", + "clarity_backend.all", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "organization", + "microprofile-jwt" + ] + }, + { + "id": "cc852506-d301-412c-9715-7a78dfe9e7e1", + "clientId": "realm-management", + "name": "${client_realm-management}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "true" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "organization", + "microprofile-jwt" + ] + }, + { + "id": "93f8b156-09d2-476f-aea2-b0fe5fcd0020", + "clientId": "security-admin-console", + "name": "${client_security-admin-console}", + "rootUrl": "${authAdminUrl}", + "baseUrl": "/admin/clarity/console/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/admin/clarity/console/*" + ], + "webOrigins": [ + "+" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "client.use.lightweight.access.token.enabled": "true", + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "1becd48e-e69c-42b9-a5e3-94f556456888", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "organization", + "microprofile-jwt" + ] + } + ], + "clientScopes": [ + { + "id": "4ea6dfe8-03ee-4cd4-b30d-135bb9149c17", + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${emailScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "fabc2d9d-0c05-4b6e-988b-5aa4d17a6df9", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + }, + { + "id": "0a9dd59c-2c4c-4a1a-b022-c094ca6d60dd", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "1561a91a-c1ab-4d2f-9646-f5afa4674ccd", + "name": "basic", + "description": "OpenID Connect scope for add all basic claims to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "f36159fd-2a8c-412c-8115-8c0bfd201707", + "name": "auth_time", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "AUTH_TIME", + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "auth_time", + "jsonType.label": "long" + } + }, + { + "id": "a7756477-6d16-4ede-b8ec-406a23f47870", + "name": "sub", + "protocol": "openid-connect", + "protocolMapper": "oidc-sub-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "id": "8940b00c-ea9d-4cd6-aa7a-4e379de0e8a9", + "name": "organization", + "description": "Additional claims about the organization a subject belongs to", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${organizationScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "630ec548-5183-4a29-87e2-bdf68331d505", + "name": "organization", + "protocol": "openid-connect", + "protocolMapper": "oidc-organization-membership-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "organization", + "jsonType.label": "String", + "multivalued": "true" + } + } + ] + }, + { + "id": "1d813831-cf38-4e84-8841-252f4b053e98", + "name": "roles", + "description": "OpenID Connect scope for add user roles to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "consent.screen.text": "${rolesScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "a42c24fc-aaee-4d97-bc66-4ff07f3b098d", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + }, + { + "id": "4abeb053-a71e-438e-b2f9-85696286e304", + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "id": "b411fec7-c740-4826-bbf5-45edb4b91ff2", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true" + } + } + ] + }, + { + "id": "ce4fefc8-8334-4e19-8a6a-c134dec4e32a", + "name": "acr", + "description": "OpenID Connect scope for add acr (authentication context class reference) to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "e0164287-0dc7-4328-a1c4-ee7e10a3c060", + "name": "acr loa level", + "protocol": "openid-connect", + "protocolMapper": "oidc-acr-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "id": "25e1b57d-3a3f-4261-80a2-65f769d18c34", + "name": "address", + "description": "OpenID Connect built-in scope: address", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${addressScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "ee967a31-d708-4556-b476-fd04a3724cdf", + "name": "address", + "protocol": "openid-connect", + "protocolMapper": "oidc-address-mapper", + "consentRequired": false, + "config": { + "user.attribute.formatted": "formatted", + "user.attribute.country": "country", + "introspection.token.claim": "true", + "user.attribute.postal_code": "postal_code", + "userinfo.token.claim": "true", + "user.attribute.street": "street", + "id.token.claim": "true", + "user.attribute.region": "region", + "access.token.claim": "true", + "user.attribute.locality": "locality" + } + } + ] + }, + { + "id": "3976f143-c48f-4849-acd9-2fdceb516330", + "name": "web-origins", + "description": "OpenID Connect scope for add allowed web origins to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "consent.screen.text": "", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "38279d20-e994-45f6-969b-b5244050453c", + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "id": "9b717435-cdd7-4583-9bdd-d0f0605e3a96", + "name": "clarity_backend.all", + "description": "Access to Clarity stack", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "true", + "gui.order": "", + "consent.screen.text": "", + "include.in.openid.provider.metadata": "true" + }, + "protocolMappers": [ + { + "id": "c35094b2-7196-49b4-87e4-56e591b29a35", + "name": "clarity_rest_aud", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "clarity-rest-api", + "id.token.claim": "false", + "lightweight.claim": "false", + "access.token.claim": "true", + "introspection.token.claim": "true" + } + }, + { + "id": "ae4e0696-bf5a-43a9-b19a-05b0908c5210", + "name": "clarity_web_aud", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "clarity-web-app", + "id.token.claim": "false", + "lightweight.claim": "false", + "access.token.claim": "true", + "introspection.token.claim": "true" + } + } + ] + }, + { + "id": "7d4183a9-57b1-49b4-b675-2cb08ff37fde", + "name": "saml_organization", + "description": "Organization Membership", + "protocol": "saml", + "attributes": { + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "d2f2ffce-7a49-4bfb-ba54-6bf3cbe5665e", + "name": "organization", + "protocol": "saml", + "protocolMapper": "saml-organization-membership-mapper", + "consentRequired": false, + "config": {} + } + ] + }, + { + "id": "12e44edb-d148-46a0-a9f6-9085a78f4ec3", + "name": "service_account", + "description": "Specific scope for a client enabled for service accounts", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "379c8b68-09f3-46c0-bbbd-5a10f28581b0", + "name": "Client ID", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "client_id", + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "client_id", + "jsonType.label": "String" + } + }, + { + "id": "bff131bb-bcd8-4288-b2f1-f9143ece356f", + "name": "Client Host", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientHost", + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientHost", + "jsonType.label": "String" + } + }, + { + "id": "f75b3cf3-0cc1-47cd-b33f-ea30b4d236ee", + "name": "Client IP Address", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientAddress", + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientAddress", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "0dde88fb-2ed8-479c-a584-f74da010d744", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + }, + { + "id": "0ed0facd-b943-4fe7-91d1-30f5bff626a6", + "name": "shared-api-access", + "description": "", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "true", + "gui.order": "", + "consent.screen.text": "", + "include.in.openid.provider.metadata": "true" + }, + "protocolMappers": [ + { + "id": "9a8cf3b0-4ba2-461c-abf2-6d29d2e22466", + "name": "shared_aud", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "false", + "lightweight.claim": "false", + "access.token.claim": "true", + "introspection.token.claim": "true" + } + } + ] + }, + { + "id": "b355b9a0-9ae5-4771-8170-bb5450ba13f8", + "name": "microprofile-jwt", + "description": "Microprofile - JWT built-in scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "57c7e70b-b4bc-4bf6-bad3-bd4ad2080dda", + "name": "upn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "upn", + "jsonType.label": "String" + } + }, + { + "id": "7995a948-cf3f-453e-9117-cbe18c9fc0cf", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "multivalued": "true", + "user.attribute": "foo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "6f8f2a38-487a-437a-9d99-1dce3e70819c", + "name": "role_list", + "description": "SAML role list", + "protocol": "saml", + "attributes": { + "consent.screen.text": "${samlRoleListScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "f60c8d5c-caf9-4c51-847f-59eb6da2f6f1", + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": false, + "config": { + "single": "false", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + } + ] + }, + { + "id": "a8527d59-ca0e-4dd1-b503-c0b78097e055", + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${profileScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "7e4b9bf8-cbbc-4f32-abb0-670ca3bcac0d", + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "profile", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "profile", + "jsonType.label": "String" + } + }, + { + "id": "8ee360d7-6d1a-4343-a314-bcaacb7d1b91", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + }, + { + "id": "560dd8c1-1074-4074-ad15-a9406215ed0e", + "name": "zoneinfo", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "zoneinfo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "zoneinfo", + "jsonType.label": "String" + } + }, + { + "id": "32889c06-3ebe-4996-b3c8-12f108576996", + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "birthdate", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "birthdate", + "jsonType.label": "String" + } + }, + { + "id": "f716c90f-126c-4582-9e2e-daba7493bb89", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "id": "2716cbc1-cd06-4268-9483-61982916f377", + "name": "website", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "website", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "website", + "jsonType.label": "String" + } + }, + { + "id": "906cb6e3-1606-497c-9810-3446bcf7597e", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } + }, + { + "id": "bd99ef34-1b92-468c-9f69-27dbf82b1afd", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "long" + } + }, + { + "id": "6329a3f6-f7aa-4d56-ac03-44ff1dd5363d", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "61eca96b-64ee-45bc-a83b-1b69d1dc4e15", + "name": "picture", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "picture", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "picture", + "jsonType.label": "String" + } + }, + { + "id": "d07336e0-a6e3-48e2-9b7b-b5e6f824fdfd", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + }, + { + "id": "11a1266f-a242-4d10-8eae-82689703b8e0", + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "gender", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "gender", + "jsonType.label": "String" + } + }, + { + "id": "ac261674-f615-4df0-bf8c-901971264ddb", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "id": "4d6febf3-908e-4f2a-bc94-f9a9bb779e79", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "2abf6865-aca1-4f7d-9298-605712443d88", + "name": "phone", + "description": "OpenID Connect built-in scope: phone", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${phoneScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "71236a44-54f9-4abb-b590-0080fc80da86", + "name": "phone number verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "phoneNumberVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number_verified", + "jsonType.label": "boolean" + } + }, + { + "id": "5fd99164-7cca-4211-842b-985a8a81a3c9", + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } + } + ] + } + ], + "defaultDefaultClientScopes": [ + "role_list", + "saml_organization", + "profile", + "email", + "roles", + "web-origins", + "acr", + "basic" + ], + "defaultOptionalClientScopes": [ + "offline_access", + "address", + "phone", + "microprofile-jwt", + "organization" + ], + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "referrerPolicy": "no-referrer", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "smtpServer": {}, + "eventsEnabled": false, + "eventsListeners": [ + "jboss-logging" + ], + "enabledEventTypes": [], + "adminEventsEnabled": false, + "adminEventsDetailsEnabled": false, + "identityProviders": [], + "identityProviderMappers": [], + "components": { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ + { + "id": "0ab68402-1a55-483f-81c9-da6180d2a5c5", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "692a088a-efb4-49cd-bddc-fec12b247647", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-full-name-mapper", + "oidc-address-mapper", + "saml-user-attribute-mapper", + "saml-user-property-mapper", + "oidc-usermodel-property-mapper", + "saml-role-list-mapper", + "oidc-usermodel-attribute-mapper", + "oidc-sha256-pairwise-sub-mapper" + ] + } + }, + { + "id": "f08b92d4-1a52-49cc-b9f1-da3a9b857772", + "name": "Full Scope Disabled", + "providerId": "scope", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "6fe20f5a-b89c-424b-9bf7-140d41290eba", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-full-name-mapper", + "oidc-usermodel-property-mapper", + "oidc-usermodel-attribute-mapper", + "saml-user-property-mapper", + "oidc-address-mapper", + "saml-role-list-mapper", + "oidc-sha256-pairwise-sub-mapper", + "saml-user-attribute-mapper" + ] + } + }, + { + "id": "2e4ec9d8-669c-4698-9f58-922540d01bb0", + "name": "Trusted Hosts", + "providerId": "trusted-hosts", + "subType": "anonymous", + "subComponents": {}, + "config": { + "host-sending-registration-request-must-match": [ + "true" + ], + "client-uris-must-match": [ + "true" + ] + } + }, + { + "id": "b32d7682-0274-43be-b7ce-38c0560a119a", + "name": "Consent Required", + "providerId": "consent-required", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "0812c5d0-8827-4875-84c3-2895f2cb8040", + "name": "Max Clients Limit", + "providerId": "max-clients", + "subType": "anonymous", + "subComponents": {}, + "config": { + "max-clients": [ + "200" + ] + } + }, + { + "id": "ee9df5e6-9a03-4a3d-bf55-3a8f5deb14b1", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "b78a01ef-a5f0-49d8-a23e-d25c84b67c0d", + "name": "Allowed Registration Web Origins", + "providerId": "registration-web-origins", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "63408495-8aac-457d-b63c-60bf2a5e8463", + "name": "Allowed Registration Web Origins", + "providerId": "registration-web-origins", + "subType": "authenticated", + "subComponents": {}, + "config": {} + } + ], + "org.keycloak.keys.KeyProvider": [ + { + "id": "2bbee01a-411a-449c-93fa-dd6f83a37f08", + "name": "hmac-generated-hs512", + "providerId": "hmac-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ], + "algorithm": [ + "HS512" + ] + } + }, + { + "id": "86f2318d-97fe-4956-b5bf-473e6ae972a9", + "name": "aes-generated", + "providerId": "aes-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ] + } + }, + { + "id": "112ddd76-5ed2-4b98-b291-f1357d618dcb", + "name": "rsa-enc-generated", + "providerId": "rsa-enc-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ], + "algorithm": [ + "RSA-OAEP" + ] + } + }, + { + "id": "90926ce3-fe01-4ffa-a5cd-17e992e32ba1", + "name": "rsa-generated", + "providerId": "rsa-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ] + } + } + ] + }, + "internationalizationEnabled": false, + "authenticationFlows": [ + { + "id": "a36604e4-2ae2-4c38-b87c-00107e65f6c2", + "alias": "Account verification options", + "description": "Method with which to verify the existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-email-verification", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Verify Existing Account by Re-authentication", + "userSetupAllowed": false + } + ] + }, + { + "id": "6976b3fa-442c-4b57-9f9c-c5b086375d27", + "alias": "Browser - Conditional 2FA", + "description": "Flow to determine if any 2FA is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorConfig": "browser-conditional-credential", + "authenticator": "conditional-credential", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "webauthn-authenticator", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 40, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-recovery-authn-code-form", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 50, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "3d878850-eecc-4485-b8ab-3e2d9e2f2f85", + "alias": "Browser - Conditional Organization", + "description": "Flow to determine if the organization identity-first login is to be used", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "organization", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "6fda0b18-74c2-44e5-b3b4-4573e5e1d4fa", + "alias": "Direct Grant - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "3df385bc-a45f-406a-b474-08fa55f51e41", + "alias": "First Broker Login - Conditional Organization", + "description": "Flow to determine if the authenticator that adds organization members is to be used", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "idp-add-organization-member", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "ae092db7-2527-48a2-92ac-0a0086b00a8f", + "alias": "First broker login - Conditional 2FA", + "description": "Flow to determine if any 2FA is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorConfig": "first-broker-login-conditional-credential", + "authenticator": "conditional-credential", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "webauthn-authenticator", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 40, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-recovery-authn-code-form", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 50, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "d5725aae-46a0-4779-a1b0-98b4cb3f5c59", + "alias": "Handle Existing Account", + "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-confirm-link", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Account verification options", + "userSetupAllowed": false + } + ] + }, + { + "id": "e470db9a-f706-44bf-83aa-232038b01c36", + "alias": "Organization", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 10, + "autheticatorFlow": true, + "flowAlias": "Browser - Conditional Organization", + "userSetupAllowed": false + } + ] + }, + { + "id": "a7d2b606-af67-493a-8bbb-bf7b41b5a3f2", + "alias": "Reset - Conditional OTP", + "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "4cc14ad9-ad8e-4d47-b0d6-fd933ce3a5cb", + "alias": "User creation or linking", + "description": "Flow for the existing/non-existing user alternatives", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "create unique user config", + "authenticator": "idp-create-user-if-unique", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Handle Existing Account", + "userSetupAllowed": false + } + ] + }, + { + "id": "65822516-68e5-44b4-a129-218e9e6c15bf", + "alias": "Verify Existing Account by Re-authentication", + "description": "Reauthentication of existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "First broker login - Conditional 2FA", + "userSetupAllowed": false + } + ] + }, + { + "id": "5b2a8295-dd5a-4042-9913-f83bae9ced28", + "alias": "browser", + "description": "Browser based authentication", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-spnego", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "identity-provider-redirector", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 25, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 26, + "autheticatorFlow": true, + "flowAlias": "Organization", + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "forms", + "userSetupAllowed": false + } + ] + }, + { + "id": "ef6d809d-474e-460e-a311-760146e74a3e", + "alias": "clients", + "description": "Base authentication for clients", + "providerId": "client-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "client-secret", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-secret-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-x509", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 40, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "c65b90e4-4f80-4a4a-aa72-c6cc35626346", + "alias": "direct grant", + "description": "OpenID Connect Resource Owner Grant", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "direct-grant-validate-username", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "Direct Grant - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "c987c37b-a1a8-4436-b615-fc70cc615ae9", + "alias": "docker auth", + "description": "Used by Docker clients to authenticate against the IDP", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "docker-http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "0f1e5942-52cf-40bc-b3bd-4cc04ac1a99e", + "alias": "first broker login", + "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "review profile config", + "authenticator": "idp-review-profile", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "User creation or linking", + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 60, + "autheticatorFlow": true, + "flowAlias": "First Broker Login - Conditional Organization", + "userSetupAllowed": false + } + ] + }, + { + "id": "b9f30b8b-828c-4062-a130-6cd6477e7811", + "alias": "forms", + "description": "Username, password, otp and other auth forms.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Browser - Conditional 2FA", + "userSetupAllowed": false + } + ] + }, + { + "id": "33f0ab17-7ba0-43b0-a337-8dbdf9b31051", + "alias": "registration", + "description": "Registration flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-page-form", + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": true, + "flowAlias": "registration form", + "userSetupAllowed": false + } + ] + }, + { + "id": "2147f4f3-bb3c-4437-ab3a-2bd6bc80c2ea", + "alias": "registration form", + "description": "Registration form", + "providerId": "form-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-user-creation", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-password-action", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 50, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-recaptcha-action", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 60, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-terms-and-conditions", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 70, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "9b7c7e93-38c3-475f-841e-55adc5a1a9cb", + "alias": "reset credentials", + "description": "Reset credentials for a user if they forgot their password or something", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "reset-credentials-choose-user", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-credential-email", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 40, + "autheticatorFlow": true, + "flowAlias": "Reset - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "bce18b32-c7b5-4c7b-9006-71f72b44c118", + "alias": "saml ecp", + "description": "SAML ECP Profile Authentication Flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + } + ], + "authenticatorConfig": [ + { + "id": "8407e750-e536-4ba2-ae96-21bf620811ef", + "alias": "browser-conditional-credential", + "config": { + "credentials": "webauthn-passwordless" + } + }, + { + "id": "2b21d039-d932-491b-81d2-9f9752ca5bfe", + "alias": "create unique user config", + "config": { + "require.password.update.after.registration": "false" + } + }, + { + "id": "b2f08c8b-6a93-410e-9f1d-1f5951c63d22", + "alias": "first-broker-login-conditional-credential", + "config": { + "credentials": "webauthn-passwordless" + } + }, + { + "id": "46332c90-0261-4244-9446-f90be6c7e574", + "alias": "review profile config", + "config": { + "update.profile.on.first.login": "missing" + } + } + ], + "requiredActions": [ + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": true, + "defaultAction": false, + "priority": 10, + "config": {} + }, + { + "alias": "TERMS_AND_CONDITIONS", + "name": "Terms and Conditions", + "providerId": "TERMS_AND_CONDITIONS", + "enabled": false, + "defaultAction": false, + "priority": 20, + "config": {} + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": true, + "defaultAction": false, + "priority": 30, + "config": {} + }, + { + "alias": "UPDATE_PROFILE", + "name": "Update Profile", + "providerId": "UPDATE_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 40, + "config": {} + }, + { + "alias": "VERIFY_EMAIL", + "name": "Verify Email", + "providerId": "VERIFY_EMAIL", + "enabled": true, + "defaultAction": false, + "priority": 50, + "config": {} + }, + { + "alias": "delete_account", + "name": "Delete Account", + "providerId": "delete_account", + "enabled": false, + "defaultAction": false, + "priority": 60, + "config": {} + }, + { + "alias": "UPDATE_EMAIL", + "name": "Update Email", + "providerId": "UPDATE_EMAIL", + "enabled": false, + "defaultAction": false, + "priority": 70, + "config": {} + }, + { + "alias": "webauthn-register", + "name": "Webauthn Register", + "providerId": "webauthn-register", + "enabled": true, + "defaultAction": false, + "priority": 80, + "config": {} + }, + { + "alias": "webauthn-register-passwordless", + "name": "Webauthn Register Passwordless", + "providerId": "webauthn-register-passwordless", + "enabled": true, + "defaultAction": false, + "priority": 90, + "config": {} + }, + { + "alias": "VERIFY_PROFILE", + "name": "Verify Profile", + "providerId": "VERIFY_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 100, + "config": {} + }, + { + "alias": "delete_credential", + "name": "Delete Credential", + "providerId": "delete_credential", + "enabled": true, + "defaultAction": false, + "priority": 110, + "config": {} + }, + { + "alias": "idp_link", + "name": "Linking Identity Provider", + "providerId": "idp_link", + "enabled": true, + "defaultAction": false, + "priority": 120, + "config": {} + }, + { + "alias": "CONFIGURE_RECOVERY_AUTHN_CODES", + "name": "Recovery Authentication Codes", + "providerId": "CONFIGURE_RECOVERY_AUTHN_CODES", + "enabled": true, + "defaultAction": false, + "priority": 130, + "config": {} + }, + { + "alias": "update_user_locale", + "name": "Update User Locale", + "providerId": "update_user_locale", + "enabled": true, + "defaultAction": false, + "priority": 1000, + "config": {} + } + ], + "browserFlow": "browser", + "registrationFlow": "registration", + "directGrantFlow": "direct grant", + "resetCredentialsFlow": "reset credentials", + "clientAuthenticationFlow": "clients", + "dockerAuthenticationFlow": "docker auth", + "firstBrokerLoginFlow": "first broker login", + "attributes": { + "cibaBackchannelTokenDeliveryMode": "poll", + "cibaExpiresIn": "120", + "cibaAuthRequestedUserHint": "login_hint", + "oauth2DeviceCodeLifespan": "600", + "oauth2DevicePollingInterval": "5", + "clientOfflineSessionMaxLifespan": "0", + "clientSessionIdleTimeout": "0", + "parRequestUriLifespan": "60", + "clientSessionMaxLifespan": "0", + "clientOfflineSessionIdleTimeout": "0", + "cibaInterval": "5", + "realmReusableOtpCode": "false" + }, + "keycloakVersion": "26.5.7", + "userManagedAccessAllowed": false, + "organizationsEnabled": false, + "verifiableCredentialsEnabled": false, + "adminPermissionsEnabled": false, + "clientProfiles": { + "profiles": [] + }, + "clientPolicies": { + "policies": [] + } +} \ No newline at end of file diff --git a/ControlPlane.AppHost/NginxConfig/clarity.test.crt b/ControlPlane.AppHost/NginxConfig/clarity.test.crt new file mode 100644 index 0000000..22ef090 --- /dev/null +++ b/ControlPlane.AppHost/NginxConfig/clarity.test.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDGzCCAgOgAwIBAgIUS0kgcdXIrlOk/K6g2bfLDRycqk8wDQYJKoZIhvcNAQEL +BQAwGTEXMBUGA1UEAwwOKi5jbGFyaXR5LnRlc3QwHhcNMjYwNDI0MjIwMDUzWhcN +MjgwNzI3MjIwMDUzWjAZMRcwFQYDVQQDDA4qLmNsYXJpdHkudGVzdDCCASIwDQYJ +KoZIhvcNAQEBBQADggEPADCCAQoCggEBAMWAJ62tsrnMaMnF3NR2Yfv1LKS9IRfm +sTtTWba7D8fcs9JXGlEn+vMa10AjV91yaSQoQdwLCOwkF58CmLBs0K+vvPoLgvcZ +BQxVrBj0t1YlTwLcez8vEgb2tHKGo914T/YLh+clF8oig9tIIiTNbngUGabpWUym +vPllDQ8nB0m4IkHbMAhgdDUG9X5Vc/lWHW6gxhRiUQt7HLqWJ2lLleQR5qEqRQx+ +RmtseS11jhzwDYf1VVzQ2AE2tUaq82p0cZAF8uFZnESuv1Hcu+1KBfjCaGXJ/485 +gg1q01sYhAkX0LAK/CqRBOd7zp9cDm3NX0tLBj4Gek6h0kFGkmRtAmcCAwEAAaNb +MFkwHQYDVR0OBBYEFJNI82Atz7k2pa2IZECO9aG30dnHMA8GA1UdEwEB/wQFMAMB +Af8wJwYDVR0RBCAwHoIOKi5jbGFyaXR5LnRlc3SCDGNsYXJpdHkudGVzdDANBgkq +hkiG9w0BAQsFAAOCAQEAO5MyjFXcOZeEwPJRel8Mvg1HRwu97tL/BB9Hb13JWzdx +FBBqwOdRrG8IB7byXLjH1ng4xMM+WI9yeZ29bV/PcrZwermGNzU+ob1SrvJYh0hb +sX0zeXKjKDGMsdlyZAERnvGOxlPzNtYRpeSD7h3qKtuzJiReCNdGzSh+2bLfxEIb +wTJJNgnXRA4GGK5zghmzOEpq/w8sqpB4hLz9OK8a33QOKp79LrfyT1B9uZq4uHZ8 +SvTX89KZOGmUQraF/6QvL3CcMutwzf4unKxyaStflrcGjCn/eEe8Ea3IWL1EwU8K +9JvyDvWgv7oib7FA2BZGbYvT+wsFjiFBzTcWUX132g== +-----END CERTIFICATE----- diff --git a/ControlPlane.AppHost/NginxConfig/clarity.test.key b/ControlPlane.AppHost/NginxConfig/clarity.test.key new file mode 100644 index 0000000..f9250ea --- /dev/null +++ b/ControlPlane.AppHost/NginxConfig/clarity.test.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDFgCetrbK5zGjJ +xdzUdmH79SykvSEX5rE7U1m2uw/H3LPSVxpRJ/rzGtdAI1fdcmkkKEHcCwjsJBef +ApiwbNCvr7z6C4L3GQUMVawY9LdWJU8C3Hs/LxIG9rRyhqPdeE/2C4fnJRfKIoPb +SCIkzW54FBmm6VlMprz5ZQ0PJwdJuCJB2zAIYHQ1BvV+VXP5Vh1uoMYUYlELexy6 +lidpS5XkEeahKkUMfkZrbHktdY4c8A2H9VVc0NgBNrVGqvNqdHGQBfLhWZxErr9R +3LvtSgX4wmhlyf+POYINatNbGIQJF9CwCvwqkQTne86fXA5tzV9LSwY+BnpOodJB +RpJkbQJnAgMBAAECggEAGc9MICXNb/t3DDtHxxorZuZc7bBrpTh4G9UiKb+badZ9 +R3UrksSDRobQ72hPALkFZXy/Upa8lUOINLb9pjyqLvNr4k9jz4/c+YYupdpBJUhd +4XVXw+OOWwudfEP9ISGqbXCHU50k1T0adysfjyirkZSq34WqLlqx4nOit8K1cJwc +5+jvApwOPz6zf9kFJYjybbUSPO8bFLVTpjs3hgUzaCMkYMn6R/5bR5SMeqCbZILB +fkGm+KaeS3cIY7PhDhSoiWJUR5/ZsaoT5s1IM5aGTe62XVY5eoMixYEibx/e68XC +eL3eWO304QU6AgMKHFhtTKFpnJHlyV/gu084/xWC7QKBgQD9lrkRgDDMXfuDtFRr +LiQ3QFEmmj0m2ekHIpdZDY3rJ0bbQzTw4cqWs437qMKcTczK70mfxp/IjPoky+8i +bSlm/pR+U/YwsgK0dxGLzHbIQYYQdI4BjBsysNOvxnKUxRciAMpIW5ULGKYUkCde +dhH5c2Rmve0yq6MYJ8DCOTXCwwKBgQDHYOd50Tjw5i+a5wcHEsfY+r/Vsu1u1BrS +/sdpJ+dKxx50TQO4F7tnrugwJ9cvxPDGQApDHFbIwn70zQuDNvYLD2CTtwHoJHx/ +wuP3p0Rw3DmhKI9CN0oXclqNV3PZ54PZ2M5HEl0zkpoIse4YtWc0uyO6RKVHHtPr +jGjTKeZ/jQKBgAc7XinGmx2o7HxUDzhDR5sfxXCxY18RRdkDPoe2oD59j0K/hun7 +tnhXxIvRw0ML4PREoLfixTnF83hLLJWxwUWDqx5zLIk0+mjFIIX5HcYWQEmF2Wrn +4PqwGklgAnKFsGQy25H2sqhvWoUpm0XRXi/b/5gCgJo6VNtiftfLI+JbAoGAC496 +3H1dJ9qw9/JdXfOg0tv3M5TkX4C87W8IcPh3WMai5Wtxw8Lcgu6JWAF3YLWyoEwm +TC3gelOMuPUKrdkJ+yoxF1+NJMC410+dmEaCmWirjsSjSdua2DExPvDLLt9VrdP8 +YfKWpN7jP43RmG0sRspzD+HbE3yeHRJPIa9URiECgYEAyxOOXDCQSPifgIRZe5hr +u+WsMukUypizXq36/ydCfMD7HcPOgO6bNkNsh6WlaaNrFQwR2O96V0BvrSAI242a +bTEyUx7fTwoZmn/8O6/WIwkyYolixNYbClcAIopbOXxJ9bJ1KqS47mHv1RrQ8FqN +OpJWMvrAktqNT5tjDeIj6mc= +-----END PRIVATE KEY----- diff --git a/ControlPlane.AppHost/NginxConfig/conf.d/.gitkeep b/ControlPlane.AppHost/NginxConfig/conf.d/.gitkeep new file mode 100644 index 0000000..83b8c6e --- /dev/null +++ b/ControlPlane.AppHost/NginxConfig/conf.d/.gitkeep @@ -0,0 +1,2 @@ +# Placeholder so the conf.d directory is tracked by git and exists at container mount time. +# The provisioning worker writes per-tenant .conf files here at runtime. diff --git a/ControlPlane.AppHost/NginxConfig/conf.d/fdev-app-clarity-01000000.conf b/ControlPlane.AppHost/NginxConfig/conf.d/fdev-app-clarity-01000000.conf new file mode 100644 index 0000000..1988f9f --- /dev/null +++ b/ControlPlane.AppHost/NginxConfig/conf.d/fdev-app-clarity-01000000.conf @@ -0,0 +1,19 @@ +# Auto-generated by ControlPlane.Worker — do not edit manually. +# Tenant: fdev-app-clarity-01000000 +server { + listen 443 ssl; + server_name fdev-app-clarity-01000000.clarity.test; + + ssl_certificate /etc/nginx/certs/clarity.test.crt; + ssl_certificate_key /etc/nginx/certs/clarity.test.key; + + location / { + # Docker DNS resolves the container name on the managed network + set $upstream http://fdev-app-clarity-01000000:8080; + proxy_pass $upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} \ No newline at end of file diff --git a/ControlPlane.AppHost/NginxConfig/conf.d/fdev-app-clarity-02000000.conf b/ControlPlane.AppHost/NginxConfig/conf.d/fdev-app-clarity-02000000.conf new file mode 100644 index 0000000..75c16d4 --- /dev/null +++ b/ControlPlane.AppHost/NginxConfig/conf.d/fdev-app-clarity-02000000.conf @@ -0,0 +1,19 @@ +# Auto-generated by ControlPlane.Worker — do not edit manually. +# Tenant: fdev-app-clarity-02000000 +server { + listen 443 ssl; + server_name fdev-app-clarity-02000000.clarity.test; + + ssl_certificate /etc/nginx/certs/clarity.test.crt; + ssl_certificate_key /etc/nginx/certs/clarity.test.key; + + location / { + # Docker DNS resolves the container name on the managed network + set $upstream http://fdev-app-clarity-02000000:8080; + proxy_pass $upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} \ No newline at end of file diff --git a/ControlPlane.AppHost/NginxConfig/conf.d/fdev-app-clarity-03000000.conf b/ControlPlane.AppHost/NginxConfig/conf.d/fdev-app-clarity-03000000.conf new file mode 100644 index 0000000..f92ebd6 --- /dev/null +++ b/ControlPlane.AppHost/NginxConfig/conf.d/fdev-app-clarity-03000000.conf @@ -0,0 +1,19 @@ +# Auto-generated by ControlPlane.Worker — do not edit manually. +# Tenant: fdev-app-clarity-03000000 +server { + listen 443 ssl; + server_name fdev-app-clarity-03000000.clarity.test; + + ssl_certificate /etc/nginx/certs/clarity.test.crt; + ssl_certificate_key /etc/nginx/certs/clarity.test.key; + + location / { + # Docker DNS resolves the container name on the managed network + set $upstream http://fdev-app-clarity-03000000:8080; + proxy_pass $upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} \ No newline at end of file diff --git a/ControlPlane.AppHost/NginxConfig/conf.d/fdev-app-clarity-04000000.conf b/ControlPlane.AppHost/NginxConfig/conf.d/fdev-app-clarity-04000000.conf new file mode 100644 index 0000000..06ca568 --- /dev/null +++ b/ControlPlane.AppHost/NginxConfig/conf.d/fdev-app-clarity-04000000.conf @@ -0,0 +1,19 @@ +# Auto-generated by ControlPlane.Worker — do not edit manually. +# Tenant: fdev-app-clarity-04000000 +server { + listen 443 ssl; + server_name fdev-app-clarity-04000000.clarity.test; + + ssl_certificate /etc/nginx/certs/clarity.test.crt; + ssl_certificate_key /etc/nginx/certs/clarity.test.key; + + location / { + # Docker DNS resolves the container name on the managed network + set $upstream http://fdev-app-clarity-04000000:8080; + proxy_pass $upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} \ No newline at end of file diff --git a/ControlPlane.AppHost/NginxConfig/conf.d/gitea.conf b/ControlPlane.AppHost/NginxConfig/conf.d/gitea.conf new file mode 100644 index 0000000..0e55de6 --- /dev/null +++ b/ControlPlane.AppHost/NginxConfig/conf.d/gitea.conf @@ -0,0 +1,21 @@ +server { + listen 443 ssl; + server_name opc.clarity.test; + + ssl_certificate /etc/nginx/certs/clarity.test.crt; + ssl_certificate_key /etc/nginx/certs/clarity.test.key; + + # Git over HTTP needs larger body and longer timeouts + client_max_body_size 100m; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + + location / { + set $upstream http://gitea:3000; + proxy_pass $upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/ControlPlane.AppHost/NginxConfig/conf.d/keycloak.conf b/ControlPlane.AppHost/NginxConfig/conf.d/keycloak.conf new file mode 100644 index 0000000..aad2671 --- /dev/null +++ b/ControlPlane.AppHost/NginxConfig/conf.d/keycloak.conf @@ -0,0 +1,16 @@ +server { + listen 443 ssl; + server_name keycloak.clarity.test; + + ssl_certificate /etc/nginx/certs/clarity.test.crt; + ssl_certificate_key /etc/nginx/certs/clarity.test.key; + + location / { + set $upstream http://keycloak:8080; + proxy_pass $upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/ControlPlane.AppHost/NginxConfig/nginx.conf b/ControlPlane.AppHost/NginxConfig/nginx.conf new file mode 100644 index 0000000..5dab5af --- /dev/null +++ b/ControlPlane.AppHost/NginxConfig/nginx.conf @@ -0,0 +1,27 @@ +events { + worker_connections 1024; +} + +http { + # Use Docker's embedded DNS resolver so container names resolve dynamically. + # This is critical — without it nginx resolves upstream names at startup only + # and won't pick up newly provisioned tenant containers. + resolver 127.0.0.11 valid=5s ipv6=off; + + # Shared log format + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent"'; + + access_log /var/log/nginx/access.log main; + error_log /var/log/nginx/error.log warn; + + # Redirect all HTTP → HTTPS + server { + listen 80 default_server; + return 301 https://$host$request_uri; + } + + # Pick up per-tenant server blocks dropped by the provisioning worker + include /etc/nginx/conf.d/*.conf; +} diff --git a/ControlPlane.AppHost/Properties/launchSettings.json b/ControlPlane.AppHost/Properties/launchSettings.json new file mode 100644 index 0000000..29f3df9 --- /dev/null +++ b/ControlPlane.AppHost/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://controlplane.dev.localhost:17000;http://controlplane.dev.localhost:15000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21000", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:21001", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:21002" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://controlplane.dev.localhost:15000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:21000", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:21001", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:21002" + } + } + } +} diff --git a/ControlPlane.AppHost/VaultConfig/entrypoint.sh b/ControlPlane.AppHost/VaultConfig/entrypoint.sh new file mode 100644 index 0000000..2b39224 --- /dev/null +++ b/ControlPlane.AppHost/VaultConfig/entrypoint.sh @@ -0,0 +1,48 @@ +#!/bin/sh +set -e + +KEYS_FILE="/vault/file/init.json" +VAULT_ADDR="http://127.0.0.1:8200" +export VAULT_ADDR + +# Start Vault server in the background +vault server -config=/vault/config/vault.hcl & +VAULT_PID=$! + +# Wait for Vault to be ready +echo "[vault-init] Waiting for Vault to start..." +until vault status > /dev/null 2>&1 || vault status 2>&1 | grep -q "Sealed\|Initialized"; do + sleep 1 +done +echo "[vault-init] Vault is up." + +# Check if already initialised +INIT_STATUS=$(vault status -format=json 2>/dev/null | grep '"initialized"' | grep -c "true" || true) + +if [ "$INIT_STATUS" = "0" ]; then + echo "[vault-init] First run — initialising Vault..." + vault operator init -key-shares=1 -key-threshold=1 -format=json > "$KEYS_FILE" + echo "[vault-init] Keys saved to $KEYS_FILE" +fi + +# Unseal using saved key +UNSEAL_KEY=$(grep '"unseal_keys_b64"' "$KEYS_FILE" -A1 | grep '"' | tail -1 | tr -d ' ",' ) +ROOT_TOKEN=$(grep '"root_token"' "$KEYS_FILE" | sed 's/.*: *"\(.*\)".*/\1/') + +echo "[vault-init] Unsealing..." +vault operator unseal "$UNSEAL_KEY" +echo "[vault-init] Vault is unsealed. Root token is stored in $KEYS_FILE" + +# Authenticate and bootstrap Transit engine + master key (idempotent) +export VAULT_TOKEN="$ROOT_TOKEN" + +echo "[vault-init] Enabling Transit secrets engine..." +vault secrets enable -path=clarity-transit transit 2>/dev/null || echo "[vault-init] clarity-transit already enabled." + +echo "[vault-init] Creating master-key..." +vault write -f clarity-transit/keys/master-key 2>/dev/null || echo "[vault-init] master-key already exists." + +echo "[vault-init] Vault bootstrap complete." + +# Keep container alive by waiting on the Vault process +wait $VAULT_PID diff --git a/ControlPlane.AppHost/VaultConfig/vault.hcl b/ControlPlane.AppHost/VaultConfig/vault.hcl new file mode 100644 index 0000000..57ea130 --- /dev/null +++ b/ControlPlane.AppHost/VaultConfig/vault.hcl @@ -0,0 +1,13 @@ +storage "file" { + path = "/vault/file" +} + +listener "tcp" { + address = "0.0.0.0:8200" + tls_disable = true +} + +ui = true +disable_mlock = true + +# Auto-unseal using a static shamir key — dev convenience only, never use in prod diff --git a/ControlPlane.AppHost/certs/localtest.me.crt b/ControlPlane.AppHost/certs/localtest.me.crt new file mode 100644 index 0000000..e866e63 --- /dev/null +++ b/ControlPlane.AppHost/certs/localtest.me.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDDTCCAfWgAwIBAgIURU3028kH3veUBjTtDis5N5SYI9AwDQYJKoZIhvcNAQEL +BQAwGTEXMBUGA1UEAwwOKi5sb2NhbHRlc3QubWUwHhcNMjYwNDI0MTYwNzU3WhcN +MzYwNDIxMTYwNzU3WjAZMRcwFQYDVQQDDA4qLmxvY2FsdGVzdC5tZTCCASIwDQYJ +KoZIhvcNAQEBBQADggEPADCCAQoCggEBALiZjuDCZ7uBicnk1ko6nlJIf/Zn2thr +ArBA9FD1wtMm0tWMA66fQ+STlkTw2LOlsjIk9d4A3s7jGhVyAikLqylm8in3WVWT +X4Ms5FB7lXqGEsuMI6Fq8l+Xw5boWE15XRGoOEPqaazfIvy4utF9Dk1TLXAv+Svv +dTTek7phU3hzWxzOTdk9fVhHdYqJy0ZjaxJxyUbTDPRf+IHad/0iWWpZaRuP5QEz +J0zujXEvJdFUVXOcPqSs0SdkaKqYbxegHwUK5ALQSVzH7CYHR4+Np6ChUw8+RFid +b9dQH2pzm9h7iaKD58AWLLB/D2uHBnSPkOahWY8oizlNRxsSuY7/x4cCAwEAAaNN +MEswJwYDVR0RBCAwHoIOKi5sb2NhbHRlc3QubWWCDGxvY2FsdGVzdC5tZTALBgNV +HQ8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwDQYJKoZIhvcNAQELBQADggEB +ALZ/RP2JFDz4QODzy+ESg5DlgQQ3CTyDn9DwR8Pojzpq+MdJQ3+g48qsCS2FwR8W +h18DCfeemrutGHGBcX6dNbjy43oFwbvdDEaK1/m82Rmr4F/u3AdpxJpXXGEBoO9O +rg2+nXQEGFwZapUnAVGUB3Iihx5FRw1Rbi910aF6TN67Og6pUf/8Jut/M5TzAiDN +scil2PpC2mWvHzGV+gBZT0lOpfo+dRlE+zzEBWt4WpZWj3bF+WbwzR2bsd2JGZsp +OtV4ErupppsGYliKi2cJG9ceqG0zEc/hUtG2SfmZvfKOxZ2p0M6SXJDHueoAOkh1 +zu/AQ0cjPBLoOy6ahVHvg20= +-----END CERTIFICATE----- diff --git a/ControlPlane.AppHost/certs/localtest.me.key b/ControlPlane.AppHost/certs/localtest.me.key new file mode 100644 index 0000000..ca953f6 --- /dev/null +++ b/ControlPlane.AppHost/certs/localtest.me.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC4mY7gwme7gYnJ +5NZKOp5SSH/2Z9rYawKwQPRQ9cLTJtLVjAOun0Pkk5ZE8NizpbIyJPXeAN7O4xoV +cgIpC6spZvIp91lVk1+DLORQe5V6hhLLjCOhavJfl8OW6FhNeV0RqDhD6mms3yL8 +uLrRfQ5NUy1wL/kr73U03pO6YVN4c1sczk3ZPX1YR3WKictGY2sScclG0wz0X/iB +2nf9IllqWWkbj+UBMydM7o1xLyXRVFVznD6krNEnZGiqmG8XoB8FCuQC0Elcx+wm +B0ePjaegoVMPPkRYnW/XUB9qc5vYe4mig+fAFiywfw9rhwZ0j5DmoVmPKIs5TUcb +ErmO/8eHAgMBAAECggEAAnNe5AnCZXYCbBpQhv9XcG6BZgJRksJZd4D7Fm62G3XB +T1pCs9IvwRujj8gsN6kIn1NI2xNOZWNZ7QpITovP6HOSRYbsElL34BXzQPiZT5gc +ePtiR+0VQkt8vxf6lHNRWmDAPREQ3UxDs7zKhEqBCLzslXYkSH0892Ibf6nImF8w +7meMsH4SxPFY16WBxWjyJNdy+TVw0BYFdPiUxE52PaIplgVZJqvmmuMUYcmOVale +lXGeWGMdvFp3Tilbj2rpnJ5p7I5av59TmIzXon/bGguhYhwus+1e8rs3WYWqibHf +bwB03kuGFaiSvuVncX3DvdBnvrz9tlCaipU+aciGUQKBgQDaQ378oDbmX1gk96/7 +3ZiU67Vqnone4X88SxiYOafwmT5NVnJYMjtbN775NCUK4aR7lYo2lodl9CW096UN +Xic186jFGey3NoqCLoVodeFe/XscZMSS+TE5FLi4B4Ih/bgpcDzDQ8++5oRYiwWk +Z1/GKOc8MxXhhZDf9wOhgWBfVwKBgQDYhBPbeJJaE3k7pREBF7abDERFbfruC4Xh +181kCIZ3oMKGj4YKtIjoLnCocOAo/uhM9DnY/cBvR+CykWpH0nBfcDE9lknvpxUn +fTitwytfjKWwE3/Z9BRK/ieBaYXwEn38KgYZJJNseZLlYTgDfAKKt4tppAQ3Tdww +9DFo47IrUQKBgQCPSWBEWKmx80XafwB5SLCyk0s2A35fY4oz+tjaln855GCSRP4s +CE4PRDmLQEBRNHDW8QUbcRbSR8W5WBpy/CyhrqRNQQe1/4hOjlvmh/y8b4wyx7SF +CDLYVlIt/j/gMMCF87jwN8RaftrDhgDePT8SyCeFzcO/mf/SCEfJ7zVlYQKBgH5A +be/RG83ogw3Tj9nKQRGiEoFFw0dhcr0hgEOvcPF6zVN3h1rgsOBqjAi8YQmmskCF +POIZ/Ucma5DUmFuvCxWrrxrRcuWK0RwIua8hGj6KHedRR4EJAXhFQTYGGTLHJa2P +t6SbnldngM++Y9IsUrMeme2M1WSGQzpMei9GbpMxAoGBAIgFav2bGZCScvp+Y917 +j5rKMLv8AN6nC3BQoraxMKl0YpCS8F58YHAfxKlmgR1Ll16reJLv5qAzSM7jViII +7vmiPGrpRnz1mUHVhVBfNF1UKIRmmJKtARlrbrVibGtLubtzBZOLh0bfzmnYH9Z8 +ncBozZmPeJAtzGfvw+7BNoM9 +-----END PRIVATE KEY----- diff --git a/ControlPlane.AppHost/certs/san.cnf b/ControlPlane.AppHost/certs/san.cnf new file mode 100644 index 0000000..537f2de --- /dev/null +++ b/ControlPlane.AppHost/certs/san.cnf @@ -0,0 +1,16 @@ +[req] +distinguished_name = req_distinguished_name +x509_extensions = v3_req +prompt = no + +[req_distinguished_name] +CN = *.localtest.me + +[v3_req] +subjectAltName = @alt_names +keyUsage = digitalSignature, keyEncipherment +extendedKeyUsage = serverAuth + +[alt_names] +DNS.1 = *.localtest.me +DNS.2 = localtest.me diff --git a/ControlPlane.Core/Config/ClarityInfraOptions.cs b/ControlPlane.Core/Config/ClarityInfraOptions.cs new file mode 100644 index 0000000..4979ee7 --- /dev/null +++ b/ControlPlane.Core/Config/ClarityInfraOptions.cs @@ -0,0 +1,51 @@ +namespace ControlPlane.Core.Config; + +/// +/// Central configuration for all infrastructure URLs, network names, and domain values. +/// Bind from the "Clarity" section in appsettings.json or via AppHost environment variables. +/// Eliminates hardcoded strings spread across Worker, AppHost, and generated configs. +/// +public sealed class ClarityInfraOptions +{ + public const string Section = "Clarity"; + + // ── Domain ──────────────────────────────────────────────────────────── + + /// The base DNS domain for all tenant subdomains. e.g. "clarity.test" + public string Domain { get; set; } = "clarity.test"; + + /// The Docker network all managed containers are attached to. + public string Network { get; set; } = "clarity-net"; + + // ── Keycloak ────────────────────────────────────────────────────────── + + /// Public browser-facing Keycloak URL — used in redirect URIs and JWT iss claim. + public string KeycloakPublicUrl { get; set; } = "https://keycloak.clarity.test"; + + /// Internal Docker DNS URL for server-side Keycloak calls (avoids self-signed cert). + public string KeycloakInternalUrl { get; set; } = "http://keycloak:8080"; + + // ── Vault ───────────────────────────────────────────────────────────── + + /// Internal Docker DNS URL for Vault — injected into tenant containers. + public string VaultInternalUrl { get; set; } = "http://vault:8200"; + + // ── nginx SSL certs ─────────────────────────────────────────────────── + + /// Path to the wildcard TLS cert inside the nginx container. + public string NginxCertPath { get; set; } = "/etc/nginx/certs/clarity.test.crt"; + + /// Path to the wildcard TLS key inside the nginx container. + public string NginxCertKeyPath { get; set; } = "/etc/nginx/certs/clarity.test.key"; + + // ── Helpers ─────────────────────────────────────────────────────────── + + /// Builds the public tenant URL for a given subdomain. + public string TenantPublicUrl(string subdomain) => $"https://{subdomain}.{Domain}"; + + /// Builds the public Keycloak realm URL for a given realm (browser-facing). + public string KeycloakRealmPublicUrl(string realm) => $"{KeycloakPublicUrl}/realms/{realm}"; + + /// Builds the internal Keycloak realm URL for a given realm (server-side). + public string KeycloakRealmInternalUrl(string realm) => $"{KeycloakInternalUrl}/realms/{realm}"; +} diff --git a/ControlPlane.Core/ControlPlane.Core.csproj b/ControlPlane.Core/ControlPlane.Core.csproj new file mode 100644 index 0000000..c6d0828 --- /dev/null +++ b/ControlPlane.Core/ControlPlane.Core.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + + + + + + + + diff --git a/ControlPlane.Core/Interfaces/ISagaStep.cs b/ControlPlane.Core/Interfaces/ISagaStep.cs new file mode 100644 index 0000000..29e31ed --- /dev/null +++ b/ControlPlane.Core/Interfaces/ISagaStep.cs @@ -0,0 +1,8 @@ +namespace ControlPlane.Core.Interfaces; + +public interface ISagaStep +{ + string StepName { get; } + Task ExecuteAsync(SagaContext context, CancellationToken cancellationToken); + Task CompensateAsync(SagaContext context, CancellationToken cancellationToken); +} diff --git a/ControlPlane.Core/Interfaces/SagaContext.cs b/ControlPlane.Core/Interfaces/SagaContext.cs new file mode 100644 index 0000000..4c80f0b --- /dev/null +++ b/ControlPlane.Core/Interfaces/SagaContext.cs @@ -0,0 +1,30 @@ +using ControlPlane.Core.Models; + +namespace ControlPlane.Core.Interfaces; + +/// +/// Mutable context bag passed through every saga step. +/// Steps read inputs and write outputs here so downstream steps can consume them. +/// +public class SagaContext +{ + public ProvisioningJob Job { get; init; } = default!; + + // Written by DatabaseStep — connection string for the tenant's Postgres (shared or own) + public string? TenantConnectionString { get; set; } + public string? TenantStackName { get; set; } + + // Written by KeycloakStep + public string? DayZeroUserSubjectId { get; set; } + public string? MagicLink { get; set; } + + // Written by LaunchStep or PulumiStep — base URL for the provisioned tenant + public string? TenantApiBaseUrl { get; set; } + + // Written by LaunchStep — primary app container name + public string? ContainerName { get; set; } + + // Written by PulumiStep (DedicatedVM/Enterprise tier) — target host details for subsequent steps + public string? VmIpAddress { get; set; } + public string? VmSshKeyPath { get; set; } +} diff --git a/ControlPlane.Core/Messages/ProvisioningMessages.cs b/ControlPlane.Core/Messages/ProvisioningMessages.cs new file mode 100644 index 0000000..6c4fc70 --- /dev/null +++ b/ControlPlane.Core/Messages/ProvisioningMessages.cs @@ -0,0 +1,38 @@ +using ControlPlane.Core.Models; + +namespace ControlPlane.Core.Messages; + +/// API -> Worker: kick off the saga. +public record ProvisionClientCommand +{ + public Guid JobId { get; init; } + public string ClientName { get; init; } = string.Empty; + public string StateCode { get; init; } = string.Empty; + public string Subdomain { get; init; } = string.Empty; + public string AdminEmail { get; init; } = string.Empty; + public string SiteCode { get; init; } = string.Empty; + public string Environment { get; init; } = "fdev"; + public TenantTier Tier { get; init; } = TenantTier.Shared; +} + +/// Worker -> API/Gateway: one log event per saga step transition. +public record ProvisioningProgressEvent +{ + public Guid JobId { get; init; } + public string Type { get; init; } = string.Empty; // step_started | step_complete | step_failed | job_complete | job_failed | diagnostic | compensation_started | compensation_complete + public string? Step { get; init; } + public string? Message { get; init; } + /// Full exception string (stack trace) for diagnostic events. + public string? Detail { get; init; } + public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow; +} + +/// Worker -> Gateway: published once when a job completes successfully. Triggers route registration. +public record TenantProvisionedEvent +{ + public Guid JobId { get; init; } + public string Subdomain { get; init; } = string.Empty; + public TenantTier Tier { get; init; } + /// Base URL of the API instance for this tenant. For Shared/Isolated this is the shared API. For Dedicated it is the per-tenant instance. + public string ApiBaseUrl { get; init; } = string.Empty; +} diff --git a/ControlPlane.Core/Models/BuildRecord.cs b/ControlPlane.Core/Models/BuildRecord.cs new file mode 100644 index 0000000..c1b8ba8 --- /dev/null +++ b/ControlPlane.Core/Models/BuildRecord.cs @@ -0,0 +1,24 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ControlPlane.Core.Models; + +public enum BuildStatus { Running, Succeeded, Failed } +public enum BuildKind { DockerImage, DotnetProject, NpmProject } + +/// +/// Persisted record of a single build run — image build, dotnet build, or npm build. +/// Stored in ClientAssets/builds.json. +/// +public class BuildRecord +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N")[..8]; + public BuildKind Kind { get; set; } + public string Target { get; set; } = string.Empty; // image name or project path + public BuildStatus Status { get; set; } = BuildStatus.Running; + public DateTimeOffset StartedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? FinishedAt { get; set; } + public int? DurationMs { get; set; } + public string? ImageDigest { get; set; } // populated for DockerImage builds + public List Log { get; set; } = []; +} diff --git a/ControlPlane.Core/Models/ComponentMode.cs b/ControlPlane.Core/Models/ComponentMode.cs new file mode 100644 index 0000000..9238b77 --- /dev/null +++ b/ControlPlane.Core/Models/ComponentMode.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; + +namespace ControlPlane.Core.Models; + +/// +/// Defines where a specific infrastructure component (Postgres, Keycloak, Vault, MinIO) +/// is hosted for a given tenant. Each component in a StackConfig is configured independently. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ComponentMode +{ + /// Shared platform instance — logical slice only (realm, schema, bucket, namespace). + SharedPlatform, + + /// Baked into the app image itself via supervisord. Trial tier only. + Bundled, + + /// Own sidecar container on ControlPlane's shared Docker host. + OwnContainer, + + /// Own VM with the component running inside Docker on it. + VpsDocker, + + /// Own VM with the component running as a native OS process (no Docker). + VpsBareMetal +} diff --git a/ControlPlane.Core/Models/GitCommit.cs b/ControlPlane.Core/Models/GitCommit.cs new file mode 100644 index 0000000..98338cc --- /dev/null +++ b/ControlPlane.Core/Models/GitCommit.cs @@ -0,0 +1,10 @@ +namespace ControlPlane.Core.Models; + +public record GitCommit( + string Hash, + string ShortHash, + string Author, + string Date, + string Subject, + string[] Files +); diff --git a/ControlPlane.Core/Models/GiteaModels.cs b/ControlPlane.Core/Models/GiteaModels.cs new file mode 100644 index 0000000..3b899b0 --- /dev/null +++ b/ControlPlane.Core/Models/GiteaModels.cs @@ -0,0 +1,63 @@ +namespace ControlPlane.Core.Models; + +// ── Repository ──────────────────────────────────────────────────────────────── + +public record GiteaRepo( + long Id, + string Name, + string FullName, + string DefaultBranch, + string CloneUrl, + string SshUrl, + bool Private +); + +// ── Branch ──────────────────────────────────────────────────────────────────── + +public record GiteaBranch( + string Name, + string CommitSha, + bool Protected +); + +// ── Pull Request ────────────────────────────────────────────────────────────── + +public record GiteaPullRequest( + long Number, + string Title, + string State, // open | closed | merged + string HeadBranch, + string BaseBranch, + string HtmlUrl, + string CreatedAt, + string UpdatedAt, + GiteaUser? User, + GiteaMergeInfo? MergeInfo +); + +public record GiteaUser(string Login, string AvatarUrl); + +public record GiteaMergeInfo(bool Mergeable, bool Merged, string? MergedAt); + +// ── Tag ─────────────────────────────────────────────────────────────────────── + +public record GiteaTag(string Name, string CommitSha, string ZipUrl); + +// ── Webhook ─────────────────────────────────────────────────────────────────── + +public record GiteaWebhook(long Id, string Url, bool Active, string[] Events); + +// ── Request shapes ──────────────────────────────────────────────────────────── + +public record CreateBranchRequest(string OpcNumber, string OpcTitle, string From = "master"); + +public record CreatePullRequestRequest( + string Title, + string Head, + string Base, + string Body +); + +public record CreateTagRequest(string TagName, string Message, string CommitSha); + +public record CreateWebhookRequest(string TargetUrl, string[] Events); diff --git a/ControlPlane.Core/Models/OpcModels.cs b/ControlPlane.Core/Models/OpcModels.cs new file mode 100644 index 0000000..1fc115e --- /dev/null +++ b/ControlPlane.Core/Models/OpcModels.cs @@ -0,0 +1,73 @@ +namespace ControlPlane.Core.Models; + +public record OpcRecord( + Guid Id, + string Number, + string Title, + string Description, + string Type, + string Status, + string Priority, + string Assignee, + DateTime CreatedAt, + DateTime UpdatedAt +); + +public record OpcNote( + Guid Id, + Guid OpcId, + string Author, + string Content, + DateTime CreatedAt +); + +public record OpcArtifact( + Guid Id, + Guid OpcId, + string ArtifactType, + string Title, + string Content, + DateTime CreatedAt, + DateTime UpdatedAt +); + +// Request / response shapes used by the API endpoints + +public record CreateOpcRequest( + string Title, + string Type, + string Priority, + string Assignee, + string Description +); + +public record UpdateOpcRequest( + string? Title, + string? Description, + string? Type, + string? Status, + string? Priority, + string? Assignee +); + +public record AddNoteRequest(string Author, string Content); + +public record UpsertArtifactRequest( + string ArtifactType, + string Title, + string Content +); + +public record AiAssistRequest(string Prompt, string? Context); + +public record OpcPinnedCommit( + Guid OpcId, + string Hash, + string ShortHash, + string Subject, + string Author, + DateTime PinnedAt, + string PinnedBy +); + +public record PinCommitRequest(string Hash, string PinnedBy); diff --git a/ControlPlane.Core/Models/PromotionRequest.cs b/ControlPlane.Core/Models/PromotionRequest.cs new file mode 100644 index 0000000..a68fd38 --- /dev/null +++ b/ControlPlane.Core/Models/PromotionRequest.cs @@ -0,0 +1,22 @@ +namespace ControlPlane.Core.Models; + +public enum PromotionStatus { Pending, Running, Succeeded, Failed } + +/// +/// Represents a request to promote (merge) one environment branch into the next. +/// e.g. develop → staging, staging → uat, uat → main +/// +public class PromotionRequest +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N")[..8]; + public string FromBranch { get; set; } = string.Empty; + public string ToBranch { get; set; } = string.Empty; + public string RequestedBy { get; set; } = "system"; + public string? Note { get; set; } + public PromotionStatus Status { get; set; } = PromotionStatus.Pending; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? CompletedAt { get; set; } + public List Log { get; set; } = []; + public int CommitCount { get; set; } // commits in from that are not in to + public string[] CommitLines { get; set; } = []; // oneline summary of those commits +} diff --git a/ControlPlane.Core/Models/ProvisioningJob.cs b/ControlPlane.Core/Models/ProvisioningJob.cs new file mode 100644 index 0000000..ec27b31 --- /dev/null +++ b/ControlPlane.Core/Models/ProvisioningJob.cs @@ -0,0 +1,47 @@ +namespace ControlPlane.Core.Models; + +public enum ProvisioningStatus +{ + Pending, + Running, + Compensating, + Failed, + Completed +} + +[Flags] +public enum CompletedSteps +{ + None = 0, + InfrastructureProvisioned = 1 << 0, + KeycloakProvisioned = 1 << 1, + VaultVerified = 1 << 2, + DatabaseMigrated = 1 << 3, + HandoffSent = 1 << 4 +} + +public class ProvisioningJob +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string ClientName { get; set; } = string.Empty; + public string StateCode { get; set; } = string.Empty; + public string Subdomain { get; set; } = string.Empty; + public string AdminEmail { get; set; } = string.Empty; + public string SiteCode { get; set; } = string.Empty; + public string Environment { get; set; } = "fdev"; + + public TenantTier Tier { get; set; } = TenantTier.Shared; + + /// + /// Snapshot of the StackConfig at the time provisioning was requested. + /// Immutable after the job is created. + /// + public StackConfig StackConfig { get; set; } = StackConfig.DefaultForTier(TenantTier.Shared); + + public ProvisioningStatus Status { get; set; } = ProvisioningStatus.Pending; + public CompletedSteps CompletedSteps { get; set; } = CompletedSteps.None; + + public string? FailureReason { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? CompletedAt { get; set; } +} diff --git a/ControlPlane.Core/Models/ProvisioningRequest.cs b/ControlPlane.Core/Models/ProvisioningRequest.cs new file mode 100644 index 0000000..60889f9 --- /dev/null +++ b/ControlPlane.Core/Models/ProvisioningRequest.cs @@ -0,0 +1,18 @@ +namespace ControlPlane.Core.Models; + +public class ProvisioningRequest +{ + public string ClientName { get; set; } = string.Empty; + public string StateCode { get; set; } = string.Empty; + public string Subdomain { get; set; } = string.Empty; + public string AdminEmail { get; set; } = string.Empty; + public string SiteCode { get; set; } = string.Empty; + public string Environment { get; set; } = "fdev"; + public TenantTier Tier { get; set; } = TenantTier.Shared; + + /// + /// Per-component infrastructure configuration. Defaults to the standard profile + /// for the selected tier if not explicitly specified. + /// + public StackConfig StackConfig { get; set; } = StackConfig.DefaultForTier(TenantTier.Shared); +} diff --git a/ControlPlane.Core/Models/ReleaseRecord.cs b/ControlPlane.Core/Models/ReleaseRecord.cs new file mode 100644 index 0000000..67c892a --- /dev/null +++ b/ControlPlane.Core/Models/ReleaseRecord.cs @@ -0,0 +1,27 @@ +namespace ControlPlane.Core.Models; + +public enum ReleaseStatus { Running, Succeeded, PartialFailure, Failed } + +/// +/// Persisted record of a release — a coordinated redeploy of all tenant containers +/// in a target environment to the latest clarity-server image. +/// Stored in ClientAssets/releases.json. +/// +public class ReleaseRecord +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N")[..8]; + public string Environment { get; set; } = string.Empty; // fdev | uat | prod | all + public string ImageName { get; set; } = string.Empty; + public ReleaseStatus Status { get; set; } = ReleaseStatus.Running; + public DateTimeOffset StartedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? FinishedAt { get; set; } + public List Tenants { get; set; } = []; +} + +public class TenantReleaseResult +{ + public string Subdomain { get; set; } = string.Empty; + public string ContainerName { get; set; } = string.Empty; + public bool Success { get; set; } + public string? Error { get; set; } +} diff --git a/ControlPlane.Core/Models/StackConfig.cs b/ControlPlane.Core/Models/StackConfig.cs new file mode 100644 index 0000000..e2ba7fd --- /dev/null +++ b/ControlPlane.Core/Models/StackConfig.cs @@ -0,0 +1,51 @@ +namespace ControlPlane.Core.Models; + +/// +/// Defines the exact infrastructure composition for a provisioned tenant. +/// Each component is configured independently — the TenantTier gates which +/// ComponentMode values are available in the UI. +/// +/// Allowed modes per tier: +/// +/// | Trial | Shared | Dedicated | Enterprise | +/// SharedPlatform | ✅ | ✅ | ✅ | ✅ | +/// Bundled | ✅ | ❌ | ❌ | ❌ | +/// OwnContainer | ❌ | ❌ | ✅ | ✅ | +/// VpsDocker | ❌ | ❌ | ❌ | ✅ | +/// VpsBareMetal | ❌ | ❌ | ❌ | ✅ | +/// +public class StackConfig +{ + public ComponentMode Postgres { get; set; } = ComponentMode.SharedPlatform; + public ComponentMode Keycloak { get; set; } = ComponentMode.SharedPlatform; + public ComponentMode Vault { get; set; } = ComponentMode.SharedPlatform; + public ComponentMode Minio { get; set; } = ComponentMode.SharedPlatform; + + /// Returns a default StackConfig for the given tier. + public static StackConfig DefaultForTier(TenantTier tier) => tier switch + { + TenantTier.Trial => new StackConfig + { + Postgres = ComponentMode.Bundled, + Keycloak = ComponentMode.SharedPlatform, + Vault = ComponentMode.SharedPlatform, + Minio = ComponentMode.SharedPlatform + }, + TenantTier.Shared => new StackConfig(), + TenantTier.Dedicated => new StackConfig + { + Postgres = ComponentMode.OwnContainer, + Keycloak = ComponentMode.OwnContainer, + Vault = ComponentMode.OwnContainer, + Minio = ComponentMode.OwnContainer + }, + TenantTier.Enterprise => new StackConfig + { + Postgres = ComponentMode.VpsDocker, + Keycloak = ComponentMode.VpsDocker, + Vault = ComponentMode.VpsDocker, + Minio = ComponentMode.VpsDocker + }, + _ => new StackConfig() + }; +} diff --git a/ControlPlane.Core/Models/TenantRecord.cs b/ControlPlane.Core/Models/TenantRecord.cs new file mode 100644 index 0000000..720689a --- /dev/null +++ b/ControlPlane.Core/Models/TenantRecord.cs @@ -0,0 +1,135 @@ +using System.Xml.Serialization; + +namespace ControlPlane.Core.Models; + +[XmlRoot("Tenant")] +public class TenantRecord +{ + // ── Identity ────────────────────────────────────────────────────────── + [XmlAttribute] + public string Subdomain { get; set; } = string.Empty; + + [XmlElement] + public string ClientName { get; set; } = string.Empty; + + [XmlElement] + public string StateCode { get; set; } = string.Empty; + + [XmlElement] + public string AdminEmail { get; set; } = string.Empty; + + [XmlElement] + public string SiteCode { get; set; } = string.Empty; + + [XmlElement] + public string Environment { get; set; } = "fdev"; + + [XmlElement] + public string Tier { get; set; } = string.Empty; + + [XmlElement] + public string Status { get; set; } = "Provisioning"; + + [XmlElement] + public string ProvisionedAt { get; set; } = DateTimeOffset.UtcNow.ToString("o"); + + [XmlElement] + public string JobId { get; set; } = string.Empty; + + // ── Container (written by InfrastructureStep / LaunchStep) ──────────── + [XmlElement(IsNullable = true)] + public string? ContainerName { get; set; } + + [XmlElement(IsNullable = true)] + public string? ContainerPort { get; set; } + + [XmlElement(IsNullable = true)] + public string? ContainerImage { get; set; } + + [XmlElement(IsNullable = true)] + public string? ContainerNetwork { get; set; } + + [XmlElement(IsNullable = true)] + public string? NginxConfPath { get; set; } + + [XmlElement(IsNullable = true)] + public string? ApiBaseUrl { get; set; } + + [XmlElement(IsNullable = true)] + public string? PublicUrl { get; set; } + + [XmlElement(IsNullable = true)] + public string? LastProvisioningStep { get; set; } + + [XmlElement(IsNullable = true)] + public string? ProvisioningNotes { get; set; } + + // ── web.config-style sections ───────────────────────────────────────── + + [XmlElement("ConnectionStrings")] + public ConnectionStringsSection ConnectionStrings { get; set; } = new(); + + [XmlElement("AppSettings")] + public AppSettingsSection AppSettings { get; set; } = new(); + + // ── Helpers ─────────────────────────────────────────────────────────── + + public void SetConnectionString(string name, string connectionString) + { + var existing = ConnectionStrings.Entries.FirstOrDefault(e => e.Name == name); + if (existing is not null) + existing.ConnectionString = connectionString; + else + ConnectionStrings.Entries.Add(new ConnectionStringEntry { Name = name, ConnectionString = connectionString }); + } + + public string? GetConnectionString(string name) => + ConnectionStrings.Entries.FirstOrDefault(e => e.Name == name)?.ConnectionString; + + public void SetAppSetting(string key, string value) + { + var existing = AppSettings.Entries.FirstOrDefault(e => e.Key == key); + if (existing is not null) + existing.Value = value; + else + AppSettings.Entries.Add(new AppSettingEntry { Key = key, Value = value }); + } + + public string? GetAppSetting(string key) => + AppSettings.Entries.FirstOrDefault(e => e.Key == key)?.Value; +} + +// ── Section types ────────────────────────────────────────────────────────── + +public class ConnectionStringsSection +{ + [XmlElement("add")] + public List Entries { get; set; } = []; +} + +public class AppSettingsSection +{ + [XmlElement("add")] + public List Entries { get; set; } = []; +} + +public class ConnectionStringEntry +{ + [XmlAttribute("name")] + public string Name { get; set; } = string.Empty; + + [XmlAttribute("connectionString")] + public string ConnectionString { get; set; } = string.Empty; + + [XmlAttribute("providerName")] + public string ProviderName { get; set; } = "System.Data.SqlClient"; +} + +public class AppSettingEntry +{ + [XmlAttribute("key")] + public string Key { get; set; } = string.Empty; + + [XmlAttribute("value")] + public string Value { get; set; } = string.Empty; +} diff --git a/ControlPlane.Core/Models/TenantTier.cs b/ControlPlane.Core/Models/TenantTier.cs new file mode 100644 index 0000000..3696842 --- /dev/null +++ b/ControlPlane.Core/Models/TenantTier.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace ControlPlane.Core.Models; + +/// +/// Defines the billing and support level for a provisioned tenant. +/// The tier gates which ComponentMode values are available per component in the StackConfig. +/// +/// Trial - ephemeral sandbox, all-in-one image, no persistent data guarantee. +/// Shared - real production data, shared platform infrastructure (logical slices only). +/// Dedicated - full container isolation per component, still on ControlPlane's shared host. +/// Enterprise - full VM isolation per component (VpsDocker or VpsBareMetal), Pulumi provisioned. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum TenantTier +{ + Trial, + Shared, + Dedicated, + Enterprise +} diff --git a/ControlPlane.Core/Services/BuildHistoryService.cs b/ControlPlane.Core/Services/BuildHistoryService.cs new file mode 100644 index 0000000..95dcbe1 --- /dev/null +++ b/ControlPlane.Core/Services/BuildHistoryService.cs @@ -0,0 +1,138 @@ +using System.Text.Json; +using ControlPlane.Core.Models; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace ControlPlane.Core.Services; + +/// +/// Persists build and release history to JSON files in the ClientAssets folder. +/// Thread-safe — all writes go through a single lock per file. +/// +public class BuildHistoryService +{ + private readonly string _buildsPath; + private readonly string _releasesPath; + private readonly ILogger _logger; + + private static readonly SemaphoreSlim _buildLock = new(1, 1); + private static readonly SemaphoreSlim _releaseLock = new(1, 1); + + private static readonly JsonSerializerOptions JsonOpts = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }, + }; + + public BuildHistoryService(IConfiguration config, ILogger logger) + { + var folder = config["ClientAssets__Folder"] ?? config["ClientAssets:Folder"] + ?? Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "ClientAssets")); + Directory.CreateDirectory(folder); + _buildsPath = Path.Combine(folder, "builds.json"); + _releasesPath = Path.Combine(folder, "releases.json"); + _logger = logger; + } + + // ── Builds ────────────────────────────────────────────────────────────── + + public async Task CreateBuildAsync(BuildKind kind, string target) + { + var record = new BuildRecord { Kind = kind, Target = target }; + await SaveBuildAsync(record); + return record; + } + + public async Task CompleteBuildAsync(BuildRecord record, BuildStatus status, string? digest = null) + { + record.Status = status; + record.FinishedAt = DateTimeOffset.UtcNow; + record.DurationMs = (int)(record.FinishedAt.Value - record.StartedAt).TotalMilliseconds; + record.ImageDigest = digest; + await SaveBuildAsync(record); + } + + public async Task AppendBuildLogAsync(BuildRecord record, string line) + { + record.Log.Add(line); + // Flush to disk every 20 lines to avoid excessive I/O but keep reasonable freshness + if (record.Log.Count % 20 == 0) + await SaveBuildAsync(record); + } + + public async Task> GetBuildsAsync() + { + await _buildLock.WaitAsync(); + try { return LoadJson(_buildsPath); } + finally { _buildLock.Release(); } + } + + private async Task SaveBuildAsync(BuildRecord record) + { + await _buildLock.WaitAsync(); + try + { + var all = LoadJson(_buildsPath); + var idx = all.FindIndex(b => b.Id == record.Id); + if (idx >= 0) all[idx] = record; + else all.Insert(0, record); + + // Keep last 100 builds + if (all.Count > 100) all = all[..100]; + await File.WriteAllTextAsync(_buildsPath, JsonSerializer.Serialize(all, JsonOpts)); + } + finally { _buildLock.Release(); } + } + + // ── Releases ──────────────────────────────────────────────────────────── + + public async Task CreateReleaseAsync(string environment, string imageName) + { + var record = new ReleaseRecord { Environment = environment, ImageName = imageName }; + await SaveReleaseAsync(record); + return record; + } + + public async Task UpdateReleaseAsync(ReleaseRecord record) + { + record.FinishedAt = DateTimeOffset.UtcNow; + await SaveReleaseAsync(record); + } + + public async Task> GetReleasesAsync() + { + await _releaseLock.WaitAsync(); + try { return LoadJson(_releasesPath); } + finally { _releaseLock.Release(); } + } + + private async Task SaveReleaseAsync(ReleaseRecord record) + { + await _releaseLock.WaitAsync(); + try + { + var all = LoadJson(_releasesPath); + var idx = all.FindIndex(r => r.Id == record.Id); + if (idx >= 0) all[idx] = record; + else all.Insert(0, record); + + if (all.Count > 50) all = all[..50]; + await File.WriteAllTextAsync(_releasesPath, JsonSerializer.Serialize(all, JsonOpts)); + } + finally { _releaseLock.Release(); } + } + + // ── Helpers ───────────────────────────────────────────────────────────── + + private static List LoadJson(string path) + { + if (!File.Exists(path)) return []; + try + { + var json = File.ReadAllText(path); + return JsonSerializer.Deserialize>(json, JsonOpts) ?? []; + } + catch { return []; } + } +} diff --git a/ControlPlane.Core/Services/TenantRegistryService.cs b/ControlPlane.Core/Services/TenantRegistryService.cs new file mode 100644 index 0000000..60fc367 --- /dev/null +++ b/ControlPlane.Core/Services/TenantRegistryService.cs @@ -0,0 +1,77 @@ +using System.Xml.Serialization; +using ControlPlane.Core.Models; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace ControlPlane.Core.Services; + +/// +/// Reads and writes per-tenant XML config files under the ClientAssets folder. +/// One file per tenant: {subdomain}.xml +/// Thread-safe for concurrent reads; writes are serialized per subdomain via per-file locking. +/// +public class TenantRegistryService +{ + private readonly string _folder; + private readonly ILogger _logger; + private static readonly XmlSerializer Serializer = new(typeof(TenantRecord)); + + // One lock object per subdomain so writes to different tenants never block each other + private readonly System.Collections.Concurrent.ConcurrentDictionary _locks = new(StringComparer.OrdinalIgnoreCase); + + public TenantRegistryService(IConfiguration configuration, ILogger logger) + { + _folder = configuration["ClientAssets__Folder"] ?? configuration["ClientAssets:Folder"] + ?? Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "ClientAssets")); + _logger = logger; + Directory.CreateDirectory(_folder); + } + + // -- Write -- + + public void Save(TenantRecord record) + { + var path = FilePath(record.Subdomain); + var gate = _locks.GetOrAdd(record.Subdomain, _ => new object()); + lock (gate) + { + using var writer = new StreamWriter(path, append: false, System.Text.Encoding.UTF8); + Serializer.Serialize(writer, record); + } + _logger.LogInformation("Saved tenant record: {Path}", path); + } + + // -- Read -- + + public TenantRecord? TryGet(string subdomain) + { + var path = FilePath(subdomain); + if (!File.Exists(path)) return null; + using var reader = new StreamReader(path, System.Text.Encoding.UTF8); + return (TenantRecord?)Serializer.Deserialize(reader); + } + + public IReadOnlyList GetAll() + { + var results = new List(); + foreach (var file in Directory.EnumerateFiles(_folder, "*.xml")) + { + try + { + using var reader = new StreamReader(file, System.Text.Encoding.UTF8); + if (Serializer.Deserialize(reader) is TenantRecord record) + results.Add(record); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Skipping malformed tenant file: {File}", file); + } + } + return results; + } + + public bool Exists(string subdomain) => File.Exists(FilePath(subdomain)); + + private string FilePath(string subdomain) => + Path.Combine(_folder, $"{subdomain.ToLowerInvariant()}.xml"); +} diff --git a/ControlPlane.Worker/ControlPlane.Worker.csproj b/ControlPlane.Worker/ControlPlane.Worker.csproj new file mode 100644 index 0000000..f712858 --- /dev/null +++ b/ControlPlane.Worker/ControlPlane.Worker.csproj @@ -0,0 +1,33 @@ + + + + net10.0 + enable + enable + controlplane-worker-secrets + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ControlPlane.Worker/Dockerfile b/ControlPlane.Worker/Dockerfile new file mode 100644 index 0000000..78adb66 --- /dev/null +++ b/ControlPlane.Worker/Dockerfile @@ -0,0 +1,30 @@ +# ── Build stage ────────────────────────────────────────────────────────────── +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +COPY ["ControlPlane.Worker/ControlPlane.Worker.csproj", "ControlPlane.Worker/"] +COPY ["ControlPlane.Core/ControlPlane.Core.csproj", "ControlPlane.Core/"] +COPY ["Clarity.ServiceDefaults/Clarity.ServiceDefaults.csproj", "Clarity.ServiceDefaults/"] +COPY ["Directory.Packages.props", "./"] + +RUN dotnet restore "ControlPlane.Worker/ControlPlane.Worker.csproj" + +COPY . . +RUN dotnet publish "ControlPlane.Worker/ControlPlane.Worker.csproj" \ + -c Release -o /app/publish --no-restore + +# ── Runtime stage ───────────────────────────────────────────────────────────── +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app + +# Install Pulumi CLI so the Automation API can shell out to it +RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \ + && curl -fsSL https://get.pulumi.com | sh \ + && apt-get purge -y curl \ + && rm -rf /var/lib/apt/lists/* + +ENV PATH="/root/.pulumi/bin:${PATH}" + +COPY --from=build /app/publish . + +ENTRYPOINT ["dotnet", "ControlPlane.Worker.dll"] diff --git a/ControlPlane.Worker/Program.cs b/ControlPlane.Worker/Program.cs new file mode 100644 index 0000000..8d17846 --- /dev/null +++ b/ControlPlane.Worker/Program.cs @@ -0,0 +1,64 @@ +using ControlPlane.Core.Config; +using ControlPlane.Core.Interfaces; +using ControlPlane.Core.Services; +using ControlPlane.Worker; +using ControlPlane.Worker.Services; +using ControlPlane.Worker.Steps; +using Keycloak.AuthServices.Sdk; +using MassTransit; + +var builder = Host.CreateApplicationBuilder(args); + +builder.AddServiceDefaults(); + +// Centralized infrastructure options — domain, network, internal URLs, cert paths +builder.Services.Configure( + builder.Configuration.GetSection(ClarityInfraOptions.Section)); + +// Keycloak Admin SDK client +builder.Services.AddKeycloakAdminHttpClient(o => +{ + o.AuthServerUrl = builder.Configuration["Keycloak:AuthServerUrl"] ?? "http://localhost:8080"; + o.Realm = builder.Configuration["Keycloak:Realm"] ?? "master"; + o.Resource = builder.Configuration["Keycloak:Resource"] ?? "admin-cli"; +}); + +// Custom admin client - handles realm creation, roles, role assignment (not in SDK) +builder.Services.AddSingleton(); + +// Docker container manager for per-tenant Clarity.Server instances +builder.Services.AddSingleton(); + +// Tenant registry - persists provisioned tenant XML files to ClientAssets folder +builder.Services.AddSingleton(); + +// Saga steps in execution order — container launches LAST once all context is populated +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +builder.Services.AddMassTransit(x => +{ + x.SetKebabCaseEndpointNameFormatter(); + + x.AddConsumer(); + + x.UsingRabbitMq((ctx, cfg) => + { + cfg.Host(builder.Configuration.GetConnectionString("rabbitmq")); + cfg.ConfigureEndpoints(ctx); + }); +}); + +try +{ + var host = builder.Build(); + host.Run(); +} +catch (Exception ex) +{ + Console.Error.WriteLine($"FATAL WORKER CRASH: {ex}"); + throw; +} diff --git a/ControlPlane.Worker/ProvisioningWorker.cs b/ControlPlane.Worker/ProvisioningWorker.cs new file mode 100644 index 0000000..bbb53b5 --- /dev/null +++ b/ControlPlane.Worker/ProvisioningWorker.cs @@ -0,0 +1,167 @@ +using ControlPlane.Core.Config; +using ControlPlane.Core.Interfaces; +using ControlPlane.Core.Messages; +using ControlPlane.Core.Models; +using ControlPlane.Core.Services; +using MassTransit; +using Microsoft.Extensions.Options; + +namespace ControlPlane.Worker; + +/// +/// MassTransit consumer. Triggered by ProvisionClientCommand off RabbitMQ. +/// Runs the saga and publishes ProvisioningProgressEvent for each step transition. +/// +public sealed class ProvisioningConsumer( + IEnumerable steps, + IPublishEndpoint bus, + IConfiguration config, + IOptions infraOptions, + TenantRegistryService registry, + ILogger logger) : IConsumer +{ + public async Task Consume(ConsumeContext context) + { + var cmd = context.Message; + var job = new ProvisioningJob + { + Id = cmd.JobId, + ClientName = cmd.ClientName, + StateCode = cmd.StateCode, + Subdomain = cmd.Subdomain, + AdminEmail = cmd.AdminEmail, + SiteCode = cmd.SiteCode, + Environment = cmd.Environment, + Status = ProvisioningStatus.Running + }; + + logger.LogInformation("Starting provisioning saga for job {JobId} ({Client})", job.Id, job.ClientName); + await RunSagaAsync(job, context.CancellationToken); + } + + private async Task RunSagaAsync(ProvisioningJob job, CancellationToken cancellationToken) + { + var sagaContext = new SagaContext { Job = job }; + var executedSteps = new Stack(); + + foreach (var step in steps) + { + try + { + await Publish(job.Id, "step_started", step.StepName, $"Starting: {step.StepName}"); + logger.LogInformation("[{JobId}] Executing: {Step}", job.Id, step.StepName); + + await step.ExecuteAsync(sagaContext, cancellationToken); + executedSteps.Push(step); + + await Publish(job.Id, "step_complete", step.StepName, $"Completed: {step.StepName}"); + } + catch (Exception ex) + { + logger.LogError(ex, "[{JobId}] Step {Step} failed", job.Id, step.StepName); + await Publish(job.Id, "step_failed", step.StepName, $"Failed: {step.StepName} - {ex.Message}"); + await PublishDiagnostic(job.Id, step.StepName, ex); + + job.Status = ProvisioningStatus.Compensating; + job.FailureReason = $"{step.StepName}: {ex.Message}"; + + await CompensateAsync(sagaContext, executedSteps, cancellationToken); + + job.Status = ProvisioningStatus.Failed; + await Publish(job.Id, "job_failed", null, job.FailureReason); + return; + } + } + + job.Status = ProvisioningStatus.Completed; + job.CompletedAt = DateTimeOffset.UtcNow; + await Publish(job.Id, "job_complete", null, "All steps completed successfully."); + + var infra = infraOptions.Value; + var apiBaseUrl = sagaContext.TenantApiBaseUrl + ?? infra.TenantPublicUrl(job.Subdomain); + + // Persist to ClientAssets/{subdomain}.xml + var nginxConfPath = config["Nginx:ConfDPath"] is { } p + ? Path.Combine(p, $"{job.Subdomain}.conf") + : null; + + var record = new TenantRecord + { + JobId = job.Id.ToString(), + Subdomain = job.Subdomain, + ClientName = job.ClientName, + StateCode = job.StateCode, + AdminEmail = job.AdminEmail, + SiteCode = job.SiteCode, + Environment = job.Environment, + Tier = job.Tier.ToString(), + ApiBaseUrl = apiBaseUrl, + Status = "Provisioned", + ProvisionedAt = job.CompletedAt!.Value.ToString("o"), + ContainerName = sagaContext.ContainerName, + ContainerPort = null, + ContainerImage = config["Docker:ClarityServerImage"] ?? "clarity-server:latest", + ContainerNetwork = infra.Network, + NginxConfPath = nginxConfPath, + PublicUrl = infra.TenantPublicUrl(job.Subdomain), + LastProvisioningStep = "LaunchStep", + ProvisioningNotes = $"Provisioned at {job.CompletedAt:o}. All {job.CompletedSteps} steps completed.", + }; + + // AppSettings — enriched by each step via SagaContext + record.SetAppSetting("Keycloak:Realm", $"clarity-{job.Subdomain.ToLowerInvariant()}"); + record.SetAppSetting("Keycloak:BaseUrl", infra.KeycloakPublicUrl); + record.SetAppSetting("Keycloak:InternalUrl", infra.KeycloakInternalUrl); + + if (!string.IsNullOrWhiteSpace(sagaContext.TenantStackName)) + record.SetAppSetting("Pulumi:StackName", sagaContext.TenantStackName); + + // ConnectionStrings — written by MigrationStep once DB is provisioned + if (!string.IsNullOrWhiteSpace(sagaContext.TenantConnectionString)) + record.SetConnectionString("TenantDb", sagaContext.TenantConnectionString); + + registry.Save(record); + + logger.LogInformation("[{JobId}] Provisioning completed. Tenant record saved.", job.Id); + } + + private async Task CompensateAsync(SagaContext sagaContext, Stack executedSteps, CancellationToken cancellationToken) + { + while (executedSteps.TryPop(out var step)) + { + try + { + logger.LogInformation("[{JobId}] Compensating: {Step}", sagaContext.Job.Id, step.StepName); + await Publish(sagaContext.Job.Id, "compensation_started", step.StepName, $"Rolling back: {step.StepName}"); + await step.CompensateAsync(sagaContext, cancellationToken); + await Publish(sagaContext.Job.Id, "compensation_complete", step.StepName, $"Rolled back: {step.StepName}"); + } + catch (Exception ex) + { + logger.LogError(ex, "[{JobId}] Compensation failed for {Step} - manual intervention required", sagaContext.Job.Id, step.StepName); + await PublishDiagnostic(sagaContext.Job.Id, $"{step.StepName} (compensation)", ex); + } + } + } + + private Task Publish(Guid jobId, string type, string? step, string? message) => + bus.Publish(new ProvisioningProgressEvent + { + JobId = jobId, + Type = type, + Step = step, + Message = message + }); + + private Task PublishDiagnostic(Guid jobId, string? step, Exception ex) => + bus.Publish(new ProvisioningProgressEvent + { + JobId = jobId, + Type = "diagnostic", + Step = step, + Message = ex.Message, + Detail = ex.ToString() + }); +} + diff --git a/ControlPlane.Worker/Services/ClarityContainerService.cs b/ControlPlane.Worker/Services/ClarityContainerService.cs new file mode 100644 index 0000000..d3f2df2 --- /dev/null +++ b/ControlPlane.Worker/Services/ClarityContainerService.cs @@ -0,0 +1,358 @@ +using ControlPlane.Core.Config; +using ControlPlane.Core.Messages; +using Docker.DotNet; +using Docker.DotNet.Models; +using MassTransit; +using Microsoft.Extensions.Options; + +namespace ControlPlane.Worker.Services; + +/// +/// Manages Clarity.Server Docker containers for provisioned tenants. +/// Container naming convention: {env}-app-clarity-{siteCode} +/// e.g. fdev-app-clarity-01000014 +/// +public class ClarityContainerService( + IConfiguration config, + IOptions infraOptions, + IPublishEndpoint bus, + ILogger logger) +{ + private ClarityInfraOptions Infra => infraOptions.Value; + + // The image to run - override via config for prod registries + private string ImageName => config["Docker:ClarityServerImage"] ?? "clarity-server:latest"; + + private DockerClient CreateClient() + { + var uri = config["Docker:Socket"] ?? "npipe://./pipe/docker_engine"; + return new DockerClientConfiguration(new Uri(uri)).CreateClient(); + } + + /// + /// Derives the container name from environment + siteCode. + /// Convention: {env}-app-clarity-{siteCode} + /// + public static string ContainerName(string environment, string siteCode) => + $"{environment.ToLowerInvariant()}-app-clarity-{siteCode.ToLowerInvariant()}"; + + /// + /// Pulls the image (if not present locally), starts the container on the managed network, + /// and writes an nginx conf.d snippet so traffic routes in. + /// No host port binding — nginx reaches the container via Docker DNS on the shared network. + /// + public async Task StartTenantContainerAsync( + string environment, + string siteCode, + string subdomain, + string keycloakRealm, + string? postgresConnectionString, + string? vaultToken, + Guid jobId, + CancellationToken cancellationToken) + { + using var docker = CreateClient(); + var name = ContainerName(environment, siteCode); + + // Stop and remove any existing container with this name (idempotent reprovision) + await TryRemoveExistingAsync(docker, name, cancellationToken); + + // Pull image if not already local + await EnsureImageAsync(docker, cancellationToken); + + // All service URLs use stable Docker DNS names on the managed network — no host ports involved. + var container = await docker.Containers.CreateContainerAsync(new CreateContainerParameters + { + Name = name, + Image = ImageName, + Env = + [ + "ASPNETCORE_ENVIRONMENT=Production", + "ASPNETCORE_URLS=http://+:8080", + $"TenantSubdomain={subdomain}", + $"Keycloak__BaseUrl={Infra.KeycloakPublicUrl}", + $"Keycloak__InternalUrl={Infra.KeycloakInternalUrl}", + $"Keycloak__Realm={keycloakRealm}", + $"Vault__Address={Infra.VaultInternalUrl}", + .. (vaultToken is not null + ? (string[])[$"Vault__Token={vaultToken}"] + : []), + .. (postgresConnectionString is not null + ? (string[])[$"ConnectionStrings__postgresdb={postgresConnectionString}"] + : []), + ], + HostConfig = new HostConfig + { + NetworkMode = Infra.Network, + RestartPolicy = new RestartPolicy { Name = RestartPolicyKind.UnlessStopped }, + }, + Labels = new Dictionary + { + ["clarity.managed"] = "true", + ["clarity.subdomain"] = subdomain, + ["clarity.siteCode"] = siteCode, + ["clarity.env"] = environment, + }, + }, cancellationToken); + + // Ensure Keycloak and Vault are reachable on the managed network via their Docker DNS aliases. + // Aspire places them on its own bridge; tenant containers on clarity-net need them aliased here. + await EnsureContainerOnNetworkAsync(docker, "keycloak", Infra.Network, "keycloak", cancellationToken); + await EnsureContainerOnNetworkAsync(docker, "vault", Infra.Network, "vault", cancellationToken); + + var started = await docker.Containers.StartContainerAsync(container.ID, null, cancellationToken); + if (!started) + throw new InvalidOperationException($"Docker failed to start container {name} (id={container.ID})."); + + logger.LogInformation("Started container {Name} on {Network} (image: {Image})", name, Infra.Network, ImageName); + + await WriteNginxConfigAsync(subdomain, name, jobId, cancellationToken); + + return name; + } + /// + /// Stops and removes a tenant container. Called from InfrastructureStep.CompensateAsync. + /// + public async Task StopAndRemoveAsync(string containerName, CancellationToken cancellationToken) + { + using var docker = CreateClient(); + await TryRemoveExistingAsync(docker, containerName, cancellationToken); + logger.LogInformation("Removed container {Name}", containerName); + } + + // -- helpers -- + + private async Task EnsureImageAsync(DockerClient docker, CancellationToken cancellationToken) + { + var images = await docker.Images.ListImagesAsync(new ImagesListParameters + { + Filters = new Dictionary> + { + ["reference"] = new Dictionary { [ImageName] = true } + } + }, cancellationToken); + + if (images.Count > 0) + { + logger.LogInformation("Image {Image} already present locally.", ImageName); + return; + } + + // Local image (no registry host) — pulling from Docker Hub will always fail. + // The image must be built manually before provisioning. + var isLocalOnly = !ImageName.Contains('/') || ImageName.StartsWith("localhost/"); + if (isLocalOnly) + { + throw new InvalidOperationException( + $"Image '{ImageName}' was not found locally and cannot be pulled from a registry. " + + $"Build it first from the repo root:{Environment.NewLine}" + + $" docker build -f Clarity.Server/Dockerfile -t {ImageName} ." + + $"{Environment.NewLine}Then retry provisioning."); + } + + // Registry image — attempt pull + logger.LogInformation("Pulling image {Image} from registry...", ImageName); + var (repo, tag) = SplitImageTag(ImageName); + await docker.Images.CreateImageAsync( + new ImagesCreateParameters { FromImage = repo, Tag = tag }, + null, + new Progress(m => + { + if (!string.IsNullOrWhiteSpace(m.Status)) + logger.LogDebug("[docker pull] {Status} {Progress}", m.Status, m.ProgressMessage); + }), + cancellationToken); + } + + // -- nginx conf.d helpers -- + + /// + /// Writes /NginxConfig/conf.d/{subdomain}.conf so nginx routes + /// {subdomain}.clarity.test → the containe + /// Then signals nginx to reload its config without dropping connections. + /// + private async Task WriteNginxConfigAsync(string subdomain, string containerName, Guid jobId, CancellationToken ct) + { + var confDPath = config["Nginx:ConfDPath"]; + if (string.IsNullOrWhiteSpace(confDPath)) + { + logger.LogWarning("Nginx:ConfDPath is not configured — skipping nginx conf write for {Subdomain}.", subdomain); + return; + } + + var confContent = $$$""" + # Auto-generated by ControlPlane.Worker — do not edit manually. + # Tenant: {{{subdomain}}} + server { + listen 443 ssl; + server_name {{{subdomain}}}.{{{Infra.Domain}}}; + + ssl_certificate {{{Infra.NginxCertPath}}}; + ssl_certificate_key {{{Infra.NginxCertKeyPath}}}; + + location / { + # Docker DNS resolves the container name on the managed network + set $upstream http://{{{containerName}}}:8080; + proxy_pass $upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } + """; + + var confFile = Path.Combine(confDPath, $"{subdomain}.conf"); + await File.WriteAllTextAsync(confFile, confContent, ct); + logger.LogInformation("Wrote nginx config for {Subdomain} → {Container}", subdomain, containerName); + + await ReloadNginxAsync(jobId, subdomain, ct); + } + + public async Task RemoveNginxConfigAsync(string subdomain, CancellationToken ct) + { + var confDPath = config["Nginx:ConfDPath"]; + if (string.IsNullOrWhiteSpace(confDPath)) return; + + var confFile = Path.Combine(confDPath, $"{subdomain}.conf"); + if (File.Exists(confFile)) + { + File.Delete(confFile); + logger.LogInformation("Removed nginx config for {Subdomain}", subdomain); + await ReloadNginxAsync(Guid.Empty, subdomain, ct); + } + } + + /// Sends SIGHUP to the nginx container which triggers a graceful config reload. + private async Task ReloadNginxAsync(Guid jobId, string subdomain, CancellationToken ct) + { + try + { + using var docker = CreateClient(); + + // Find the nginx container by image name — Aspire appends a random suffix to the name + // so we can't rely on the static name "nginx". + var containers = await docker.Containers.ListContainersAsync( + new ContainersListParameters + { + Filters = new Dictionary> + { + ["ancestor"] = new Dictionary { ["nginx"] = true } + } + }, ct); + + var nginx = containers.FirstOrDefault(); + if (nginx is null) + { + logger.LogWarning("nginx container not found — skipping reload."); + return; + } + + await docker.Containers.KillContainerAsync(nginx.ID, new ContainerKillParameters { Signal = "HUP" }, ct); + var containerName = nginx.Names.FirstOrDefault() ?? nginx.ID; + logger.LogInformation("nginx reloaded (container: {Name}).", containerName); + + if (jobId != Guid.Empty) + { + await bus.Publish(new ProvisioningProgressEvent + { + JobId = jobId, + Type = "nginx_reloaded", + Step = "Container Launch", + Message = $"nginx reloaded — route for {subdomain}.{Infra.Domain} is live.", + }, ct); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to reload nginx — new tenant route may not be active until next nginx restart."); + + if (jobId != Guid.Empty) + { + await bus.Publish(new ProvisioningProgressEvent + { + JobId = jobId, + Type = "diagnostic", + Step = "Container Launch", + Message = "nginx reload failed — route may not be active.", + Detail = ex.ToString(), + }, ct); + } + } + } + + // -- docker helpers -- + + private static async Task TryRemoveExistingAsync(DockerClient docker, string name, CancellationToken cancellationToken) + { + try + { + await docker.Containers.StopContainerAsync(name, + new ContainerStopParameters { WaitBeforeKillSeconds = 5 }, cancellationToken); + await docker.Containers.RemoveContainerAsync(name, + new ContainerRemoveParameters { Force = true }, cancellationToken); + } + catch (DockerContainerNotFoundException) { /* already gone - fine */ } + catch (DockerApiException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) { /* same */ } + } + + private static (string repo, string tag) SplitImageTag(string image) + { + var colon = image.LastIndexOf(':'); + return colon < 0 ? (image, "latest") : (image[..colon], image[(colon + 1)..]); + } + + /// + /// Connects to with the given + /// if it isn't already connected. + /// Silently no-ops if the container isn't found (it may not be running in all environments). + /// + private async Task EnsureContainerOnNetworkAsync( + DockerClient docker, + string containerName, + string network, + string alias, + CancellationToken cancellationToken) + { + try + { + var inspect = await docker.Containers.InspectContainerAsync(containerName, cancellationToken); + + if (inspect.NetworkSettings.Networks.TryGetValue(network, out var existing)) + { + // Already connected — check whether our alias is present. + var hasAlias = existing.Aliases?.Contains(alias, StringComparer.OrdinalIgnoreCase) == true; + if (hasAlias) return; + + // Connected but without the alias — disconnect so we can reconnect with it. + await docker.Networks.DisconnectNetworkAsync(network, new NetworkDisconnectParameters + { + Container = inspect.ID, + Force = true, + }, cancellationToken); + } + + await docker.Networks.ConnectNetworkAsync(network, new NetworkConnectParameters + { + Container = inspect.ID, + EndpointConfig = new EndpointSettings + { + Aliases = [alias], + }, + }, cancellationToken); + logger.LogInformation("Connected container '{Container}' to network '{Network}' with alias '{Alias}'.", containerName, network, alias); + } + catch (DockerContainerNotFoundException) + { + logger.LogWarning("Container '{Container}' not found — skipping network connect.", containerName); + } + catch (DockerApiException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) + { + logger.LogWarning("Container '{Container}' not found — skipping network connect.", containerName); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Could not connect '{Container}' to '{Network}' — tenant JWT validation may fail.", containerName, network); + } + } +} diff --git a/ControlPlane.Worker/Services/KeycloakAdminClient.cs b/ControlPlane.Worker/Services/KeycloakAdminClient.cs new file mode 100644 index 0000000..35c83a5 --- /dev/null +++ b/ControlPlane.Worker/Services/KeycloakAdminClient.cs @@ -0,0 +1,194 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; + +namespace ControlPlane.Worker.Services; + +public class KeycloakAdminClient +{ + private readonly HttpClient _http; + private readonly string _baseUrl; + private readonly string _adminUser; + private readonly string _adminPassword; + private readonly ILogger _logger; + + private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); + + public KeycloakAdminClient(IConfiguration config, ILogger logger) + { + _logger = logger; + _baseUrl = (config["Keycloak:AuthServerUrl"] ?? config["Keycloak:BaseUrl"])?.TrimEnd('/') ?? "http://localhost:8080"; + _adminUser = config["Keycloak:AdminUser"] ?? "admin"; + _adminPassword = config["Keycloak:AdminPassword"] ?? "admin"; + + var maskedPw = _adminPassword.Length > 2 ? $"{_adminPassword[0]}***{_adminPassword[^1]}" : "***"; + _logger.LogInformation("KeycloakAdminClient base URL: {Url}, user: {User}, password: {Password}", + _baseUrl, _adminUser, maskedPw); + + _http = new HttpClient { BaseAddress = new Uri(_baseUrl) }; + } + + private async Task AuthorizeAsync(CancellationToken ct) + { + var form = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "password", + ["client_id"] = "admin-cli", + ["username"] = _adminUser, + ["password"] = _adminPassword, + }); + + var res = await _http.PostAsync("/realms/master/protocol/openid-connect/token", form, ct); + res.EnsureSuccessStatusCode(); + + using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct)); + var token = doc.RootElement.GetProperty("access_token").GetString()!; + _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + } + + public async Task CreateRealmAsync(string realmId, string displayName, CancellationToken ct) + { + await AuthorizeAsync(ct); + var res = await _http.PostAsync("/admin/realms", Json(new + { + realm = realmId, + displayName = displayName, + enabled = true, + registrationAllowed = true, + registrationEmailAsUsername = false, + loginWithEmailAllowed = true, + resetPasswordAllowed = true, + verifyEmail = false, + sslRequired = "external", + }), ct); + + if (res.StatusCode == System.Net.HttpStatusCode.Conflict) + { + _logger.LogWarning("Realm {Realm} already exists - skipping.", realmId); + return; + } + + res.EnsureSuccessStatusCode(); + _logger.LogInformation("Realm {Realm} created.", realmId); + } + + public async Task DeleteRealmAsync(string realmId, CancellationToken ct) + { + await AuthorizeAsync(ct); + var res = await _http.DeleteAsync($"/admin/realms/{realmId}", ct); + if (res.StatusCode != System.Net.HttpStatusCode.NotFound) + res.EnsureSuccessStatusCode(); + _logger.LogInformation("Realm {Realm} deleted.", realmId); + } + + public async Task CreateRealmRoleAsync(string realmId, string roleName, string description, CancellationToken ct) + { + await AuthorizeAsync(ct); + var res = await _http.PostAsync($"/admin/realms/{realmId}/roles", + Json(new { name = roleName, description }), ct); + if (res.StatusCode != System.Net.HttpStatusCode.Conflict) + res.EnsureSuccessStatusCode(); + } + + public async Task CreateUserAsync(string realmId, string email, string firstName, CancellationToken ct) + { + await AuthorizeAsync(ct); + var res = await _http.PostAsync($"/admin/realms/{realmId}/users", + Json(new { username = email, email, firstName, enabled = true, emailVerified = true }), ct); + if (res.StatusCode != System.Net.HttpStatusCode.Conflict) + res.EnsureSuccessStatusCode(); + return await GetUserIdAsync(realmId, email, ct); + } + + public async Task GetUserIdAsync(string realmId, string email, CancellationToken ct) + { + await AuthorizeAsync(ct); + var res = await _http.GetAsync( + $"/admin/realms/{realmId}/users?email={Uri.EscapeDataString(email)}&exact=true", ct); + res.EnsureSuccessStatusCode(); + using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct)); + var users = doc.RootElement.EnumerateArray().ToList(); + if (users.Count == 0) + throw new InvalidOperationException($"User {email} not found in realm {realmId}."); + return users[0].GetProperty("id").GetString()!; + } + + public async Task AssignRealmRoleAsync(string realmId, string userId, string roleName, CancellationToken ct) + { + await AuthorizeAsync(ct); + var roleRes = await _http.GetAsync($"/admin/realms/{realmId}/roles/{roleName}", ct); + roleRes.EnsureSuccessStatusCode(); + var roleJson = await roleRes.Content.ReadAsStringAsync(ct); + var res = await _http.PostAsync( + $"/admin/realms/{realmId}/users/{userId}/role-mappings/realm", + new StringContent($"[{roleJson}]", Encoding.UTF8, "application/json"), ct); + res.EnsureSuccessStatusCode(); + } + + public async Task CreateClientAsync(string realmId, object clientRepresentation, CancellationToken ct) + { + await AuthorizeAsync(ct); + var res = await _http.PostAsync($"/admin/realms/{realmId}/clients", + Json(clientRepresentation), ct); + if (res.StatusCode != System.Net.HttpStatusCode.Conflict) + res.EnsureSuccessStatusCode(); + } + + /// + /// Returns the internal Keycloak UUID for a client by its clientId string. + /// + public async Task GetClientUuidAsync(string realmId, string clientId, CancellationToken ct) + { + await AuthorizeAsync(ct); + var res = await _http.GetAsync( + $"/admin/realms/{realmId}/clients?clientId={Uri.EscapeDataString(clientId)}&search=false", ct); + res.EnsureSuccessStatusCode(); + using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct)); + var clients = doc.RootElement.EnumerateArray().ToList(); + if (clients.Count == 0) + throw new InvalidOperationException($"Client '{clientId}' not found in realm '{realmId}'."); + return clients[0].GetProperty("id").GetString()!; + } + + /// + /// Adds an audience protocol mapper to a client so that the named audience is included in every + /// access token issued by that client. + /// + public async Task AddAudienceMapperAsync(string realmId, string clientUuid, string audienceName, CancellationToken ct) + { + await AuthorizeAsync(ct); + var res = await _http.PostAsync( + $"/admin/realms/{realmId}/clients/{clientUuid}/protocol-mappers/models", + Json(new + { + name = $"audience-{audienceName}", + protocol = "openid-connect", + protocolMapper = "oidc-audience-mapper", + consentRequired = false, + config = new Dictionary + { + ["included.client.audience"] = audienceName, + ["id.token.claim"] = "false", + ["access.token.claim"] = "true", + }, + }), ct); + if (res.StatusCode != System.Net.HttpStatusCode.Conflict) + res.EnsureSuccessStatusCode(); + _logger.LogInformation("Added audience mapper '{Audience}' to client {ClientUuid} in realm {Realm}.", + audienceName, clientUuid, realmId); + } + + public async Task SendRequiredActionsEmailAsync( + string realmId, string userId, IEnumerable actions, CancellationToken ct) + { + await AuthorizeAsync(ct); + var res = await _http.PutAsync( + $"/admin/realms/{realmId}/users/{userId}/execute-actions-email?lifespan=86400", + new StringContent(JsonSerializer.Serialize(actions, JsonOpts), Encoding.UTF8, "application/json"), + ct); + res.EnsureSuccessStatusCode(); + } + + private static StringContent Json(object payload) => + new(JsonSerializer.Serialize(payload, JsonOpts), Encoding.UTF8, "application/json"); +} \ No newline at end of file diff --git a/ControlPlane.Worker/Steps/HandoffStep.cs b/ControlPlane.Worker/Steps/HandoffStep.cs new file mode 100644 index 0000000..bec21e9 --- /dev/null +++ b/ControlPlane.Worker/Steps/HandoffStep.cs @@ -0,0 +1,27 @@ +using ControlPlane.Core.Interfaces; +using ControlPlane.Core.Models; + +namespace ControlPlane.Worker.Steps; + +public class HandoffStep(ILogger logger) : ISagaStep +{ + public string StepName => "Handoff (Email Magic Link)"; + + public Task ExecuteAsync(SagaContext context, CancellationToken cancellationToken) + { + // TODO: SendGrid / AWS SES + // 1. Send email to context.Job.AdminEmail + // 2. Include context.MagicLink for password setup + // 3. Include login URL: https://{context.Job.Subdomain} + logger.LogInformation("[{JobId}] Handoff step is a stub - email provider not yet wired.", context.Job.Id); + context.Job.CompletedSteps |= CompletedSteps.HandoffSent; + return Task.CompletedTask; + } + + public Task CompensateAsync(SagaContext context, CancellationToken cancellationToken) + { + // Email already sent cannot be recalled - log only + logger.LogWarning("[{JobId}] Handoff step: email cannot be compensated if already sent.", context.Job.Id); + return Task.CompletedTask; + } +} diff --git a/ControlPlane.Worker/Steps/KeycloakStep.cs b/ControlPlane.Worker/Steps/KeycloakStep.cs new file mode 100644 index 0000000..b512d18 --- /dev/null +++ b/ControlPlane.Worker/Steps/KeycloakStep.cs @@ -0,0 +1,90 @@ +using ControlPlane.Core.Config; +using ControlPlane.Core.Interfaces; +using ControlPlane.Core.Models; +using ControlPlane.Worker.Services; +using Microsoft.Extensions.Options; + +namespace ControlPlane.Worker.Steps; + +public class KeycloakStep( + KeycloakAdminClient adminClient, + IConfiguration config, + IOptions infraOptions, + ILogger logger) : ISagaStep +{ + public string StepName => "Identity Bootstrapping (Keycloak)"; + + public async Task ExecuteAsync(SagaContext context, CancellationToken cancellationToken) + { + var realmId = RealmId(context); + + logger.LogInformation("[{JobId}] Creating Keycloak realm {Realm}.", context.Job.Id, realmId); + await adminClient.CreateRealmAsync(realmId, context.Job.ClientName, cancellationToken); + + logger.LogInformation("[{JobId}] Creating AgencyAdmin role.", context.Job.Id); + await adminClient.CreateRealmRoleAsync(realmId, "AgencyAdmin", "Day-zero administrator for this Clarity tenant.", cancellationToken); + + // Derive the tenant's public-facing origin from ClarityInfraOptions. + var tenantOrigin = infraOptions.Value.TenantPublicUrl(context.Job.Subdomain); + + logger.LogInformation("[{JobId}] Creating Keycloak clients for realm {Realm} (origin: {Origin}).", + context.Job.Id, realmId, tenantOrigin); + + // clarity-rest-api: bearer-only resource server — just registers the audience so JWT validation passes. + await adminClient.CreateClientAsync(realmId, new + { + clientId = "clarity-rest-api", + name = "Clarity REST API", + enabled = true, + bearerOnly = true, + publicClient = false, + }, cancellationToken); + + // clarity-web-app: public OIDC client used by the React frontend. + await adminClient.CreateClientAsync(realmId, new + { + clientId = "clarity-web-app", + name = "Clarity Web App", + enabled = true, + publicClient = true, + standardFlowEnabled = true, + directAccessGrantsEnabled = false, + rootUrl = tenantOrigin, + baseUrl = "/", + redirectUris = new[] { $"{tenantOrigin}/*" }, + webOrigins = new[] { tenantOrigin }, + }, cancellationToken); + + // Ensure tokens issued by clarity-web-app include "clarity-rest-api" in the `aud` claim + // so that Clarity.Server JWT bearer validation (Audience = "clarity-rest-api") passes. + logger.LogInformation("[{JobId}] Adding audience mapper for clarity-rest-api on clarity-web-app.", context.Job.Id); + var webAppUuid = await adminClient.GetClientUuidAsync(realmId, "clarity-web-app", cancellationToken); + await adminClient.AddAudienceMapperAsync(realmId, webAppUuid, "clarity-rest-api", cancellationToken); + + logger.LogInformation("[{JobId}] Creating day-zero user {Email}.", context.Job.Id, context.Job.AdminEmail); + var userId = await adminClient.CreateUserAsync(realmId, context.Job.AdminEmail, context.Job.ClientName, cancellationToken); + + logger.LogInformation("[{JobId}] Assigning AgencyAdmin role.", context.Job.Id); + await adminClient.AssignRealmRoleAsync(realmId, userId, "AgencyAdmin", cancellationToken); + + // TODO No SMTP right now + //logger.LogInformation("[{JobId}] Sending required actions email to {Email}.", context.Job.Id, context.Job.AdminEmail); + //await adminClient.SendRequiredActionsEmailAsync(realmId, userId, ["UPDATE_PASSWORD", "VERIFY_EMAIL"], cancellationToken); + + context.DayZeroUserSubjectId = userId; + //context.MagicLink = $"Action email sent to {context.Job.AdminEmail} for realm '{realmId}'."; + context.Job.CompletedSteps |= CompletedSteps.KeycloakProvisioned; + + logger.LogInformation("[{JobId}] Keycloak provisioning complete for realm {Realm}.", context.Job.Id, realmId); + } + + public async Task CompensateAsync(SagaContext context, CancellationToken cancellationToken) + { + var realmId = RealmId(context); + logger.LogWarning("[{JobId}] Compensating Keycloak - deleting realm {Realm}.", context.Job.Id, realmId); + await adminClient.DeleteRealmAsync(realmId, cancellationToken); + } + + private static string RealmId(SagaContext context) => + $"clarity-{context.Job.Subdomain.ToLowerInvariant()}"; +} diff --git a/ControlPlane.Worker/Steps/LaunchStep.cs b/ControlPlane.Worker/Steps/LaunchStep.cs new file mode 100644 index 0000000..650457e --- /dev/null +++ b/ControlPlane.Worker/Steps/LaunchStep.cs @@ -0,0 +1,76 @@ +using ControlPlane.Core.Config; +using ControlPlane.Core.Interfaces; +using ControlPlane.Core.Models; +using ControlPlane.Worker.Services; +using Microsoft.Extensions.Options; + +namespace ControlPlane.Worker.Steps; + +/// +/// Final saga step — launches the clarity-server Docker container with the fully +/// enriched SagaContext (connection strings, Keycloak realm, etc. all known). +/// Runs LAST so all env vars are available at container start. +/// +public class LaunchStep( + ILogger logger, + IConfiguration config, + IOptions infraOptions, + ClarityContainerService containers) : ISagaStep +{ + public string StepName => "Container Launch"; + + public async Task ExecuteAsync(SagaContext context, CancellationToken cancellationToken) + { + var job = context.Job; + + logger.LogInformation("[{JobId}] Launching container {Env}-app-clarity-{Site}", + job.Id, job.Environment, job.SiteCode); + + var containerName = await containers.StartTenantContainerAsync( + environment: job.Environment, + siteCode: job.SiteCode, + subdomain: job.Subdomain, + keycloakRealm: $"clarity-{job.Subdomain.ToLowerInvariant()}", + postgresConnectionString: context.TenantConnectionString, + vaultToken: ReadVaultToken(config), + jobId: job.Id, + cancellationToken: cancellationToken); + + context.ContainerName = containerName; + context.TenantApiBaseUrl = infraOptions.Value.TenantPublicUrl(job.Subdomain); + + logger.LogInformation("[{JobId}] Container {Name} live at {Url}", + job.Id, containerName, context.TenantApiBaseUrl); + + context.Job.CompletedSteps |= CompletedSteps.InfrastructureProvisioned; + } + + public async Task CompensateAsync(SagaContext context, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(context.ContainerName)) return; + + logger.LogWarning("[{JobId}] Compensating: removing container {Name}", context.Job.Id, context.ContainerName); + await containers.StopAndRemoveAsync(context.ContainerName, cancellationToken); + await containers.RemoveNginxConfigAsync(context.Job.Subdomain, cancellationToken); + } + + // Reads the Vault root token from the persisted init.json on the Vault volume. + // Falls back to config["Vault:Token"] then "root" for local dev. + private static string? ReadVaultToken(IConfiguration config) + { + var keysFile = config["Vault:KeysFile"]; + if (!string.IsNullOrWhiteSpace(keysFile) && File.Exists(keysFile)) + { + try + { + var json = File.ReadAllText(keysFile); + using var doc = System.Text.Json.JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("root_token", out var tok)) + return tok.GetString(); + } + catch { /* fall through */ } + } + + return config["Vault:Token"] ?? "root"; + } +} diff --git a/ControlPlane.Worker/Steps/MigrationStep.cs b/ControlPlane.Worker/Steps/MigrationStep.cs new file mode 100644 index 0000000..3da7f0c --- /dev/null +++ b/ControlPlane.Worker/Steps/MigrationStep.cs @@ -0,0 +1,119 @@ +using ControlPlane.Core.Interfaces; +using ControlPlane.Core.Models; +using Npgsql; + +namespace ControlPlane.Worker.Steps; + +/// +/// Provisions a per-tenant Postgres database on the shared Postgres instance. +/// Writes TenantConnectionString to SagaContext for downstream steps (LaunchStep). +/// Compensation drops the database. +/// +public class MigrationStep( + IConfiguration config, + ILogger logger) : ISagaStep +{ + public string StepName => "Database Migration & Seeding (EF Core)"; + + public async Task ExecuteAsync(SagaContext context, CancellationToken cancellationToken) + { + var job = context.Job; + var dbName = TenantDbName(job.Subdomain); + + var adminConnStr = config.GetConnectionString("postgres") + ?? throw new InvalidOperationException( + "ConnectionStrings:postgres is missing. " + + "Ensure ControlPlane.Worker has .WithReference(postgres) in AppHost."); + + logger.LogInformation("[{JobId}] Provisioning database '{Db}'.", job.Id, dbName); + await CreateDatabaseIfNotExistsAsync(adminConnStr, dbName, cancellationToken); + + context.TenantConnectionString = BuildTenantConnectionString(adminConnStr, dbName); + logger.LogInformation("[{JobId}] Database '{Db}' ready.", job.Id, dbName); + + // TODO: Run EF Core migrations once dynamic DbContext is wired: + // var opts = new DbContextOptionsBuilder().UseNpgsql(context.TenantConnectionString).Options; + // await using var db = new ApplicationDbContext(opts); + // await db.Database.MigrateAsync(cancellationToken); + + context.Job.CompletedSteps |= CompletedSteps.DatabaseMigrated; + } + + public async Task CompensateAsync(SagaContext context, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(context.TenantConnectionString)) return; + + var dbName = TenantDbName(context.Job.Subdomain); + var adminConnStr = config.GetConnectionString("postgres"); + if (string.IsNullOrWhiteSpace(adminConnStr)) return; + + logger.LogWarning("[{JobId}] Compensating: dropping database '{Db}'.", context.Job.Id, dbName); + try + { + await using var conn = new NpgsqlConnection(adminConnStr); + await conn.OpenAsync(cancellationToken); + + await using var terminate = conn.CreateCommand(); + terminate.CommandText = $""" + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = '{dbName}' AND pid <> pg_backend_pid(); + """; + await terminate.ExecuteNonQueryAsync(cancellationToken); + + await using var drop = conn.CreateCommand(); + drop.CommandText = $"DROP DATABASE IF EXISTS \"{dbName}\";"; + await drop.ExecuteNonQueryAsync(cancellationToken); + + logger.LogInformation("[{JobId}] Dropped database '{Db}'.", context.Job.Id, dbName); + } + catch (Exception ex) + { + logger.LogError(ex, "[{JobId}] Failed to drop database '{Db}' during compensation.", context.Job.Id, dbName); + } + } + + // ── helpers ────────────────────────────────────────────────────────────── + + // Deterministic DB name from subdomain: fdev-app-clarity-01000014 → clarity_fdev_app_clarity_01000014 + internal static string TenantDbName(string subdomain) => + $"clarity_{subdomain.Replace('-', '_').ToLowerInvariant()}"; + + private static async Task CreateDatabaseIfNotExistsAsync( + string adminConnStr, string dbName, CancellationToken ct) + { + await using var conn = new NpgsqlConnection(adminConnStr); + await conn.OpenAsync(ct); + + await using var check = conn.CreateCommand(); + check.CommandText = "SELECT 1 FROM pg_database WHERE datname = $1;"; + check.Parameters.AddWithValue(dbName); + var exists = await check.ExecuteScalarAsync(ct) is not null; + + if (!exists) + { + await using var create = conn.CreateCommand(); + // DB name is internally derived, not user input — safe to interpolate + create.CommandText = $"CREATE DATABASE \"{dbName}\";"; + await create.ExecuteNonQueryAsync(ct); + } + } + + private static string BuildTenantConnectionString(string adminConnStr, string dbName) + { + var b = new NpgsqlConnectionStringBuilder(adminConnStr) { Database = dbName }; + + // Tenant containers reach Postgres via the Aspire shared network using the stable + // DNS alias "postgres" (the Aspire resource name) at the standard port 5432. + // The port in the admin connection string is Aspire's random host-side proxy port — + // reset it to 5432 so the in-network address is correct. + if (b.Host is "localhost" or "127.0.0.1") + { + b.Host = "postgres"; + b.Port = 5432; + } + + return b.ConnectionString; + } +} + diff --git a/ControlPlane.Worker/Steps/VaultStep.cs b/ControlPlane.Worker/Steps/VaultStep.cs new file mode 100644 index 0000000..f665782 --- /dev/null +++ b/ControlPlane.Worker/Steps/VaultStep.cs @@ -0,0 +1,45 @@ +using ControlPlane.Core.Interfaces; +using ControlPlane.Core.Models; +using System.Text.Json; + +namespace ControlPlane.Worker.Steps; + +public class VaultStep(ILogger logger, IConfiguration config) : ISagaStep +{ + public string StepName => "Cryptographic Pre-Flight (Vault)"; + + public Task ExecuteAsync(SagaContext context, CancellationToken cancellationToken) + { + // TODO: VaultSharp + // 1. Assert Transit engine is active and healthy + // 2. Derive/validate TenantContextId (e.g. FL_COM_001) + // 3. Register TenantContextId in a KV entry or TenantRegistry table + // so Clarity.Server can resolve the derivation path later + // + // Root token is read at runtime from the persisted init.json on the Vault volume: + // var token = ReadRootToken(); + logger.LogInformation("[{JobId}] Vault step is a stub - VaultSharp not yet wired.", context.Job.Id); + context.Job.CompletedSteps |= CompletedSteps.VaultVerified; + return Task.CompletedTask; + } + + public Task CompensateAsync(SagaContext context, CancellationToken cancellationToken) + { + logger.LogInformation("[{JobId}] Vault step: no compensation needed.", context.Job.Id); + return Task.CompletedTask; + } + + /// + /// Reads the root token from the init.json written by the Vault entrypoint on first boot. + /// Path is injected via Vault__KeysFile config. + /// + internal string ReadRootToken() + { + var path = config["Vault__KeysFile"] + ?? throw new InvalidOperationException("Vault__KeysFile is not configured."); + + using var doc = JsonDocument.Parse(File.ReadAllText(path)); + return doc.RootElement.GetProperty("root_token").GetString() + ?? throw new InvalidOperationException("root_token not found in Vault init.json."); + } +} diff --git a/ControlPlane.slnx b/ControlPlane.slnx new file mode 100644 index 0000000..803be9d --- /dev/null +++ b/ControlPlane.slnx @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..4705b7f --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,53 @@ + + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/clarity.controlplane/.env b/clarity.controlplane/.env new file mode 100644 index 0000000..28b9cc0 --- /dev/null +++ b/clarity.controlplane/.env @@ -0,0 +1,2 @@ +VITE_CLARITY_DOMAIN=clarity.test +OPEN_ROUTER_KEY=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 \ No newline at end of file diff --git a/clarity.controlplane/.gitignore b/clarity.controlplane/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/clarity.controlplane/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/clarity.controlplane/CHANGELOG.md b/clarity.controlplane/CHANGELOG.md new file mode 100644 index 0000000..3e9a1d3 --- /dev/null +++ b/clarity.controlplane/CHANGELOG.md @@ -0,0 +1,12 @@ +This file explains how Visual Studio created the project. + +The following tools were used to generate this project: +- create-vite + +The following steps were used to generate this project: +- Create react project with create-vite: `npm init --yes vite@latest clarity.controlplane -- --template=react-ts --no-rolldown --no-immediate`. +- Updating `vite.config.ts` with port. +- Create project file (`clarity.controlplane.esproj`). +- Create `launch.json` to enable debugging. +- Add project to solution. +- Write this file. diff --git a/clarity.controlplane/README.md b/clarity.controlplane/README.md new file mode 100644 index 0000000..7dbf7eb --- /dev/null +++ b/clarity.controlplane/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/clarity.controlplane/clarity.controlplane.esproj b/clarity.controlplane/clarity.controlplane.esproj new file mode 100644 index 0000000..6c4bab2 --- /dev/null +++ b/clarity.controlplane/clarity.controlplane.esproj @@ -0,0 +1,11 @@ + + + npm run dev + src\ + Vitest + + false + + $(MSBuildProjectDirectory)\dist + + \ No newline at end of file diff --git a/clarity.controlplane/eslint.config.js b/clarity.controlplane/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/clarity.controlplane/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/clarity.controlplane/index.html b/clarity.controlplane/index.html new file mode 100644 index 0000000..8a355f3 --- /dev/null +++ b/clarity.controlplane/index.html @@ -0,0 +1,13 @@ + + + + + + + clarity.controlplane + + +
+ + + diff --git a/clarity.controlplane/package-lock.json b/clarity.controlplane/package-lock.json new file mode 100644 index 0000000..a9a13ef --- /dev/null +++ b/clarity.controlplane/package-lock.json @@ -0,0 +1,3537 @@ +{ + "name": "clarity.controlplane", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "clarity.controlplane", + "version": "0.0.0", + "dependencies": { + "@blueprintjs/core": "^6.12.0", + "diff2html": "^3.4.56", + "highlight.js": "^11.11.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-multistep": "^7.0.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/node": "^24.12.2", + "@types/react": "^18.3.23", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.5.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.58.2", + "vite": "^8.0.9" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@blueprintjs/colors": { + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/@blueprintjs/colors/-/colors-5.1.16.tgz", + "integrity": "sha512-P9uX0Aj2TP9+6aUcori1iPl4snxM/Vgq0LZbhUl1l5bHTgNxxwm/0+IoS/SlQg93HBRl8KTAM1evEqtPbwV10A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "~2.6.2" + } + }, + "node_modules/@blueprintjs/colors/node_modules/tslib": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", + "license": "0BSD" + }, + "node_modules/@blueprintjs/core": { + "version": "6.12.0", + "resolved": "https://registry.npmjs.org/@blueprintjs/core/-/core-6.12.0.tgz", + "integrity": "sha512-huBwfAU0/n4XG33C1xl4cWd/f+POtU11AL1wjTG/zWqyV4HRd57TE92z+SdioBbvCm6fmwEjNfjKAu1P1ojWxw==", + "license": "Apache-2.0", + "dependencies": { + "@blueprintjs/colors": "^5.1.16", + "@blueprintjs/icons": "^6.9.0", + "@floating-ui/react": "^0.27.13", + "@popperjs/core": "^2.11.8", + "classnames": "^2.3.1", + "normalize.css": "^8.0.1", + "react-popper": "^2.3.0", + "react-transition-group": "^4.4.5", + "tslib": "~2.6.2", + "use-sync-external-store": "^1.2.0" + }, + "bin": { + "upgrade-blueprint-2.0.0-rename": "scripts/upgrade-blueprint-2.0.0-rename.sh", + "upgrade-blueprint-3.0.0-rename": "scripts/upgrade-blueprint-3.0.0-rename.sh" + }, + "peerDependencies": { + "@types/react": "18", + "react": "18", + "react-dom": "18" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@blueprintjs/core/node_modules/tslib": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", + "license": "0BSD" + }, + "node_modules/@blueprintjs/icons": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/@blueprintjs/icons/-/icons-6.9.0.tgz", + "integrity": "sha512-pnwnw6dPARk7Q4CZ9ZKeVe9C8PO1OE9cv9DO4mPgNqJBIOrI9aVAWbHsfjqpbOPXx82/O65kNOQweP8foAzPRA==", + "license": "Apache-2.0", + "dependencies": { + "change-case": "^4.1.2", + "classnames": "^2.3.1", + "tslib": "~2.6.2" + }, + "peerDependencies": { + "@types/react": "18", + "react": "18", + "react-dom": "18" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@blueprintjs/icons/node_modules/tslib": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", + "license": "0BSD" + }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.27.19", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.19.tgz", + "integrity": "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.8", + "@floating-ui/utils": "^0.2.11", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.126.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.126.0.tgz", + "integrity": "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@profoundlogic/hogan": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@profoundlogic/hogan/-/hogan-3.0.4.tgz", + "integrity": "sha512-pmNVGuooS30Mm7YbZd5T7E5zYVO6D5Ct91sn4T39mUvMUc3sCGridcnhAufL1/Bz2QzAtzEn0agNrdk3+5yWzw==", + "license": "Apache-2.0", + "dependencies": { + "nopt": "1.0.10" + }, + "bin": { + "hulk": "bin/hulk" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.16.tgz", + "integrity": "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.16.tgz", + "integrity": "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.16.tgz", + "integrity": "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.16.tgz", + "integrity": "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.16.tgz", + "integrity": "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.16.tgz", + "integrity": "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.16.tgz", + "integrity": "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.16.tgz", + "integrity": "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.16.tgz", + "integrity": "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.16.tgz", + "integrity": "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.16.tgz", + "integrity": "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz", + "integrity": "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/type-utils": "8.59.0", + "@typescript-eslint/utils": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.0.tgz", + "integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.0.tgz", + "integrity": "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.0", + "@typescript-eslint/types": "^8.59.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz", + "integrity": "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.0.tgz", + "integrity": "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.0.tgz", + "integrity": "sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/utils": "8.59.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.0.tgz", + "integrity": "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.0.tgz", + "integrity": "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.0", + "@typescript-eslint/tsconfig-utils": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.0.tgz", + "integrity": "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz", + "integrity": "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.21.tgz", + "integrity": "sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camel-case": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", + "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", + "license": "MIT", + "dependencies": { + "pascal-case": "^3.1.2", + "tslib": "^2.0.3" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001790", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz", + "integrity": "sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/capital-case": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/capital-case/-/capital-case-1.0.4.tgz", + "integrity": "sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==", + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3", + "upper-case-first": "^2.0.2" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/change-case": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-4.1.2.tgz", + "integrity": "sha512-bSxY2ws9OtviILG1EiY5K7NNxkqg/JnRnFxLtKQ96JaviiIxi7djMrSd0ECT9AC+lttClmYwKw53BWpOMblo7A==", + "license": "MIT", + "dependencies": { + "camel-case": "^4.1.2", + "capital-case": "^1.0.4", + "constant-case": "^3.0.4", + "dot-case": "^3.0.4", + "header-case": "^2.0.4", + "no-case": "^3.0.4", + "param-case": "^3.0.4", + "pascal-case": "^3.1.2", + "path-case": "^3.0.4", + "sentence-case": "^3.0.4", + "snake-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/constant-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/constant-case/-/constant-case-3.0.4.tgz", + "integrity": "sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==", + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3", + "upper-case": "^2.0.2" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/diff2html": { + "version": "3.4.56", + "resolved": "https://registry.npmjs.org/diff2html/-/diff2html-3.4.56.tgz", + "integrity": "sha512-u9gfn+BlbHcyO7vItCIC4z49LJDUt31tODzOfAuJ5R1E7IdlRL6KjugcB9zOpejD+XiR+dDZbsnHSQ3g6A/u8A==", + "license": "MIT", + "dependencies": { + "@profoundlogic/hogan": "^3.0.4", + "diff": "^8.0.3" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "highlight.js": "11.11.1" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.343", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.343.tgz", + "integrity": "sha512-YHnQ3MXI08icvL9ZKnEBy05F2EQ8ob01UaMOuMbM8l+4UcAq6MPPbBTJBbsBUg3H8JeZNt+O4fjsoWth3p6IFg==", + "dev": true, + "license": "ISC" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz", + "integrity": "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/header-case": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/header-case/-/header-case-2.0.4.tgz", + "integrity": "sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==", + "license": "MIT", + "dependencies": { + "capital-case": "^1.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nopt": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", + "integrity": "sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==", + "license": "MIT", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/normalize.css": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/normalize.css/-/normalize.css-8.0.1.tgz", + "integrity": "sha512-qizSNPO93t1YUuUhP22btGOo3chcvDFqFaj2TRybP0DMxkHOCTYwp3n34fel4a31ORXy4m1Xq0Gyqpb5m33qIg==", + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/param-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", + "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pascal-case": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", + "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/path-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/path-case/-/path-case-3.0.4.tgz", + "integrity": "sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg==", + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", + "license": "MIT" + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-multistep": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/react-multistep/-/react-multistep-7.0.0.tgz", + "integrity": "sha512-lmICXTaPSwCLl227mizV4dE+AOUkU9Z03O+8CRINYcltG9Fa2DcBG1OB68OfviCOItuUJ7uncNJX916f8dj11g==", + "license": "MIT", + "peerDependencies": { + "react": "18.3.1" + } + }, + "node_modules/react-popper": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.3.0.tgz", + "integrity": "sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==", + "license": "MIT", + "dependencies": { + "react-fast-compare": "^3.0.1", + "warning": "^4.0.2" + }, + "peerDependencies": { + "@popperjs/core": "^2.0.0", + "react": "^16.8.0 || ^17 || ^18", + "react-dom": "^16.8.0 || ^17 || ^18" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.16.tgz", + "integrity": "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.126.0", + "@rolldown/pluginutils": "1.0.0-rc.16" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.16", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.16", + "@rolldown/binding-darwin-x64": "1.0.0-rc.16", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.16", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.16", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.16", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.16", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.16.tgz", + "integrity": "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA==", + "dev": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/sentence-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/sentence-case/-/sentence-case-3.0.4.tgz", + "integrity": "sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==", + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3", + "upper-case-first": "^2.0.2" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/snake-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", + "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.0.tgz", + "integrity": "sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.0", + "@typescript-eslint/parser": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/utils": "8.59.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/upper-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/upper-case/-/upper-case-2.0.2.tgz", + "integrity": "sha512-KgdgDGJt2TpuwBUIjgG6lzw2GWFRCW9Qkfkiv0DxqHHLYJHmtmdUIKcZd8rHgFSjopVTlw6ggzCm1b8MFQwikg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/upper-case-first": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/upper-case-first/-/upper-case-first-2.0.2.tgz", + "integrity": "sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "8.0.9", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz", + "integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.16", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/clarity.controlplane/package.json b/clarity.controlplane/package.json new file mode 100644 index 0000000..d1d8d2d --- /dev/null +++ b/clarity.controlplane/package.json @@ -0,0 +1,34 @@ +{ + "name": "clarity.controlplane", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@blueprintjs/core": "^6.12.0", + "diff2html": "^3.4.56", + "highlight.js": "^11.11.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-multistep": "^7.0.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/node": "^24.12.2", + "@types/react": "^18.3.23", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.5.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.58.2", + "vite": "^8.0.9" + } +} diff --git a/clarity.controlplane/public/favicon.svg b/clarity.controlplane/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/clarity.controlplane/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/clarity.controlplane/public/icons.svg b/clarity.controlplane/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/clarity.controlplane/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/clarity.controlplane/src/App.css b/clarity.controlplane/src/App.css new file mode 100644 index 0000000..f197ec9 --- /dev/null +++ b/clarity.controlplane/src/App.css @@ -0,0 +1 @@ +/* App-level overrides — component styles live in index.css */ diff --git a/clarity.controlplane/src/App.tsx b/clarity.controlplane/src/App.tsx new file mode 100644 index 0000000..1f2e905 --- /dev/null +++ b/clarity.controlplane/src/App.tsx @@ -0,0 +1,76 @@ +import '@blueprintjs/core/lib/css/blueprint.css'; +import './App.css'; +import { useState } from 'react'; +import { Menu, MenuItem, MenuDivider } from '@blueprintjs/core'; +import DashboardPage from './pages/DashboardPage'; +import PipelinesPage from './pages/PipelinesPage'; +import BuildMonitorPage from './pages/BuildMonitorPage'; +import ImageBuildPage from './pages/ImageBuildPage'; +import BranchPage from './pages/BranchPage'; +import OpcPage from './opc/OpcPage'; +import InfraPage from './pages/InfraPage'; + +function App() { + const [activeNav, setActiveNav] = useState('opc'); + + return ( +
+ {/* ── Sidebar ── */} + + + {/* ── Main content ── */} +
+ {activeNav === 'deployments' && } + {activeNav === 'pipelines' && } + {activeNav === 'branches' && } + {activeNav === 'image-build' && } + {activeNav === 'build-monitor' && } + {activeNav === 'infra' && } + {activeNav === 'opc' && } + {activeNav === 'clients' && } + {activeNav === 'settings' && } +
+
+ ); +} + +function PlaceholderPage({ title }: { title: string }) { + return ( +
+

{title}

+

Coming soon.

+
+ ); +} + +export default App; diff --git a/clarity.controlplane/src/api/infraApi.ts b/clarity.controlplane/src/api/infraApi.ts new file mode 100644 index 0000000..93450dc --- /dev/null +++ b/clarity.controlplane/src/api/infraApi.ts @@ -0,0 +1,44 @@ +const BASE_URL = import.meta.env.VITE_API_URL ?? ''; + +export type ServiceStatus = 'running' | 'stopped' | 'unhealthy' | 'unknown'; + +export interface InfraService { + name: string; + container: string; + status: ServiceStatus; + ports: string[]; + uptime?: string; +} + +export interface InfraStatusResponse { + services: InfraService[]; + checkedAt: string; +} + +export async function getInfraStatus(): Promise { + const res = await fetch(`${BASE_URL}/api/infra/status`); + if (!res.ok) throw new Error('Failed to fetch infra status'); + return res.json(); +} + +export async function infraServiceAction( + service: string, + action: 'start' | 'stop' | 'restart' +): Promise { + const res = await fetch(`${BASE_URL}/api/infra/${service}/${action}`, { method: 'POST' }); + if (!res.ok) throw new Error(`Failed to ${action} ${service}`); +} + +export function streamComposeUp(onLine: (line: string) => void, onDone: () => void): EventSource { + const src = new EventSource(`${BASE_URL}/api/infra/compose/up/stream`); + src.onmessage = (e) => onLine(e.data); + src.onerror = () => { onDone(); src.close(); }; + return src; +} + +export function streamComposeDown(onLine: (line: string) => void, onDone: () => void): EventSource { + const src = new EventSource(`${BASE_URL}/api/infra/compose/down/stream`); + src.onmessage = (e) => onLine(e.data); + src.onerror = () => { onDone(); src.close(); }; + return src; +} diff --git a/clarity.controlplane/src/api/opcApi.ts b/clarity.controlplane/src/api/opcApi.ts new file mode 100644 index 0000000..6415e95 --- /dev/null +++ b/clarity.controlplane/src/api/opcApi.ts @@ -0,0 +1,313 @@ +import type { Opc, OpcNote, OpcArtifact, OpcType, OpcStatus, OpcPriority } from '../types/opc'; + +const BASE_URL = import.meta.env.VITE_API_URL ?? ''; + +// ── OPC CRUD ────────────────────────────────────────────────────────────────── + +export async function getNextNumber(): Promise { + const res = await fetch(`${BASE_URL}/api/opc/next-number`); + if (!res.ok) throw new Error('Failed to fetch next OPC number'); + const data = await res.json(); + return data.number as string; +} + +export async function listOpcs(type?: string, status?: string): Promise { + const params = new URLSearchParams(); + if (type && type !== 'all') params.set('type', type); + if (status && status !== 'all') params.set('status', status); + const res = await fetch(`${BASE_URL}/api/opc?${params}`); + if (!res.ok) throw new Error(`Failed to load OPCs: ${res.statusText}`); + // API returns OpcRecord (camelCase from .NET JsonSerializerDefaults.Web) + return (await res.json()).map(mapRecord); +} + +export async function getOpc(id: string): Promise { + const res = await fetch(`${BASE_URL}/api/opc/${id}`); + if (!res.ok) throw new Error(`Failed to load OPC: ${res.statusText}`); + return mapRecord(await res.json()); +} + +export async function createOpc(req: { + title: string; type: OpcType; priority: OpcPriority; + assignee: string; description: string; +}): Promise { + const res = await fetch(`${BASE_URL}/api/opc`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(req), + }); + if (!res.ok) throw new Error(`Failed to create OPC: ${res.statusText}`); + return mapRecord(await res.json()); +} + +export async function updateOpc(id: string, req: { + title?: string; description?: string; type?: OpcType; + status?: OpcStatus; priority?: OpcPriority; assignee?: string; +}): Promise { + const res = await fetch(`${BASE_URL}/api/opc/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(req), + }); + if (!res.ok) throw new Error(`Failed to update OPC: ${res.statusText}`); + return mapRecord(await res.json()); +} + +// ── Notes ───────────────────────────────────────────────────────────────────── + +export async function listNotes(opcId: string): Promise { + const res = await fetch(`${BASE_URL}/api/opc/${opcId}/notes`); + if (!res.ok) throw new Error(`Failed to load notes: ${res.statusText}`); + return (await res.json()).map(mapNote); +} + +export async function addNote(opcId: string, author: string, content: string): Promise { + const res = await fetch(`${BASE_URL}/api/opc/${opcId}/notes`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ author, content }), + }); + if (!res.ok) throw new Error(`Failed to add note: ${res.statusText}`); + return mapNote(await res.json()); +} + +// ── Artifacts ───────────────────────────────────────────────────────────────── + +export async function listArtifacts(opcId: string, type?: string): Promise { + const params = type ? `?type=${type}` : ''; + const res = await fetch(`${BASE_URL}/api/opc/${opcId}/artifacts${params}`); + if (!res.ok) throw new Error(`Failed to load artifacts: ${res.statusText}`); + return (await res.json()).map(mapArtifact); +} + +export async function createArtifact(opcId: string, req: { + artifactType: string; title: string; content: string; +}): Promise { + const res = await fetch(`${BASE_URL}/api/opc/${opcId}/artifacts`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(req), + }); + if (!res.ok) throw new Error(`Failed to create artifact: ${res.statusText}`); + return mapArtifact(await res.json()); +} + +export async function updateArtifact(artifactId: string, req: { + artifactType: string; title: string; content: string; +}): Promise { + const res = await fetch(`${BASE_URL}/api/opc/artifacts/${artifactId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(req), + }); + if (!res.ok) throw new Error(`Failed to update artifact: ${res.statusText}`); + return mapArtifact(await res.json()); +} + +export async function deleteArtifact(artifactId: string): Promise { + const res = await fetch(`${BASE_URL}/api/opc/artifacts/${artifactId}`, { method: 'DELETE' }); + if (!res.ok && res.status !== 404) throw new Error(`Failed to delete artifact: ${res.statusText}`); +} + +// ── Git commit linkage ──────────────────────────────────────────────────────── +// Commits are linked by convention: developers include "OPC # XXXX" in their commit message. +// The git log endpoint supports ?grep=OPC+%230001 to filter. + +export interface LinkedCommit { + hash: string; + shortHash: string; + author: string; + date: string; + subject: string; + files: string[]; +} + +export async function getLinkedCommits(opcNumber: string): Promise { + const res = await fetch(`${BASE_URL}/api/git/log?grep=${encodeURIComponent(opcNumber)}&limit=50`); + if (!res.ok) throw new Error(`Failed to load commits: ${res.statusText}`); + return res.json(); +} + +// ── Pinned commits ──────────────────────────────────────────────────────────── + +export interface PinnedCommit { + opcId: string; + hash: string; + shortHash: string; + subject: string; + author: string; + pinnedAt: string; + pinnedBy: string; +} + +function mapPinnedCommit(d: Record): PinnedCommit { + return { + opcId: d.opcId as string, + hash: d.hash as string, + shortHash: d.shortHash as string, + subject: d.subject as string, + author: d.author as string, + pinnedAt: d.pinnedAt as string, + pinnedBy: d.pinnedBy as string, + }; +} + +export async function getPinnedCommits(opcId: string): Promise { + const res = await fetch(`${BASE_URL}/api/opc/${opcId}/pinned-commits`); + if (!res.ok) throw new Error(`Failed to load pinned commits: ${res.statusText}`); + return (await res.json()).map(mapPinnedCommit); +} + +export async function pinCommit(opcId: string, hash: string, pinnedBy: string): Promise { + const res = await fetch(`${BASE_URL}/api/opc/${opcId}/pinned-commits`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hash, pinnedBy }), + }); + if (!res.ok) { + const body = await res.text().catch(() => res.statusText); + throw new Error(body || res.statusText); + } + return mapPinnedCommit(await res.json()); +} + +export async function unpinCommit(opcId: string, hash: string): Promise { + const res = await fetch(`${BASE_URL}/api/opc/${opcId}/pinned-commits/${hash}`, { method: 'DELETE' }); + if (!res.ok && res.status !== 404) throw new Error(`Failed to unpin commit: ${res.statusText}`); +} + +// ── Branch coverage ─────────────────────────────────────────────────────────── + +export interface BranchCoverage { + branch: string; + contains: boolean; + tipHash: string; + isHead: boolean; +} + +export async function getBranchCoverage(hashes: string[]): Promise { + if (hashes.length === 0) return []; + const res = await fetch(`${BASE_URL}/api/git/branch-coverage?commits=${hashes.join(',')}`); + if (!res.ok) throw new Error(`Failed to get branch coverage: ${res.statusText}`); + return res.json(); +} + +// ── Commit detail (full diff) ───────────────────────────────────────────────── + +export interface CommitFile { + path: string; + oldPath: string; + status: string; + additions: number; + deletions: number; + patch: string; +} + +export interface CommitDetail { + hash: string; + shortHash: string; + author: string; + email: string; + date: string; + subject: string; + body: string; + files: CommitFile[]; +} + +export async function getCommitDetail(hash: string): Promise { + const res = await fetch(`${BASE_URL}/api/git/commits/${hash}`); + if (!res.ok) throw new Error(`Failed to load commit ${hash}: ${res.statusText}`); + return res.json(); +} + +// ── AI assist ───────────────────────────────────────────────────────────────── + +export async function aiAssist(prompt: string, context?: string): Promise { + const res = await fetch(`${BASE_URL}/api/opc/ai-assist`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt, context }), + }); + if (!res.ok) { + const err = await res.text(); + throw new Error(`AI assist failed: ${err}`); + } + const data = await res.json(); + return data.text as string; +} + +// ── Field mappers (snake_case/PascalCase → camelCase) ───────────────────────── +// .NET JsonSerializerDefaults.Web produces camelCase already, so these are +// lightweight guards in case of null/missing fields. + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function mapRecord(r: any): Opc { + return { + id: r.id, + number: r.number, + title: r.title, + description: r.description ?? '', + type: r.type as OpcType, + status: r.status as OpcStatus, + priority: r.priority as OpcPriority, + assignee: r.assignee ?? '', + createdAt: r.createdAt, + updatedAt: r.updatedAt, + notes: [], // loaded separately on drawer open + commits: [], // loaded separately on drawer open + }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function mapNote(r: any): OpcNote { + return { + id: r.id, + author: r.author, + timestamp: r.createdAt, + content: r.content, + }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function mapArtifact(r: any): OpcArtifact { + return { + id: r.id, + opcId: r.opcId, + artifactType: r.artifactType, + title: r.title, + content: r.content, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + }; +} + +// ── Gitea branch integration ─────────────────────────────────────────────────── + +export interface GiteaBranch { + name: string; + commitSha: string; + protected: boolean; +} + +export async function listGiteaBranches(): Promise { + const res = await fetch(`${BASE_URL}/api/gitea/branches`); + if (!res.ok) throw new Error(`Failed to load Gitea branches: ${res.statusText}`); + return res.json(); +} + +export async function createGiteaBranch( + opcNumber: string, + opcTitle: string, + from = 'master', +): Promise { + const res = await fetch(`${BASE_URL}/api/gitea/branches`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ opcNumber, opcTitle, from }), + }); + if (!res.ok) { + const body = await res.text().catch(() => res.statusText); + throw new Error(body || res.statusText); + } + return res.json(); +} diff --git a/clarity.controlplane/src/api/provisioningApi.ts b/clarity.controlplane/src/api/provisioningApi.ts new file mode 100644 index 0000000..b5f724a --- /dev/null +++ b/clarity.controlplane/src/api/provisioningApi.ts @@ -0,0 +1,290 @@ +import type { ProvisioningProgressEvent, ProvisioningRequest, TenantRecord } from '../types/provisioning'; + +const BASE_URL = import.meta.env.VITE_API_URL ?? ''; + +export async function submitProvisioningJob(request: ProvisioningRequest): Promise { + const res = await fetch(`${BASE_URL}/api/provision`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }); + + if (!res.ok) throw new Error(`Failed to queue job: ${res.statusText}`); + + const data = await res.json(); + return data.id as string; +} + +export async function getTenants(): Promise { + const res = await fetch(`${BASE_URL}/api/tenants`); + if (!res.ok) throw new Error(`Failed to load tenants: ${res.statusText}`); + return res.json(); +} + +export function subscribeToTenantLogs( + subdomain: string, + onLine: (line: string) => void, + onError: (err: Event) => void +): EventSource { + const source = new EventSource(`${BASE_URL}/api/tenants/${subdomain}/logs`); + source.onmessage = (e) => { if (e.data) onLine(e.data); }; + source.onerror = onError; + return source; +} + +export function subscribeToJobStream( + jobId: string, + onEvent: (event: ProvisioningProgressEvent) => void, + onError: (err: Event) => void +): EventSource { + const source = new EventSource(`${BASE_URL}/api/provision/${jobId}/stream`); + + source.onmessage = (e) => { + try { onEvent(JSON.parse(e.data)); } catch { /* ignore */ } + }; + + source.onerror = onError; + return source; +} + +export interface ImageBuildStatus { + imageName: string | null; + builtAt: string | null; + lastMessage: string; + isBuilding: boolean; +} + +export async function getImageStatus(): Promise { + const res = await fetch(`${BASE_URL}/api/image/status`); + if (!res.ok) throw new Error(`Failed to get image status: ${res.statusText}`); + return res.json(); +} + +/** Triggers a build and streams log lines. Calls onLine for each log chunk, onDone when finished. */ +export function triggerImageBuild( + onLine: (line: string) => void, + onDone: (success: boolean) => void, + onError: (err: Event) => void +): EventSource { + const source = new EventSource(`${BASE_URL}/api/image/build-stream`); + + source.onmessage = (e) => { + try { + const msg = JSON.parse(e.data); + if (msg.done) { onDone(true); source.close(); } + else if (msg.line) onLine(msg.line); + } catch { /* ignore */ } + }; + + source.onerror = (e) => { onDone(false); onError(e); }; + return source; +} + +/** POST to kick off the build — returns immediately; use subscribeToJobStream for progress */ +export async function startImageBuild(): Promise { + const res = await fetch(`${BASE_URL}/api/image/build`, { method: 'POST' }); + if (!res.ok) throw new Error(`Build trigger failed: ${res.statusText}`); +} + +// ── Release API ────────────────────────────────────────────────────────────── + +export interface TenantReleaseResult { + subdomain: string; + containerName: string; + success: boolean; + error?: string; +} + +export interface ReleaseRecord { + id: string; + environment: string; + imageName: string; + status: 'Running' | 'Succeeded' | 'PartialFailure' | 'Failed'; + startedAt: string; + finishedAt?: string; + tenants: TenantReleaseResult[]; +} + +export async function getReleaseHistory(): Promise { + const res = await fetch(`${BASE_URL}/api/release/history`); + if (!res.ok) throw new Error(`Failed to get release history: ${res.statusText}`); + return res.json(); +} + +/** Triggers a release to the given environment and streams log lines as SSE. */ +export function triggerRelease( + env: string, + onLine: (line: string) => void, + onDone: (record: ReleaseRecord) => void, + onError: (err: Event) => void +): EventSource { + const source = new EventSource(`${BASE_URL}/api/release/${env}`); + source.onmessage = (e) => { + try { + const msg = JSON.parse(e.data); + if (msg.done && msg.release) { onDone(msg.release as ReleaseRecord); source.close(); } + else if (typeof msg.line === 'string') onLine(msg.line); + } catch { /* ignore */ } + }; + source.onerror = (e) => { onError(e); }; + return source; +} + +// ── Project Build API ──────────────────────────────────────────────────────── + +export interface ProjectDefinition { + name: string; + kind: 'DotnetProject' | 'NpmProject'; + relativePath: string; +} + +export interface BuildRecord { + id: string; + kind: 'DockerImage' | 'DotnetProject' | 'NpmProject'; + target: string; + status: 'Running' | 'Succeeded' | 'Failed'; + startedAt: string; + finishedAt?: string; + durationMs?: number; + log: string[]; +} + +export async function getProjects(): Promise { + const res = await fetch(`${BASE_URL}/api/builds/projects`); + if (!res.ok) throw new Error(`Failed to get projects: ${res.statusText}`); + return res.json(); +} + +export async function getBuildHistory(): Promise { + const res = await fetch(`${BASE_URL}/api/builds/history`); + if (!res.ok) throw new Error(`Failed to get build history: ${res.statusText}`); + return res.json(); +} + +/** Triggers a project build and streams log lines. */ +export function triggerProjectBuild( + projectName: string, + onLine: (line: string) => void, + onDone: (record: BuildRecord) => void, + onError: (err: Event) => void +): EventSource { + const source = new EventSource(`${BASE_URL}/api/builds/${encodeURIComponent(projectName)}`); + source.onmessage = (e) => { + try { + const msg = JSON.parse(e.data); + if (msg.done && msg.build) { onDone(msg.build as BuildRecord); source.close(); } + else if (typeof msg.line === 'string') onLine(msg.line); + } catch { /* ignore */ } + }; + source.onerror = (e) => { onError(e); }; + return source; +} + +// ── Git History API ────────────────────────────────────────────────────────── + +export interface GitCommit { + hash: string; + shortHash: string; + author: string; + date: string; + subject: string; + files: string[]; +} + +export async function getGitLog(path?: string, limit = 20): Promise { + const params = new URLSearchParams({ limit: String(limit) }); + if (path) params.set('path', path); + const res = await fetch(`${BASE_URL}/api/git/log?${params}`); + if (!res.ok) throw new Error(`Failed to get git log: ${res.statusText}`); + return res.json(); +} + +// ── Promotion / Branch Ladder API ──────────────────────────────────────────── + +export interface BranchStatus { + branch: string; + exists: boolean; + shortHash: string | null; + lastCommitSummary: string | null; + aheadOfNext: number; + behindNext: number; + unreleasedLines: string[]; +} + +export interface PromotionRecord { + id: string; + fromBranch: string; + toBranch: string; + requestedBy: string; + note: string | null; + status: 'Pending' | 'Running' | 'Succeeded' | 'Failed'; + createdAt: string; + completedAt: string | null; + commitCount: number; + commitLines: string[]; + log: string[]; +} + +export async function getLadderStatus(): Promise { + const res = await fetch(`${BASE_URL}/api/promotions/ladder`); + if (!res.ok) throw new Error(`Failed to get ladder status: ${res.statusText}`); + return res.json(); +} + +export async function getPromotionHistory(): Promise { + const res = await fetch(`${BASE_URL}/api/promotions/history`); + if (!res.ok) throw new Error(`Failed to get promotion history: ${res.statusText}`); + return res.json(); +} + +/** Triggers a promotion and streams SSE lines. Calls onDone with the final record. */ +export function triggerPromotion( + from: string, + to: string, + requestedBy: string, + note: string | undefined, + onLine: (line: string) => void, + onDone: (record: PromotionRecord) => void, + onError: (err: string) => void, +): () => void { + let cancelled = false; + const controller = new AbortController(); + + (async () => { + try { + const res = await fetch(`${BASE_URL}/api/promotions/promote`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ from, to, requestedBy, note }), + signal: controller.signal, + }); + + if (!res.ok || !res.body) { onError(res.statusText); return; } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (!cancelled) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const parts = buffer.split('\n\n'); + buffer = parts.pop() ?? ''; + for (const chunk of parts) { + const dataLine = chunk.replace(/^data:\s*/m, '').trim(); + if (!dataLine) continue; + try { + const msg = JSON.parse(dataLine); + if (msg.done && msg.promotion) onDone(msg.promotion as PromotionRecord); + else if (typeof msg.line === 'string') onLine(msg.line); + } catch { /* skip */ } + } + } + } catch (e) { + if (!cancelled) onError(e instanceof Error ? e.message : 'Unknown error'); + } + })(); + + return () => { cancelled = true; controller.abort(); }; +} diff --git a/clarity.controlplane/src/assets/hero.png b/clarity.controlplane/src/assets/hero.png new file mode 100644 index 0000000000000000000000000000000000000000..02251f4b956c55af2d76fd0788124d7eee2b45eb GIT binary patch literal 13057 zcmV+cGycqpP)V|)f$;Qooc7=_G zlYe)HToTQIc!$)^+J1M1y0*T%w!p~7%ux`!eRhO?c80XDxKQ*R^lUUMnA>6NT^?feoZ8xxvP32D&s-9ow zqjcM}eesrC)NeDmsf)*P7wJ|K!&xP%Zy4iI8lF)Tv2!reW)tCzg_1=PmOwd1SQfxa z8;58t!=z~Ba7CYlNWVG>he8aRPY|+-JmozNhn!#9i#77Aa_Edt$ijyCWL#=~I>~2X zZNrQ8I0=D+NWD4pq=7~(i zhfThMNw|G>g^y9pGzxX7ZSApl@tIxFcs{p#MX{Ax&XZT+cR#U+OWc@S)pkIuI}dzu zH?^Q=<(y&Vq-oxSLfc0Zmq81bjZWf}RnssBaD6}2g-XJHLcN_|*IOu>m|x$nbm(?E zyNy!Zp=RroS;?Vg*kmoJYBi!n5{_^@rA!)=t#a^;N$8GL!*DsQb}`yvEuX!G@||An znOfUZAevPrkV_qjl|<~3QRZzG&h@C9Y5z zqpNH4xqbF_InIPh)kX}Vn^5kyed|mOuq+2>M;v~KO37a#yrEn3XDqtOl=rc6_KZ!; zreo)DFVB4|>1Zd(bvMI%8uM;3!)YMYu&cG?(PE!B~y@3yKBMt|R zAf=I16tFwPsl)!jDqvYkLHaAQ+f@W1m6F5aZvwhm4JL z{_l)@b;)mDSzle2gyFP5-r1x-5X{G}ot%VyWP@vEW80!Q=f%RTfpg>B*TA^pyWYUQ z<=xPtz}WcZ!;rFl4m1D&FFHv?K~#9!?A%+fn=lXt;9!Fc#kQ;zk~gZFsH z8e5iu@c_pzX&qb8&Dum*oXwB+fm6l6gFfC|o*wgEiy6tw~&co z9Vd_4)P%wP-KwQW7|lN-znGK#?N+j24U=$982myIBM+vsiKsc*@4-rwJxuAaHKna6 zT3wi!C~a4ZKH03qU}_1bKyx0&$CaK7_%Z+Kl$)fF5^op zZApQF2TvDav!s|krTjw-8US6ep z%!VmX4luub+fseQz_D9ATJQ?iQQwD}TZz{-yo#l12a%+7bT@E(X-hyaVS-5vuXc#^ zx^w;L21;NphGVoj*{s3f4dme0y2LC=G1-7THd`#z?;tuC{^9k(dM{Rf2GOxg7Jzho z7nSZHl7?M9kdalX`)YgoKEfiae5+;$(OGeN1eqxrv!ZCVKyH>xiyNqfe8xzY8*7)H zQls8KMp)F4D>ED;idMOU^^WhVF@q>ZSmeB0y~qC~|DB648hr%Sh|*T(4q|w2l?m2+ zvBVw3@7+Mz?^Yc#+se6KM;a<=(W-I>k)$-qL2V*t}VaW`;?P4)WqI%maIDq8!oUcSYAD`}wWjkSyAVsnF65#2zQ zZ>(K*TlS(E#4y$4Zq+e^_&}d)q20hCe3!LfLYP%nQpLJ~gM6a1hJlz3)aS<9C9me| zAcmJ#>tOwBy{HoP0Sm1&_(E+S@6 zgBIFUoei8zJmdpiq8q5=OY7t@`)JWxn_&GvKVr=Zdb_pEL_j|=?f;WK^U9Q0efd#K z9q7SfJTl4pmA$jsZ5oK8@O9#!I3Cv-kL)<8SalSsp#dcpvJ}Nz#G6FC0%9|7Fi#8; zGDJXtj!&GljT3*HE@0EE>G8Se&d)*nkqe}-?`3vPl&UqK?xG z!3XJ4M-x`EuQjhBbu?ik-)rmIt=DF_N?TVMP)8Gjn)TZ2V%H|zENbeix}kOxd@0}Q z>)HuH6Ean!uS#~4g2Ne2WsMGel|h%j9*W_quQheG^JqmKhc*RYzp0wKlGjBq2VzY_ zgOv8WC1+%W=W)k)Yp_`8kfE=uiiwOZTXi8Uj9YGr$f@yJcJ;#&-Nq~sJ7anE(@;QN z=~br%7%7`isKStX|7!1?L(apl^QvPKlrHV4S+6tNVQ*R1iGdC~WMNE1$a+=rpQmcB z>wxiLIBvOnm;u*;9Y!kJdy(T4lk|8>JAm(&wEsFIF1$_*{>2ZNd$V6DS=SfrGxAv0 zzKe377JI`&o9Ljr+VnS*EwehA{f&{cKZF(6*MG5!p5MvrFA3ll{fmRG*L@6^cb;o^ z3Wm8c?Sc6$`>~VEWw(c$Y?nRO;2Q$=ulpqPtM^=1IZx;@xK0PgO7rKQ^WHVLwtgUT z%|JF{^f(VH)wLKQ%dYiu2RmchBdxL0-M?wxxul_z*{h6ZZ`>-k(vizs((vW8Lt6Z6 zY;Dt?@JWyN`O`f;&d1Mb?e%9oyRK1ql?EE5XB2(W)|D1~Rx35$H6@6)$F?)7V|zEO zI}fu0-0}8W5=6sg$fPnZ~7=tTudl?Ecb@pxbo)vni%gP-?hL|%*?62C;x6?@E`VRnJv z?fTb;k4x;TS7Cu-z%J}uy}e-pwpLQ17Q@4DC+FCdAmNKklG$`I_pyw7E{fYmw~{Fj zi?6KcVy=Wrel)EB_DWO|0CKmI|13!gBV?X`Ozp7x>?6jr`>Qz=^4ea35!$*f}) zS$i+x_k+@P2q1RFUH^ZTTk7=n?cjfR>hTq3l3SY~#w+I8SSutXGyhw;Ws~=zMQ%Vc z>$On~47Ut?P*_!TOQ&PFmLAyJieB2X4_Fd_!WxI-AY`q1Lc-oK?+qcOTzlQ?@~x@OT}*9jTVNfl@3rGvZpWI=eKg>T zZb@6YWz)J=IhP7CF|c?G62vMEG%#U}?#86$0jR4sG~i(jRd#jmn`7b(O#?N;3a;1t zhXLssmUwGhp79luw#(*V8WL0|8+E z6=YZ_O@er~$LrD_PYGc(kJgB=;yw#+Z3X6LDUZ(NcwN=B-hjdiHm!JFar%m{(5bEW z@@_VEtG$5;`EJZ|OkJ@l&G9n((w@uNFwmU%bG|s#TbcJJos!{e+bjCjrCq_}LcN!UFgKtgg7siV*7# z!}1whTRRi*-avJPu->C}Z8EiuK$#886+H_#_!btv+rsiBbv2jAJvJ+O0{#}y(%L3H zfjU-kq_-L@2XrL*ae{{qYJkD{@dw%*bkh2P&YS-0!Xt!PRz7KHV0+~j(t9W8lAVWR zt@B*DgURgEz4>WuN>o?_iKcw$?k{||Pg7{Q2o4|VmJ)mg?{VQJA<}zEr^YAAS zgGm5RT4T3p)U;yz-tfBO^kw8?IoG!IVmc+Z3m#}AOQ?5MRa>)OcU!$N^_+yK6ayn? zK>~WK0!#ysuj^oNLakm)Zvu+J)OSubX^kv!c*xgdIvs;kln!rgG4*uZ;w0mQQO4XD zO9P{GNdv!=cQ(CAL{S(%KtuV^zC&Q{%g)PoXnp^gn^>c*`E>$hLYg2HjnbVGtWLa{7zHdG1jT@B{|Dm16 z7K2(jsfG+m*Zxof)iXxu+!H5Mo-0$pkyV3VV4B@Qms46M zuBxGRV@HxU7Wwx-6CB zaU*HO<_qn$5GH>&@?nRy1{z zkik!sLfWQ)r#75)vVwCBU*r_)Q6mp?!j85{#Xqse)ApRdE$V0%I0*~e(_{)5H)`Mk z#rExC>yjhZxuL@|+#v4#<Axw$+VpV zuT;!2Vww$je$DpAW`$FX_Ab|Ip%$;&T$-lW8jS~B$>G}rd>eQG+$h9lQx4Mx0w={m zx9?T6VU`>sR}XClkAhHEShOUe8awiq zmizhL+}5UKs3}6~It7vBTig9dfQ2Q8coo+Miiaw7n~>4ybv2Ptt0^^=VqX(t*Yya9 zr`FxxFX8(v*H=+uJ#JJWIB2A(==HDYx~^zZ2nu?2`}|Wsa*f3h3ixc+U|FDtAG$Y! z*lc_7se5Oso-Cgqe0){{!8H4g$3<8!R<6JOurD;((({c$1(pwb>(#TT!sge@4>r2@ zVL7>U`0`nsWAYErezk4(Z!gMI2?UTo{J3Ajo(u4)KYIRd>BRcG4BoS3G0EXyEp@tw z%P7__?A^a>Q&AKL@ayDO9D*Qkc!NHnO9l}kpp_6hXbMppYL(X1L?njdFT|-h2<_$; zAtDZ!1Rf%|yb!qbWKd}%0b`LzBeyNy43|QO(&h2mxQLUL)|0%agVOW)6TV!&Ip^Ls z`PG2cygM8)IecQx=Fc+nqYRo4hS^^-nM_&-y8?EJXUczP=DIw(GkTJdpEdh<_STs{ z|A)4n1GKdE=Wu!!nYoZHcUQ4S&R;oDOKX2lrkdF(mK>hz<$Pp>igjOcvoRIjlN=W8 zu8Gx5(roqn8$>gEE5vy{GiGeW8Tq{vnf3hS-V=$tZkQuftUVuU8o6k&dn=Yg3)6MOIH>nlK^-2+C6BZITr~1@So?NvG#TwL)|~=1YXGMTLpS<)ziK_CSOabe z=cB#5)yz|@0i9dSo?*CX)}UP=s6)B+F@~Em(u@Q(I9J9i_V{LmMu8BfXYMh~*oPP+ z!3~xTv|(>|=n6ZOtT~C@V!z!w%18*8T2t6}U2S##rC)mekBql&VsBX;$~ByGE$oA9 z`0Wzq8p?R{4)$l*on;!cLa}Dh^Xe?owiQZt9nH1fxxh$pN9K%CtOw?u3>85L7rr!d zXs)l{TZ{xXP&U8exz?9cv~dNNibOmt*K4I$?RxqIBZ0(?Mg-9FS{*9Bc49Qc1`=sIF-rye`aNT1G@4NwXcnyc@+bw_mTsR>5< zF<2;X0QesG_pw|TonqVBhRtfqI>ty(SIu&VOXd0CrLlfp+;WH7HYjhqnu^oAY!9cB z=B6#R?Rfz9BP`dJ=@v_?70s3HxQPk+{6Y+lM85f2NF^00*^OcM0~?JOZfR9ZPYF+# zYSs}(_BUYV8{n@2a1hD^SV41bwmi2uztR;PeBgF1F-`9>`zoNss-@3LaF2sjl~>OaaVmp7PNp+UT`6@}gR%uzqHDVeEZ14{Yt?n%JeQm+t(1_u zSc}oj^{b;+rlS|ME%+LjzSI&xu0Bblxo$MJ-J$kJ?Qu_XUXh}*@*-x@ny|}wVM%Lg z3tNB`yvr*}N?ClGL;H2cglcvErIccU3(eP7>@~4nOIcI~-`P8tSQnx=jI&{9)!1}l z;gQ%_h>ZlPSV@o@Azq1R$C6ja5!^ZGh;YRhhxs58qJWo9@Bceac&yy(pET1hnn`~7@}2L0&dfPKYs$ih7m2}R!25!(hxqA(!UIw; zK4+~Jowy3=RNC6nE=ncU{LH5?*9@W24lacJlvCZXB$CYtE@>c+~H zkV=(5I&gb{xn2!~f&fs2NQgAL6`p|kyt6kpWk}iVlqIp(H;ig`{_U9yxs1jzu^ETM z7~)Rg8C-NueqTYP&U8l{DY=Y47cR zOR@U%$KQV{mkRF|4)z9Y^t3K`@p>duY&QLUFeh6VoV`a`$U@)(z!-N*5Cj<11$EZW&hJLX83TO{lJYP74rlDZQPkm@t<=U^I)x@|UnHHkdQlh?!ltZwl92rE;;^ zZuIappj4dhld1}kttYYV-j|KF1Kus zWBnzttD^00%LFK(wrwNragFub6xiV8QE2rm<`&fcR4SLFcdtLxVuN!Aal-g6dE4%k zARZ}|xeo;K{0yf7@9aua%2j5o)CPcIOc6uLHFJOcgtB5owlcNAwyAHc0QB0Dts?c@ zUemG~j_E&W7R%+x-IO4FJl8e&*2Blmp1S#RA|)geVrxvP)NHdYuxi~g&Etn?QdNK8ZDKZ?QFLU?zh30G|t9G>a_X4zk}Ygw<^$7K!GIn(Io$>(d4ODJQ2XSd%jpK zm7>ptl$a3GyB}5-%p4>Q*p#VL^B{yQMuFCM^#l#+N!Ne z5_PrJWB=@Iy+t)H`g1lX`{bm($KE5I?0c(JEYm#t{F}j!xtsbob0{xu@0TB_*>G7w0ICn zr#VoBktqHZ~XxhiKD*lcG|b;H*|Ny3P^8ceV`sfBRfrhwZ!T+MFZ!F1Bt{q$8d9i6o?~ zODj^POr}&ivSa^R^YFIq7o0giLBKCycH_aU`F6)O6JX%nPTwh~Q`eq6*0iE#Srj2^ z*_hN3%*b83zfafy60@Cp3{J({RlSaEn&E?mrxRNC9GQ7#+f=s! z0KBf-9Ny_v2VbE%aB|Di)5kNJ^t&C`4D(>t7zYUWUFtbxt+Oq=!@O7BU)}>d*R72o zFF)3jQD_lLe4is&xzyJYC1-c{8TX$RU>&>P$%)ufpez0XSAukmh!xcekg`s$c<>-q zI#zn^JU0zzF}V60)o$_gY}PQH>b2M9&8fRZa#OauglPb zeQ@pMm&=!vNgos4CluQjLMV!pfkmxK+35bi^k&=k>9h02?l+u+m0agG;(h2|Jslc-llvtEwn~*w3bx7qnvZACG<8}AGeaDVvcHbKd2>3G^ zSFPULUn-?Pmo^-_`mLZr??uNH`2=I&yajlrF{DtUxMy#Nu}z=3y7qbUA;5`)hibMR zhXL@@uKyV0-2&A@t@!xyrBnMJl&^o@Gx$&5_q6?D=ji5grd-~=?dlg;ur(_V0wjh! zA=JV^C1m+DDkOsgr<%O9ZQFg!0}pD(#PSz4Dr_EyS5$`)VIAv);4n-SFP~YtC7sH= z7&*MfpH;gd*FHbkmD#)hVxb6xjc9~`t?_{=JS+@ip_cTicXxG<=7m9& zPX+Z8IC*GSAXuGCrZDHgR$r%jyk-fctis2Kx4HvZ|B~8uC@o)m^>Hy-O!&TKA?$&n zkP2Xc54w~!=z2?^NafyL*L0V9cbYrugHBBUj`xVyZmGFR&kvk#>1J*Z~i zNTz}?IAdJ$gkqd2!Gw(%LzE!O5s4C7q4%T~e_P{+z=DNDKrG**p=U`d5yg^vp`;Zn zsU=8gd0a9s4s0FPJePWR9eH5=+O^Kks&kC-iblNqTh2&Pw*^(4384f+D8N|fewZu_ zg2ejQ)ov;ztz;NQl7yj;A`(!H!XQu_$sqY9h_IrH*}_%1{L&_YLDvO?%R5Z-t+ClW z_qERbL?HKUZ!nt+!E9S`uoh^5A|DaIHe*_gf1`E_Vq+}{&T@t$EGhMnRjJ4z2w_W8 zp+qjs7as22^&S3wY1?+}^j-I=RcCE>#|39)g(lU7v_8;?=qK(9D8-*pPdiy)P3lIblG`+?%ea| zYoD3dopYt!tKgFicfNmNi(EWE=E4hC6(r|PYtanqJlmt57YOVrr2^tfrG(eG9C##X zu&1t@%L$RIvpj!wUA z8i>Pqot#_+Cnp6L2XPcZy1ar|9MnY+7eNvK1E)@Tr#2KsXq1*>)uUCozT7L##ok?o zhA6ofP4E|b*9tAfG?uf$#}>TIR&1A!yslP8}i7w-EzW(x#9VEvx18k%Tn=-$VV zkOtUr0b2!w3t>h?#8AZl^Az*(6KCGlD;4j~yx};`#2gN1_gv=%7KVzecIRakN{f*4 zeaI>yH;-o4OGhvGTU)(quWI)-q?V*(sVesSMv|wMUQ3hLEt=lBB$KZ9TyHr>)f7o%) zPYeU<3P)*P10*7vE)nA5#{c=6-E-_>r_u4e3i!I2+UksELwDqwMeBZ9FSP$;^Ajro z_@M#_Ss$?ejoB@!wN|kbGKs(0zLo%0QpQXW#t;oC$B0MZYZ&Ej?8~fNhcCVvPo3vo zFn0WWZaPliF^8_}yzb`*f@yg0uWv6HgNI)xa=pO%Ck(C<=-60l#uD3(wXP~c7!NoX z0&^6=N`zcc90F#qt@=Rn@r!3(*1v(Tl{B!m?Mc7yIA+nEHpY{YWr$=)F7rhR1P}(v zt{YhY#;jsW6G>#xhP*B`OCk|Pf+NN;ju1rxa*HAgoGq*rvqw&xe~;t1JA31$s?GBb z*g7&@cbKo4n<`>)!UlIAgR6q&))B0KYU8r66GbFj?8Guw4E%&}Qi_lT003LtoIZei zwD~=XZmeo+yZ2Pq3KYCF-R&11^p= z@H%s+=G`}wrbJ{()Mh71#2SP3Zy3m>l1n?0N-N1Q;z6?oSxr-G(H5m4EO>~&;}VKi zfY}3w+9z>vp#d)hVuu`)vG_aaH%3b=WKMnSu&c31;<3O;bz2iD=w+o4#oBb36 z5ZCF*Gu?zjZIR0S>_%pHY2$k8D^n7Sz_K8tCDeXM+dO<#LSg%h6`~dnVG1N@T7v&e z%wEd1!k{^zfz_1BTW{!$!B%g)J^2b87!9Y>>100X1SgT7s0z$o>^lAA=Gp_cC1(h=*5Tmf8z&LGJJ>$|K^~s`z9*OWz5MFUr?>Bi?_PGBB)#psD5?>n+q{o_ zz7~ez&;t#h8l$jwGPCC&xq2YetXYQT+0F3j(`xmNGf8dj#an|p#I*pvI*kwW4iuB> z+q3_7xB8y;pLzHG-S%+UHQA zvqp;$kmGJY>lLsN4C~&TcvAS1SErTcwcw0r@wngk zShAUA1M9b#g}^pL-zH7Q#z^&j#r9F8BTVfkR&qF<=e35goTu7c|GN)0mokj4m0%~0 zXJ8j4Hc_l;HJ&uU*Iw`8d_EscJ``s0tk9mkKo^&#TYXm-EoAzTQObxa@^u~g2t#T) zJz|rE!I_?i4dCJC=B8(_pZ{YR>|V?0iCcnU;E@$239^x?SYCfNaMHN;CtHIS_zHN9 zTkQc1v@O35okiFtq5_u+5FkY55ap@pi)O?}x0D1c*qB0KpYR}>Ul+B0Vmr}Z@+%mJ|As}sis_=ROPbov@*2thpE&?!V#Qgu$snYvCZ zrkhmkMU+fSf-s8(L37fPr&M*jRs{{THb!aXQu|P9l_-vJhHvLzMGH zE?1U0H_+PmNABp9`|KzkGfrrZ%XvdGo6*<{d5m9~L7 z_^`M;X6xDo=m6LY6RfvJEvsTK1!u8d2HPx|$S}p;sRy!I zWL55Yxu~_B`OP@~(q6&W3#)~I&+MGL%GWR$#udC151^wsswhqlii;rP9jJpiI7o&Z zAb})=HY7?4HA|re3ns`%$)FuvKCFWjhb~?IE)F6dF2K5}poj-NK6Gf;hw$t3=1txY zoxQxZWrQU6K!%|~!m?~Bnw-6Rr!F3BZ{u5!LqnZTDON}Coj9^@&le)V!NYrVwS~B% zEL+>Sr@}qGwGvu|HrOo|gSt__ezN^&%~{*)a=rf7y1HujUcr`zZB<4#l@T#eN)si} z)lZA<{=tKx8E%c9>A(##6}_p+~EZpKsl5a4pj`E*;_-6`ysiv zffA!7=MT1vCz}-m4~tjVey1b2KSR4OEtLd-(_DdUqYZ74LaDkhH?KFh?%WAOP2WbX zp@zT+Dx|5_f%JQiAGvVw!oh+g3e50u!aPfMxdC=E)XB{F5IcEZhePIM- zph6Y`$Oy?JBL<8Ex(SqEhLeQ@XcrdA>a?rx+_~HLA;l14)WmmpH}_w?Pg#HBZs0eS zwypwAW?M-x+3AU-(GGWSJ=ngxUEcEZ5OsX(Qlt!MQ zn^(`S{GHkAv(8@D`EAfSYig%Cxv?z!{=w^F#y)5_d7FuKZH7qlR-#5B0bt806%D0I zT7VdVP_?q*%Rq8UR;JkD4i^RXowt+E%#V2U>TfDqzZSDZ+dR!a#T3I>-z_$q9@k|m zy5~A*m~&JWP@E7a=pc}4kVHTc4h&R;Li7d@f`|hKMLkbb^uhOakNr3&FLjlm~i5NBM< zFaYI{;cpiHCNRdE0dg*>qIm(_t?#$h=(SCw?h3rJV2*ER8{O4^3#=dO)KwklZkoqU zS8i5c%YL*y*4;FY#D=XmkQnYj%LH)?02~gSJH`Qp1XY64g>%c_K$xseI&|e)7vRoL zAqRba$G@%fSGA7X7hQk%_3NVOYVS+$leU_!&6*5uN)8#5ZBz_6ASCA;azYS-Rt@ki zg2NWz(=;t}SC(~Ibl63$5C8FPmhXqb^)5#jaJ~I{Ex3xZ!+2h8$}}h_g@Be>HZ;72 z6#y#>AY3^skuVKF#0WxFBQ()5d5_nWb?c6c>EeMM|Mh+*&wEpPyxHCq{R-Gdr-`hN zF=1sxl&mBoK+#qRLl9#CEN|Fg8>nbmsTg3a1;#M9enQ$RgWk}kp#-5wh=EF&1tl%mJln2V^8o%Qv(*=zEuO7y z=m*8?xpUn-*@h5Cl_3BK3joiGkyaScK+>|MWdMRWm@RT!Q1piAlv5hL@B6>3&GI8) zP!xBc6}ZNIpJLL%2a8Y!+(<=f%WX>_uWVxlga9!D*oYt$l0cxRDMvqfU;Kq_mLK5k z)dvqYcgLa_Lz?3HyeF)@$%$&6lI?r4I>6W#M*<)vq{?&Oqrx``d`mhpVPr> z#q078F6gw_X<=?KR>8%^t%@wbITvNMu!hKiTSkCTJkw>1!e*Y{%31#_yMf=LW7{RJ zYoC^w$6%3cBtVG5)x#{Hg6IVTh9XEcM{gQwXk!R^y95^f-hZ`d{aVa+xW1EO4wDV4 zB?JgD7*?qkvc|$nIykTvNl2x0j3Q!MXoLL^)~}d7jcYf(H8D~c+?$pKL(px>Z3`eb z04RzS6_AgFT6Pn#iZAg$Sl_j8#;6ShF%&(Fag#E2asU@@LaN;=b=Wf7sgPKhfzhBM zC@eFL8^MrnA*9&Khe*Ab@CC9*uyJGXyi(;y2>lQLJZt;ShtJi?3Yf_t`F+$hY!+Q2Ndsx=U+bjTiAy7djLji>7k%k`$9&--f<*BNA3Hy&ZrHH|4 zG5H&9cB?O#zI1_OOf0Ce%mDfQxdtp3vU%(iY6yji3iISS61XLv#z|!zI_sZqza@B+ zyu9st5-h+`H7QUKx9}3w@oU@EO}&cEzG?fu!!bLO->%zkcg;i9^j`S~=WKMnDi1f= P00000NkvXXu0mjft=yBf literal 0 HcmV?d00001 diff --git a/clarity.controlplane/src/assets/react.svg b/clarity.controlplane/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/clarity.controlplane/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/clarity.controlplane/src/assets/vite.svg b/clarity.controlplane/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/clarity.controlplane/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/clarity.controlplane/src/components/GitCommitDrawer.tsx b/clarity.controlplane/src/components/GitCommitDrawer.tsx new file mode 100644 index 0000000..efdabd4 --- /dev/null +++ b/clarity.controlplane/src/components/GitCommitDrawer.tsx @@ -0,0 +1,117 @@ +import { useEffect, useState, useRef } from 'react'; +import { Button, Drawer, Intent, NonIdealState, Spinner, Tag, Tooltip } from '@blueprintjs/core'; +import { html as diff2htmlHtml } from 'diff2html'; +import 'diff2html/bundles/css/diff2html.min.css'; +import hljs from 'highlight.js'; +import 'highlight.js/styles/github.css'; +import { getCommitDetail, type CommitDetail } from '../api/opcApi'; + +interface Props { + hash: string | null; + onClose: () => void; +} + +export function GitCommitDrawer({ hash, onClose }: Props) { + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const diffRef = useRef(null); + + useEffect(() => { + if (!hash) { setDetail(null); setError(null); return; } + setLoading(true); setDetail(null); setError(null); + getCommitDetail(hash) + .then(setDetail) + .catch(e => setError(String(e))) + .finally(() => setLoading(false)); + }, [hash]); + + // After diff HTML is injected, run highlight.js over code blocks + useEffect(() => { + if (detail && diffRef.current) { + diffRef.current.querySelectorAll('code[class]').forEach(el => { + hljs.highlightElement(el); + }); + } + }, [detail]); + + const combinedPatch = detail?.files.map(f => f.patch).join('\n') ?? ''; + const diffHtml = combinedPatch + ? diff2htmlHtml(combinedPatch, { + drawFileList: true, + matching: 'lines', + outputFormat: 'line-by-line', + renderNothingWhenEmpty: false, + }) + : ''; + + return ( + + {detail.shortHash} + {detail.subject} + + ) : 'Commit Diff'} + size="70%" + position="right" + className="git-commit-drawer" + > +
+ {loading && } title="Loading diff…" />} + {error && } + + {detail && ( + <> + {/* Metadata bar */} +
+
+ + navigator.clipboard.writeText(detail.hash)} + style={{ cursor: 'pointer' }} + > + {detail.shortHash} + + + {detail.author} + {detail.date} +
+
+ + +{detail.files.reduce((a, f) => a + f.additions, 0)} + + + -{detail.files.reduce((a, f) => a + f.deletions, 0)} + + {detail.files.length} file{detail.files.length !== 1 ? 's' : ''} +
+
+ + {/* Commit body if multiline */} + {detail.body.trim() !== detail.subject.trim() && ( +
{detail.body.trim()}
+ )} + + {/* Diff */} + {diffHtml + ?
+ : + } + + )} + + {!loading && !error && !detail && hash && ( + } title="Loading…" /> + )} +
+ +
+
+ + ); +} diff --git a/clarity.controlplane/src/components/ImageBuildPanel.tsx b/clarity.controlplane/src/components/ImageBuildPanel.tsx new file mode 100644 index 0000000..7264e3d --- /dev/null +++ b/clarity.controlplane/src/components/ImageBuildPanel.tsx @@ -0,0 +1,132 @@ +import { useEffect, useRef, useState } from 'react'; +import { Button, Callout, Intent, Tag } from '@blueprintjs/core'; +import { getImageStatus, type ImageBuildStatus } from '../api/provisioningApi'; + +const BASE_URL = import.meta.env.VITE_API_URL ?? ''; + +export default function ImageBuildPanel() { + const [status, setStatus] = useState(null); + const [building, setBuilding] = useState(false); + const [logs, setLogs] = useState([]); + const [open, setOpen] = useState(false); + const [error, setError] = useState(null); + const logRef = useRef(null); + + useEffect(() => { + getImageStatus().then(setStatus).catch(() => {}); + }, []); + + // Auto-scroll log panel + useEffect(() => { + if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; + }, [logs]); + + const handleBuild = async () => { + if (building) return; + setBuilding(true); + setOpen(true); + setLogs([]); + setError(null); + + try { + // POST /api/image/build — the response body IS the SSE stream + const res = await fetch(`${BASE_URL}/api/image/build`, { method: 'POST' }); + + if (!res.ok || !res.body) { + setError(`Build failed to start: ${res.statusText}`); + setBuilding(false); + return; + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const parts = buffer.split('\n\n'); + buffer = parts.pop() ?? ''; + + for (const chunk of parts) { + const dataLine = chunk.replace(/^data:\s*/m, '').trim(); + if (!dataLine) continue; + try { + const msg = JSON.parse(dataLine); + if (msg.done) { + // Build finished — refresh status + getImageStatus().then(setStatus).catch(() => {}); + } else if (typeof msg.line === 'string') { + setLogs((prev) => [...prev.slice(-1000), msg.line]); + } + } catch { /* ignore non-JSON */ } + } + } + } catch (e) { + setError(e instanceof Error ? e.message : 'Unknown error during build'); + } finally { + setBuilding(false); + } + }; + + const lastBuilt = status?.builtAt + ? new Date(status.builtAt).toLocaleString() + : 'Never'; + + return ( +
+
+
+ + {error && ( + {error} + )} + + {open && logs.length > 0 && ( +
+ {logs.map((l, i) =>
{l}
)} +
+ )} +
+ ); +} diff --git a/clarity.controlplane/src/components/wizard/ClientDetailsStep.tsx b/clarity.controlplane/src/components/wizard/ClientDetailsStep.tsx new file mode 100644 index 0000000..1c28a66 --- /dev/null +++ b/clarity.controlplane/src/components/wizard/ClientDetailsStep.tsx @@ -0,0 +1,102 @@ +import { useEffect } from 'react'; +import { Callout, FormGroup, InputGroup, Intent } from '@blueprintjs/core'; +import { CLARITY_DOMAIN } from '../../config'; +import type { ProvisioningRequest } from '../../types/provisioning'; + +interface Props { + signalParent: (state: { isValid: boolean }) => void; + data: ProvisioningRequest; + onChange: (updated: Partial) => void; +} + +function deriveSubdomain(environment: string, siteCode: string): string { + const env = environment.toLowerCase().replace(/[^a-z]/g, ''); + const site = siteCode.toLowerCase().replace(/[^a-z0-9]/g, ''); + if (!env || !site) return ''; + return `${env}-app-clarity-${site}`; +} + +export default function ClientDetailsStep({ signalParent, data, onChange }: Props) { + // Keep a flag so we can show the user the derived name immediately + const derivedSubdomain = deriveSubdomain(data.environment, data.siteCode); + + useEffect(() => { + if (derivedSubdomain !== data.subdomain) { + onChange({ subdomain: derivedSubdomain }); + } + }, [derivedSubdomain]); + + const isValid = + data.clientName.trim().length > 0 && + data.stateCode.trim().length === 2 && + data.siteCode.trim().length > 0 && + data.adminEmail.includes('@') && + data.subdomain.length > 0; + + useEffect(() => { + signalParent({ isValid }); + }, [isValid, signalParent]); + + return ( +
+

Enter the details for the new client tenant.

+ + + onChange({ clientName: v })} + large + /> + + + + onChange({ stateCode: v.toUpperCase() })} + large + style={{ maxWidth: 120 }} + /> + + + + onChange({ siteCode: v.replace(/[^0-9]/g, '') })} + large + style={{ maxWidth: 200 }} + /> + + + + onChange({ adminEmail: v })} + large + /> + + + {data.subdomain && ( + + Container name: {data.subdomain} +
+ Client URL: {data.subdomain}.{CLARITY_DOMAIN} +
+ )} +
+ ); +} diff --git a/clarity.controlplane/src/components/wizard/DeployWizard.tsx b/clarity.controlplane/src/components/wizard/DeployWizard.tsx new file mode 100644 index 0000000..48f2a63 --- /dev/null +++ b/clarity.controlplane/src/components/wizard/DeployWizard.tsx @@ -0,0 +1,124 @@ +import { useState } from 'react'; +import { Button, Intent } from '@blueprintjs/core'; +import ClientDetailsStep from './ClientDetailsStep'; +import DeploymentConfigStep from './DeploymentConfigStep'; +import ReviewStep from './ReviewStep'; +import DeploymentLiveStep from './DeploymentLiveStep'; +import { submitProvisioningJob } from '../../api/provisioningApi'; +import type { ProvisioningRequest } from '../../types/provisioning'; + +const EMPTY: ProvisioningRequest = { + clientName: '', stateCode: '', subdomain: '', adminEmail: '', + siteCode: '', environment: 'fdev', tier: 'Shared', +}; + +const STEP_LABELS = ['Client Details', 'Deployment Config', 'Review', 'Deploying']; + +interface Props { + onClose: () => void; +} + +export default function DeployWizard({ onClose }: Props) { + const [activeStep, setActiveStep] = useState(0); + const [formData, setFormData] = useState(EMPTY); + const [step0Valid, setStep0Valid] = useState(false); + const [step1Valid, setStep1Valid] = useState(true); // tier has a default + const [jobId, setJobId] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + + const handleChange = (u: Partial) => + setFormData((p) => ({ ...p, ...u })); + + const handleDeploy = async () => { + setSubmitting(true); + setSubmitError(null); + try { + const id = await submitProvisioningJob(formData); + setJobId(id); + setActiveStep(3); + } catch (e: unknown) { + setSubmitError(e instanceof Error ? e.message : 'Deployment failed. Please try again.'); + } finally { + setSubmitting(false); + } + }; + + const canGoBack = activeStep > 0 && !jobId; + const isLastFormStep = activeStep === 2; + + return ( +
+ {/* Header */} +
+
+

Deploy New Client

+

Provision a new Clarity tenant from scratch.

+
+ {!jobId && ( +
+ + {/* Step progress */} +
+ {STEP_LABELS.map((label, i) => ( +
+
{i < activeStep ? '✓' : i + 1}
+ {label} +
+ ))} +
+ + {/* Step content */} +
+ {activeStep === 0 && ( + setStep0Valid(isValid)} + /> + )} + {activeStep === 1 && ( + setStep1Valid(isValid)} + /> + )} + {activeStep === 2 && ( + {}} /> + )} + {activeStep === 3 && jobId && ( + + )} + + {submitError && ( +

{submitError}

+ )} +
+ + {/* Footer nav */} + {activeStep < 3 && ( +
+ {canGoBack && ( +
+ )} +
+ ); +} diff --git a/clarity.controlplane/src/components/wizard/DeploymentConfigStep.tsx b/clarity.controlplane/src/components/wizard/DeploymentConfigStep.tsx new file mode 100644 index 0000000..a268cc2 --- /dev/null +++ b/clarity.controlplane/src/components/wizard/DeploymentConfigStep.tsx @@ -0,0 +1,82 @@ +import { useEffect } from 'react'; +import type { ProvisioningRequest, TenantEnvironment, TenantTier } from '../../types/provisioning'; + +interface Props { + signalParent: (state: { isValid: boolean }) => void; + data: ProvisioningRequest; + onChange: (updated: Partial) => void; +} + +const ENVIRONMENTS: { value: TenantEnvironment; label: string; description: string }[] = [ + { value: 'fdev', label: 'Dev (fdev)', description: 'Feature development - fast provisioning, no production data.' }, + { value: 'uat', label: 'UAT', description: 'User acceptance testing - mirrors production configuration.' }, + { value: 'prod', label: 'Production', description: 'Live production environment. Full isolation enforced.' }, +]; + +const TIERS: { value: TenantTier; label: string; description: string; badge: string }[] = [ + { + value: 'Shared', + label: 'Shared', + badge: 'Standard', + description: 'Shared Keycloak, Vault, Postgres and MinIO. Isolated by realm, namespace and bucket.', + }, + { + value: 'Isolated', + label: 'Isolated', + badge: 'Professional', + description: 'Shared Keycloak and Vault, but a dedicated Postgres container and MinIO bucket per tenant.', + }, + { + value: 'Dedicated', + label: 'Dedicated', + badge: 'Enterprise', + description: 'Fully dedicated Keycloak, Vault, Postgres and MinIO containers for complete hard isolation.', + }, +]; + +export default function DeploymentConfigStep({ signalParent, data, onChange }: Props) { + useEffect(() => { + signalParent({ isValid: !!data.tier && !!data.environment }); + }, [data.tier, data.environment, signalParent]); + + return ( +
+

Choose the deployment environment and infrastructure isolation tier.

+ +

Environment

+
+ {ENVIRONMENTS.map((env) => ( + + ))} +
+ +

Isolation Tier

+
+ {TIERS.map((tier) => ( + + ))} +
+
+ ); +} diff --git a/clarity.controlplane/src/components/wizard/DeploymentLiveStep.tsx b/clarity.controlplane/src/components/wizard/DeploymentLiveStep.tsx new file mode 100644 index 0000000..3ceb03f --- /dev/null +++ b/clarity.controlplane/src/components/wizard/DeploymentLiveStep.tsx @@ -0,0 +1,166 @@ +import { useEffect, useRef, useState } from 'react'; +import { AnchorButton, Callout, Intent, ProgressBar, Spinner, Tab, Tabs, Tag } from '@blueprintjs/core'; +import { subscribeToJobStream } from '../../api/provisioningApi'; +import { tenantUrl } from '../../config'; +import type { ProvisioningProgressEvent } from '../../types/provisioning'; + +const SAGA_STEPS = [ + 'Infrastructure Provisioning', + 'Identity Bootstrapping (Keycloak)', + 'Cryptographic Pre-Flight (Vault)', + 'Database Migration & Seeding (EF Core)', + 'Handoff (Email Magic Link)', +]; + +type StepStatus = 'pending' | 'running' | 'complete' | 'failed'; + +interface Props { + jobId: string; + subdomain: string; +} + +export default function DeploymentLiveStep({ jobId, subdomain }: Props) { + const [stepStatuses, setStepStatuses] = useState>( + Object.fromEntries(SAGA_STEPS.map((s) => [s, 'pending' as StepStatus])) + ); + const [logs, setLogs] = useState([]); + const [diagnostics, setDiagnostics] = useState([]); + const [finalStatus, setFinalStatus] = useState<'running' | 'complete' | 'failed'>('running'); + const logEndRef = useRef(null); + const diagEndRef = useRef(null); + + useEffect(() => { + let terminal = false; + + const source = subscribeToJobStream(jobId, (evt) => { + if (evt.type === 'diagnostic') { + setDiagnostics((prev) => [...prev, evt]); + return; + } + setLogs((prev) => [...prev, evt]); + if (evt.type === 'step_started' && evt.step) + setStepStatuses((p) => ({ ...p, [evt.step!]: 'running' })); + else if (evt.type === 'step_complete' && evt.step) + setStepStatuses((p) => ({ ...p, [evt.step!]: 'complete' })); + else if (evt.type === 'step_failed' && evt.step) { + setStepStatuses((p) => ({ ...p, [evt.step!]: 'failed' })); + setFinalStatus('failed'); + } else if (evt.type === 'job_complete') { + terminal = true; + setFinalStatus('complete'); + } else if (evt.type === 'job_failed') { + terminal = true; + setFinalStatus('failed'); + } + }, () => { if (!terminal) setFinalStatus('failed'); }); + + return () => source.close(); + }, [jobId]); + + useEffect(() => { logEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [logs]); + useEffect(() => { diagEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [diagnostics]); + + const completedCount = Object.values(stepStatuses).filter((s) => s === 'complete').length; + const clientUrl = tenantUrl(subdomain); + + const progressPanel = ( + <> +
+ {SAGA_STEPS.map((step) => { + const status = stepStatuses[step]; + return ( +
+ {status === 'running' && } + {status === 'complete' && } + {status === 'failed' && } + {status === 'pending' && } + {step} +
+ ); + })} +
+ +
+ {logs.map((log, i) => ( +
+ {new Date(log.timestamp).toLocaleTimeString()} + {log.message ?? log.type} +
+ ))} +
+
+ + ); + + const diagnosticsPanel = ( +
+ {diagnostics.length === 0 + ? No diagnostics captured. + : diagnostics.map((d, i) => ( +
+ {new Date(d.timestamp).toLocaleTimeString()} + {d.step} +
{d.detail ?? d.message}
+
+ )) + } +
+
+ ); + + return ( +
+

+ {finalStatus === 'running' ? 'Provisioning in progress - do not close this window.' : + finalStatus === 'complete' ? 'Deployment complete.' : 'Deployment failed. Rollback triggered.'} +

+ + + + + + + Diagnostics + {diagnostics.length > 0 && ( + + {diagnostics.length} + + )} + + } + panel={diagnosticsPanel} + /> + + + {finalStatus === 'complete' && ( + +

+ Tenant {subdomain} is live. The day-zero admin has been set up in Keycloak. +

+ +
+ )} + {finalStatus === 'failed' && ( + + A compensating rollback has been triggered. Check the Diagnostics tab for the full stack trace. + + )} +
+ ); +} diff --git a/clarity.controlplane/src/components/wizard/ReviewStep.tsx b/clarity.controlplane/src/components/wizard/ReviewStep.tsx new file mode 100644 index 0000000..f445236 --- /dev/null +++ b/clarity.controlplane/src/components/wizard/ReviewStep.tsx @@ -0,0 +1,38 @@ +import { Callout, HTMLTable, Intent, Tag } from '@blueprintjs/core'; +import { tenantUrl } from '../../config'; +import type { ProvisioningRequest } from '../../types/provisioning'; + +interface Props { + signalParent: (state: { isValid: boolean }) => void; + data: ProvisioningRequest; +} + +export default function ReviewStep({ data }: Props) { + const clientUrl = tenantUrl(data.subdomain); + const containerName = data.subdomain; + + return ( +
+

Confirm the details below before deploying.

+ + + + Client Name{data.clientName} + State Code{data.stateCode} + Site Code{data.siteCode} + Environment{data.environment} + Container Name{containerName} + Client URL{clientUrl} + Admin Email{data.adminEmail} + Tier{data.tier} + + + + + Clicking Deploy will start a {containerName} Docker container running Clarity.Server, + create a Keycloak realm, unseal Vault, and register the subdomain route in the Gateway. + A compensating rollback will trigger automatically on failure. + +
+ ); +} diff --git a/clarity.controlplane/src/config.ts b/clarity.controlplane/src/config.ts new file mode 100644 index 0000000..b15b7ea --- /dev/null +++ b/clarity.controlplane/src/config.ts @@ -0,0 +1,11 @@ +/** + * Central runtime configuration for the Control Plane UI. + * Override defaults via Vite env vars in .env.local: + * VITE_CLARITY_DOMAIN=clarity.test + */ +export const CLARITY_DOMAIN: string = + import.meta.env.VITE_CLARITY_DOMAIN ?? 'clarity.test'; + +/** Builds the public HTTPS URL for a provisioned tenant subdomain. */ +export const tenantUrl = (subdomain: string): string => + `https://${subdomain}.${CLARITY_DOMAIN}`; diff --git a/clarity.controlplane/src/index.css b/clarity.controlplane/src/index.css new file mode 100644 index 0000000..4143b89 --- /dev/null +++ b/clarity.controlplane/src/index.css @@ -0,0 +1,928 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: 'Inter', 'Segoe UI', system-ui, sans-serif; + background: #f4f6f9; + color: #1c2127; + -webkit-font-smoothing: antialiased; +} + +/* ═══════════════════════════════════════════ + Shell — sidebar + main +═══════════════════════════════════════════ */ +.cp-shell { + display: flex; + min-height: 100vh; +} + +/* ── Sidebar ── */ +.cp-sidebar { + width: 240px; + flex-shrink: 0; + background: #1a2332; + display: flex; + flex-direction: column; + position: sticky; + top: 0; + height: 100vh; +} + +.cp-sidebar-brand { + display: flex; + align-items: center; + gap: 10px; + padding: 1.4rem 1.25rem 1.25rem; + border-bottom: 1px solid rgba(255,255,255,0.07); +} + +.brand-mark { + width: 32px; + height: 32px; + background: #215db0; + border-radius: 8px; + display: grid; + place-items: center; + font-size: 0.75rem; + font-weight: 700; + color: #fff; + flex-shrink: 0; +} + +.brand-name { + font-size: 0.875rem; + font-weight: 600; + color: #e5e8eb; + letter-spacing: -0.01em; +} + +.cp-sidebar-nav { + flex: 1; + padding: 0.5rem 0.5rem; + overflow-y: auto; +} + +/* Make Blueprint Menu transparent on dark sidebar */ +.cp-sidebar-menu.bp5-menu { + background: transparent; + padding: 0; +} + +/* Nav items */ +.cp-sidebar-menu .bp5-menu-item { + color: #8f99a8; + border-radius: 6px; + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + font-weight: 500; +} + +.cp-sidebar-menu .bp5-menu-item:hover { + background: rgba(255,255,255,0.06); + color: #d3d8de; +} + +.cp-sidebar-menu .bp5-menu-item.bp5-active, +.cp-sidebar-menu .bp5-menu-item.bp5-intent-primary { + background: rgba(33, 93, 176, 0.35) !important; + color: #fff !important; +} + +.cp-sidebar-menu .bp5-menu-item .bp5-icon { + color: inherit; + opacity: 0.8; +} + +.cp-sidebar-menu .bp5-menu-item.bp5-active .bp5-icon { + opacity: 1; +} + +/* Divider */ +.cp-sidebar-menu .bp5-menu-divider { + border-color: rgba(255,255,255,0.07); + margin: 0.25rem 0; +} + +.cp-sidebar-footer { + padding: 0.85rem 1rem; + border-top: 1px solid rgba(255,255,255,0.07); +} + +.cp-sidebar-user { + display: flex; + align-items: center; + gap: 10px; +} + +.user-avatar { + width: 30px; + height: 30px; + border-radius: 50%; + background: #215db0; + color: #fff; + font-size: 0.8rem; + font-weight: 700; + display: grid; + place-items: center; + flex-shrink: 0; +} + +.user-info { display: flex; flex-direction: column; } +.user-name { font-size: 0.8rem; font-weight: 600; color: #d3d8de; } +.user-role { font-size: 0.7rem; color: #5f6b7c; } + +/* ── Main content area ── */ +.cp-main { + flex: 1; + padding: 2rem 2.5rem; + min-width: 0; +} + +/* ── Page header ── */ +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 2rem; + gap: 1rem; +} + +.page-header h1 { + font-size: 1.4rem; + font-weight: 700; + color: #1c2127; + letter-spacing: -0.02em; + margin-bottom: 0.2rem; +} + +.page-header p { + font-size: 0.875rem; + color: #738091; +} + +/* ═══════════════════════════════════════════ + Job cards +═══════════════════════════════════════════ */ +.job-list { display: flex; flex-direction: column; gap: 0.75rem; } + +.job-card { + background: #fff; + border: 1px solid #e5e8eb; + border-radius: 10px; + padding: 1.1rem 1.25rem; + box-shadow: 0 1px 3px rgba(0,0,0,0.04); + transition: box-shadow 0.15s, border-color 0.15s; +} + +.job-card:hover { + box-shadow: 0 4px 12px rgba(0,0,0,0.08); + border-color: #c5cbd3; +} + +.job-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.job-card-header strong { font-size: 0.95rem; display: block; } + +.job-card-subdomain { + font-size: 0.78rem; + color: #738091; + display: block; + margin-top: 1px; +} + +.job-card-meta { + display: flex; + justify-content: space-between; + font-size: 0.78rem; + color: #8f99a8; +} + +/* ── Empty state ── */ +.empty-state { + background: #fff; + border: 1px dashed #d3d8de; + border-radius: 10px; + padding: 4rem 2rem; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; +} + +.empty-state-icon { font-size: 2.5rem; } +.empty-state h3 { font-size: 1rem; font-weight: 600; color: #1c2127; } +.empty-state p { font-size: 0.875rem; color: #738091; margin-bottom: 0.5rem; } + +/* ═══════════════════════════════════════════ + Wizard +═══════════════════════════════════════════ */ +.wizard-progress { + display: flex; + align-items: center; + gap: 0; + padding: 1rem 1.5rem 0; + border-bottom: 1px solid #e5e8eb; + margin-bottom: 0; +} + +.wizard-progress-step { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + padding-bottom: 0.85rem; + font-size: 0.8rem; + font-weight: 500; + color: #8f99a8; + border-bottom: 2px solid transparent; +} + +.wizard-progress-step.active { + color: #215db0; + border-bottom-color: #215db0; +} + +.wizard-progress-step.done { color: #1c6e42; } + +.wizard-progress-dot { + width: 22px; + height: 22px; + border-radius: 50%; + background: #e5e8eb; + color: #738091; + font-size: 0.7rem; + font-weight: 700; + display: grid; + place-items: center; + flex-shrink: 0; +} + +.wizard-progress-step.active .wizard-progress-dot { + background: #215db0; + color: #fff; +} + +.wizard-progress-step.done .wizard-progress-dot { + background: #1c6e42; + color: #fff; +} + +.wizard-step { padding: 0.25rem 0; } + +.step-description { + font-size: 0.875rem; + color: #738091; + margin-bottom: 1.5rem; +} + +.wizard-step .bp5-form-group { margin-bottom: 1rem; } + +.review-table { width: 100%; font-size: 0.875rem; } +.review-table td:first-child { width: 150px; color: #738091; padding-right: 1rem; padding-bottom: 0.6rem; } +.review-table td:last-child { font-weight: 500; } + +.wizard-footer-actions { display: flex; gap: 8px; align-items: center; } + +.wizard-error { color: #c23030; font-size: 0.85rem; margin-top: 0.5rem; } + +/* ── Wizard as inline page ── */ +.wizard-page { + display: flex; + flex-direction: column; + height: 100%; +} + +.wizard-page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 1.5rem; +} + +.wizard-page-title h2 { + font-size: 1.4rem; + font-weight: 700; + color: #1c2127; + letter-spacing: -0.02em; + margin-bottom: 0.2rem; +} + +.wizard-page-title p { + font-size: 0.875rem; + color: #738091; +} + +.wizard-page-body { + flex: 1; + max-width: 640px; + padding-top: 1.5rem; +} + +.wizard-page-footer { + display: flex; + align-items: center; + gap: 8px; + padding-top: 1.5rem; + margin-top: 1.5rem; + border-top: 1px solid #e5e8eb; + max-width: 640px; +} + +/* ── Tier cards ── */ +.tier-cards { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.tier-card { + width: 100%; + text-align: left; + background: #fff; + border: 2px solid #e5e8eb; + border-radius: 10px; + padding: 1rem 1.25rem; + cursor: pointer; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.tier-card:hover { + border-color: #215db0; + box-shadow: 0 2px 8px rgba(33,93,176,0.1); +} + +.tier-card.selected { + border-color: #215db0; + background: #f0f4ff; + box-shadow: 0 2px 8px rgba(33,93,176,0.12); +} + +.tier-card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.35rem; +} + +.tier-card-label { + font-size: 0.95rem; + font-weight: 600; + color: #1c2127; +} + +.tier-card-badge { + font-size: 0.7rem; + font-weight: 700; + padding: 2px 8px; + border-radius: 99px; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.tier-badge-shared { background: #e5e8eb; color: #5f6b7c; } +.tier-badge-isolated { background: #fef3c7; color: #92400e; } +.tier-badge-dedicated { background: #fee2e2; color: #991b1b; } + +.tier-card-description { + font-size: 0.8rem; + color: #738091; + line-height: 1.5; + margin: 0; +} + +/* ═══════════════════════════════════════════ + OPC — Online Project Communication +═══════════════════════════════════════════ */ + +/* Filter bar */ +.opc-filter-bar { + display: flex; + align-items: center; + gap: 0.6rem; + margin-bottom: 1.25rem; + flex-wrap: wrap; +} + +.opc-count-badge { + margin-left: auto; + font-size: 0.78rem; + color: #738091; + font-weight: 500; +} + +/* Table */ +.opc-table.bp5-html-table { + width: 100%; + background: #fff; + border-radius: 10px; + overflow: hidden; + border: 1px solid #e5e8eb; + box-shadow: 0 1px 3px rgba(0,0,0,0.04); +} + +.opc-table.bp5-html-table thead tr th { + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #738091; + padding: 0.6rem 0.85rem; + background: #f8f9fb; + border-bottom: 1px solid #e5e8eb; +} + +.opc-table.bp5-html-table tbody tr td { + padding: 0.65rem 0.85rem; + vertical-align: middle; +} + +.opc-table.bp5-html-table.bp5-interactive tbody tr:hover td { + cursor: pointer; + background: #f0f4ff; +} + +.opc-row-selected td { + background: #e8f0fb !important; +} + +.opc-number-chip { + font-family: 'Consolas', 'Courier New', monospace; + font-size: 0.78rem; + color: #215db0; + background: #e8f0fb; + padding: 2px 7px; + border-radius: 4px; + white-space: nowrap; +} + +.opc-title-cell { + font-weight: 500; + max-width: 340px; +} + +.opc-date-cell { + font-size: 0.78rem; + color: #738091; + white-space: nowrap; +} + +/* Drawer body */ +.opc-drawer-body { + padding: 1.25rem 1.5rem; + height: 100%; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +/* Meta strip */ +.opc-meta-strip { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + margin-bottom: 0.25rem; +} + +.opc-meta-label { + font-size: 0.75rem; + font-weight: 600; + color: #738091; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +/* Tab panel */ +.opc-tab-panel { + padding-top: 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +/* Field display */ +.opc-field-label { + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #738091; + margin-bottom: 0.2rem; +} + +.opc-field-value { + font-size: 0.875rem; + color: #1c2127; +} + +.opc-field-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + margin-top: 0.5rem; +} + +.opc-description { + font-size: 0.875rem; + color: #1c2127; + line-height: 1.65; + white-space: pre-wrap; + margin: 0; +} + +/* Notes */ +.opc-notes-feed { + display: flex; + flex-direction: column; + gap: 0.75rem; + min-height: 60px; +} + +.opc-note-card { + background: #f6f7f9; + border: 1px solid #e5e8eb; + border-radius: 8px; + padding: 0.8rem 1rem; +} + +.opc-note-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.35rem; +} + +.opc-note-author { + font-size: 0.8rem; + font-weight: 600; + color: #1c2127; +} + +.opc-note-time { + font-size: 0.73rem; + color: #738091; +} + +.opc-note-content { + font-size: 0.875rem; + color: #1c2127; + line-height: 1.6; + white-space: pre-wrap; +} + +.opc-note-compose { + border-top: 1px solid #e5e8eb; + padding-top: 1rem; + display: flex; + flex-direction: column; +} + +/* Commits */ +.opc-commits-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.opc-commit-row { + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 0.6rem 0.75rem; + background: #f6f7f9; + border: 1px solid #e5e8eb; + border-radius: 6px; +} + +.opc-commit-hash { + font-family: 'Consolas', 'Courier New', monospace; + font-size: 0.75rem; + color: #215db0; + background: #e8f0fb; + padding: 2px 6px; + border-radius: 4px; + flex-shrink: 0; + white-space: nowrap; + align-self: center; +} + +.opc-commit-info { flex: 1; } +.opc-commit-msg { font-size: 0.82rem; font-weight: 500; color: #1c2127; } +.opc-commit-meta { font-size: 0.72rem; color: #738091; margin-top: 2px; } + +.opc-commit-files { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + margin-top: 0.35rem; +} + +.opc-commit-files span { + font-size: 0.68rem; + font-family: 'Consolas', monospace; + background: #e5e8eb; + color: #5f6b7c; + padding: 1px 5px; + border-radius: 3px; +} + +/* Drawer form actions */ +.opc-drawer-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + padding-top: 1rem; + border-top: 1px solid #e5e8eb; + margin-top: auto; +} + +/* Inline editable description */ +.opc-editable-desc { + font-size: 0.875rem !important; + color: #1c2127; + line-height: 1.65; + white-space: pre-wrap; + min-height: 60px; + width: 100%; + border-radius: 4px; + padding: 4px 6px; +} + +.opc-editable-desc.bp5-editable-text-editing { + box-shadow: 0 0 0 1px #215db0, 0 0 0 3px rgba(33,93,176,0.2); +} + +/* AI assist box */ +.opc-ai-box { + background: #faf7ff; + border: 1px dashed #c4b5fd; + border-radius: 8px; + padding: 0.75rem 1rem; + margin-top: 0.25rem; +} + +.opc-ai-label { margin-bottom: 0.4rem; } + +.opc-ai-input-row { display: flex; gap: 0.5rem; align-items: center; } + +.opc-ai-result { + margin-top: 0.75rem; + background: #fff; + border: 1px solid #e5e8eb; + border-radius: 6px; + overflow: hidden; +} + +.opc-ai-result-text { + font-size: 0.8rem; + color: #1c2127; + white-space: pre-wrap; + word-break: break-word; + margin: 0; + padding: 0.75rem 1rem; + max-height: 220px; + overflow-y: auto; + line-height: 1.65; +} + +.opc-ai-result-actions { + display: flex; + gap: 0.4rem; + padding: 0.5rem 0.75rem; + border-top: 1px solid #e5e8eb; + background: #f8f9fb; +} + +/* Artifact panel */ +.opc-artifact-panel { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.opc-artifact-card { + background: #fff; + border: 1px solid #e5e8eb; + border-radius: 8px; + padding: 0.9rem 1rem; +} + +.opc-artifact-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; + font-size: 0.875rem; +} + +.opc-artifact-body { + font-size: 0.82rem; + color: #1c2127; + white-space: pre-wrap; + word-break: break-word; + margin: 0; + max-height: 300px; + overflow-y: auto; + line-height: 1.6; +} + +.opc-artifact-meta { + font-size: 0.7rem; + color: #738091; + margin-top: 0.5rem; +} + +.opc-artifact-form { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +/* Branch coverage */ +.opc-branch-coverage { + background: #f8f9fb; + border: 1px solid #e5e8eb; + border-radius: 8px; + padding: 0.75rem 1rem; + margin-bottom: 0.75rem; +} + +.opc-branch-chips { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +/* Commits section labels */ +.opc-commits-section-label { + display: flex; + align-items: baseline; + gap: 0.25rem; + margin-bottom: 0.5rem; +} + +.opc-section-hint { + font-size: 0.8rem; + color: #738091; +} + +.opc-empty-hint { + font-size: 0.82rem; + color: #738091; + margin: 0 0 0.5rem; +} + +/* ── Git Commit Drawer ──────────────────────────────────────────────────────── */ +.git-commit-drawer .bp5-drawer-header { + padding: 0.75rem 1rem; +} + +.git-drawer-title { + display: flex; + align-items: center; + gap: 0.6rem; + min-width: 0; +} + +.git-drawer-hash { + font-size: 0.78rem; + background: #e8edf2; + border-radius: 4px; + padding: 2px 6px; + flex-shrink: 0; + color: #215db0; + font-family: 'JetBrains Mono', 'Fira Code', monospace; +} + +.git-drawer-subject { + font-size: 0.92rem; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: #1c2127; +} + +.git-drawer-body { + flex: 1; + overflow-y: auto; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.git-drawer-footer { + padding: 0.75rem 1rem; + border-top: 1px solid #d3d8de; + display: flex; + justify-content: flex-end; +} + +.git-commit-meta-bar { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.6rem 0.75rem; + background: #f6f8fa; + border: 1px solid #d0d7de; + border-radius: 6px; +} + +.git-commit-meta-left { + display: flex; + align-items: center; + gap: 0.6rem; +} + +.git-commit-meta-right { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.git-commit-hash-chip { + font-size: 0.78rem; + background: #dbe7ff; + color: #215db0; + border-radius: 4px; + padding: 2px 7px; + font-family: 'JetBrains Mono', 'Fira Code', monospace; + transition: background 0.15s; +} + +.git-commit-hash-chip:hover { + background: #c3d6ff; +} + +.git-commit-author { + font-size: 0.83rem; + font-weight: 600; + color: #1c2127; +} + +.git-commit-date { + font-size: 0.8rem; + color: #738091; +} + +.git-commit-body { + font-size: 0.82rem; + color: #404854; + background: #f6f8fa; + border: 1px solid #d0d7de; + border-radius: 6px; + padding: 0.75rem; + white-space: pre-wrap; + word-break: break-word; + font-family: 'Inter', system-ui, sans-serif; +} + +.git-diff-container { + font-size: 0.78rem; + line-height: 1.45; + border-radius: 6px; + overflow: hidden; + border: 1px solid #d0d7de; +} + +/* Tune diff2html table to fit drawer width */ +.git-diff-container .d2h-wrapper { + overflow-x: auto; +} + +.git-diff-container .d2h-file-header { + background: #f0f3f6; + font-size: 0.78rem; +} + +.git-diff-container .d2h-code-line { + font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; + font-size: 0.75rem; +} + +.git-diff-container .d2h-ins { + background: #e6ffec; +} + +.git-diff-container .d2h-del { + background: #ffebe9; +} + +.git-diff-container .d2h-ins > td.d2h-code-linenumber { + background: #ccffd8; + color: #196c2e; +} + +.git-diff-container .d2h-del > td.d2h-code-linenumber { + background: #ffd7d5; + color: #82071e; +} + diff --git a/clarity.controlplane/src/main.tsx b/clarity.controlplane/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/clarity.controlplane/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/clarity.controlplane/src/opc/OpcPage.tsx b/clarity.controlplane/src/opc/OpcPage.tsx new file mode 100644 index 0000000..a14b30e --- /dev/null +++ b/clarity.controlplane/src/opc/OpcPage.tsx @@ -0,0 +1,813 @@ +import { useState, useMemo, useEffect, useCallback } from 'react'; +import { GitCommitDrawer } from '../components/GitCommitDrawer'; +import { + Button, Callout, Divider, Drawer, FormGroup, + HTMLSelect, HTMLTable, InputGroup, Intent, + NonIdealState, Spinner, Tab, Tabs, Tag, TextArea, Tooltip, + EditableText, +} from '@blueprintjs/core'; +import type { Opc, OpcArtifact, OpcNote, OpcPriority, OpcStatus, OpcType, ArtifactType } from '../types/opc'; +import { + listOpcs, createOpc, updateOpc, getNextNumber, + listNotes, addNote, + listArtifacts, createArtifact, updateArtifact, deleteArtifact, + getLinkedCommits, getPinnedCommits, pinCommit, unpinCommit, getBranchCoverage, + listGiteaBranches, createGiteaBranch, + aiAssist, + type LinkedCommit, type PinnedCommit, type BranchCoverage, type GiteaBranch, +} from '../api/opcApi'; + +// -- Label / intent maps ------------------------------------------------------- + +const TYPE_LABELS: Record = { + ChangeOrder: 'Change Order', + NonDevTask: 'Non-Dev Task', + QaTask: 'QA Task', + BusinessRequirement: 'Business Req.', + Feature: 'Feature', + General: 'General', +}; + +const TYPE_INTENT: Record = { + ChangeOrder: Intent.PRIMARY, + NonDevTask: Intent.NONE, + QaTask: Intent.WARNING, + BusinessRequirement: Intent.SUCCESS, + Feature: Intent.SUCCESS, + General: Intent.NONE, +}; + +const STATUS_INTENT: Record = { + New: Intent.PRIMARY, + InProgress: Intent.WARNING, + InReview: Intent.PRIMARY, + Blocked: Intent.DANGER, + Closed: Intent.SUCCESS, + Cancelled: Intent.NONE, +}; + +const STATUS_LABELS: Record = { + New: 'New', + InProgress: 'In Progress', + InReview: 'In Review', + Blocked: 'Blocked', + Closed: 'Closed', + Cancelled: 'Cancelled', +}; + +const PRIORITY_INTENT: Record = { + Low: Intent.NONE, + Medium: Intent.WARNING, + High: Intent.DANGER, + Critical: Intent.DANGER, +}; + +const ARTIFACT_TABS: { type: ArtifactType; label: string; placeholder: string }[] = [ + { type: 'BusinessRequirement', label: 'Business Req.', placeholder: 'Document business requirements, JAD/JAR outputs, acceptance criteria...' }, + { type: 'Rule', label: 'Rules', placeholder: 'Define rule engine rules, validation logic, business rules...' }, + { type: 'Spec', label: 'Spec', placeholder: 'Technical specification — data contracts, API shapes, architecture decisions...' }, + { type: 'Documentation', label: 'Docs', placeholder: 'End-user or developer documentation for this change...' }, + { type: 'QaTestPath', label: 'QA Test Paths', placeholder: 'Step-by-step QA test scenarios, edge cases, regression checks...' }, +]; + +// -- Helpers ------------------------------------------------------------------- + +function fmtDate(iso: string): string { + return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); +} + +function fmtDateTime(iso: string): string { + return new Date(iso).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); +} + +// -- AI Assist Box ------------------------------------------------------------- + +function AiAssistBox({ context, onApply }: { context?: string; onApply: (text: string) => void }) { + const [prompt, setPrompt] = useState(''); + const [result, setResult] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const run = async () => { + if (!prompt.trim()) return; + setLoading(true); setError(null); setResult(''); + try { + const text = await aiAssist(prompt.trim(), context); + setResult(text); + } catch (e) { setError(String(e)); } + finally { setLoading(false); } + }; + + return ( +
+
+ AI Assist +
+
+ setPrompt(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); run(); } }} + rightElement={ + +
+ {error && {error}} + {result && ( +
+
{result}
+
+
+
+ )} +
+ ); +} + +// -- Artifact panel ------------------------------------------------------------ + +function ArtifactPanel({ opcId, opcNumber, artifactType, placeholder }: { + opcId: string; opcNumber: string; artifactType: ArtifactType; placeholder: string; +}) { + const [artifacts, setArtifacts] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [editId, setEditId] = useState(null); + const [editTitle, setEditTitle] = useState(''); + const [editBody, setEditBody] = useState(''); + const [adding, setAdding] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + try { setArtifacts(await listArtifacts(opcId, artifactType)); } + catch { /* API may not be up */ } + finally { setLoading(false); } + }, [opcId, artifactType]); + + useEffect(() => { load(); }, [load]); + + const startAdd = () => { setEditId(null); setEditTitle(''); setEditBody(''); setAdding(true); }; + const startEdit = (a: OpcArtifact) => { setAdding(false); setEditId(a.id); setEditTitle(a.title); setEditBody(a.content); }; + const cancel = () => { setAdding(false); setEditId(null); }; + + const save = async () => { + setSaving(true); + try { + if (editId) { + const u = await updateArtifact(editId, { artifactType, title: editTitle, content: editBody }); + setArtifacts(prev => prev.map(a => a.id === editId ? u : a)); + } else { + const c = await createArtifact(opcId, { artifactType, title: editTitle, content: editBody }); + setArtifacts(prev => [...prev, c]); + } + cancel(); + } catch { /* no-op */ } + finally { setSaving(false); } + }; + + const remove = async (id: string) => { + await deleteArtifact(id); + setArtifacts(prev => prev.filter(a => a.id !== id)); + }; + + const isEditing = adding || editId !== null; + + if (loading) return
; + + return ( +
+ {!isEditing && ( + <> + {artifacts.length === 0 ? ( + } /> + ) : ( + <> + {artifacts.map(a => ( +
+
+ {a.title || '(untitled)'} +
+
+
+
{a.content}
+
{fmtDate(a.updatedAt)}
+
+ ))} +