You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
src/NCode.Identity.OpenId.Host/Program.cs and src/NCode.Identity.ManagementConsole.Host/Program.cs each build HealthCheckOptions for /health/live and /health/ready inline; one package extension both hosts call keeps them from drifting. #606 (comment)
The OP host and the management console host map their liveness and readiness probes through one NCode.AspNetCore
extension, MapHealthProbes() (#609). The interactive surface and the console read and serve their built single-page
bundles through one general SinglePageBundle<TIndex> in NCode.AspNetCore (#612). The console host gets a
production container image, a CI image workflow, and an opt-in managementConsole component of the host Helm chart
that mounts its mgmt-client/mgmt-dpop PEM keys from Secrets and routes manage.<apex> (/api to the OP, every
other path to the console) (#610).
Context for the executor
Build: dotnet build <project.csproj> -m:2 (never two builds or test runs at once; set $env:MSBUILDDISABLENODEREUSE='1' in your shell first).
Test: dotnet test tests/<P>/<P>.csproj -m:2 --filter "FullyQualifiedName~<Class>" -- RunConfiguration.TreatNoTestsAsError=true.
Format: after editing .cs/.csproj files run dotnet tool restore once, then dotnet csharpier format <file> on each file you touched (the repo-pinned CSharpier). Files are UTF-8 without BOM, LF line endings.
Tests: xUnit, a class per unit deriving the project's BaseTests (strict MockRepository), Method_State_Expected names, #region <Member> Tests regions, no Arrange/Act/Assert comments.
PublicAPI: every public change is recorded in the project's PublicAPI.Unshipped.txt (LF endings). A C# 14 extension(Receiver) member needs two lines: the ….extension(Receiver!) container line and the member line. If the build reports RS0016/RS0017, the exact lines it wants are authoritative: fix the file to match, dotnet format analyzers <project> --diagnostics RS0016 adds missing lines (restore LF afterwards).
Wording: describe the product as it is. Never write "now", "no longer", "previously", "renamed", "moved", "replaces", "new" against an earlier state, or any migration/compatibility note, in code, comments, docs, ADRs, the changelog or commit messages.
Helm is installed locally (helm). Chart files are YAML/Go templates; keep 2-space indentation.
Gotchas:
HealthCheckTags lives in NCode.Registration after step 1; a file in a namespace under NCode.Identity that imports NCode.Registration sees it through that using.
A using that becomes unused fails the build (IDE0005); remove it.
Do not run docker; the Dockerfile and workflow steps are checked statically.
Decisions
Share the hosts' health endpoint mapping #609: MapHealthProbes() lives in NCode.AspNetCore (general ASP.NET Core helper, ADR-1355) and returns the route group holding both probes, so a host can add RequireHost to both (project-template ADR-0128 decision 6).
Share the hosts' health endpoint mapping #609: HealthCheckTags lives in NCode.Registration: the dependency-free composition package that both NCode.AspNetCore and the ASP.NET-free Valkey adapter reference; NCode.AspNetCore names no identity concept, and the Valkey adapter must not take the ASP.NET Core shared framework. Recorded as ADR-1456.
Share one single-page bundle reader between the interactive surface and the console #612: one public SinglePageBundle<TIndex> where TIndex : class (sealed, IDisposable) takes the bundle root's absolute path and a parser Func<string, TIndex?>; it caches the parsed index per file version (last-write time + length), resolves only files under assets, and ServeAsset answers a file immutable with validators and ranges or 404. Each surface registers its own instance: SinglePageBundle<InteractiveBundleShell> and SinglePageBundle<ManagementConsolePage>. ManagementConsolePage carries the console's per-response nonce rendering. Recorded as ADR-1457, amending ADR-1254 decision 2.
Publish and deploy the management console host #610: the console image mirrors the host image (same pinned base images and digests, Node stage builds @ncode/app-mgmt, chiseled-extra, non-root UID 1654, port 8080, one-line JSON logs) and serves the bundle from /app/mgmt. No new root build file is imported, so .dockerignore gains no re-admit; only its comment widens to both images.
Publish and deploy the management console host #610: a separate Console image workflow (job Build console image) keeps the Host image required check's name unchanged; Dependabot's docker stream uses directories: so one pull request moves both images' digests.
Publish and deploy the management console host #610: the console is an opt-in component of the existing charts/ncode-identity-host chart (managementConsole.enabled), not a chart of its own: the manage host's Ingress joins the server's and the console's Services, the server must admit the manage host in allowedHosts, and one release keeps both images on the chart's appVersion. The console gets its own ServiceAccount (no token), its own Valkey Secret (never the server's, ADR-1437), and the restricted Pod Security Standard. Bff:Frontends:mgmt:Authority defaults to the StaticSingle issuer and ManagementConsole:ManagementApiUrl to https://<managementConsole.host>/api. Recorded as ADR-1458, amending ADR-1437, ADR-1179 and ADR-1249.
ADR numbers 1456, 1457, 1458 leave room for records other plans claim in parallel.
Out of scope
No change to the BFF library, the console bundle (src/frontend/** source), the OP's management API, or the AppHost (NCode.Identity.OpenId.LocalDev).
No image publishing workflow (neither image is published by CI; that belongs to the release workflow).
No change to the GitHub ruleset (see Follow-ups); no docker build.
No reformatting of files beyond the lines a step names.
Steps
[ ] 1. Move HealthCheckTags into NCode.Registration
Change: create src/NCode.Registration/HealthCheckTags.cs with the same content as the old file but namespace NCode.Registration; (keep [PublicAPI], the summary and public const string Readiness = "ready";), then git rm the old file. In NCode.Identity.Abstractions/PublicAPI.Unshipped.txt delete the two lines const NCode.Identity.HealthCheckTags.Readiness = "ready" -> string! and NCode.Identity.HealthCheckTags; in NCode.Registration/PublicAPI.Unshipped.txt add const NCode.Registration.HealthCheckTags.Readiness = "ready" -> string! and NCode.Registration.HealthCheckTags (sorted). In both Valkey test files add using NCode.Registration;. In both Program.cs files replace using NCode.Identity; with using NCode.Registration; (OP host: place it just above using NCode.Registration.AspNetCore;). src/NCode.Identity.SharedState.Valkey/DefaultRegistration.cs already imports NCode.Registration and needs no edit.
Pattern: the old src/NCode.Identity.Abstractions/HealthCheckTags.cs.
Done when: if (Test-Path src/NCode.Identity.Abstractions/HealthCheckTags.cs) { exit 1 }; dotnet build src/NCode.Identity.OpenId.Host/NCode.Identity.OpenId.Host.csproj -m:2; if ($LASTEXITCODE) { exit 1 }; dotnet build src/NCode.Identity.ManagementConsole.Host/NCode.Identity.ManagementConsole.Host.csproj -m:2; if ($LASTEXITCODE) { exit 1 }; dotnet test tests/NCode.Identity.SharedState.Valkey.Tests/NCode.Identity.SharedState.Valkey.Tests.csproj -m:2 --filter "FullyQualifiedName~DefaultRegistrationTests|FullyQualifiedName~ValkeyHealthCheckTests" -- RunConfiguration.TreatNoTestsAsError=true
Change: [PublicAPI] public static class HealthProbeDefaults with public const stringLivenessPath = "/health/live", ReadinessPath = "/health/ready", LivenessEndpointName = "health_live_endpoint", ReadinessEndpointName = "health_ready_endpoint", each with a summary. [PublicAPI] public static class HealthProbeEndpointRouteBuilderExtensions with extension(IEndpointRouteBuilder endpoints) { public RouteGroupBuilder MapHealthProbes() }: null-guard endpoints, var group = endpoints.MapGroup(string.Empty);, map LivenessPath with new HealthCheckOptions { Predicate = static _ => false }.WithName(LivenessEndpointName), map ReadinessPath with Predicate = static check => check.Tags.Contains(HealthCheckTags.Readiness).WithName(ReadinessEndpointName), return group. Summary: liveness runs no check so a dependency outage never restarts a replica; readiness runs the checks tagged HealthCheckTags.Readiness; the returned group applies a convention (such as RequireHost) to both. Add a // See ADR-1456. line under the class's doc comment. PublicAPI lines: the class line, ….extension(Microsoft.AspNetCore.Routing.IEndpointRouteBuilder!), ….extension(Microsoft.AspNetCore.Routing.IEndpointRouteBuilder!).MapHealthProbes() -> Microsoft.AspNetCore.Routing.RouteGroupBuilder!, NCode.AspNetCore.HealthProbeDefaults and one const NCode.AspNetCore.HealthProbeDefaults.<Name> = "<value>" -> string! per constant. Tests (#region MapHealthProbes Tests): build with WebApplication.CreateSlimBuilder(), builder.Services.AddHealthChecks() plus test checks, app.MapHealthProbes(), read ((IEndpointRouteBuilder)app).DataSources.SelectMany(x => x.Endpoints).OfType<RouteEndpoint>(); invoke an endpoint with new DefaultHttpContext { RequestServices = app.Services } and await endpoint.RequestDelegate!(httpContext). Tests: MapHealthProbes_Always_MapsTheLivenessAndReadinessPaths (patterns and EndpointNameMetadata names), MapHealthProbes_WhenAReadinessCheckFails_AnswersReadyUnhealthyAndLiveHealthy (check tagged HealthCheckTags.Readiness returns Unhealthy: ready 503, live 200), MapHealthProbes_WhenAnUntaggedCheckFails_AnswersReadyHealthy (200), MapHealthProbes_WhenTheGroupRequiresAHost_RequiresItOnBothProbes (call .RequireHost("probe.example.com") on the result; both endpoints carry IHostMetadata with that host), MapHealthProbes_WhenEndpointsIsNull_Throws.
Change: in each Program.cs replace the two app.MapHealthChecks(...)...WithName(...) statements and their three-line comment with a two-line comment (liveness runs no checks so a dependency outage never restarts a replica; readiness runs the checks tagged ready) and app.MapHealthProbes();. Remove using Microsoft.AspNetCore.Diagnostics.HealthChecks; from both files, and remove using NCode.Registration; from the console host's Program.cs (unused there); the OP host keeps it for its AddDbContextCheck tag. New test class ManagementConsoleHealthEndpointTests (sealed): [Theory] over /health/live and /health/ready → GetHealth_WhenEveryCheckPasses_ReturnsHealthy (200, body Healthy), and GetHealth_WhenAReadinessCheckFails_ReportsNotReadyButAlive (add a check tagged HealthCheckTags.Readiness returning Unhealthy through factory.WithWebHostBuilder(b => b.ConfigureTestServices(...)): ready 503, live 200). Create each factory with await using var factory = new ManagementConsoleApplicationFactory(); and a client whose base address is the one CreateBrowserClient() in Infrastructure/ManagementConsoleApplicationFactory.cs uses.
Change: write ADR-1456 from docs/decisions/0000-template.md. Title # 1456. The hosts map their health probes through one general helper; Status Accepted; Date 2026-11-06; Deciders NCode Group; - **Refines:** project-template ADR-0128. Context: two hosts (NCode.Identity.OpenId.Host, NCode.Identity.ManagementConsole.Host) serve the same probes, and inline copies of their options drift (issue #609); the readiness tag must be reachable by a package that registers a check without ASP.NET Core (the Valkey adapter); NCode.AspNetCore names no identity concept (ADR-1355) and references NCode.Registration (ADR-1447). Decision: (1) NCode.AspNetCore holds MapHealthProbes() and HealthProbeDefaults; liveness at /health/live runs no check, readiness at /health/ready runs the checks tagged ready, both in one returned route group; (2) HealthCheckTags lives in NCode.Registration; (3) every host calls MapHealthProbes() and builds no HealthCheckOptions. Options considered (each rejected with its reason): inline copies (drift); the tag stays in NCode.Identity.Abstractions and NCode.AspNetCore references it (ADR-1355); the tag in NCode.AspNetCore (the Valkey adapter would take the ASP.NET Core shared framework); a tag parameter on MapHealthProbes (every caller passes one constant); a hosting package (one constant). Consequences: one call per host; a package tags a readiness check with NCode.Registration.HealthCheckTags.Readiness; the paths are the ones the chart's probes and the AppHost's WithHttpHealthCheck call. References: the two source files, issue Share the hosts' health endpoint mapping #609, ADR-1355, ADR-1447. Add the index row | [1456](./1456-the-hosts-map-their-health-probes-through-one-general-helper.md) | The hosts map their health probes through one general helper | Accepted | after the 1447 row. In csharp-production.instructions.md §4, after the bullet "A host Program.cs is composition-only", add the bullet: - 👁 **A host maps its probes with `MapHealthProbes()`** (`NCode.AspNetCore`), and a package tags a check a replica cannot serve without with `HealthCheckTags.Readiness` (`NCode.Registration`) ([ADR-1456](../../docs/decisions/1456-the-hosts-map-their-health-probes-through-one-general-helper.md)).
Change: [PublicAPI] public static class SinglePageBundleDefaults with public const string AssetsDirectory = "assets"; and IndexFileName = "index.html";. internal sealed record SinglePageBundleIndex<TIndex>(DateTime LastWriteTimeUtc, long Length, TIndex? Index) where TIndex : class;. [PublicAPI] public sealed class SinglePageBundle<TIndex>(string? rootPath, Func<string, TIndex?> parseIndex) : IDisposable where TIndex : class built by merging ManagementConsoleBundleReader and InteractiveBundleReader: blank rootPath (string.IsNullOrWhiteSpace) means no bundle; IndexPath/AssetsRootPath are Path.Combine(rootPath, SinglePageBundleDefaults.IndexFileName|AssetsDirectory); the lazily created assets-rooted PhysicalFileProvider (Volatile.Read/Interlocked.CompareExchange, disposed in Dispose); public IFileInfo? TryGetAsset(string assetPath) unchanged; public async ValueTask<TIndex?> TryReadIndexAsync(CancellationToken cancellationToken) reads index.html with SecureEncoding.UTF8, calls parseIndex(html) once per file version and caches new SinglePageBundleIndex<TIndex>(lastWriteTimeUtc, length, parsed), returning cached.Index while last-write time and length match; public IResult ServeAsset(HttpResponse response, string assetPath) holds the body of ManagementConsoleAssetHandler.Handle after the route-value read (private const string ImmutableCacheControl, private static FileExtensionContentTypeProvider AssetContentTypes, 404 for no file or unknown type, otherwise Cache-Control immutable and TypedResults.PhysicalFile with last-modified, entity tag and range processing). Null-guard parseIndex, assetPath and response. Keep the file-watcher // comment of the readers. Summaries: the constructor's rootPath is the absolute path of the bundle's root directory. Add // See ADR-1457. under the class doc comment. PublicAPI: the two classes, the constructor, Dispose, TryGetAsset, TryReadIndexAsync, ServeAsset, and the two constants (generic members are written NCode.AspNetCore.SinglePageBundle<TIndex>.…).
Change: public class SinglePageBundleTests : BaseTests over a temp directory (Directory.CreateTempSubdirectory("bundle-"), deleted in Dispose(bool)), with a private sealed record TestIndex(string Html) and a parser that counts its calls. Port every test of tests/NCode.Identity.ManagementConsole.Tests/ManagementConsoleBundleReaderTests.cs regions TryReadIndexAsync Tests and TryGetAsset Tests and of tests/NCode.Identity.OpenId.Authentication.Interactive.Tests/InteractiveBundleReaderTests.cs (renaming TryReadShellAsync_* to TryReadIndexAsync_*), and every test of tests/NCode.Identity.ManagementConsole.Tests/ManagementConsoleAssetHandlerTests.cs as ServeAsset_* in #region ServeAsset Tests (call bundle.ServeAsset(httpContext.Response, "<asset>")). Add TryReadIndexAsync_WhenTheIndexIsUnchanged_ParsesItOnce (parser call count 1 after two reads) and TryReadIndexAsync_WhenTheParserFindsNoIndex_ReturnsNull (parser returns null). Construct with the absolute dist path; drop the content-root-relative and IHostEnvironment cases (resolution belongs to the callers).
Pattern: the three test files named above.
Done when: dotnet test tests/NCode.AspNetCore.Tests/NCode.AspNetCore.Tests.csproj -m:2 --filter "FullyQualifiedName~SinglePageBundleTests" -- RunConfiguration.TreatNoTestsAsError=true
[ ] 7. Serve the interactive bundle through SinglePageBundle<InteractiveBundleShell>
Change: in DefaultRegistration.cs replace serviceCollection.TryAddSingleton<InteractiveBundleReader>(); with serviceCollection.TryAddSingleton(static serviceProvider => new SinglePageBundle<InteractiveBundleShell>(serviceProvider.GetRequiredService<IOptions<InteractiveAuthenticationOptions>>().Value.BundleRootPath, InteractiveBundleShell.TryCreate)); and make ResolveBundleRootPath return Path.GetFullPath(bundleRootPath, contentRootPath) for a non-blank path (blank stays as it is). In the endpoint handler and the error formatter change the constructor parameter and property to SinglePageBundle<InteractiveBundleShell> bundle / Bundle and each BundleReader.TryReadShellAsync(...) to Bundle.TryReadIndexAsync(...). In InteractiveBundleAssetEndpointHandler take SinglePageBundle<InteractiveBundleShell> bundle, build the route with SinglePageBundleDefaults.AssetsDirectory, and make GetAsset read the route value and return Bundle.ServeAsset(httpContext.Response, assetPath);; delete its ImmutableCacheControl and AssetContentTypes members and the usings that become unused. Add using NCode.AspNetCore; where needed.
Pattern: step 5's API.
Done when: dotnet test tests/NCode.Identity.OpenId.Integration.Tests/NCode.Identity.OpenId.Integration.Tests.csproj -m:2 --filter "FullyQualifiedName~InteractiveTenantRouteTests|FullyQualifiedName~HtmlPageSecurityTests" -- RunConfiguration.TreatNoTestsAsError=true
Change: internal sealed record ManagementConsolePage(string Html) (summary: the console bundle's index.html, which each response renders with its own nonce) with public string Render(string nonce) => Html.Replace(ManagementConsoleDefaults.NoncePlaceholder, nonce, StringComparison.Ordinal);. Move the RenderIndex Tests region of ManagementConsoleBundleReaderTests.cs into ManagementConsolePageTests : BaseTests as #region Render Tests with Render_WhenThePageHasPlaceholders_ReplacesEveryPlaceholder, Render_WhenThePageHasNoPlaceholder_ReturnsThePageUnchanged and the [BuiltConsoleBundleFact]Render_WhenGivenTheBuiltBundlesIndex_LeavesNoPlaceholderAndNoncesEveryScript (calling new ManagementConsolePage(html).Render(...)); leave the old file untouched (step 11 deletes it). In frontend.yml, step Test the console host against the built console bundle, change the filter to "FullyQualifiedName~ManagementConsolePageTests.Render_WhenGivenTheBuiltBundlesIndex".
Done when: dotnet test tests/NCode.Identity.ManagementConsole.Tests/NCode.Identity.ManagementConsole.Tests.csproj -m:2 --filter "FullyQualifiedName~ManagementConsolePageTests" -- RunConfiguration.TreatNoTestsAsError=true
[ ] 10. Serve the console bundle through SinglePageBundle<ManagementConsolePage>
Change: in ManagementConsoleBuilder replace the ManagementConsoleBundleReader and ManagementConsoleAssetHandler registrations with serviceCollection.TryAddSingleton<SinglePageBundle<ManagementConsolePage>>(CreateBundle); and add private static SinglePageBundle<ManagementConsolePage> CreateBundle(IServiceProvider serviceProvider) that reads IOptions<ManagementConsoleOptions> and IHostEnvironment, resolves a non-blank BundleRootPath with Path.GetFullPath(path, environment.ContentRootPath) (blank → null) and returns new SinglePageBundle<ManagementConsolePage>(rootPath, static html => new ManagementConsolePage(html)). ManagementConsolePageHandler takes SinglePageBundle<ManagementConsolePage> bundle, reads var page = await bundle.TryReadIndexAsync(...), and writes page.Render(security.Nonce). In MapManagementConsole the asset route's handler becomes static (HttpContext context, string? asset) => context.RequestServices.GetRequiredService<SinglePageBundle<ManagementConsolePage>>().ServeAsset(context.Response, asset ?? string.Empty). In ManagementConsoleDefaults set AssetsPath = "/" + SinglePageBundleDefaults.AssetsDirectory; (value stays /assets). Tests: ManagementConsolePageHandlerTests.CreateHandler builds new SinglePageBundle<ManagementConsolePage>(DistPath, static html => new ManagementConsolePage(html)) (drop the IHostEnvironment mock); DefaultRegistrationTests resolves SinglePageBundle<ManagementConsolePage> instead of the reader and drops the asset handler assertion (rename the test AddManagementConsole_Always_RegistersTheHandlersAndTheBundle).
Done when: dotnet test tests/NCode.Identity.ManagementConsole.Tests/NCode.Identity.ManagementConsole.Tests.csproj -m:2 --filter "FullyQualifiedName~ManagementConsolePageHandlerTests|FullyQualifiedName~DefaultRegistrationTests|FullyQualifiedName~ManagementConsoleDefaultsTests" -- RunConfiguration.TreatNoTestsAsError=true; if ($LASTEXITCODE) { exit 1 }; dotnet test tests/NCode.Identity.OpenId.Integration.Tests/NCode.Identity.OpenId.Integration.Tests.csproj -m:2 --filter "FullyQualifiedName~ManagementConsoleHostTests" -- RunConfiguration.TreatNoTestsAsError=true
[ ] 11. Delete the console's bundle reader and asset handler
Change: ADR-1457, # 1457. A single-page bundle is read and served through one general helper, Status Accepted (amends [ADR-1254](1254-the-interactive-bundles-hashed-files-are-served-immutable-without-resolving-the-tenant.md) decision 2: the assets-rooted file provider is `SinglePageBundle<TIndex>`'s), Date 2026-11-06. Context: apps/auth and apps/mgmt are Vite bundles whose index.html each host reads per version and whose assets each host serves immutable; two copies drift (issue #612); the surfaces differ only in what they keep of the index (the interactive surface splits it at its mount point once per version; the console renders a nonce into it per response). Decision: (1) NCode.AspNetCore holds SinglePageBundle<TIndex> and SinglePageBundleDefaults (assets, index.html), with the behavior of step 5; (2) each surface registers one instance with its index type, SinglePageBundle<InteractiveBundleShell> and SinglePageBundle<ManagementConsolePage>, and resolves its configured root against the content root; (3) metadata revalidation, not a file watcher. Options (rejected): copies; caching raw text and parsing per request; static-file middleware (ADR-1254); a file watcher. Consequences: one implementation, tested in NCode.AspNetCore.Tests; assets stays the contract with @ncode/vite-config. In ADR-1254 append to its Status line ; and by [ADR-1457](1457-a-single-page-bundle-is-read-and-served-through-one-general-helper.md): the assets-rooted file provider is `SinglePageBundle<TIndex>`'s inside the parentheses, and in its README row replace Accepted (amended by [1388](./1388-the-html-page-security-model-and-the-front-channel-marker-are-general-aspnet-core-helpers.md)) with Accepted (amended by [1388](./1388-the-html-page-security-model-and-the-front-channel-marker-are-general-aspnet-core-helpers.md) and [1457](./1457-a-single-page-bundle-is-read-and-served-through-one-general-helper.md)). Add the row | [1457](./1457-a-single-page-bundle-is-read-and-served-through-one-general-helper.md) | A single-page bundle is read and served through one general helper | Accepted (amends [1254](./1254-the-interactive-bundles-hashed-files-are-served-immutable-without-resolving-the-tenant.md)) | after the 1456 row. In csharp-production.instructions.md §4 bullet "A content-hashed static file is served by a route group that resolves no tenant", replace the sentence starting It reads through the `assets`-rooted `InteractiveBundleReader` with: It reads through `NCode.AspNetCore`'s `SinglePageBundle<InteractiveBundleShell>`, whose file provider is rooted at `assets`, so nothing outside `assets`, the bundle's `index.html` included, is servable, and `ServeAsset` sends a served file `Cache-Control: public, max-age=31536000, immutable`. Every surface that serves a built bundle registers its own `SinglePageBundle<TIndex>` and add [ADR-1457](../../docs/decisions/1457-a-single-page-bundle-is-read-and-served-through-one-general-helper.md) beside the ADR-1254 link.
Change: copy src/NCode.Identity.OpenId.Host/Dockerfile and change only: the header names NCode.Identity.ManagementConsole.Host and (ADR-1458), and its build command uses --file src/NCode.Identity.ManagementConsole.Host/Dockerfile; the frontend stage comment names the management console bundle (apps/mgmt) and runs RUN pnpm exec turbo run build --filter=@ncode/app-mgmt; the build stage publishes only src/NCode.Identity.ManagementConsole.Host/NCode.Identity.ManagementConsole.Host.csproj to /app/publish (drop the migration worker publish and its comment, keep the NuGet cache comment); the final stage drops the migrations copy and its comment, copies COPY --from=frontend /source/src/frontend/apps/mgmt/dist ./mgmt/, sets ENV ManagementConsole__BundleRootPath=/app/mgmt, keeps the JSON log ENV block, and ends ENTRYPOINT ["dotnet", "NCode.Identity.ManagementConsole.Host.dll"]. Every FROM line stays byte-identical to the host Dockerfile's. In the .csproj add <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> after UserSecretsId and an ItemGroup with <PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" />, as the host's .csproj has.
Change: .dockerignore first comment line: # An allow-list: the build context carries only what the host and management console images are built from (ADR-1178, and the following text unchanged except only the Host image check finds a missing one → only the Host image and Console image checks find a missing one. No allow-list entry changes. In dependabot.yml the docker entry with directory: /src/NCode.Identity.OpenId.Host uses directories: listing - /src/NCode.Identity.OpenId.Host and - /src/NCode.Identity.ManagementConsole.Host, and its comment says both images share the base images, so one pull request moves both. In repository.instructions.md (push protocol gate 4) change or only the `Host image` check on the / pull request finds it to say the Host image and Console image checks on the pull request find it.
Change: copy .github/workflows/host-image.yml and change: header comment names the production image of NCode.Identity.ManagementConsole.Host (ADR-1458) and keeps the rest of its reasoning; name: Console image; concurrency group prefix console-image-; job name: Build console image; the Node check greps src/NCode.Identity.ManagementConsole.Host/Dockerfile; both build steps use file: src/NCode.Identity.ManagementConsole.Host/Dockerfile; the load tag is ncode-identity-management-console:ci (and the inspect command uses it); the log check also requires ManagementConsole__BundleRootPath=/app/mgmt (add it to the for setting in list and name the step Check the image logs one-line JSON and serves its bundle). Keep the same action pins, triggers, permissions and persist-credentials: false.
Change: append to values.yaml, before the extraEnv comment, a commented managementConsole: block (ADR-1458): enabled: false; replicaCount: 1 (more than one requires valkey.existingSecret below); image: {repository: ghcr.io/ncodegroup/ncode-identity-management-console, tag: "", digest: "", pullPolicy: IfNotPresent}; host: "" (the console's public host name, such as manage.example.com; its AllowedHosts, its origin https://, and the host its Ingress routes; allowedHosts must list it, because /api on it reaches the server); authority: "" (Bff:Frontends:mgmt:Authority; defaults to tenantResolution.staticSingle.issuer under StaticSingle); managementApiUrl: "" (ManagementConsole:ManagementApiUrl; defaults to https:///api); keys: {clientKeySecret: "" (Secret holding the client's private key under mgmt-client.pem), dpopKeySecret: "" (under mgmt-dpop.pem)}; valkey: {existingSecret: "", connectionStringKey: connection-string} (the console's own Valkey, never the server's); service: {type: ClusterIP, port: 80}; ingress: {enabled: false, className: "", annotations: {}, tls: []} (routes /api to the server and / to the console); serviceAccount: {create: true, name: "", annotations: {}}; podDisruptionBudget: {enabled: true, maxUnavailable: 1}; preStopSleepSeconds: 10; terminationGracePeriodSeconds: 45; probes: {startup, liveness, readiness with the server's numbers}; resources: {requests cpu: 50m, memory: 128Mi; limits memory: 256Mi}; topologySpread: (as the server's); podAnnotations: {}, podLabels: {}, nodeSelector: {}, tolerations: [], affinity: {}, priorityClassName: "", extraEnv: [], extraEnvFrom: [], extraVolumes: [], extraVolumeMounts: []. In values.schema.json add a managementConsole object with additionalProperties: false whose properties mirror the top-level schemas of the same shape (image, service, serviceAccount, podDisruptionBudget, topologySpread, probes without host, valkey, the extra* arrays, resources, scheduling), plus enabled boolean, replicaCount integer ≥ 1, host string, authority and managementApiUrl strings with "pattern": "^(https://.+)?$", keys object {clientKeySecret, dpopKeySecret strings}, and ingress object {enabled boolean, className string, annotations object, tls array of objects}. New ci/management-console-values.yaml: the content of ci/single-replica-values.yaml with allowedHostsid.example.com and manage.example.com, ingress enabled (className traefik, host id.example.com, path / Prefix), and managementConsole {enabled: true, host: manage.example.com, keysidentity-mgmt-client-key/identity-mgmt-dpop-key, ingress enabled, className traefik, tls manage-example-com-tls for manage.example.com}, headed # A production release with the management console on its own host, which routes /api to the server.
Change: append, each with a {{/* … */}} description: identity-host.managementConsole.fullname = printf "%s-management-console" (include "identity-host.fullname" . | trunc 44 | trimSuffix "-"); …selectorLabels (name, instance, app.kubernetes.io/component: management-console); …labels (identity-host.commonLabels + that component); …serviceAccountName (as identity-host.serviceAccountName over .Values.managementConsole.serviceAccount, defaulting to the console fullname); …image (as identity-host.image over .Values.managementConsole.image, tag defaulting to .Chart.AppVersion); …authority (managementConsole.authority, else tenantResolution.staticSingle.issuer when strategyCode is StaticSingle, else empty); …managementApiUrl (managementConsole.managementApiUrl or printf "https://%s/api" managementConsole.host); …valkeyEnabled (true when managementConsole.valkey.existingSecret); …validate, which fails with a message naming the value when: host is empty; host contains *; allowedHosts does not contain host ("allowedHosts must list managementConsole.host: the management host routes /api to the server."); the authority is empty or does not start with https://; keys.clientKeySecret or keys.dpopKeySecret is empty; replicaCount > 1 without console Valkey; …env: ASPNETCORE_ENVIRONMENT (.Values.environment), AllowedHosts (host), the three ForwardedHeaders__* entries exactly as identity-host.hostEnv writes them, Bff__Frontends__mgmt__Authority, Bff__Frontends__mgmt__PublicOrigin (https://<host>), Bff__KeyRings__mgmt-client__PemFiles__0 = /etc/ncode-identity/management-console/client-key/mgmt-client.pem, Bff__KeyRings__mgmt-dpop__PemFiles__0 = /etc/ncode-identity/management-console/dpop-key/mgmt-dpop.pem, ManagementConsole__ManagementApiUrl, ConnectionStrings__valkey from the console Valkey Secret when enabled, then managementConsole.extraEnv.
Pattern: the existing helpers in the same file (identity-host.validate, identity-host.hostEnv).
Change: each file is wrapped in {{- if .Values.managementConsole.enabled }}. Deployment: copy templates/deployment.yaml and use the console helpers and values; it includes identity-host.managementConsole.validate; strategy RollingUpdate (maxSurge 1, maxUnavailable 0) with console Valkey, else Recreate with the server's comment adapted to sessions; no Google Cloud annotation; automountServiceAccountToken: false; pod and container security contexts from the existing helpers; container name: management-console, port http 8080; env from identity-host.managementConsole.env; envFrom from managementConsole.extraEnvFrom; the three probes on /health/live, /health/live, /health/ready with Host = managementConsole.host; preStop sleep; volume mounts tmp → /tmp, client-key → /etc/ncode-identity/management-console/client-key and dpop-key → /etc/ncode-identity/management-console/dpop-key (readOnly), then managementConsole.extraVolumeMounts; volumes tmp (emptyDir, sizeLimit: 64Mi), client-key (secret keys.clientKeySecret, items: [{key: mgmt-client.pem, path: mgmt-client.pem}], defaultMode: 0440), dpop-key (same for mgmt-dpop.pem), then managementConsole.extraVolumes; imagePullSecrets from identity-host.imagePullSecrets; nodeSelector/affinity/tolerations/priorityClassName and topologySpread from managementConsole. Service: copy templates/service.yaml with the console fullname, labels, managementConsole.service and console selector labels. ServiceAccount: copy templates/serviceaccount.yaml over managementConsole.serviceAccount, automountServiceAccountToken: false.
Change: Ingress, rendered when managementConsole.enabled and managementConsole.ingress.enabled: name = console fullname, console labels, managementConsole.ingress.annotations/className/tls, one rule for managementConsole.host with two Prefix paths: /api → service identity-host.fullname port name http, and / → the console Service port name http; a comment says /api reaches the server for machine callers and every other path reaches the console (ADR-1437). PodDisruptionBudget: copy templates/poddisruptionbudget.yaml over managementConsole.podDisruptionBudget and the console selector labels, rendered when the console is enabled. NOTES.txt: when the console is enabled, append a paragraph: the console answers to <host> through the <console fullname> Service; register the confidential client mgmt-console with the server with jwks_urihttps://<host>/bff/jwks; and, without console Valkey, the console runs one replica whose sessions end on every update.
Change: the existing loop lints the new ci file already. Add a step Deploy the management console on its own host after Never run two replicas at once without Valkey: with base="$CHART/ci/management-console-values.yaml" and the same refuse() function, refuse --set managementConsole.host=, --set 'managementConsole.host=*.example.com', --set 'allowedHosts={id.example.com}', --set managementConsole.authority=http://id.example.com, --set tenantResolution.strategyCode=DynamicByHost --set tenantResolution.staticSingle.issuer=, --set managementConsole.keys.clientKeySecret=, --set managementConsole.keys.dpopKeySecret=, --set managementConsole.replicaCount=2; then fail unless the rendered templates/management-console-ingress.yaml routes path: /api to the server Service, and fail when helm template release "$CHART" --values "$CHART/ci/single-replica-values.yaml" output contains management-console (the console is opt-in). Update the workflow's header comment to say the chart's management console component is checked too.
Change: Chart.yamldescription: NCode.Identity.OpenId.Host, the OpenID Connect and OAuth 2.0 server, as a rolling, non-root deployment behind an ingress, and optionally its management console on a host of its own. README: in the intro add one sentence that managementConsole.enabled also deploys the management console host (ADR-1458); in "What the chart refuses" add the console's refusals (step 17's list); add a ## Management console section before ## Strict-Transport-Security covering: what it deploys (Deployment, Service, ServiceAccount without a token, PodDisruptionBudget, Ingress on managementConsole.host routing /api to the server and / to the console); the Secrets to create, with commands openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out mgmt-client.pem (same for mgmt-dpop.pem) and kubectl create secret generic identity-mgmt-client-key --from-file=mgmt-client.pem (same for dpop); registering mgmt-console with the server with the metadata of ADR-1437 decision 4 and jwks_urihttps://<host>/bff/jwks; allowedHosts listing the console host; the defaults of authority and managementApiUrl; managementConsole.valkey.existingSecret for more than one replica (the console's own Valkey, never the server's); and console settings such as ManagementConsole__Branding__DisplayName passed through managementConsole.extraEnv; and an install example extending the existing one with --set managementConsole.enabled=true --set managementConsole.host=manage.example.com --set 'allowedHosts={id.example.com,manage.example.com}' --set managementConsole.keys.clientKeySecret=identity-mgmt-client-key --set managementConsole.keys.dpopKeySecret=identity-mgmt-dpop-key.
Change: new README # NCode.Identity.ManagementConsole.Host with a one-paragraph intro (the management console's BFF host, ADR-1437) and a ## Container image section modeled on the section of the same name in src/NCode.Identity.OpenId.Host/README.md: the build command with --file src/NCode.Identity.ManagementConsole.Host/Dockerfile --tag ncode-identity-management-console; chiseled, no shell, non-root UID 1654, port 8080 plain HTTP, serves apps/mgmt from /app/mgmt, probes /health/live and /health/ready, one-line JSON logs, linux/amd64 and linux/arm64 built by the Console image workflow (ADR-1458); the settings the Production environment requires: AllowedHosts, Bff__Frontends__mgmt__Authority, Bff__Frontends__mgmt__PublicOrigin, Bff__KeyRings__mgmt-client__PemFiles__0, Bff__KeyRings__mgmt-dpop__PemFiles__0, ManagementConsole__ManagementApiUrl, and ConnectionStrings__valkey for more than one replica; and a link to the chart's Management console section. In docs/backend-for-frontend.md §5.15, after the paragraph that ends "…ADR-1439)." add a Deployment. paragraph: the host's image (src/NCode.Identity.ManagementConsole.Host/Dockerfile) and the managementConsole component of charts/ncode-identity-host, which mounts the two key rings from Secrets and routes manage.<apex>'s /api to the OP and every other path to the console host (ADR-1458).
Change: ADR-1458, # 1458. The management console ships as its own image and an opt-in component of the host chart, Status Accepted (amends [ADR-1437](1437-the-management-console-is-a-confidential-client-served-by-a-bff-host-of-its-own.md), [ADR-1179](1179-the-host-chart-references-its-secrets-and-refuses-a-release-the-host-would-refuse.md) and [ADR-1249](1249-the-host-images-base-images-are-pinned-by-digest-and-tracked-by-dependabot.md)), Date 2026-11-06. Context: the console is a host and origin of its own (ADR-1437); manage.<apex> routes /api to the OP and every other path to the console; the console's key rings are PEM files (ADR-1439); the image and chart are the deployable artifacts (ADR-1178, ADR-1179); issue #610. Decision (numbered): (1) the image as step 13 builds it; (2) the Console image workflow as step 15, triggered like Host image, including merge_group; (3) the Dependabot docker stream lists both Dockerfile directories; (4) the chart component as steps 16–19 build it, with its defaults; (5) its refusals. Options (rejected with reasons): a chart of its own (the manage host's Ingress joins two Services, the server must admit the host, one release keeps both images on one version); sharing the server's Valkey (the console would reach the server's single-use state, ADR-1437); the server's ServiceAccount (it may carry the server's cloud identities); routing /api through the console's BFF (machine callers authenticate at the OP directly). Consequences: one release deploys both; an operator registers mgmt-console with jwks_urihttps://<host>/bff/jwks and creates two key Secrets; the Build console image check runs on every pull request. Let L be 1458-the-management-console-ships-as-its-own-image-and-an-opt-in-component-of-the-host-chart.md. Status lines: ADR-1437 ends its parentheses with ; amended by [ADR-1458](L): the console's image and the host chart's management console component; ADR-1179 ends its parentheses with ; and by [ADR-1458](L): the chart deploys the management console as an opt-in component; ADR-1249's Accepted becomes Accepted (amended by [ADR-1458](L): the docker stream also tracks the management console image's Dockerfile). README index: in the 1437 and 1179 rows add ; amended by [1458](./L) inside the status parentheses, the 1249 row's status becomes Accepted (amended by [1458](./L)), and add the row | [1458](./L) | The management console ships as its own image and an opt-in component of the host chart | Accepted (amends [1437](./1437-the-management-console-is-a-confidential-client-served-by-a-bff-host-of-its-own.md), [1179](./1179-the-host-chart-references-its-secrets-and-refuses-a-release-the-host-would-refuse.md), [1249](./1249-the-host-images-base-images-are-pinned-by-digest-and-tracked-by-dependabot.md)) | after the 1457 row (write L out in full everywhere).
Change: under ## [Unreleased] / ### Added, edit in place: (a) the entry starting - **The reference host separates liveness from readiness.** becomes - **The reference hosts separate liveness from readiness through one helper.** `MapHealthProbes()` (`NCode.AspNetCore`) maps `/health/live`, which runs no checks and reports only that the process answers, so a dependency outage never makes Kubernetes restart a replica, and `/health/ready`, which runs every check tagged `HealthCheckTags.Readiness` and returns `503` while any of them fails, taking the replica out of rotation until its dependencies return; it returns their route group, so a host adds a convention such as `RequireHost` to both. `NCode.Identity.OpenId.Host` and `NCode.Identity.ManagementConsole.Host` call it. The server's readiness checks are the database (`AddDbContextCheck<OpenIdDbContext>`, registered with the `openiddb` connection string) and Valkey when configured. The Aspire orchestrator gates dependents on `/health/ready` ([ADR-1456](docs/decisions/1456-the-hosts-map-their-health-probes-through-one-general-helper.md), [#609](https://github.com/NCodeGroup/Identity/issues/609)).; (b) in the entry starting - **`HealthCheckTags.Readiness` tags every readiness health check.** change `NCode.Identity.Abstractions` publishes to `NCode.Registration` publishes and the reference host's `/health/ready` endpoint to `MapHealthProbes()`'s `/health/ready` endpoint; (c) in the entry starting - **The server-rendered pages stream into the response…** change `InteractiveBundleReader` reads to `SinglePageBundle<InteractiveBundleShell>` reads; (d) after that entry add - **`SinglePageBundle<TIndex>` reads and serves a built single-page bundle.** `NCode.AspNetCore` reads a bundle's `index.html` once per version into the index type its surface parses, revalidated per request by the file's last-write time and length, resolves only the files of its `assets` directory, and serves them with `Cache-Control: public, max-age=31536000, immutable`, validators and range requests; the interactive surface and the management console each register one ([ADR-1457](docs/decisions/1457-a-single-page-bundle-is-read-and-served-through-one-general-helper.md), [#612](https://github.com/NCodeGroup/Identity/issues/612)).; (e) after the entry starting - **The host image writes one-line JSON logs.** add - **The management console host ships a production container image.** `src/NCode.Identity.ManagementConsole.Host/Dockerfile` builds the console host on the host image's pinned chiseled base images: no shell, the non-root `app` user, port 8080, the `apps/mgmt` bundle served from `/app/mgmt`, and one-line JSON logs; the `Console image` CI workflow builds it for `linux/amd64` and `linux/arm64` ([ADR-1458](docs/decisions/1458-the-management-console-ships-as-its-own-image-and-an-opt-in-component-of-the-host-chart.md), [#610](https://github.com/NCodeGroup/Identity/issues/610)).; (f) after the entry starting - **The host has a Helm chart.** add - **The host chart deploys the management console on a host of its own.** With `managementConsole.enabled`, `charts/ncode-identity-host` runs the console host under the restricted Pod Security Standard with its own ServiceAccount without a token, mounts its `mgmt-client` and `mgmt-dpop` keys read-only from Secrets, probes `/health/live` and `/health/ready`, and routes `managementConsole.host` through an Ingress that sends `/api` to the server and every other path to the console. Rendering fails for a missing or wildcard console host, an `allowedHosts` that does not list it, an authority that is not `https`, a missing key Secret, or more than one console replica without the console's own Valkey ([ADR-1458](docs/decisions/1458-the-management-console-ships-as-its-own-image-and-an-opt-in-component-of-the-host-chart.md), [#610](https://github.com/NCodeGroup/Identity/issues/610)).; (g) in the entry starting - **The host image's base images are pinned by digest and tracked by Dependabot.** replace Every `FROM` of the Dockerfile names with Every `FROM` of the host and console Dockerfiles names, the Dockerfile takes its .NET and Node versions with each Dockerfile takes its .NET and Node versions, and a weekly `docker` Dependabot stream moves the digests with a weekly `docker` Dependabot stream moves the digests of both images in one pull request. Keep every other entry; no ### Changed/### Fixed heading.
MapHealthProbes() keeps the exact paths and endpoint names; the chart probes, NCode.Identity.OpenId.LocalDev's WithHttpHealthCheck("/health/ready") and the integration tests still match.
HealthCheckTags has one definition (in NCode.Registration) and the Valkey adapter still tags its check with it.
SinglePageBundle<TIndex> keeps the readers' guarantees: nothing outside assets (including index.html, hidden files and .. paths) is servable; a 404 carries no cache lifetime; a rebuilt index.html is picked up without a restart; the parser runs once per file version; the interactive bundle's root resolves against the content root exactly as before (absolute stays absolute).
No delta wording anywhere (code comments, ADRs, READMEs, changelog).
The console Dockerfile's FROM lines are byte-identical to the host Dockerfile's, so Dependabot moves both in one pull request; .dockerignore needs no re-admit (no new root build file import).
The chart renders nothing of the console when managementConsole.enabled is false; the console pods never match the server Service, PodDisruptionBudget or topology selectors (component label), and vice versa.
The console pod has no service-account token, a read-only root filesystem with /tmp only, and key files mounted 0440 read-only; the console Ingress sends /api to the server Service.
Run ./build/dod.ps1 -Fast -Affected and the chart's helm lint/helm template loop before landing; the frontend gate is unaffected (only frontend.yml's filter changes).
Follow-ups
Require the Console image check on main: add the Build console image check of .github/workflows/console-image.yml to the main ruleset's required status checks, so a pull request that breaks the console image cannot merge (the ruleset lives in GitHub settings, not the repository).
Activity
polewskm commentedon Nov 6, 2026
Plan snapshot. The working copy is
PLAN.mdin the plan's checkout; this comment is not updated as steps run.Plan: feat: deploy the management console and share host helpers (24 steps)
Plan: feat: deploy the management console and share host helpers
Issue: #609 #610 #612
Goal
The OP host and the management console host map their liveness and readiness probes through one
NCode.AspNetCoreextension,
MapHealthProbes()(#609). The interactive surface and the console read and serve their built single-pagebundles through one general
SinglePageBundle<TIndex>inNCode.AspNetCore(#612). The console host gets aproduction container image, a CI image workflow, and an opt-in
managementConsolecomponent of the host Helm chartthat mounts its
mgmt-client/mgmt-dpopPEM keys from Secrets and routesmanage.<apex>(/apito the OP, everyother path to the console) (#610).
Context for the executor
dotnet build <project.csproj> -m:2(never two builds or test runs at once; set$env:MSBUILDDISABLENODEREUSE='1'in your shell first).dotnet test tests/<P>/<P>.csproj -m:2 --filter "FullyQualifiedName~<Class>" -- RunConfiguration.TreatNoTestsAsError=true..cs/.csprojfiles rundotnet tool restoreonce, thendotnet csharpier format <file>on each file you touched (the repo-pinned CSharpier). Files are UTF-8 without BOM, LF line endings..github/instructions/csharp-production.instructions.md(productionsrc/) and.github/instructions/csharp-testing.instructions.md(tests) before editing.cs. Every.csfile starts with// Copyright © NCode Group/// SPDX-License-Identifier: Apache-2.0and a blank line. One type per file. File-scoped namespaces. Auto-properties over fields exceptref/Interlockeduse. Primary constructors. XML<summary>on every new public or internal type and member, multi-line, present tense. Never cite an ADR in a///line; put// See ADR-NNNN.on a//line under the doc comment. Strict encodings only (SecureEncoding.UTF8).BaseTests(strictMockRepository),Method_State_Expectednames,#region <Member> Testsregions, no Arrange/Act/Assert comments.PublicAPI.Unshipped.txt(LF endings). A C# 14extension(Receiver)member needs two lines: the….extension(Receiver!)container line and the member line. If the build reportsRS0016/RS0017, the exact lines it wants are authoritative: fix the file to match,dotnet format analyzers <project> --diagnostics RS0016adds missing lines (restore LF afterwards).helm). Chart files are YAML/Go templates; keep 2-space indentation.HealthCheckTagslives inNCode.Registrationafter step 1; a file in a namespace underNCode.Identitythat importsNCode.Registrationsees it through thatusing.usingthat becomes unused fails the build (IDE0005); remove it.docker; the Dockerfile and workflow steps are checked statically.Decisions
MapHealthProbes()lives inNCode.AspNetCore(general ASP.NET Core helper, ADR-1355) and returns the route group holding both probes, so a host can addRequireHostto both (project-template ADR-0128 decision 6).HealthCheckTagslives inNCode.Registration: the dependency-free composition package that bothNCode.AspNetCoreand the ASP.NET-free Valkey adapter reference;NCode.AspNetCorenames no identity concept, and the Valkey adapter must not take the ASP.NET Core shared framework. Recorded as ADR-1456.SinglePageBundle<TIndex> where TIndex : class(sealed,IDisposable) takes the bundle root's absolute path and a parserFunc<string, TIndex?>; it caches the parsed index per file version (last-write time + length), resolves only files underassets, andServeAssetanswers a file immutable with validators and ranges or404. Each surface registers its own instance:SinglePageBundle<InteractiveBundleShell>andSinglePageBundle<ManagementConsolePage>.ManagementConsolePagecarries the console's per-response nonce rendering. Recorded as ADR-1457, amending ADR-1254 decision 2.InteractiveBundleReader,InteractiveBundleIndex,ManagementConsoleBundleReader,ManagementConsoleBundleIndexandManagementConsoleAssetHandlerare deleted; their tests move toSinglePageBundleTestsandManagementConsolePageTests.@ncode/app-mgmt, chiseled-extra, non-root UID 1654, port 8080, one-line JSON logs) and serves the bundle from/app/mgmt. No new root build file is imported, so.dockerignoregains no re-admit; only its comment widens to both images.Console imageworkflow (jobBuild console image) keeps theHost imagerequired check's name unchanged; Dependabot's docker stream usesdirectories:so one pull request moves both images' digests.charts/ncode-identity-hostchart (managementConsole.enabled), not a chart of its own: the manage host's Ingress joins the server's and the console's Services, the server must admit the manage host inallowedHosts, and one release keeps both images on the chart's appVersion. The console gets its own ServiceAccount (no token), its own Valkey Secret (never the server's, ADR-1437), and the restricted Pod Security Standard.Bff:Frontends:mgmt:Authoritydefaults to the StaticSingle issuer andManagementConsole:ManagementApiUrltohttps://<managementConsole.host>/api. Recorded as ADR-1458, amending ADR-1437, ADR-1179 and ADR-1249.Out of scope
src/frontend/**source), the OP's management API, or the AppHost (NCode.Identity.OpenId.LocalDev).docker build.Steps
[ ] 1. Move
HealthCheckTagsintoNCode.Registrationsrc/NCode.Registration/HealthCheckTags.cs(new),src/NCode.Identity.Abstractions/HealthCheckTags.cs(delete),src/NCode.Registration/PublicAPI.Unshipped.txt(edit),src/NCode.Identity.Abstractions/PublicAPI.Unshipped.txt(edit),tests/NCode.Identity.SharedState.Valkey.Tests/DefaultRegistrationTests.cs(edit),tests/NCode.Identity.SharedState.Valkey.Tests/ValkeyHealthCheckTests.cs(edit),src/NCode.Identity.OpenId.Host/Program.cs(edit),src/NCode.Identity.ManagementConsole.Host/Program.cs(edit)src/NCode.Registration/HealthCheckTags.cswith the same content as the old file butnamespace NCode.Registration;(keep[PublicAPI], the summary andpublic const string Readiness = "ready";), thengit rmthe old file. InNCode.Identity.Abstractions/PublicAPI.Unshipped.txtdelete the two linesconst NCode.Identity.HealthCheckTags.Readiness = "ready" -> string!andNCode.Identity.HealthCheckTags; inNCode.Registration/PublicAPI.Unshipped.txtaddconst NCode.Registration.HealthCheckTags.Readiness = "ready" -> string!andNCode.Registration.HealthCheckTags(sorted). In both Valkey test files addusing NCode.Registration;. In bothProgram.csfiles replaceusing NCode.Identity;withusing NCode.Registration;(OP host: place it just aboveusing NCode.Registration.AspNetCore;).src/NCode.Identity.SharedState.Valkey/DefaultRegistration.csalready importsNCode.Registrationand needs no edit.src/NCode.Identity.Abstractions/HealthCheckTags.cs.if (Test-Path src/NCode.Identity.Abstractions/HealthCheckTags.cs) { exit 1 }; dotnet build src/NCode.Identity.OpenId.Host/NCode.Identity.OpenId.Host.csproj -m:2; if ($LASTEXITCODE) { exit 1 }; dotnet build src/NCode.Identity.ManagementConsole.Host/NCode.Identity.ManagementConsole.Host.csproj -m:2; if ($LASTEXITCODE) { exit 1 }; dotnet test tests/NCode.Identity.SharedState.Valkey.Tests/NCode.Identity.SharedState.Valkey.Tests.csproj -m:2 --filter "FullyQualifiedName~DefaultRegistrationTests|FullyQualifiedName~ValkeyHealthCheckTests" -- RunConfiguration.TreatNoTestsAsError=true[ ] 2. Add
MapHealthProbes()toNCode.AspNetCoresrc/NCode.AspNetCore/HealthProbeDefaults.cs(new),src/NCode.AspNetCore/HealthProbeEndpointRouteBuilderExtensions.cs(new),src/NCode.AspNetCore/PublicAPI.Unshipped.txt(edit),tests/NCode.AspNetCore.Tests/HealthProbeEndpointRouteBuilderExtensionsTests.cs(new)[PublicAPI] public static class HealthProbeDefaultswithpublic const stringLivenessPath = "/health/live",ReadinessPath = "/health/ready",LivenessEndpointName = "health_live_endpoint",ReadinessEndpointName = "health_ready_endpoint", each with a summary.[PublicAPI] public static class HealthProbeEndpointRouteBuilderExtensionswithextension(IEndpointRouteBuilder endpoints) { public RouteGroupBuilder MapHealthProbes() }: null-guardendpoints,var group = endpoints.MapGroup(string.Empty);, mapLivenessPathwithnew HealthCheckOptions { Predicate = static _ => false }.WithName(LivenessEndpointName), mapReadinessPathwithPredicate = static check => check.Tags.Contains(HealthCheckTags.Readiness).WithName(ReadinessEndpointName), returngroup. Summary: liveness runs no check so a dependency outage never restarts a replica; readiness runs the checks taggedHealthCheckTags.Readiness; the returned group applies a convention (such asRequireHost) to both. Add a// See ADR-1456.line under the class's doc comment. PublicAPI lines: the class line,….extension(Microsoft.AspNetCore.Routing.IEndpointRouteBuilder!),….extension(Microsoft.AspNetCore.Routing.IEndpointRouteBuilder!).MapHealthProbes() -> Microsoft.AspNetCore.Routing.RouteGroupBuilder!,NCode.AspNetCore.HealthProbeDefaultsand oneconst NCode.AspNetCore.HealthProbeDefaults.<Name> = "<value>" -> string!per constant. Tests (#region MapHealthProbes Tests): build withWebApplication.CreateSlimBuilder(),builder.Services.AddHealthChecks()plus test checks,app.MapHealthProbes(), read((IEndpointRouteBuilder)app).DataSources.SelectMany(x => x.Endpoints).OfType<RouteEndpoint>(); invoke an endpoint withnew DefaultHttpContext { RequestServices = app.Services }andawait endpoint.RequestDelegate!(httpContext). Tests:MapHealthProbes_Always_MapsTheLivenessAndReadinessPaths(patterns andEndpointNameMetadatanames),MapHealthProbes_WhenAReadinessCheckFails_AnswersReadyUnhealthyAndLiveHealthy(check taggedHealthCheckTags.Readinessreturns Unhealthy: ready503, live200),MapHealthProbes_WhenAnUntaggedCheckFails_AnswersReadyHealthy(200),MapHealthProbes_WhenTheGroupRequiresAHost_RequiresItOnBothProbes(call.RequireHost("probe.example.com")on the result; both endpoints carryIHostMetadatawith that host),MapHealthProbes_WhenEndpointsIsNull_Throws.src/NCode.AspNetCore/StrictTransportSecurityApplicationBuilderExtensions.cs(extension block style);tests/NCode.Identity.Bff.Tests/Endpoints/BffEndpointRouteBuilderExtensionsTests.cs(CreateSlimBuilder + DataSources).dotnet test tests/NCode.AspNetCore.Tests/NCode.AspNetCore.Tests.csproj -m:2 --filter "FullyQualifiedName~HealthProbeEndpointRouteBuilderExtensionsTests" -- RunConfiguration.TreatNoTestsAsError=true[ ] 3. Map both hosts' probes with
MapHealthProbes()src/NCode.Identity.OpenId.Host/Program.cs(edit),src/NCode.Identity.ManagementConsole.Host/Program.cs(edit),tests/NCode.Identity.OpenId.Integration.Tests/ManagementConsole/ManagementConsoleHealthEndpointTests.cs(new)Program.csreplace the twoapp.MapHealthChecks(...)...WithName(...)statements and their three-line comment with a two-line comment (liveness runs no checks so a dependency outage never restarts a replica; readiness runs the checks tagged ready) andapp.MapHealthProbes();. Removeusing Microsoft.AspNetCore.Diagnostics.HealthChecks;from both files, and removeusing NCode.Registration;from the console host'sProgram.cs(unused there); the OP host keeps it for itsAddDbContextChecktag. New test classManagementConsoleHealthEndpointTests(sealed):[Theory]over/health/liveand/health/ready→GetHealth_WhenEveryCheckPasses_ReturnsHealthy(200, bodyHealthy), andGetHealth_WhenAReadinessCheckFails_ReportsNotReadyButAlive(add a check taggedHealthCheckTags.Readinessreturning Unhealthy throughfactory.WithWebHostBuilder(b => b.ConfigureTestServices(...)): ready503, live200). Create each factory withawait using var factory = new ManagementConsoleApplicationFactory();and a client whose base address is the oneCreateBrowserClient()inInfrastructure/ManagementConsoleApplicationFactory.csuses.tests/NCode.Identity.OpenId.Integration.Tests/Endpoints/HealthEndpointTests.cs,tests/NCode.Identity.OpenId.Integration.Tests/ManagementConsole/ManagementConsoleHostTests.cs.if (Select-String -Path src/NCode.Identity.OpenId.Host/Program.cs,src/NCode.Identity.ManagementConsole.Host/Program.cs -Pattern 'MapHealthChecks' -Quiet) { exit 1 }; dotnet test tests/NCode.Identity.OpenId.Integration.Tests/NCode.Identity.OpenId.Integration.Tests.csproj -m:2 --filter "FullyQualifiedName~HealthEndpointTests|FullyQualifiedName~ManagementConsoleHealthEndpointTests|FullyQualifiedName~ManagementConsoleTransportSecurityTests" -- RunConfiguration.TreatNoTestsAsError=true[ ] 4. Record the health-probe decision (ADR-1456)
docs/decisions/1456-the-hosts-map-their-health-probes-through-one-general-helper.md(new),docs/decisions/README.md(edit),.github/instructions/csharp-production.instructions.md(edit)docs/decisions/0000-template.md. Title# 1456. The hosts map their health probes through one general helper; StatusAccepted; Date2026-11-06; DecidersNCode Group;- **Refines:** project-template ADR-0128. Context: two hosts (NCode.Identity.OpenId.Host,NCode.Identity.ManagementConsole.Host) serve the same probes, and inline copies of their options drift (issue #609); the readiness tag must be reachable by a package that registers a check without ASP.NET Core (the Valkey adapter);NCode.AspNetCorenames no identity concept (ADR-1355) and referencesNCode.Registration(ADR-1447). Decision: (1)NCode.AspNetCoreholdsMapHealthProbes()andHealthProbeDefaults; liveness at/health/liveruns no check, readiness at/health/readyruns the checks taggedready, both in one returned route group; (2)HealthCheckTagslives inNCode.Registration; (3) every host callsMapHealthProbes()and builds noHealthCheckOptions. Options considered (each rejected with its reason): inline copies (drift); the tag stays inNCode.Identity.AbstractionsandNCode.AspNetCorereferences it (ADR-1355); the tag inNCode.AspNetCore(the Valkey adapter would take the ASP.NET Core shared framework); a tag parameter onMapHealthProbes(every caller passes one constant); a hosting package (one constant). Consequences: one call per host; a package tags a readiness check withNCode.Registration.HealthCheckTags.Readiness; the paths are the ones the chart's probes and the AppHost'sWithHttpHealthCheckcall. References: the two source files, issue Share the hosts' health endpoint mapping #609, ADR-1355, ADR-1447. Add the index row| [1456](./1456-the-hosts-map-their-health-probes-through-one-general-helper.md) | The hosts map their health probes through one general helper | Accepted |after the 1447 row. Incsharp-production.instructions.md§4, after the bullet "A hostProgram.csis composition-only", add the bullet:- 👁 **A host maps its probes with `MapHealthProbes()`** (`NCode.AspNetCore`), and a package tags a check a replica cannot serve without with `HealthCheckTags.Readiness` (`NCode.Registration`) ([ADR-1456](../../docs/decisions/1456-the-hosts-map-their-health-probes-through-one-general-helper.md)).docs/decisions/1447-forwarded-header-trust-and-strict-transport-security-are-general-aspnet-core-helpers-each-host-composes.md.$p = & ./build/adr-index.ps1; if ($p) { $p; exit 1 }; if (Select-String -Path .github/instructions/csharp-production.instructions.md -Pattern 'MapHealthProbes' -Quiet) { exit 0 } else { exit 1 }[ ] 5. Add
SinglePageBundle<TIndex>toNCode.AspNetCoresrc/NCode.AspNetCore/SinglePageBundle.cs(new),src/NCode.AspNetCore/SinglePageBundleIndex.cs(new),src/NCode.AspNetCore/SinglePageBundleDefaults.cs(new),src/NCode.AspNetCore/PublicAPI.Unshipped.txt(edit)[PublicAPI] public static class SinglePageBundleDefaultswithpublic const string AssetsDirectory = "assets";andIndexFileName = "index.html";.internal sealed record SinglePageBundleIndex<TIndex>(DateTime LastWriteTimeUtc, long Length, TIndex? Index) where TIndex : class;.[PublicAPI] public sealed class SinglePageBundle<TIndex>(string? rootPath, Func<string, TIndex?> parseIndex) : IDisposable where TIndex : classbuilt by mergingManagementConsoleBundleReaderandInteractiveBundleReader: blankrootPath(string.IsNullOrWhiteSpace) means no bundle;IndexPath/AssetsRootPatharePath.Combine(rootPath, SinglePageBundleDefaults.IndexFileName|AssetsDirectory); the lazily created assets-rootedPhysicalFileProvider(Volatile.Read/Interlocked.CompareExchange, disposed inDispose);public IFileInfo? TryGetAsset(string assetPath)unchanged;public async ValueTask<TIndex?> TryReadIndexAsync(CancellationToken cancellationToken)readsindex.htmlwithSecureEncoding.UTF8, callsparseIndex(html)once per file version and cachesnew SinglePageBundleIndex<TIndex>(lastWriteTimeUtc, length, parsed), returningcached.Indexwhile last-write time and length match;public IResult ServeAsset(HttpResponse response, string assetPath)holds the body ofManagementConsoleAssetHandler.Handleafter the route-value read (privateconst string ImmutableCacheControl, private staticFileExtensionContentTypeProvider AssetContentTypes,404for no file or unknown type, otherwiseCache-Controlimmutable andTypedResults.PhysicalFilewith last-modified, entity tag and range processing). Null-guardparseIndex,assetPathandresponse. Keep the file-watcher//comment of the readers. Summaries: the constructor'srootPathis the absolute path of the bundle's root directory. Add// See ADR-1457.under the class doc comment. PublicAPI: the two classes, the constructor,Dispose,TryGetAsset,TryReadIndexAsync,ServeAsset, and the two constants (generic members are writtenNCode.AspNetCore.SinglePageBundle<TIndex>.…).src/NCode.Identity.ManagementConsole/ManagementConsoleBundleReader.cs,src/NCode.Identity.ManagementConsole/ManagementConsoleAssetHandler.cs,src/NCode.Identity.OpenId.Authentication.Interactive/InteractiveBundleReader.cs.dotnet build src/NCode.AspNetCore/NCode.AspNetCore.csproj -m:2[ ] 6. Test
SinglePageBundle<TIndex>tests/NCode.AspNetCore.Tests/SinglePageBundleTests.cs(new)public class SinglePageBundleTests : BaseTestsover a temp directory (Directory.CreateTempSubdirectory("bundle-"), deleted inDispose(bool)), with aprivate sealed record TestIndex(string Html)and a parser that counts its calls. Port every test oftests/NCode.Identity.ManagementConsole.Tests/ManagementConsoleBundleReaderTests.csregionsTryReadIndexAsync TestsandTryGetAsset Testsand oftests/NCode.Identity.OpenId.Authentication.Interactive.Tests/InteractiveBundleReaderTests.cs(renamingTryReadShellAsync_*toTryReadIndexAsync_*), and every test oftests/NCode.Identity.ManagementConsole.Tests/ManagementConsoleAssetHandlerTests.csasServeAsset_*in#region ServeAsset Tests(callbundle.ServeAsset(httpContext.Response, "<asset>")). AddTryReadIndexAsync_WhenTheIndexIsUnchanged_ParsesItOnce(parser call count1after two reads) andTryReadIndexAsync_WhenTheParserFindsNoIndex_ReturnsNull(parser returnsnull). Construct with the absolute dist path; drop the content-root-relative andIHostEnvironmentcases (resolution belongs to the callers).dotnet test tests/NCode.AspNetCore.Tests/NCode.AspNetCore.Tests.csproj -m:2 --filter "FullyQualifiedName~SinglePageBundleTests" -- RunConfiguration.TreatNoTestsAsError=true[ ] 7. Serve the interactive bundle through
SinglePageBundle<InteractiveBundleShell>src/NCode.Identity.OpenId.Authentication.Interactive/DefaultRegistration.cs(edit),src/NCode.Identity.OpenId.Authentication.Interactive/InteractiveAuthenticationEndpointHandler.cs(edit),src/NCode.Identity.OpenId.Authentication.Interactive/InteractiveOpenIdErrorResponseFormatter.cs(edit),src/NCode.Identity.OpenId.Authentication.Interactive/InteractiveBundleAssetEndpointHandler.cs(edit)DefaultRegistration.csreplaceserviceCollection.TryAddSingleton<InteractiveBundleReader>();withserviceCollection.TryAddSingleton(static serviceProvider => new SinglePageBundle<InteractiveBundleShell>(serviceProvider.GetRequiredService<IOptions<InteractiveAuthenticationOptions>>().Value.BundleRootPath, InteractiveBundleShell.TryCreate));and makeResolveBundleRootPathreturnPath.GetFullPath(bundleRootPath, contentRootPath)for a non-blank path (blank stays as it is). In the endpoint handler and the error formatter change the constructor parameter and property toSinglePageBundle<InteractiveBundleShell> bundle/Bundleand eachBundleReader.TryReadShellAsync(...)toBundle.TryReadIndexAsync(...). InInteractiveBundleAssetEndpointHandlertakeSinglePageBundle<InteractiveBundleShell> bundle, build the route withSinglePageBundleDefaults.AssetsDirectory, and makeGetAssetread the route value andreturn Bundle.ServeAsset(httpContext.Response, assetPath);; delete itsImmutableCacheControlandAssetContentTypesmembers and the usings that become unused. Addusing NCode.AspNetCore;where needed.dotnet test tests/NCode.Identity.OpenId.Integration.Tests/NCode.Identity.OpenId.Integration.Tests.csproj -m:2 --filter "FullyQualifiedName~InteractiveTenantRouteTests|FullyQualifiedName~HtmlPageSecurityTests" -- RunConfiguration.TreatNoTestsAsError=true[ ] 8. Delete the interactive bundle reader
src/NCode.Identity.OpenId.Authentication.Interactive/InteractiveBundleReader.cs(delete),src/NCode.Identity.OpenId.Authentication.Interactive/InteractiveBundleIndex.cs(delete),tests/NCode.Identity.OpenId.Authentication.Interactive.Tests/InteractiveBundleReaderTests.cs(delete)git rmthe three files.if (git grep -n "InteractiveBundleReader\|InteractiveBundleIndex" -- src tests) { exit 1 }; dotnet test tests/NCode.Identity.OpenId.Authentication.Interactive.Tests/NCode.Identity.OpenId.Authentication.Interactive.Tests.csproj -m:2 --filter "FullyQualifiedName~InteractiveBundleShellTests" -- RunConfiguration.TreatNoTestsAsError=true[ ] 9. Add
ManagementConsolePagesrc/NCode.Identity.ManagementConsole/ManagementConsolePage.cs(new),tests/NCode.Identity.ManagementConsole.Tests/ManagementConsolePageTests.cs(new),.github/workflows/frontend.yml(edit)internal sealed record ManagementConsolePage(string Html)(summary: the console bundle'sindex.html, which each response renders with its own nonce) withpublic string Render(string nonce) => Html.Replace(ManagementConsoleDefaults.NoncePlaceholder, nonce, StringComparison.Ordinal);. Move theRenderIndex Testsregion ofManagementConsoleBundleReaderTests.csintoManagementConsolePageTests : BaseTestsas#region Render TestswithRender_WhenThePageHasPlaceholders_ReplacesEveryPlaceholder,Render_WhenThePageHasNoPlaceholder_ReturnsThePageUnchangedand the[BuiltConsoleBundleFact]Render_WhenGivenTheBuiltBundlesIndex_LeavesNoPlaceholderAndNoncesEveryScript(callingnew ManagementConsolePage(html).Render(...)); leave the old file untouched (step 11 deletes it). Infrontend.yml, stepTest the console host against the built console bundle, change the filter to"FullyQualifiedName~ManagementConsolePageTests.Render_WhenGivenTheBuiltBundlesIndex".dotnet test tests/NCode.Identity.ManagementConsole.Tests/NCode.Identity.ManagementConsole.Tests.csproj -m:2 --filter "FullyQualifiedName~ManagementConsolePageTests" -- RunConfiguration.TreatNoTestsAsError=true[ ] 10. Serve the console bundle through
SinglePageBundle<ManagementConsolePage>src/NCode.Identity.ManagementConsole/ManagementConsoleBuilder.cs(edit),src/NCode.Identity.ManagementConsole/ManagementConsolePageHandler.cs(edit),src/NCode.Identity.ManagementConsole/ManagementConsoleEndpointRouteBuilderExtensions.cs(edit),src/NCode.Identity.ManagementConsole/ManagementConsoleDefaults.cs(edit),tests/NCode.Identity.ManagementConsole.Tests/ManagementConsolePageHandlerTests.cs(edit),tests/NCode.Identity.ManagementConsole.Tests/DefaultRegistrationTests.cs(edit)ManagementConsoleBuilderreplace theManagementConsoleBundleReaderandManagementConsoleAssetHandlerregistrations withserviceCollection.TryAddSingleton<SinglePageBundle<ManagementConsolePage>>(CreateBundle);and addprivate static SinglePageBundle<ManagementConsolePage> CreateBundle(IServiceProvider serviceProvider)that readsIOptions<ManagementConsoleOptions>andIHostEnvironment, resolves a non-blankBundleRootPathwithPath.GetFullPath(path, environment.ContentRootPath)(blank →null) and returnsnew SinglePageBundle<ManagementConsolePage>(rootPath, static html => new ManagementConsolePage(html)).ManagementConsolePageHandlertakesSinglePageBundle<ManagementConsolePage> bundle, readsvar page = await bundle.TryReadIndexAsync(...), and writespage.Render(security.Nonce). InMapManagementConsolethe asset route's handler becomesstatic (HttpContext context, string? asset) => context.RequestServices.GetRequiredService<SinglePageBundle<ManagementConsolePage>>().ServeAsset(context.Response, asset ?? string.Empty). InManagementConsoleDefaultssetAssetsPath = "/" + SinglePageBundleDefaults.AssetsDirectory;(value stays/assets). Tests:ManagementConsolePageHandlerTests.CreateHandlerbuildsnew SinglePageBundle<ManagementConsolePage>(DistPath, static html => new ManagementConsolePage(html))(drop theIHostEnvironmentmock);DefaultRegistrationTestsresolvesSinglePageBundle<ManagementConsolePage>instead of the reader and drops the asset handler assertion (rename the testAddManagementConsole_Always_RegistersTheHandlersAndTheBundle).dotnet test tests/NCode.Identity.ManagementConsole.Tests/NCode.Identity.ManagementConsole.Tests.csproj -m:2 --filter "FullyQualifiedName~ManagementConsolePageHandlerTests|FullyQualifiedName~DefaultRegistrationTests|FullyQualifiedName~ManagementConsoleDefaultsTests" -- RunConfiguration.TreatNoTestsAsError=true; if ($LASTEXITCODE) { exit 1 }; dotnet test tests/NCode.Identity.OpenId.Integration.Tests/NCode.Identity.OpenId.Integration.Tests.csproj -m:2 --filter "FullyQualifiedName~ManagementConsoleHostTests" -- RunConfiguration.TreatNoTestsAsError=true[ ] 11. Delete the console's bundle reader and asset handler
src/NCode.Identity.ManagementConsole/ManagementConsoleBundleReader.cs(delete),src/NCode.Identity.ManagementConsole/ManagementConsoleBundleIndex.cs(delete),src/NCode.Identity.ManagementConsole/ManagementConsoleAssetHandler.cs(delete),tests/NCode.Identity.ManagementConsole.Tests/ManagementConsoleBundleReaderTests.cs(delete),tests/NCode.Identity.ManagementConsole.Tests/ManagementConsoleAssetHandlerTests.cs(delete)git rmthe five files.if (git grep -n "ManagementConsoleBundleReader\|ManagementConsoleBundleIndex\|ManagementConsoleAssetHandler" -- src tests .github) { exit 1 }; dotnet test tests/NCode.Identity.ManagementConsole.Tests/NCode.Identity.ManagementConsole.Tests.csproj -m:2 --filter "FullyQualifiedName~ManagementConsolePage" -- RunConfiguration.TreatNoTestsAsError=true[ ] 12. Record the single-page bundle decision (ADR-1457)
docs/decisions/1457-a-single-page-bundle-is-read-and-served-through-one-general-helper.md(new),docs/decisions/README.md(edit),docs/decisions/1254-the-interactive-bundles-hashed-files-are-served-immutable-without-resolving-the-tenant.md(edit),.github/instructions/csharp-production.instructions.md(edit)# 1457. A single-page bundle is read and served through one general helper, StatusAccepted (amends [ADR-1254](1254-the-interactive-bundles-hashed-files-are-served-immutable-without-resolving-the-tenant.md) decision 2: the assets-rooted file provider is `SinglePageBundle<TIndex>`'s), Date2026-11-06. Context:apps/authandapps/mgmtare Vite bundles whoseindex.htmleach host reads per version and whoseassetseach host serves immutable; two copies drift (issue #612); the surfaces differ only in what they keep of the index (the interactive surface splits it at its mount point once per version; the console renders a nonce into it per response). Decision: (1)NCode.AspNetCoreholdsSinglePageBundle<TIndex>andSinglePageBundleDefaults(assets,index.html), with the behavior of step 5; (2) each surface registers one instance with its index type,SinglePageBundle<InteractiveBundleShell>andSinglePageBundle<ManagementConsolePage>, and resolves its configured root against the content root; (3) metadata revalidation, not a file watcher. Options (rejected): copies; caching raw text and parsing per request; static-file middleware (ADR-1254); a file watcher. Consequences: one implementation, tested inNCode.AspNetCore.Tests;assetsstays the contract with@ncode/vite-config. In ADR-1254 append to its Status line; and by [ADR-1457](1457-a-single-page-bundle-is-read-and-served-through-one-general-helper.md): the assets-rooted file provider is `SinglePageBundle<TIndex>`'sinside the parentheses, and in its README row replaceAccepted (amended by [1388](./1388-the-html-page-security-model-and-the-front-channel-marker-are-general-aspnet-core-helpers.md))withAccepted (amended by [1388](./1388-the-html-page-security-model-and-the-front-channel-marker-are-general-aspnet-core-helpers.md) and [1457](./1457-a-single-page-bundle-is-read-and-served-through-one-general-helper.md)). Add the row| [1457](./1457-a-single-page-bundle-is-read-and-served-through-one-general-helper.md) | A single-page bundle is read and served through one general helper | Accepted (amends [1254](./1254-the-interactive-bundles-hashed-files-are-served-immutable-without-resolving-the-tenant.md)) |after the 1456 row. Incsharp-production.instructions.md§4 bullet "A content-hashed static file is served by a route group that resolves no tenant", replace the sentence startingIt reads through the `assets`-rooted `InteractiveBundleReader`with:It reads through `NCode.AspNetCore`'s `SinglePageBundle<InteractiveBundleShell>`, whose file provider is rooted at `assets`, so nothing outside `assets`, the bundle's `index.html` included, is servable, and `ServeAsset` sends a served file `Cache-Control: public, max-age=31536000, immutable`. Every surface that serves a built bundle registers its own `SinglePageBundle<TIndex>`and add[ADR-1457](../../docs/decisions/1457-a-single-page-bundle-is-read-and-served-through-one-general-helper.md)beside the ADR-1254 link.$p = & ./build/adr-index.ps1; if ($p) { $p; exit 1 }; if (Select-String -Path .github/instructions/csharp-production.instructions.md -Pattern 'InteractiveBundleReader' -Quiet) { exit 1 } else { exit 0 }[ ] 13. Add the console host's Dockerfile
src/NCode.Identity.ManagementConsole.Host/Dockerfile(new),src/NCode.Identity.ManagementConsole.Host/NCode.Identity.ManagementConsole.Host.csproj(edit)src/NCode.Identity.OpenId.Host/Dockerfileand change only: the header namesNCode.Identity.ManagementConsole.Hostand(ADR-1458), and its build command uses--file src/NCode.Identity.ManagementConsole.Host/Dockerfile; the frontend stage comment names the management console bundle (apps/mgmt) and runsRUN pnpm exec turbo run build --filter=@ncode/app-mgmt; the build stage publishes onlysrc/NCode.Identity.ManagementConsole.Host/NCode.Identity.ManagementConsole.Host.csprojto/app/publish(drop the migration worker publish and its comment, keep the NuGet cache comment); the final stage drops the migrations copy and its comment, copiesCOPY --from=frontend /source/src/frontend/apps/mgmt/dist ./mgmt/, setsENV ManagementConsole__BundleRootPath=/app/mgmt, keeps the JSON logENVblock, and endsENTRYPOINT ["dotnet", "NCode.Identity.ManagementConsole.Host.dll"]. EveryFROMline stays byte-identical to the host Dockerfile's. In the.csprojadd<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>afterUserSecretsIdand anItemGroupwith<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" />, as the host's.csprojhas.$c = Get-Content src/NCode.Identity.ManagementConsole.Host/Dockerfile; $h = Get-Content src/NCode.Identity.OpenId.Host/Dockerfile; $from = $c | Where-Object { $_ -like 'FROM *' }; if ($from.Count -ne 4 -or ($from | Where-Object { $h -notcontains $_ })) { exit 1 }; $t = $c -join ' '; foreach ($s in '--filter=@ncode/app-mgmt','ENV ManagementConsole__BundleRootPath=/app/mgmt','USER $APP_UID','ENTRYPOINT ["dotnet", "NCode.Identity.ManagementConsole.Host.dll"]','Logging__Console__FormatterName=json') { if (-not $t.Contains($s)) { exit 1 } }; if ($t.Contains('MigrationService')) { exit 1 }; dotnet build src/NCode.Identity.ManagementConsole.Host/NCode.Identity.ManagementConsole.Host.csproj -m:2[ ] 14. Point the build context and Dependabot at both images
.dockerignore(edit),.github/dependabot.yml(edit),.github/instructions/repository.instructions.md(edit).dockerignorefirst comment line:# An allow-list: the build context carries only what the host and management console images are built from (ADR-1178,and the following text unchanged exceptonly the Host image check finds a missing one→only the Host image and Console image checks find a missing one. No allow-list entry changes. Independabot.ymlthe docker entry withdirectory: /src/NCode.Identity.OpenId.Hostusesdirectories:listing- /src/NCode.Identity.OpenId.Hostand- /src/NCode.Identity.ManagementConsole.Host, and its comment says both images share the base images, so one pull request moves both. Inrepository.instructions.md(push protocol gate 4) changeor only the `Host image` check on the/pull request finds itto say theHost imageandConsole imagechecks on the pull request find it.if ((Select-String -Path .github/dependabot.yml -Pattern '- /src/NCode.Identity.ManagementConsole.Host' -SimpleMatch -Quiet) -and (Select-String -Path .dockerignore -Pattern 'Console image' -Quiet) -and (Select-String -Path .github/instructions/repository.instructions.md -Pattern 'Console image' -Quiet)) { exit 0 } else { exit 1 }[ ] 15. Add the
Console imageworkflow.github/workflows/console-image.yml(new).github/workflows/host-image.ymland change: header comment names the production image ofNCode.Identity.ManagementConsole.Host(ADR-1458) and keeps the rest of its reasoning;name: Console image; concurrency group prefixconsole-image-; jobname: Build console image; the Node check grepssrc/NCode.Identity.ManagementConsole.Host/Dockerfile; both build steps usefile: src/NCode.Identity.ManagementConsole.Host/Dockerfile; the load tag isncode-identity-management-console:ci(and the inspect command uses it); the log check also requiresManagementConsole__BundleRootPath=/app/mgmt(add it to thefor setting inlist and name the stepCheck the image logs one-line JSON and serves its bundle). Keep the same action pins, triggers, permissions andpersist-credentials: false.$w = Get-Content .github/workflows/console-image.yml -Raw; if ($w.Contains('name: Console image') -and $w.Contains('name: Build console image') -and $w.Contains('src/NCode.Identity.ManagementConsole.Host/Dockerfile') -and $w.Contains('ManagementConsole__BundleRootPath=/app/mgmt') -and $w.Contains('merge_group:') -and -not $w.Contains('src/NCode.Identity.OpenId.Host/Dockerfile')) { exit 0 } else { exit 1 }[ ] 16. Add the chart's
managementConsolevalues and schemacharts/ncode-identity-host/values.yaml(edit),charts/ncode-identity-host/values.schema.json(edit),charts/ncode-identity-host/ci/management-console-values.yaml(new)values.yaml, before theextraEnvcomment, a commentedmanagementConsole:block (ADR-1458):enabled: false;replicaCount: 1(more than one requiresvalkey.existingSecretbelow);image:{repository: ghcr.io/ncodegroup/ncode-identity-management-console,tag: "",digest: "",pullPolicy: IfNotPresent};host: ""(the console's public host name, such as manage.example.com; its AllowedHosts, its origin https://, and the host its Ingress routes;allowedHostsmust list it, because/apion it reaches the server);authority: ""(Bff:Frontends:mgmt:Authority; defaults to tenantResolution.staticSingle.issuer under StaticSingle);managementApiUrl: ""(ManagementConsole:ManagementApiUrl; defaults to https:///api);keys:{clientKeySecret: ""(Secret holding the client's private key undermgmt-client.pem),dpopKeySecret: ""(undermgmt-dpop.pem)};valkey:{existingSecret: "",connectionStringKey: connection-string} (the console's own Valkey, never the server's);service:{type: ClusterIP,port: 80};ingress:{enabled: false,className: "",annotations: {},tls: []} (routes/apito the server and/to the console);serviceAccount:{create: true,name: "",annotations: {}};podDisruptionBudget:{enabled: true,maxUnavailable: 1};preStopSleepSeconds: 10;terminationGracePeriodSeconds: 45;probes:{startup,liveness,readinesswith the server's numbers};resources:{requestscpu: 50m,memory: 128Mi; limitsmemory: 256Mi};topologySpread:(as the server's);podAnnotations: {},podLabels: {},nodeSelector: {},tolerations: [],affinity: {},priorityClassName: "",extraEnv: [],extraEnvFrom: [],extraVolumes: [],extraVolumeMounts: []. Invalues.schema.jsonadd amanagementConsoleobject withadditionalProperties: falsewhose properties mirror the top-level schemas of the same shape (image,service,serviceAccount,podDisruptionBudget,topologySpread,probeswithouthost,valkey, the extra* arrays,resources, scheduling), plusenabledboolean,replicaCountinteger ≥ 1,hoststring,authorityandmanagementApiUrlstrings with"pattern": "^(https://.+)?$",keysobject {clientKeySecret,dpopKeySecretstrings}, andingressobject {enabledboolean,classNamestring,annotationsobject,tlsarray of objects}. Newci/management-console-values.yaml: the content ofci/single-replica-values.yamlwithallowedHostsid.example.comandmanage.example.com,ingressenabled (classNametraefik, hostid.example.com, path/Prefix), andmanagementConsole{enabled: true,host: manage.example.com,keysidentity-mgmt-client-key/identity-mgmt-dpop-key,ingressenabled, classNametraefik, tlsmanage-example-com-tlsformanage.example.com}, headed# A production release with the management console on its own host, which routes /api to the server.helm lint --strict charts/ncode-identity-host --values charts/ncode-identity-host/ci/management-console-values.yaml; if ($LASTEXITCODE) { exit 1 }; helm lint --strict charts/ncode-identity-host --values charts/ncode-identity-host/ci/single-replica-values.yaml[ ] 17. Add the console's chart helpers
charts/ncode-identity-host/templates/_helpers.tpl(edit){{/* … */}}description:identity-host.managementConsole.fullname=printf "%s-management-console" (include "identity-host.fullname" . | trunc 44 | trimSuffix "-");…selectorLabels(name, instance,app.kubernetes.io/component: management-console);…labels(identity-host.commonLabels+ that component);…serviceAccountName(asidentity-host.serviceAccountNameover.Values.managementConsole.serviceAccount, defaulting to the console fullname);…image(asidentity-host.imageover.Values.managementConsole.image, tag defaulting to.Chart.AppVersion);…authority(managementConsole.authority, elsetenantResolution.staticSingle.issuerwhenstrategyCodeisStaticSingle, else empty);…managementApiUrl(managementConsole.managementApiUrlorprintf "https://%s/api" managementConsole.host);…valkeyEnabled(truewhenmanagementConsole.valkey.existingSecret);…validate, which fails with a message naming the value when:hostis empty;hostcontains*;allowedHostsdoes not containhost("allowedHosts must list managementConsole.host: the management host routes /api to the server."); the authority is empty or does not start withhttps://;keys.clientKeySecretorkeys.dpopKeySecretis empty;replicaCount> 1 without console Valkey;…env:ASPNETCORE_ENVIRONMENT(.Values.environment),AllowedHosts(host), the threeForwardedHeaders__*entries exactly asidentity-host.hostEnvwrites them,Bff__Frontends__mgmt__Authority,Bff__Frontends__mgmt__PublicOrigin(https://<host>),Bff__KeyRings__mgmt-client__PemFiles__0=/etc/ncode-identity/management-console/client-key/mgmt-client.pem,Bff__KeyRings__mgmt-dpop__PemFiles__0=/etc/ncode-identity/management-console/dpop-key/mgmt-dpop.pem,ManagementConsole__ManagementApiUrl,ConnectionStrings__valkeyfrom the console Valkey Secret when enabled, thenmanagementConsole.extraEnv.identity-host.validate,identity-host.hostEnv).helm lint --strict charts/ncode-identity-host --values charts/ncode-identity-host/ci/management-console-values.yaml; if ($LASTEXITCODE) { exit 1 }; if ((Select-String -Path charts/ncode-identity-host/templates/_helpers.tpl -Pattern 'define "identity-host.managementConsole.(validate|env|fullname)"').Count -eq 3) { exit 0 } else { exit 1 }[ ] 18. Add the console's Deployment, Service and ServiceAccount
charts/ncode-identity-host/templates/management-console-deployment.yaml(new),charts/ncode-identity-host/templates/management-console-service.yaml(new),charts/ncode-identity-host/templates/management-console-serviceaccount.yaml(new){{- if .Values.managementConsole.enabled }}. Deployment: copytemplates/deployment.yamland use the console helpers and values; it includesidentity-host.managementConsole.validate; strategy RollingUpdate (maxSurge 1, maxUnavailable 0) with console Valkey, elseRecreatewith the server's comment adapted to sessions; no Google Cloud annotation;automountServiceAccountToken: false; pod and container security contexts from the existing helpers; containername: management-console, porthttp8080;envfromidentity-host.managementConsole.env;envFromfrommanagementConsole.extraEnvFrom; the three probes on/health/live,/health/live,/health/readywithHost=managementConsole.host;preStopsleep; volume mountstmp→/tmp,client-key→/etc/ncode-identity/management-console/client-keyanddpop-key→/etc/ncode-identity/management-console/dpop-key(readOnly), thenmanagementConsole.extraVolumeMounts; volumestmp(emptyDir,sizeLimit: 64Mi),client-key(secretkeys.clientKeySecret,items: [{key: mgmt-client.pem, path: mgmt-client.pem}],defaultMode: 0440),dpop-key(same formgmt-dpop.pem), thenmanagementConsole.extraVolumes;imagePullSecretsfromidentity-host.imagePullSecrets;nodeSelector/affinity/tolerations/priorityClassNameandtopologySpreadfrommanagementConsole. Service: copytemplates/service.yamlwith the console fullname, labels,managementConsole.serviceand console selector labels. ServiceAccount: copytemplates/serviceaccount.yamlovermanagementConsole.serviceAccount,automountServiceAccountToken: false.$o = helm template release charts/ncode-identity-host --values charts/ncode-identity-host/ci/management-console-values.yaml --show-only templates/management-console-deployment.yaml --show-only templates/management-console-service.yaml --show-only templates/management-console-serviceaccount.yaml 2>&1 | Out-String; if ($LASTEXITCODE -eq 0 -and $o.Contains('Bff__KeyRings__mgmt-client__PemFiles__0') -and $o.Contains('path: mgmt-dpop.pem') -and $o.Contains('app.kubernetes.io/component: management-console') -and $o.Contains('readOnlyRootFilesystem: true') -and $o.Contains('type: Recreate') -and $o.Contains('value: "manage.example.com"')) { exit 0 } else { $o; exit 1 }[ ] 19. Add the console's Ingress, PodDisruptionBudget and notes
charts/ncode-identity-host/templates/management-console-ingress.yaml(new),charts/ncode-identity-host/templates/management-console-poddisruptionbudget.yaml(new),charts/ncode-identity-host/templates/NOTES.txt(edit)managementConsole.enabledandmanagementConsole.ingress.enabled: name = console fullname, console labels,managementConsole.ingress.annotations/className/tls, one rule formanagementConsole.hostwith twoPrefixpaths:/api→ serviceidentity-host.fullnameport namehttp, and/→ the console Service port namehttp; a comment says/apireaches the server for machine callers and every other path reaches the console (ADR-1437). PodDisruptionBudget: copytemplates/poddisruptionbudget.yamlovermanagementConsole.podDisruptionBudgetand the console selector labels, rendered when the console is enabled.NOTES.txt: when the console is enabled, append a paragraph: the console answers to<host>through the<console fullname>Service; register the confidential clientmgmt-consolewith the server withjwks_urihttps://<host>/bff/jwks; and, without console Valkey, the console runs one replica whose sessions end on every update.$o = helm template release charts/ncode-identity-host --values charts/ncode-identity-host/ci/management-console-values.yaml --show-only templates/management-console-ingress.yaml 2>&1 | Out-String; if ($LASTEXITCODE -ne 0 -or -not $o.Contains('path: /api') -or -not ($o -match '(?m)name: release-ncode-identity-host-management-console\s*$') -or -not ($o -match '(?m)name: release-ncode-identity-host\s*$')) { $o; exit 1 }; helm lint --strict charts/ncode-identity-host --values charts/ncode-identity-host/ci/management-console-values.yaml[ ] 20. Check the console component in the
Host chartworkflow.github/workflows/host-chart.yml(edit)Deploy the management console on its own hostafterNever run two replicas at once without Valkey: withbase="$CHART/ci/management-console-values.yaml"and the samerefuse()function, refuse--set managementConsole.host=,--set 'managementConsole.host=*.example.com',--set 'allowedHosts={id.example.com}',--set managementConsole.authority=http://id.example.com,--set tenantResolution.strategyCode=DynamicByHost --set tenantResolution.staticSingle.issuer=,--set managementConsole.keys.clientKeySecret=,--set managementConsole.keys.dpopKeySecret=,--set managementConsole.replicaCount=2; then fail unless the renderedtemplates/management-console-ingress.yamlroutespath: /apito the server Service, and fail whenhelm template release "$CHART" --values "$CHART/ci/single-replica-values.yaml"output containsmanagement-console(the console is opt-in). Update the workflow's header comment to say the chart's management console component is checked too.$w = Get-Content .github/workflows/host-chart.yml -Raw; if ($w.Contains('management-console-values.yaml') -and $w.Contains('managementConsole.keys.dpopKeySecret=') -and $w.Contains('managementConsole.replicaCount=2')) { exit 0 } else { exit 1 }[ ] 21. Document the console component in the chart
charts/ncode-identity-host/Chart.yaml(edit),charts/ncode-identity-host/README.md(edit)Chart.yamldescription:NCode.Identity.OpenId.Host, the OpenID Connect and OAuth 2.0 server, as a rolling, non-root deployment behind an ingress, and optionally its management console on a host of its own.README: in the intro add one sentence thatmanagementConsole.enabledalso deploys the management console host (ADR-1458); in "What the chart refuses" add the console's refusals (step 17's list); add a## Management consolesection before## Strict-Transport-Securitycovering: what it deploys (Deployment, Service, ServiceAccount without a token, PodDisruptionBudget, Ingress onmanagementConsole.hostrouting/apito the server and/to the console); the Secrets to create, with commandsopenssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out mgmt-client.pem(same formgmt-dpop.pem) andkubectl create secret generic identity-mgmt-client-key --from-file=mgmt-client.pem(same for dpop); registeringmgmt-consolewith the server with the metadata of ADR-1437 decision 4 andjwks_urihttps://<host>/bff/jwks;allowedHostslisting the console host; the defaults ofauthorityandmanagementApiUrl;managementConsole.valkey.existingSecretfor more than one replica (the console's own Valkey, never the server's); and console settings such asManagementConsole__Branding__DisplayNamepassed throughmanagementConsole.extraEnv; and an install example extending the existing one with--set managementConsole.enabled=true --set managementConsole.host=manage.example.com --set 'allowedHosts={id.example.com,manage.example.com}' --set managementConsole.keys.clientKeySecret=identity-mgmt-client-key --set managementConsole.keys.dpopKeySecret=identity-mgmt-dpop-key.if ((Select-String -Path charts/ncode-identity-host/README.md -Pattern '^## Management console' -Quiet) -and (Select-String -Path charts/ncode-identity-host/Chart.yaml -Pattern 'management console' -Quiet)) { helm lint --strict charts/ncode-identity-host --values charts/ncode-identity-host/ci/management-console-values.yaml } else { exit 1 }[ ] 22. Document the console image
src/NCode.Identity.ManagementConsole.Host/README.md(new),docs/backend-for-frontend.md(edit)# NCode.Identity.ManagementConsole.Hostwith a one-paragraph intro (the management console's BFF host, ADR-1437) and a## Container imagesection modeled on the section of the same name insrc/NCode.Identity.OpenId.Host/README.md: the build command with--file src/NCode.Identity.ManagementConsole.Host/Dockerfile --tag ncode-identity-management-console; chiseled, no shell, non-root UID 1654, port 8080 plain HTTP, servesapps/mgmtfrom/app/mgmt, probes/health/liveand/health/ready, one-line JSON logs,linux/amd64andlinux/arm64built by theConsole imageworkflow (ADR-1458); the settings the Production environment requires:AllowedHosts,Bff__Frontends__mgmt__Authority,Bff__Frontends__mgmt__PublicOrigin,Bff__KeyRings__mgmt-client__PemFiles__0,Bff__KeyRings__mgmt-dpop__PemFiles__0,ManagementConsole__ManagementApiUrl, andConnectionStrings__valkeyfor more than one replica; and a link to the chart'sManagement consolesection. Indocs/backend-for-frontend.md§5.15, after the paragraph that ends "…ADR-1439)." add a Deployment. paragraph: the host's image (src/NCode.Identity.ManagementConsole.Host/Dockerfile) and themanagementConsolecomponent ofcharts/ncode-identity-host, which mounts the two key rings from Secrets and routesmanage.<apex>'s/apito the OP and every other path to the console host (ADR-1458).if ((Select-String -Path src/NCode.Identity.ManagementConsole.Host/README.md -Pattern '^## Container image' -Quiet) -and (Select-String -Path docs/backend-for-frontend.md -Pattern '\*\*Deployment\.\*\*' -Quiet)) { exit 0 } else { exit 1 }[ ] 23. Record the console deployment decision (ADR-1458)
docs/decisions/1458-the-management-console-ships-as-its-own-image-and-an-opt-in-component-of-the-host-chart.md(new),docs/decisions/README.md(edit),docs/decisions/1437-the-management-console-is-a-confidential-client-served-by-a-bff-host-of-its-own.md(edit)# 1458. The management console ships as its own image and an opt-in component of the host chart, StatusAccepted (amends [ADR-1437](1437-the-management-console-is-a-confidential-client-served-by-a-bff-host-of-its-own.md), [ADR-1179](1179-the-host-chart-references-its-secrets-and-refuses-a-release-the-host-would-refuse.md) and [ADR-1249](1249-the-host-images-base-images-are-pinned-by-digest-and-tracked-by-dependabot.md)), Date2026-11-06. Context: the console is a host and origin of its own (ADR-1437);manage.<apex>routes/apito the OP and every other path to the console; the console's key rings are PEM files (ADR-1439); the image and chart are the deployable artifacts (ADR-1178, ADR-1179); issue #610. Decision (numbered): (1) the image as step 13 builds it; (2) theConsole imageworkflow as step 15, triggered likeHost image, includingmerge_group; (3) the Dependabot docker stream lists both Dockerfile directories; (4) the chart component as steps 16–19 build it, with its defaults; (5) its refusals. Options (rejected with reasons): a chart of its own (the manage host's Ingress joins two Services, the server must admit the host, one release keeps both images on one version); sharing the server's Valkey (the console would reach the server's single-use state, ADR-1437); the server's ServiceAccount (it may carry the server's cloud identities); routing/apithrough the console's BFF (machine callers authenticate at the OP directly). Consequences: one release deploys both; an operator registersmgmt-consolewithjwks_urihttps://<host>/bff/jwksand creates two key Secrets; theBuild console imagecheck runs on every pull request. LetLbe1458-the-management-console-ships-as-its-own-image-and-an-opt-in-component-of-the-host-chart.md. Status lines: ADR-1437 ends its parentheses with; amended by [ADR-1458](L): the console's image and the host chart's management console component; ADR-1179 ends its parentheses with; and by [ADR-1458](L): the chart deploys the management console as an opt-in component; ADR-1249'sAcceptedbecomesAccepted (amended by [ADR-1458](L): the docker stream also tracks the management console image's Dockerfile). README index: in the 1437 and 1179 rows add; amended by [1458](./L)inside the status parentheses, the 1249 row's status becomesAccepted (amended by [1458](./L)), and add the row| [1458](./L) | The management console ships as its own image and an opt-in component of the host chart | Accepted (amends [1437](./1437-the-management-console-is-a-confidential-client-served-by-a-bff-host-of-its-own.md), [1179](./1179-the-host-chart-references-its-secrets-and-refuses-a-release-the-host-would-refuse.md), [1249](./1249-the-host-images-base-images-are-pinned-by-digest-and-tracked-by-dependabot.md)) |after the 1457 row (writeLout in full everywhere).$p = & ./build/adr-index.ps1; if ($p) { $p; exit 1 }; if ((Select-String -Path docs/decisions/1437-the-management-console-is-a-confidential-client-served-by-a-bff-host-of-its-own.md,docs/decisions/1179-the-host-chart-references-its-secrets-and-refuses-a-release-the-host-would-refuse.md,docs/decisions/1249-the-host-images-base-images-are-pinned-by-digest-and-tracked-by-dependabot.md -Pattern '1458-' | Select-Object -ExpandProperty Path -Unique).Count -eq 3) { exit 0 } else { exit 1 }[ ] 24. Catalog the change in CHANGELOG.md
CHANGELOG.md(edit)## [Unreleased]/### Added, edit in place: (a) the entry starting- **The reference host separates liveness from readiness.**becomes- **The reference hosts separate liveness from readiness through one helper.** `MapHealthProbes()` (`NCode.AspNetCore`) maps `/health/live`, which runs no checks and reports only that the process answers, so a dependency outage never makes Kubernetes restart a replica, and `/health/ready`, which runs every check tagged `HealthCheckTags.Readiness` and returns `503` while any of them fails, taking the replica out of rotation until its dependencies return; it returns their route group, so a host adds a convention such as `RequireHost` to both. `NCode.Identity.OpenId.Host` and `NCode.Identity.ManagementConsole.Host` call it. The server's readiness checks are the database (`AddDbContextCheck<OpenIdDbContext>`, registered with the `openiddb` connection string) and Valkey when configured. The Aspire orchestrator gates dependents on `/health/ready` ([ADR-1456](docs/decisions/1456-the-hosts-map-their-health-probes-through-one-general-helper.md), [#609](https://github.com/NCodeGroup/Identity/issues/609)).; (b) in the entry starting- **`HealthCheckTags.Readiness` tags every readiness health check.**change`NCode.Identity.Abstractions` publishesto`NCode.Registration` publishesandthe reference host's `/health/ready` endpointto`MapHealthProbes()`'s `/health/ready` endpoint; (c) in the entry starting- **The server-rendered pages stream into the response…**change`InteractiveBundleReader` readsto`SinglePageBundle<InteractiveBundleShell>` reads; (d) after that entry add- **`SinglePageBundle<TIndex>` reads and serves a built single-page bundle.** `NCode.AspNetCore` reads a bundle's `index.html` once per version into the index type its surface parses, revalidated per request by the file's last-write time and length, resolves only the files of its `assets` directory, and serves them with `Cache-Control: public, max-age=31536000, immutable`, validators and range requests; the interactive surface and the management console each register one ([ADR-1457](docs/decisions/1457-a-single-page-bundle-is-read-and-served-through-one-general-helper.md), [#612](https://github.com/NCodeGroup/Identity/issues/612)).; (e) after the entry starting- **The host image writes one-line JSON logs.**add- **The management console host ships a production container image.** `src/NCode.Identity.ManagementConsole.Host/Dockerfile` builds the console host on the host image's pinned chiseled base images: no shell, the non-root `app` user, port 8080, the `apps/mgmt` bundle served from `/app/mgmt`, and one-line JSON logs; the `Console image` CI workflow builds it for `linux/amd64` and `linux/arm64` ([ADR-1458](docs/decisions/1458-the-management-console-ships-as-its-own-image-and-an-opt-in-component-of-the-host-chart.md), [#610](https://github.com/NCodeGroup/Identity/issues/610)).; (f) after the entry starting- **The host has a Helm chart.**add- **The host chart deploys the management console on a host of its own.** With `managementConsole.enabled`, `charts/ncode-identity-host` runs the console host under the restricted Pod Security Standard with its own ServiceAccount without a token, mounts its `mgmt-client` and `mgmt-dpop` keys read-only from Secrets, probes `/health/live` and `/health/ready`, and routes `managementConsole.host` through an Ingress that sends `/api` to the server and every other path to the console. Rendering fails for a missing or wildcard console host, an `allowedHosts` that does not list it, an authority that is not `https`, a missing key Secret, or more than one console replica without the console's own Valkey ([ADR-1458](docs/decisions/1458-the-management-console-ships-as-its-own-image-and-an-opt-in-component-of-the-host-chart.md), [#610](https://github.com/NCodeGroup/Identity/issues/610)).; (g) in the entry starting- **The host image's base images are pinned by digest and tracked by Dependabot.**replaceEvery `FROM` of the Dockerfile nameswithEvery `FROM` of the host and console Dockerfiles names,the Dockerfile takes its .NET and Node versionswitheach Dockerfile takes its .NET and Node versions, anda weekly `docker` Dependabot stream moves the digestswitha weekly `docker` Dependabot stream moves the digests of both images in one pull request. Keep every other entry; no### Changed/### Fixedheading.$c = Get-Content CHANGELOG.md -Raw; if ($c.Contains('SinglePageBundle<TIndex>') -and $c.Contains('MapHealthProbes()') -and $c.Contains('The host chart deploys the management console') -and -not $c.Contains('InteractiveBundleReader') -and -not ($c -match 'NCode\.Identity\.Abstractions.{1,2} publishes the')) { exit 0 } else { exit 1 }Review checklist
MapHealthProbes()keeps the exact paths and endpoint names; the chart probes,NCode.Identity.OpenId.LocalDev'sWithHttpHealthCheck("/health/ready")and the integration tests still match.HealthCheckTagshas one definition (inNCode.Registration) and the Valkey adapter still tags its check with it.SinglePageBundle<TIndex>keeps the readers' guarantees: nothing outsideassets(includingindex.html, hidden files and..paths) is servable; a404carries no cache lifetime; a rebuiltindex.htmlis picked up without a restart; the parser runs once per file version; the interactive bundle's root resolves against the content root exactly as before (absolute stays absolute).FROMlines are byte-identical to the host Dockerfile's, so Dependabot moves both in one pull request;.dockerignoreneeds no re-admit (no new root build file import).managementConsole.enabledis false; the console pods never match the server Service, PodDisruptionBudget or topology selectors (component label), and vice versa./tmponly, and key files mounted0440read-only; the console Ingress sends/apito the server Service../build/dod.ps1 -Fast -Affectedand the chart'shelm lint/helm templateloop before landing; the frontend gate is unaffected (onlyfrontend.yml's filter changes).Follow-ups
Build console imagecheck of.github/workflows/console-image.ymlto themainruleset's required status checks, so a pull request that breaks the console image cannot merge (the ruleset lives in GitHub settings, not the repository).