Skip to content

Serve the management console through the BFF instead of as a browser public client #158

Closed

Description

@polewskm

The console runs authorization code + PKCE in JavaScript through @ncode/auth, keeps the access and refresh tokens in the Redux store, and @ncode/store attaches Authorization: Bearer to every management API call — so the family's most privileged surface holds tokens any injected script can take. Once #123 lands, register the console as a confidential BFF client of the root tenant, serve it through the BFF so it calls the management API with the session cookie and the anti-forgery header, and retire the console's in-browser token custody (the Playground stays a public client on purpose — showing tokens is its job). Decide whether the BFF runs in the OP host process or a separate host (docs/backend-for-frontend.md §11). Planning found that the console cannot keep its standalone Vite dev server: proxying /bff and the API through it hides the browser's origin from the BFF unless the host trusts loopback as a reverse proxy, and the forwarded http scheme then trips the host's HTTPS redirection. Serving the console from the OP host under /mgmt/, as apps/auth is served under /account/ (ADR-0065), keeps the console, /bff/*, and the forwarded API on one origin. Touch points: apps/mgmt/src/auth.ts, apps/mgmt/src/AuthGate.tsx, packages/store/src/baseQuery.ts, packages/auth/, src/NCode.Identity.OpenId.Host/Program.cs.

Activity

  1. polewskm commented on Nov 6, 2026

    @polewskm
    ContributorAuthor

    Plan snapshot. The working copy is PLAN.md in the plan's checkout; this comment is not updated as steps run. The plan is longer than one comment allows, so it is split in two: this comment holds steps 1–24, the next one steps 25–48 and the review checklist.

    Plan: feat(mgmt): serve the management console through its own BFF host (48 steps) — part 1 of 2

    Plan: feat(mgmt): serve the management console through its own BFF host

    Issue: #158
    Refs: #123

    Goal

    The management console (src/frontend/apps/mgmt) becomes a BFF frontend: NCode.Identity.ManagementConsole.Host, a
    host of its own composed by the new NCode.Identity.ManagementConsole package over AddIdentityBff, serves the built
    console bundle, its /bff/* endpoints and the forwarded management API on one origin, as the confidential client
    mgmt-console of the control-plane tenant (private_key_jwt, PAR, DPoP, back-channel logout). The browser holds no
    token: the console's @ncode/auth custody, the Redux token store and the Authorization: Bearer header disappear, and
    the console reads its session from /bff/user. The Playground stays a public client.

    Context for the executor

    • Setup (once, in this worktree): dotnet tool restore, then
      Push-Location src/frontend; npx -y pnpm@10.34.7 install --frozen-lockfile; Pop-Location.
    • Prelude: none.
    • Build: $env:MSBUILDDISABLENODEREUSE = '1'; dotnet build <csproj> -m:2. Run one build or test at a time.
    • .NET test: $env:MSBUILDDISABLENODEREUSE = '1'; dotnet test tests/<P>/<P>.csproj -m:2 --filter "FullyQualifiedName~<Class>" -- RunConfiguration.TreatNoTestsAsError=true
    • Frontend check of one workspace <W> (typecheck, lint, tests with the coverage floors):
      Push-Location src/frontend; npx -y pnpm@10.34.7 exec turbo run typecheck lint --filter=<W>; $a = $LASTEXITCODE; npx -y pnpm@10.34.7 --filter <W> exec vitest run --coverage; $b = $LASTEXITCODE; Pop-Location; if ($a -or $b) { exit 1 } else { exit 0 }
    • Format: after editing a .cs file run dotnet csharpier format <file>; after editing a frontend file run
      npx -y pnpm@10.34.7 exec prettier --write <file> from src/frontend.
    • Abbreviations: MC = src/NCode.Identity.ManagementConsole/, MCH = src/NCode.Identity.ManagementConsole.Host/,
      MCT = tests/NCode.Identity.ManagementConsole.Tests/, IT = tests/NCode.Identity.OpenId.Integration.Tests/,
      FE = src/frontend/, MG = src/frontend/apps/mgmt/.
    • Conventions: read .github/instructions/csharp-production.instructions.md before editing src/**/*.cs, and
      .github/instructions/csharp-testing.instructions.md before editing tests/**/*.cs (strict mocks from a
      MockRepository, Method_State_Expected names, #region per member, no Arrange/Act/Assert comments). Every public
      type gets XML docs and [PublicAPI]; every public member of a packable project is listed in its
      PublicAPI.Unshipped.txt (the RS0016 code fix writes the line). One type per file, no nested types, file-scoped
      namespaces, primary constructors, the license header // Copyright © NCode Group + // SPDX-License-Identifier: Apache-2.0.
    • Text rule (binding): every comment, XML doc, test name, doc and changelog line describes the product as it is. Never
      write "now", "no longer", "previously", "replaces", "renamed", "moved", "instead of the old" or a removed name.
    • Frontend conventions: src/frontend/README.md (stateless components in @ncode/ui, tests treat console warnings as
      errors, coverage floors in each workspace's Vite/Vitest config). jsdom does not implement HTMLFormElement.submit:
      a test that submits a form spies on it with vi.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation(() => {}).
    • Gotchas:
      • The OP's outbound guard (DefaultOutboundHttpForgeryGuard) never reaches loopback or private addresses, so on a
        developer machine the OP cannot fetch a jwks_uri or deliver a back-channel logout to https://localhost:5005.
        That is why the developer seed registers the console's public key by value.
      • MapBffRemoteApi forwards through its own SocketsHttpHandler, which a TestServer cannot receive: integration
        tests prove the BFF guard on /management/api/* (401/403) and never a forwarded call.
      • WebApplicationFactory hosts run in Development without their launch profile (see IT/Infrastructure/HostApplicationFactory.cs).
      • The BFF frontend of the console is named mgmt, its client id is mgmt-console, its key rings are mgmt-client
        (private_key_jwt) and mgmt-dpop (DPoP), the console host listens on https://localhost:5005 and http://localhost:5004.

    Decisions

    • D1 — Separate host (ADR-1437). The console's BFF runs in its own process and origin, NCode.Identity.ManagementConsole.Host,
      not in the OP host under /mgmt/ as the issue sketched. The most privileged UI never shares an origin with the OP's
      login, consent and account pages (path scoping is no boundary, ADR-1193; per-surface origins, ADR-1064); the
      management API keeps one authentication model, sender-constrained bearer tokens of the control-plane tenant
      (ADR-1191); the OP holds no console key and the console holds no OP key, database or signing credential; the console
      is a client of the OP exactly as a third party's BFF is (design goal G3); AddIdentityServer and AddIdentityBff
      never share a service collection. The console bundle, /bff/* and the forwarded API share the console host's origin.
    • D2 — Composition. The packable NCode.Identity.ManagementConsole holds the console's server: AddManagementConsole(configuration)
      (composes AddIdentityBff, binds ManagementConsole strictly) returning IManagementConsoleBuilder (exposes
      IdentityBffBuilder), and MapManagementConsole(). The host's Program.cs stays composition-only.
    • D3 — Topology. The console is served at the root of its origin; its BFF frontend mgmt is the fallback frontend
      (no selection rule; cookie __Host-Http-bff.mgmt). The management API is forwarded at /management/api/* to
      ManagementConsole:ManagementApiUrl (https://localhost:5001/api in Development) with the user token requirement
      and resource urn:ncode:management, so the console's API base is /management. /api stays free on the console's
      origin, so the hosted platform's manage.<apex> routes /api to the OP for machine callers and every other path to
      the console host (amends ADR-1131).
    • D4 — Client registration. mgmt-console is a confidential client of the control-plane tenant: private_key_jwt,
      require_pushed_authorization_requests, require_dpop_bound_access_tokens, grant types authorization_code and
      refresh_token, redirect URI <origin>/bff/callback, post-logout URI <origin>/, back-channel logout URI
      <origin>/bff/backchannel-logout with backchannel_logout_session_required. It requests profile email offline_access read:clients (the BFF adds openid) and resource urn:ncode:management, loads UserInfo, and
      allow-lists preferred_username and email for /bff/user. A deployment registers jwks_uri = <origin>/bff/jwks
      (ADR-1294); the developer seed registers the public key by value (amends ADR-1294 and ADR-1146).
    • D5 — Key rings from PEM files (ADR-1439). Bff:KeyRings:{name}:PemFiles registers a keyed ISecretKeyCollectionProvider
      named {name} that holds the EC or RSA private key of each PEM file (PKCS#8, SEC1 or PKCS#1), read through sensitive
      buffers; a key's kid is its file name without the extension; a relative path is resolved against the content root;
      startup validation requires at least one existing file per ring. A host that registers a keyed provider in code (a
      KMS-held key, for example) keeps doing so; the configuration is the built-in source.
    • D6 — Local development. The AppHost generates per-machine P-256 keys mgmt-client.pem and mgmt-dpop.pem beside
      the other development secrets, hands their paths to the console (Bff__KeyRings__…) and the client key's public
      JWKS to the OP (DeveloperInteractiveSeed__BffClients__0__Jwks). apps/mgmt is a build-watched bundle (vite build --watch) served by the console host; the Vite dev server on 5173, mgmt-spa, its CORS origin and the Vite /api/
      proxy are gone. On a developer machine the OP cannot deliver back-channel logout to the console (loopback guard).
    • D7 — The browser holds no token (ADR-1438). The console host publishes the console's bootstrap document at
      /config/bootstrap.json: { "apiBaseUrl": "/management", "bff": { "basePath": "/bff", "antiforgeryHeader": { "name": "X-CSRF", "value": "1" } }, "features": {…}, "branding": {…} }. @ncode/config models RuntimeConfig as the union
      of OpenIdRuntimeConfig (kind: 'openid') and BffRuntimeConfig (kind: 'bff'), and loadBffRuntimeConfig fetches
      no discovery. @ncode/store has no token slice: a session slice (unknown | signed-in | signed-out, BffUser) fed
      by fetchBffUser; the management base query sends the anti-forgery header with same-origin credentials, never
      Authorization, and dispatches sessionEnded() on 401. @ncode/auth keeps the Playground's protocol helpers only:
      client.ts, renew.ts and getSubject are deleted.
    • D8 — Session UX. SessionGate probes /bff/user, polls every 60 s, probes 1 s after min(idleExpiresAt, expiresAt), re-probes when a hidden tab becomes visible, and shares an end through the BroadcastChannel
      ncode.mgmt.session. Any end returns the console to its sign-in screen ("Your session ended. Sign in to continue.")
      whose button navigates to /bff/login?returnUrl=<path>; this refines ADR-1434's "navigate to /bff/login" so a logout
      in one tab never races a login in another. Sign-out posts the logout form (sid, returnUrl=/). The BFF's ?error=
      shows on the sign-in screen as "Sign-in failed ()."
    • D9 — Page security. The console host serves index.html for every non-file GET/HEAD path with a fresh nonce in
      place of __NCODE_CSP_NONCE__ under script-src 'nonce' 'strict-dynamic'; style-src 'nonce'; img-src 'self' [logo origin]; connect-src 'self'; form-action 'self' <authority origin>; base-uri 'none'; frame-ancestors 'none', no-store
      (SetHtmlPageSecurity); /assets/* is immutable. A missing bundle answers 503 and never stops startup. The bundle
      builds with the new defineBffServedSurfaceConfig (base /, nonce placeholder); emotion takes the nonce from
      <meta property="csp-nonce"> through readCspNonce, which lives in @ncode/ui.
    • D10 — OP bootstrap. The OP's Frontend default names no client and no feature flag; it serves the public-client
      and read-only surfaces only.
    • D11 — Verification. Unit tests per package; an in-process end-to-end test logs in through the OP host into the
      console host with the real developer seed; a drift test ties the console's Development configuration to the OP's
      seed entry; CI tests the console host against the built console bundle.

    Out of scope

    • A container image, CI image workflow or Helm chart for the console host (Follow-ups).
    • Forwarded-headers trust and HSTS in the console host (Follow-ups).
    • Sharing one single-page bundle reader between the interactive surface and the console (Follow-ups); do not touch
      NCode.Identity.OpenId.Authentication.Interactive.
    • Selecting the fapi2 profile for mgmt-console, Valkey for the console under the AppHost, any change to the
      management API's authentication, the Playground's or conformance dashboard's behavior.

    Steps

    [ ] 1. Add the Bff:KeyRings options and their validation

    • Files: src/NCode.Identity.Bff/Options/BffKeyRingOptions.cs (new), src/NCode.Identity.Bff/Options/BffOptions.cs,
      src/NCode.Identity.Bff/Options/BffOptionsValidator.cs, src/NCode.Identity.Bff/PublicAPI.Unshipped.txt,
      tests/NCode.Identity.Bff.Tests/Options/BffOptionsValidatorTests.cs
    • Change: [PublicAPI] public sealed class BffKeyRingOptions { public List<string> PemFiles { get; set; } = []; } with
      XML docs (section Bff:KeyRings:{name}; each entry is the path of a PEM-encoded EC or RSA private key, relative to
      the content root or absolute; a key's id is its file name without the extension). BffOptions gains
      public Dictionary<string, BffKeyRingOptions> KeyRings { get; set; } = new(StringComparer.Ordinal); (section
      Bff:KeyRings). BffOptionsValidator.Validate calls a new ValidateKeyRings(options.KeyRings, failures), which adds:
      Bff:KeyRings must be set. (null); Bff:KeyRings has a key ring without a name. (blank key);
      Bff:KeyRings:{name}:PemFiles must list at least one file. (null ring or empty list);
      Bff:KeyRings:{name}:PemFiles:{i} must not be blank.; Bff:KeyRings:{name}:PemFiles:{i} names '{path}', which does not exist.
      (resolve with Path.GetFullPath(path, environment.ContentRootPath) and File.Exists).
    • Tests: one per failure plus Validate_WithExistingPemFiles_Succeeds (write a file in
      Directory.CreateTempSubdirectory("bff-keys-"), delete it in Dispose); set up ContentRootPath on the
      environment mock where the validator reads it.
    • Pattern: ValidateAntiforgery / ValidateTokens in BffOptionsValidator.cs.
    • Done when: $env:MSBUILDDISABLENODEREUSE = '1'; dotnet test tests/NCode.Identity.Bff.Tests/NCode.Identity.Bff.Tests.csproj -m:2 --filter "FullyQualifiedName~BffOptionsValidatorTests" -- RunConfiguration.TreatNoTestsAsError=true

    [ ] 2. Register a keyed key-ring provider for every configured Bff:KeyRings entry

    • Files: src/NCode.Identity.Bff/Secrets/BffPemKeyRing.cs (new, internal), src/NCode.Identity.Bff/BffLibraryRegistration.cs,
      tests/NCode.Identity.Bff.Tests/Secrets/BffPemKeyRingTests.cs (new)
    • Change: internal static class BffPemKeyRing with
      public static ISecretKeyCollectionProvider Load(IServiceProvider serviceProvider, string name): read
      IOptions<BffOptions>.Value.KeyRings[name].PemFiles, IHostEnvironment, ISecretKeyFactory and
      ISecretKeyCollectionProviderFactory; for each path (resolved as in step 1) read the bytes with
      File.OpenHandle + RandomAccess.Read into BufferFactory.Rent(length, isSensitive: true, out Span<byte> bytes),
      decode with SecureEncoding.UTF8 into a second sensitive buffer of chars, and create the key with
      factory.CreateEccPem(new KeyMetadata { KeyId = Path.GetFileNameWithoutExtension(path) }, chars); when that throws
      CryptographicException or ArgumentException, use CreateRsaPem with the same metadata; when both throw, throw
      InvalidOperationException($"The key ring '{name}' cannot read '{path}': it holds no PEM-encoded EC or RSA private key.").
      Return collectionFactory.CreateStatic(keys). In AddBffConfiguration, after the options binding, for each child of
      section.GetSection("KeyRings").GetChildren() take var name = child.Key; and call builder.ServiceCollection.AddKeyedSingleton<ISecretKeyCollectionProvider>(name, (sp, _) => BffPemKeyRing.Load(sp, name))
      (no null-forgiving operator: it fails the build outside tests);
      update the method's XML summary to say it also registers the configured key rings.
    • Tests (real ISecretKeyFactory/ISecretKeyCollectionProviderFactory from AddSecretsLibrary() in a
      ServiceCollection, disposed with DisposeAsync): an EC PEM and an RSA PEM ring load with key ids equal to the file
      names; a non-key file throws naming the ring; AddBffConfiguration with Bff:KeyRings:ring:PemFiles:0 resolves
      GetRequiredKeyedService<ISecretKeyCollectionProvider>("ring"). Write PEMs with ECDsa.ExportPkcs8PrivateKeyPem() and
      RSA.ExportRSAPrivateKeyPem() into a temp subdirectory.
    • Pattern: DevelopmentSecretFile.Read in src/NCode.Identity.OpenId.LocalDev/DevelopmentSecrets/DevelopmentSecretFile.cs;
      the keyed ring in IT/Bff/BffTestHost.cs.
    • Done when: $env:MSBUILDDISABLENODEREUSE = '1'; dotnet test tests/NCode.Identity.Bff.Tests/NCode.Identity.Bff.Tests.csproj -m:2 --filter "FullyQualifiedName~BffPemKeyRingTests" -- RunConfiguration.TreatNoTestsAsError=true

    [ ] 3. Scaffold the NCode.Identity.ManagementConsole package and its test project

    • Files: MC/NCode.Identity.ManagementConsole.csproj, MC/AssemblyInfo.cs, MC/PublicAPI.Shipped.txt,
      MC/PublicAPI.Unshipped.txt, MC/ManagementConsole.cs, MC/ManagementConsoleDefaults.cs,
      MCT/NCode.Identity.ManagementConsole.Tests.csproj, MCT/BaseTests.cs, MCT/ManagementConsoleDefaultsTests.cs, NCode.Identity.slnx (all new except the slnx)
    • Change: csproj like src/NCode.Identity.Bff.Server/NCode.Identity.Bff.Server.csproj (FrameworkReference
      Microsoft.AspNetCore.App; ProjectReferences NCode.Identity.Bff.Server, NCode.Identity.Bff, NCode.AspNetCore,
      NCode.Registration; JetBrains.Annotations PrivateAssets All; RootNamespace NCode.Identity.ManagementConsole).
      AssemblyInfo.cs: InternalsVisibleTo("NCode.Identity.ManagementConsole.Tests") and
      InternalsVisibleTo("NCode.Identity.OpenId.Integration.Tests"). PublicAPI files start with #nullable enable.
      ManagementConsole: [PublicAPI] public readonly struct ManagementConsole : IRegistrationMarker<ManagementConsole>;
      (pattern src/NCode.Identity.Bff.Server/IdentityBff.cs). ManagementConsoleDefaults (public static, documented
      consts): ConfigurationSectionName = "ManagementConsole", FrontendName = "mgmt", ApiBasePath = "/management",
      ApiPath = "/management/api", ManagementApiResource = "urn:ncode:management", BootstrapPath = "/config/bootstrap.json",
      AssetsPath = "/assets", NoncePlaceholder = "__NCODE_CSP_NONCE__" (must equal CSP_NONCE_PLACEHOLDER of @ncode/vite-config).
      Test project like tests/NCode.Identity.Bff.Server.Tests/NCode.Identity.Bff.Server.Tests.csproj referencing MC; one test
      NoncePlaceholder_Always_MatchesTheFrontendPreset asserting "__NCODE_CSP_NONCE__". Add both projects:
      dotnet sln NCode.Identity.slnx add src/NCode.Identity.ManagementConsole --solution-folder src and
      dotnet sln NCode.Identity.slnx add tests/NCode.Identity.ManagementConsole.Tests --solution-folder tests.
    • Done when: $env:MSBUILDDISABLENODEREUSE = '1'; dotnet test tests/NCode.Identity.ManagementConsole.Tests/NCode.Identity.ManagementConsole.Tests.csproj -m:2 --filter "FullyQualifiedName~ManagementConsoleDefaultsTests" -- RunConfiguration.TreatNoTestsAsError=true

    [ ] 4. Add the console's options and their validator

    • Files: MC/ManagementConsoleOptions.cs, MC/ManagementConsoleBrandingOptions.cs, MC/ManagementConsoleOptionsValidator.cs
      (all new), MC/PublicAPI.Unshipped.txt, MCT/ManagementConsoleOptionsValidatorTests.cs (new)
    • Change: [PublicAPI] public sealed class ManagementConsoleOptions (section ManagementConsole):
      string? ManagementApiUrl (absolute URL of the OP's management API base, such as https://id.example.com/api),
      string? BundleRootPath (directory of the built apps/mgmt bundle; relative to the content root or absolute),
      Dictionary<string, bool> Features { get; set; } = new(StringComparer.Ordinal),
      ManagementConsoleBrandingOptions Branding { get; set; } = new(). ManagementConsoleBrandingOptions: DisplayName,
      LogoUrl, PrimaryColor, SecondaryColor, Mode (all string?). internal sealed class ManagementConsoleOptionsValidator(IHostEnvironment environment) : IValidateOptions<ManagementConsoleOptions> fails with:
      ManagementConsole:ManagementApiUrl must be an absolute https URL without user info, query or fragment. (http
      accepted only when environment.IsDevelopment()); ManagementConsole:BundleRootPath must name the directory of the built apps/mgmt bundle.
      (blank); ManagementConsole:Branding:LogoUrl must be an absolute https URL.; ManagementConsole:Branding:Mode must be 'light' or 'dark'.;
      ManagementConsole:Features has a feature without a name.
    • Tests: one per failure, the Development http case and a valid set.
    • Pattern: src/NCode.Identity.Bff/Options/BffOptionsValidator.cs.
    • Done when: $env:MSBUILDDISABLENODEREUSE = '1'; dotnet test tests/NCode.Identity.ManagementConsole.Tests/NCode.Identity.ManagementConsole.Tests.csproj -m:2 --filter "FullyQualifiedName~ManagementConsoleOptionsValidatorTests" -- RunConfiguration.TreatNoTestsAsError=true

    [ ] 5. Publish the console's bootstrap document

    • Files: MC/ManagementConsoleBootstrapDocument.cs, MC/ManagementConsoleBffDocument.cs, MC/ManagementConsoleHeaderDocument.cs,
      MC/ManagementConsoleBrandingDocument.cs, MC/ManagementConsoleBootstrapHandler.cs (all new, internal),
      MCT/ManagementConsoleBootstrapHandlerTests.cs (new)
    • Change: sealed records serialized with [JsonPropertyName] camelCase names: document apiBaseUrl, bff, features,
      branding (omitted when null); bff basePath, antiforgeryHeader; header name, value; branding displayName,
      logoUrl, primaryColor, secondaryColor, mode (each omitted when null or blank; the whole branding is null when
      every value is). ManagementConsoleBootstrapDocument.Create(ManagementConsoleOptions options, BffOptions bff, PathString pathBase):
      apiBaseUrl = pathBase + ManagementConsoleDefaults.ApiBasePath; basePath = pathBase + bff.Frontends[ManagementConsoleDefaults.FrontendName].PathPrefix; header from bff.Antiforgery. internal sealed class ManagementConsoleBootstrapHandler(IOptions<ManagementConsoleOptions>, IOptions<BffOptions>) with
      public IResult Handle(HttpContext httpContext): httpContext.Response.SetNoStore() then
      TypedResults.Json(document, JsonSerializerOptions.Web).
    • Tests: the document for defaults (/management, /bff, X-CSRF/1), a path base /console, a custom prefix and
      header, branding omitted when empty, and the handler's Cache-Control: no-store (execute the IResult against a
      DefaultHttpContext with a MemoryStream body and a service provider that has AddLogging()).
    • Pattern: src/NCode.Identity.Server/Frontend/FrontendBootstrapDocument.cs and FrontendBootstrapEndpointProvider.cs.
    • Done when: $env:MSBUILDDISABLENODEREUSE = '1'; dotnet test tests/NCode.Identity.ManagementConsole.Tests/NCode.Identity.ManagementConsole.Tests.csproj -m:2 --filter "FullyQualifiedName~ManagementConsoleBootstrapHandlerTests" -- RunConfiguration.TreatNoTestsAsError=true

    [ ] 6. Read the console bundle and build each page's security

    • Files: MC/ManagementConsoleBundleReader.cs, MC/ManagementConsoleBundleIndex.cs, MC/ManagementConsolePageSecurity.cs
      (all new, internal), MCT/ManagementConsoleBundleReaderTests.cs, MCT/ManagementConsolePageSecurityTests.cs (new)
    • Change: internal sealed class ManagementConsoleBundleReader(IOptions<ManagementConsoleOptions> options, IHostEnvironment environment) : IDisposable
      — the root is Path.GetFullPath(options.BundleRootPath, environment.ContentRootPath);
      ValueTask<string?> TryReadIndexAsync(CancellationToken) returns index.html's text or null when missing, cached in a
      ManagementConsoleBundleIndex(DateTime LastWriteTimeUtc, long Length, string Html) record and re-read when the file's
      last-write time or length changes; IFileInfo? TryGetAsset(string assetPath) serves only the assets directory
      through a lazily created PhysicalFileProvider; public static string RenderIndex(string html, string nonce) returns
      html.Replace(ManagementConsoleDefaults.NoncePlaceholder, nonce, StringComparison.Ordinal).
      internal readonly record struct ManagementConsolePageSecurity(string Nonce, HtmlPageSecurityPolicy Policy) with
      public static ManagementConsolePageSecurity Create(Uri authority, string? logoUrl): a fresh
      ContentSecurityPolicySources.CreateNonce(); directives script-src (nonce, StrictDynamic), style-src (nonce),
      img-src (Self, plus ContentSecurityPolicySources.Origin(logo) when logoUrl parses as an absolute URI),
      connect-src (Self), form-action (Self, ContentSecurityPolicySources.Origin(authority)); leave base-uri
      and framing at their strict defaults.
    • Tests: reader — missing bundle, cached read, re-read after a rewrite with a new length, an asset inside assets, a
      path that leaves it, RenderIndex replacing every placeholder; security — the exact header value
      default-src 'none'; script-src 'nonce-<n>' 'strict-dynamic'; style-src 'nonce-<n>'; img-src 'self'; connect-src 'self'; form-action 'self' https://id.example; base-uri 'none'; frame-ancestors 'none'
      and the logo origin case.
    • Pattern: src/NCode.Identity.OpenId.Authentication.Interactive/InteractiveBundleReader.cs,
      InteractivePageSecurity.cs; tests InteractiveBundleReaderTests.cs (temp subdirectory).
    • Done when: $env:MSBUILDDISABLENODEREUSE = '1'; dotnet test tests/NCode.Identity.ManagementConsole.Tests/NCode.Identity.ManagementConsole.Tests.csproj -m:2 --filter "FullyQualifiedName~ManagementConsoleBundleReaderTests|FullyQualifiedName~ManagementConsolePageSecurityTests" -- RunConfiguration.TreatNoTestsAsError=true

    [ ] 7. Serve the console page and its assets

    • Files: MC/ManagementConsolePageHandler.cs, MC/ManagementConsoleAssetHandler.cs (new, internal),
      MCT/ManagementConsolePageHandlerTests.cs, MCT/ManagementConsoleAssetHandlerTests.cs (new)
    • Change: ManagementConsolePageHandler(ManagementConsoleBundleReader reader, IOptions<BffOptions> bff, IOptions<ManagementConsoleOptions> options)
      with public async Task<IResult> HandleAsync(HttpContext httpContext): no index → SetNoStore() and
      TypedResults.Problem(statusCode: 503, title: "The management console is not built.", detail: "Build apps/mgmt (pnpm --filter @ncode/app-mgmt build) and set ManagementConsole:BundleRootPath to its dist directory.");
      otherwise security = ManagementConsolePageSecurity.Create(new Uri(bff.Value.Frontends["mgmt"].Authority), options.Value.Branding.LogoUrl),
      httpContext.Response.SetHtmlPageSecurity(security.Policy) and
      TypedResults.Text(ManagementConsoleBundleReader.RenderIndex(html, security.Nonce), "text/html; charset=utf-8").
      ManagementConsoleAssetHandler(ManagementConsoleBundleReader reader) with public IResult Handle(HttpContext httpContext):
      the asset route value → reader.TryGetAsset; unknown file or content type → 404; otherwise
      Cache-Control: public, max-age=31536000, immutable and TypedResults.PhysicalFile(...) with last-modified, entity tag
      and range processing.
    • Tests: 503 problem with no-store; 200 page whose body carries the CSP header's nonce and no placeholder; the asset's
      immutable cache header; 404 for a missing or unknown-type asset.
    • Pattern: src/NCode.Identity.OpenId.Authentication.Interactive/InteractiveBundleAssetEndpointHandler.cs.
    • Done when: $env:MSBUILDDISABLENODEREUSE = '1'; dotnet test tests/NCode.Identity.ManagementConsole.Tests/NCode.Identity.ManagementConsole.Tests.csproj -m:2 --filter "FullyQualifiedName~ManagementConsolePageHandlerTests|FullyQualifiedName~ManagementConsoleAssetHandlerTests" -- RunConfiguration.TreatNoTestsAsError=true

    [ ] 8. Compose the console in one call

    • Files: MC/IManagementConsoleBuilder.cs, MC/ManagementConsoleBuilder.cs, MC/DefaultRegistration.cs,
      MC/ManagementConsoleFrontendValidator.cs (all new), MC/PublicAPI.Unshipped.txt, MCT/DefaultRegistrationTests.cs (new)
    • Change: [PublicAPI] public interface IManagementConsoleBuilder : IServiceBuilder<ManagementConsole> { IIdentityBffBuilder IdentityBffBuilder { get; } }.
      internal sealed class ManagementConsoleBuilder : ServiceBuilder<ManagementConsole>, IManagementConsoleBuilder — its
      constructor (IServiceCollection serviceCollection, IConfiguration configuration) sets
      IdentityBffBuilder = serviceCollection.AddIdentityBff(configuration), binds ManagementConsoleOptions strictly from
      ManagementConsoleDefaults.ConfigurationSectionName with ValidateOnStart(), TryAddEnumerables
      IValidateOptions<ManagementConsoleOptions> → ManagementConsoleOptionsValidator and IValidateOptions<BffOptions>
      → ManagementConsoleFrontendValidator, and adds singletons ManagementConsoleBundleReader,
      ManagementConsoleBootstrapHandler, ManagementConsolePageHandler, ManagementConsoleAssetHandler.
      ManagementConsoleFrontendValidator fails with Bff:Frontends must configure the 'mgmt' frontend that serves the management console.
      [PublicAPI] public static class DefaultRegistration with extension(IServiceCollection serviceCollection) →
      public IManagementConsoleBuilder AddManagementConsole(IConfiguration configuration) (null guards).
    • Tests: the builder exposes the BFF builder; the handlers, reader and both validators are registered; the frontend
      validator's failure and success.
    • Pattern: src/NCode.Identity.Bff.Server/DefaultRegistration.cs, IdentityBffBuilder.cs, IIdentityBffBuilder.cs.
    • Done when: $env:MSBUILDDISABLENODEREUSE = '1'; dotnet test tests/NCode.Identity.ManagementConsole.Tests/NCode.Identity.ManagementConsole.Tests.csproj -m:2 --filter "FullyQualifiedName~DefaultRegistrationTests" -- RunConfiguration.TreatNoTestsAsError=true

    [ ] 9. Map the console's endpoints

    • Files: MC/ManagementConsoleEndpointRouteBuilderExtensions.cs (new), MC/PublicAPI.Unshipped.txt
    • Change: [PublicAPI] public static class ManagementConsoleEndpointRouteBuilderExtensions with
      extension(IEndpointRouteBuilder endpoints) → public RouteGroupBuilder MapManagementConsole() that, in order:
      calls endpoints.MapBffEndpoints() and endpoints.MapBffRemoteApis(); maps
      endpoints.MapBffRemoteApi(ManagementConsoleDefaults.ApiPath, managementApiUrl, api => { api.TokenRequirement = BffTokenRequirementNames.User; api.Resource = ManagementConsoleDefaults.ManagementApiResource; }).RequireBffFrontend(ManagementConsoleDefaults.FrontendName)
      where managementApiUrl is IOptions<ManagementConsoleOptions>.Value.ManagementApiUrl or, when it is null,
      throw new InvalidOperationException("ManagementConsole:ManagementApiUrl must be set.") (no null-forgiving operator);
      creates group = endpoints.MapGroup(string.Empty).AllowAnonymous(); maps on it GET BootstrapPath →
      ManagementConsoleBootstrapHandler.Handle (name ManagementConsole.Bootstrap), GET|HEAD /assets/{**asset} →
      ManagementConsoleAssetHandler.Handle (name ManagementConsole.Asset), and
      group.MapFallback("{*path:nonfile}", …PageHandler.HandleAsync).WithMetadata(new HttpMethodMetadata([HttpMethods.Get, HttpMethods.Head]))
      (name ManagementConsole.Page); excludes the group from the API description; returns the group. Resolve the handlers
      from context.RequestServices inside static lambdas, as MapBffEndpoints does. XML docs say what each route serves.
    • Pattern: src/NCode.Identity.Bff/Endpoints/BffEndpointRouteBuilderExtensions.cs.
    • Done when: $env:MSBUILDDISABLENODEREUSE = '1'; dotnet build src/NCode.Identity.ManagementConsole/NCode.Identity.ManagementConsole.csproj -m:2

    [ ] 10. Add the console's reference host

    • Files: MCH/NCode.Identity.ManagementConsole.Host.csproj, MCH/Program.cs, MCH/ManagementConsoleHostMarker.cs,
      MCH/Properties/AssemblyInfo.cs, MCH/Properties/launchSettings.json, MCH/appsettings.json,
      MCH/appsettings.Development.json (all new), NCode.Identity.slnx
    • Change: Microsoft.NET.Sdk.Web, net10.0, IsPackable false, a new UserSecretsId; references MC and
      NCode.Identity.SharedState.Valkey. Program.cs (composition only, header comment like the OP host's): create the
      builder; builder.Logging.ClearProviders().AddConsole().AddEventSourceLogger();
      var managementConsole = builder.Services.AddManagementConsole(builder.Configuration);
      managementConsole.IdentityBffBuilder.SharedStateLibraryBuilder.AddValkey(builder.Configuration);
      builder.Services.AddHealthChecks(); build; UseHttpsRedirection(), UseBffSecurityHeaders(), UseBff(),
      UseAuthentication(), UseAuthorization(), MapManagementConsole(), /health/live (no checks) and /health/ready
      (tag HealthCheckTags.Readiness) exactly as src/NCode.Identity.OpenId.Host/Program.cs; await app.RunAsync().
      Marker like HostApiMarker.cs; AssemblyInfo.cs grants NCode.Identity.OpenId.Integration.Tests. Launch profile
      NCode.Identity.ManagementConsole.Host: Project, ASPNETCORE_ENVIRONMENT=Development, DOTNET_ReadyToRun=0,
      applicationUrl https://localhost:5005;http://localhost:5004. appsettings.json: Logging as the OP host, and
      Bff:Frontends:mgmt = ClientId mgmt-console, ClientAuthentication { Method: private_key_jwt, KeyRing: mgmt-client },
      Scopes [profile, email, offline_access, read:clients], Resources [urn:ncode:management], ReturnUrls ["/"],
      UserClaims [preferred_username, email], UserInfo { Load: true }, DPoP { Mode: required, KeyRing: mgmt-dpop },
      Jwks { Enabled: true }. appsettings.Development.json: Bff:Frontends:mgmt Authority https://localhost:5001,
      PublicOrigin https://localhost:5005; ManagementConsole ManagementApiUrl https://localhost:5001/api,
      BundleRootPath ../frontend/apps/mgmt/dist, Features { dcr: true }, Branding { DisplayName: "NCode.Identity (dev)", Mode: light }.
      Run dotnet sln NCode.Identity.slnx add src/NCode.Identity.ManagementConsole.Host --solution-folder src.
    • Done when: $env:MSBUILDDISABLENODEREUSE = '1'; dotnet build src/NCode.Identity.ManagementConsole.Host/NCode.Identity.ManagementConsole.Host.csproj -m:2

    [ ] 11. Prove the console host over HTTP

    • Files: IT/NCode.Identity.OpenId.Integration.Tests.csproj, IT/Infrastructure/ManagementConsoleApplicationFactory.cs (new),
      IT/ManagementConsole/ManagementConsoleHostTests.cs (new)
    • Change: reference MCH. ManagementConsoleApplicationFactory : WebApplicationFactory<ManagementConsoleHostMarker>
      with constructor arguments HostApplicationFactory? provider = null, ECDsa? clientKey = null: Development environment; configuration
      overlay Bff:Frontends:mgmt:Authority = HostApplicationFactory.TestServerIssuer, ManagementConsole:ManagementApiUrl
      = http://localhost/api, ManagementConsole:BundleRootPath = a temp subdirectory holding an index.html with
      <meta property="csp-nonce" nonce="__NCODE_CSP_NONCE__">, an empty <div id="root"></div> and a nonce'd module
      script, plus assets/index-test.js; test services: keyed ISecretKeyCollectionProvider mgmt-client and
      mgmt-dpop from P-256 ECDsa keys (CreateEcc(new KeyMetadata { KeyId = "mgmt-client" }, key); the client key is
      clientKey when given, otherwise one the factory creates and owns), and, when provider is set, AddHttpClient(OpenIdClientDefaults.HttpClientName).ConfigurePrimaryHttpMessageHandler(() => provider.Server.CreateHandler()).
      CreateBrowserClient() returns a client with base https://localhost:5005/, no redirects, no cookie handling.
      Dispose the keys and the directory in DisposeAsync.
    • Tests: GetPage_AtAConsoleRoute_ServesTheBundleUnderAFreshNonce (/tenants/acme/clients: 200, nonce in body equals
      the CSP's, no placeholder, frame-ancestors 'none', form-action 'self' http://localhost, no-store); asset →
      immutable; GetBootstrap_Always_PublishesTheBffDocument (/management, /bff, X-CSRF, 1, branding display name);
      /bff/user and /management/api/caller with X-CSRF: 1 and no cookie → 401; /management/api/caller without the
      header → 403.
    • Pattern: IT/Infrastructure/HostApplicationFactory.cs, IT/Bff/BffTestHost.cs.
    • Done when: $env:MSBUILDDISABLENODEREUSE = '1'; dotnet test tests/NCode.Identity.OpenId.Integration.Tests/NCode.Identity.OpenId.Integration.Tests.csproj -m:2 --filter "FullyQualifiedName~ManagementConsoleHostTests" -- RunConfiguration.TreatNoTestsAsError=true

    [ ] 12. Declare confidential BFF clients in the developer seed's options

    • Files: src/NCode.Identity.Server/DevelopmentEnvironment/DeveloperBffClientSeed.cs (new),
      src/NCode.Identity.Server/DevelopmentEnvironment/DeveloperInteractiveSeedOptions.cs,
      src/NCode.Identity.Server/DevelopmentEnvironment/DeveloperInteractiveSeedRegistration.cs,
      src/NCode.Identity.Server/PublicAPI.Unshipped.txt, tests/NCode.Identity.Server.Tests/DevelopmentEnvironment/DeveloperInteractiveSeedOptionsTests.cs
    • Change: [PublicAPI] public sealed class DeveloperBffClientSeed: string? ClientId, string? Jwks (JSON text of the
      public JSON Web Key Set registered by value; supplied from outside the repository, such as the AppHost),
      string[] RedirectUris = [], string[] PostLogoutRedirectUris = [], string? BackchannelLogoutUri,
      bool ShouldSeed => !string.IsNullOrEmpty(ClientId) && !string.IsNullOrEmpty(Jwks) && RedirectUris.Length > 0.
      Its XML summary: a development-only confidential BFF client (private_key_jwt, PAR, DPoP-bound tokens, back-channel
      logout) whose keys are registered by value because the server's outbound guard never reaches a loopback jwks_uri.
      DeveloperInteractiveSeedOptions gains public IList<DeveloperBffClientSeed> BffClients { get; set; } = []; (docs as
      SpaClients), and its class summary names confidential BFF clients beside the public SPA clients.
      AddDeveloperInteractiveSeed's hasSeedData also counts options.BffClients.Count > 0; its XML docs and the
      registration's class summary say it seeds the BFF clients too.
    • Tests: BffClients_ByDefault_IsEmpty, BffClients_WhenBoundFromConfiguration_HoldsExactlyTheConfiguredClients, and
      ShouldSeed false without a client id, a key set or a redirect URI, true with all three.
    • Pattern: DeveloperSpaClientSeed and its tests.
    • Done when: $env:MSBUILDDISABLENODEREUSE = '1'; dotnet test tests/NCode.Identity.Server.Tests/NCode.Identity.Server.Tests.csproj -m:2 --filter "FullyQualifiedName~DeveloperInteractiveSeedOptionsTests" -- RunConfiguration.TreatNoTestsAsError=true

    [ ] 13. Seed the configured BFF clients

    • Files: src/NCode.Identity.Server/DevelopmentEnvironment/DeveloperInteractiveSeedHandler.cs,
      tests/NCode.Identity.Server.Tests/DevelopmentEnvironment/DeveloperInteractiveSeedHandlerTests.cs
    • Change: the handler's primary constructor also takes IClientKeyImporter clientKeyImporter and TimeProvider timeProvider.
      HandleAsync seeds each BffClients entry whose ShouldSeed is true, after the SPA clients, through a new
      SeedBffClientAsync: skip an existing client; settings redirect_uris, post_logout_redirect_uris (when any),
      backchannel_logout_uri + backchannel_logout_session_required = true (when set), token_endpoint_auth_method =
      private_key_jwt, grant_types_supported = [authorization_code, refresh_token],
      require_pushed_authorization_requests = true, require_dpop_bound_access_tokens = true (use the
      OpenIdSettingNames / OpenIdProtocol constants); SeededClientValidator.EnsureCanBeSeededAsync("developer BFF client", …);
      secrets = the keys clientKeyImporter.TryImportRegisteredKeySet(JsonElement.Parse(jwks), timeProvider.GetUtcNow(), out keys, out error)
      imports; an unparsable or refused key set throws InvalidOperationException naming the client, as the conformance seed does.
      Update the class summary to name the BFF clients.
    • Tests: a seeded BFF client's stored settings and secrets; an existing client is left alone; an invalid key set throws
      naming the client; an entry that should not seed is skipped. Follow the file's strict-mock scaffold.
    • Pattern: SeedClientAsync / ImportKeys in src/NCode.Identity.Server/ConformanceEnvironment/ConformanceSeedHandler.cs.
    • Done when: $env:MSBUILDDISABLENODEREUSE = '1'; dotnet test tests/NCode.Identity.Server.Tests/NCode.Identity.Server.Tests.csproj -m:2 --filter "FullyQualifiedName~DeveloperInteractiveSeedHandlerTests" -- RunConfiguration.TreatNoTestsAsError=true

    [ ] 14. Point the OP's Development overlay at the console host

    • Files: src/NCode.Identity.OpenId.Host/appsettings.Development.json, IT/Endpoints/FrontChannelCorsTests.cs,
      src/NCode.Identity.Server/Frontend/FrontendBootstrapOptions.cs, src/NCode.Identity.Server/Frontend/FrontendIssuerResolver.cs
    • Change: in the overlay, delete Frontend:ClientId and Frontend:Features, the mgmt-spa entry of
      DeveloperInteractiveSeed:SpaClients and https://localhost:5173 from Cors:AllowedOrigins; add
      DeveloperInteractiveSeed:BffClients = [{ "ClientId": "mgmt-console", "RedirectUris": ["https://localhost:5005/bff/callback"], "PostLogoutRedirectUris": ["https://localhost:5005/"], "BackchannelLogoutUri": "https://localhost:5005/bff/backchannel-logout" }]
      (no Jwks: the AppHost supplies it). In FrontChannelCorsTests set AllowedOrigin to https://localhost:5174. In
      the XML docs of FrontendBootstrapOptions (Issuer, Surfaces) and FrontendIssuerResolver, replace "served to the
      Management console" / "which serves the Management console" with wording that the default document serves every
      surface that has no Surfaces entry, and that the root tenant serves the surfaces under the dynamic strategies.
    • Done when: $env:MSBUILDDISABLENODEREUSE = '1'; dotnet test tests/NCode.Identity.OpenId.Integration.Tests/NCode.Identity.OpenId.Integration.Tests.csproj -m:2 --filter "FullyQualifiedName~DeveloperSpaClientSeedingTests|FullyQualifiedName~FrontChannelCorsTests" -- RunConfiguration.TreatNoTestsAsError=true

    [ ] 15. Log in through the OP into the console host, end to end

    • Files: IT/Infrastructure/ManagementConsoleSeedingHostApplicationFactory.cs (new),
      IT/ManagementConsole/ManagementConsoleEndToEndTests.cs (new)
    • Change: ManagementConsoleSeedingHostApplicationFactory(string consoleClientJwks) : HostApplicationFactory with
      SeedsDeveloperClients => true and a configuration overlay DeveloperInteractiveSeed:BffClients:0:Jwks =
      consoleClientJwks (pattern ConfiguredHostApplicationFactory.cs). The test creates a P-256 ECDsa client key, the
      JWKS {"keys":[{"kty":"EC","crv":"P-256","x":…,"y":…,"kid":"mgmt-client","use":"sig","alg":"ES256"}]} from
      key.ExportParameters(false) (Base64Url), the OP factory with that JWKS, and the console factory with
      provider = the OP factory and clientKey = the key; it seeds a local account (SeedLocalAccountAsync), signs the browser in at the OP
      (InteractiveLogin.SignInAsync), then: GET /bff/login?returnUrl=%2Fteams → 302 to the OP; the authorize request with
      the OP session cookie → 302 to https://localhost:5005/bff/callback…; the callback → 302 to /teams with a
      __Host-Http-bff.mgmt cookie; GET /bff/user with the cookie and X-CSRF: 1 → 200 whose JSON has sub and logoutSid.
      Keep cookies in a dictionary as BffTestHost.SendAsync does.
    • Pattern: Login_ThenUserAndApi_Succeed in IT/Bff/BffEndToEndTests.cs.
    • Done when: $env:MSBUILDDISABLENODEREUSE = '1'; dotnet test tests/NCode.Identity.OpenId.Integration.Tests/NCode.Identity.OpenId.Integration.Tests.csproj -m:2 --filter "FullyQualifiedName~ManagementConsoleEndToEndTests" -- RunConfiguration.TreatNoTestsAsError=true

    [ ] 16. Keep the console's Development configuration and the OP's seed in step

    • Files: IT/ManagementConsole/ManagementConsoleDevelopmentOverlayTests.cs (new)
    • Change: one class with a DeveloperSeedingHostApplicationFactory class fixture and a ManagementConsoleApplicationFactory
      (step 11; it overlays only the authority, the API URL and the bundle root, so ClientId, PublicOrigin and
      PathPrefix come from the console host's own appsettings*.json). Read the console's
      IOptions<BffOptions>.Value.Frontends["mgmt"] and the OP's DeveloperInteractiveSeedOptions. Assert that the OP's
      BffClients holds one entry whose ClientId equals the console's ClientId, whose RedirectUris contain
      PublicOrigin + PathPrefix + "/callback", whose PostLogoutRedirectUris contain PublicOrigin + "/", and whose
      BackchannelLogoutUri equals PublicOrigin + PathPrefix + "/backchannel-logout".
    • Pattern: IT/Endpoints/DeveloperSpaClientSeedingTests.cs.
    • Done when: $env:MSBUILDDISABLENODEREUSE = '1'; dotnet test tests/NCode.Identity.OpenId.Integration.Tests/NCode.Identity.OpenId.Integration.Tests.csproj -m:2 --filter "FullyQualifiedName~ManagementConsoleDevelopmentOverlayTests" -- RunConfiguration.TreatNoTestsAsError=true

    [ ] 17. Generate the console's development keys per machine

    • Files: src/NCode.Identity.OpenId.LocalDev/DevelopmentSecrets/DevelopmentKeyFile.cs (new),
      tests/NCode.Identity.OpenId.LocalDev.Tests/DevelopmentSecrets/DevelopmentKeyFileTests.cs (new)
    • Change: internal static class DevelopmentKeyFile with internal static string EnsureEcPrivateKey(string directory, string fileName)
      — the same create-once contract as DevelopmentSecretFile.Ensure (temporary file, CreateNew, user-only Unix mode,
      DevelopmentSecretFile.MoveIntoPlace, an empty file is replaced), writing a P-256 key as PKCS#8 PEM through
      ECDsa.TryExportPkcs8PrivateKeyPem into a rented sensitive buffer; and internal static string ReadPublicJwks(string path)
      — the JSON text {"keys":[{"kty":"EC","crv":"P-256","x":…,"y":…,"kid":<file name without extension>,"use":"sig","alg":"ES256"}]}
      written with Utf8JsonWriter and Base64Url coordinates. XML docs say why: the OP registers the console's public key
      by value because its outbound guard never fetches a loopback jwks_uri.
    • Tests: creates once and reuses; replaces an empty file; the JWKS parses, its key id is the file stem and its
      coordinates match the PEM's public key.
    • Pattern: DevelopmentSecretFile.cs and DevelopmentSecretFileTests.cs.
    • Done when: $env:MSBUILDDISABLENODEREUSE = '1'; dotnet test tests/NCode.Identity.OpenId.LocalDev.Tests/NCode.Identity.OpenId.LocalDev.Tests.csproj -m:2 --filter "FullyQualifiedName~DevelopmentKeyFileTests" -- RunConfiguration.TreatNoTestsAsError=true

    [ ] 18. Run the console host from the AppHost

    • Files: src/NCode.Identity.OpenId.LocalDev/Program.cs, src/NCode.Identity.OpenId.LocalDev/NCode.Identity.OpenId.LocalDev.csproj
    • Change: reference MCH. After the admin password: consoleClientKeyPath = DevelopmentKeyFile.EnsureEcPrivateKey(developmentSecretsDirectory, "mgmt-client.pem"),
      consoleDPoPKeyPath = … "mgmt-dpop.pem", consoleClientJwks = DevelopmentKeyFile.ReadPublicJwks(consoleClientKeyPath);
      api gets .WithEnvironment("DeveloperInteractiveSeed__BffClients__0__Jwks", consoleClientJwks). Replace the
      AddViteApp("mgmt", …) block with builder.AddJavaScriptApp("mgmt", "../frontend/apps/mgmt").WithPnpm(install: false);
      and add builder.AddProject<Projects.NCode_Identity_ManagementConsole_Host>("console").WithHttpHealthCheck("/health/ready").WithEnvironment("Bff__KeyRings__mgmt-client__PemFiles__0", consoleClientKeyPath).WithEnvironment("Bff__KeyRings__mgmt-dpop__PemFiles__0", consoleDPoPKeyPath).WaitFor(api).WithExternalHttpEndpoints();.
      Rewrite the comments: mgmt is a build-watcher whose dist the console host serves (like auth); console is the
      console's BFF host on https://localhost:5005, a confidential client of the api whose key the AppHost generates;
      the playground comment describes its own pins without naming mgmt; the header comment of the key files sits beside the
      root-key comment. Keep the file declarative.
    • Done when: $env:MSBUILDDISABLENODEREUSE = '1'; dotnet build src/NCode.Identity.OpenId.LocalDev/NCode.Identity.OpenId.LocalDev.csproj -m:2

    [ ] 19. Add the BFF-served surface preset to @ncode/vite-config

    • Files: FE/packages/vite-config/src/preset.ts, FE/packages/vite-config/src/preset.test.ts, FE/packages/vite-config/package.json
    • Change: add export type BffServedSurfaceConfigOptions = { readonly coverage: CoverageFloors }; and
      export function defineBffServedSurfaceConfig({ coverage }: BffServedSurfaceConfigOptions): ViteUserConfig returning
      { base: '/', html: { cspNonce: CSP_NONCE_PLACEHOLDER }, plugins: [react(), failOnOversizedChunks()], build: buildOptions(), test: testGates({ environment: 'jsdom', coverage }) },
      documented as the configuration of a surface its own BFF host serves at the root of its origin, under the page's CSP
      nonce, with no dev server (its dev script rebuilds dist). Delete API_PATH and the /api/ proxy entry from
      defineStandaloneSurfaceConfig and its doc sentence about the management API (no standalone surface calls it); the
      CSP_NONCE_PLACEHOLDER doc names the console host's ManagementConsoleDefaults.NoncePlaceholder beside
      InteractiveBundleShell.NoncePlaceholder. package.json description: "…the test gates and the standalone,
      backend-served and BFF-served surface presets."
    • Tests: the standalone proxy holds only /config/bootstrap.json; a describe('defineBffServedSurfaceConfig') block
      mirroring the backend-served one (base /, nonce placeholder, no server, jsdom test gates).
    • Done when: Push-Location src/frontend; npx -y pnpm@10.34.7 exec turbo run typecheck lint --filter=@ncode/vite-config; $a = $LASTEXITCODE; npx -y pnpm@10.34.7 --filter @ncode/vite-config exec vitest run --coverage; $b = $LASTEXITCODE; Pop-Location; if ($a -or $b) { exit 1 } else { exit 0 }

    [ ] 20. Model the BFF runtime configuration in @ncode/config

    • Files: FE/packages/config/src/types.ts, FE/packages/config/src/parse.ts, FE/packages/config/src/parse.test.ts
    • Change: types.ts: rename the resolved OpenID type to OpenIdRuntimeConfig and add readonly kind: 'openid'; add
      AntiforgeryHeader = { readonly name: string; readonly value: string }, BffConfig = { readonly basePath: string; readonly antiforgeryHeader: AntiforgeryHeader },
      BffBootstrapDocument = { readonly apiBaseUrl: string; readonly bff: BffConfig; readonly features?: …; readonly branding?: BrandingConfig },
      BffRuntimeConfig = { readonly kind: 'bff'; readonly apiBaseUrl: string; readonly bff: BffConfig; readonly features: Readonly<Record<string, boolean>>; readonly branding?: BrandingConfig }
      and export type RuntimeConfig = OpenIdRuntimeConfig | BffRuntimeConfig;. Update the module and BootstrapDocument
      docs (an OpenID surface's document names the public client; a BFF-served surface's document names its BFF and no
      token-related value). parse.ts: mergeRuntimeConfig returns OpenIdRuntimeConfig with kind: 'openid'; add
      parseBffBootstrapDocument(raw: unknown): BffBootstrapDocument (object required; apiBaseUrl through
      requireNonEmpty + normalizeBaseUrl; bff object required — error Bootstrap document is missing required field 'bff'.;
      bff.basePath non-empty, starting with /, normalized; bff.antiforgeryHeader.name and .value non-empty, with field
      names bff.basePath, bff.antiforgeryHeader.name, bff.antiforgeryHeader.value; features and branding as
      parseBootstrapDocument reads them) and toBffRuntimeConfig(document: BffBootstrapDocument): BffRuntimeConfig.
    • Tests: kind: 'openid' in every merged expectation; BFF parsing of a full document, each missing field, a base path
      without a leading slash, and toBffRuntimeConfig defaults (features: {}, no branding).
    • Done when: Push-Location src/frontend/packages/config; npx -y pnpm@10.34.7 exec vitest run src/parse.test.ts; $c = $LASTEXITCODE; Pop-Location; exit $c

    [ ] 21. Load a BFF-served surface's configuration without discovery

    • Files: FE/packages/config/src/loader.ts, FE/packages/config/src/loader.test.ts, FE/packages/config/src/index.ts
    • Change: loadRuntimeConfig returns Promise<OpenIdRuntimeConfig>. Add
      loadBffRuntimeConfig(options: Omit<LoadRuntimeConfigOptions, 'surface'> = {}): Promise<BffRuntimeConfig> — the same
      fetch and origin resolution, fetching only ${origin}${BOOTSTRAP_PATH} (no surface, no discovery), then
      toBffRuntimeConfig(parseBffBootstrapDocument(raw)); and
      createBffRuntimeConfigLoader(options: Omit<LoadRuntimeConfigOptions, 'surface' | 'signal'> = {}): RuntimeConfigLoader.
      RuntimeConfigLoader stays (signal: AbortSignal) => Promise<RuntimeConfig> (the union). Export the new types and
      functions from index.ts (AntiforgeryHeader, BffBootstrapDocument, BffConfig, BffRuntimeConfig,
      OpenIdRuntimeConfig, parseBffBootstrapDocument, toBffRuntimeConfig, loadBffRuntimeConfig,
      createBffRuntimeConfigLoader).
    • Tests: the BFF loader fetches exactly one URL, honors the origin default and the abort signal, surfaces an HTTP
      failure, and the bound loader passes its signal.
    • Done when: Push-Location src/frontend; npx -y pnpm@10.34.7 exec turbo run typecheck lint --filter=@ncode/config; $a = $LASTEXITCODE; npx -y pnpm@10.34.7 --filter @ncode/config exec vitest run --coverage; $b = $LASTEXITCODE; Pop-Location; if ($a -or $b) { exit 1 } else { exit 0 }

    [ ] 22. Narrow the Playground to the OpenID configuration

    • Files: FE/apps/playground/src/App.tsx, FE/apps/playground/src/flow.ts, FE/apps/playground/src/containers/FlowExplorer.tsx,
      FE/apps/playground/src/App.test.tsx, FE/apps/playground/src/flow.test.ts, FE/apps/playground/src/containers/FlowExplorer.test.tsx
    • Change: every RuntimeConfig type in these files becomes OpenIdRuntimeConfig; App renders nothing unless
      runtime?.kind === 'openid'; every runtime literal in the tests gains kind: 'openid'; App.test.tsx adds a case
      where a BFF runtime renders nothing.
    • Done when: Push-Location src/frontend; npx -y pnpm@10.34.7 exec turbo run typecheck lint --filter=@ncode/app-playground; $a = $LASTEXITCODE; npx -y pnpm@10.34.7 --filter @ncode/app-playground exec vitest run --coverage; $b = $LASTEXITCODE; Pop-Location; if ($a -or $b) { exit 1 } else { exit 0 }

    [ ] 23. Narrow the conformance dashboard and the boot gate's tests

    • Files: FE/apps/conformance/src/App.tsx, FE/apps/conformance/src/containers/ConformanceDashboard.tsx,
      FE/apps/conformance/src/App.test.tsx, FE/apps/conformance/src/containers/ConformanceDashboard.test.tsx,
      FE/packages/shell/src/BootstrapGate.test.tsx
    • Change: as step 22 for the conformance app (OpenIdRuntimeConfig, runtime?.kind === 'openid', kind: 'openid'
      literals, a BFF-runtime case in App.test.tsx); the gate's test runtime literal gains kind: 'openid', and the
      BootstrapGate doc comment ("there is no API base URL or issuer") reads "no API base URL" only.
    • Done when: Push-Location src/frontend; npx -y pnpm@10.34.7 exec turbo run typecheck lint --filter=@ncode/app-conformance --filter=@ncode/shell; $a = $LASTEXITCODE; npx -y pnpm@10.34.7 --filter @ncode/app-conformance --filter @ncode/shell exec vitest run --coverage; $b = $LASTEXITCODE; Pop-Location; if ($a -or $b) { exit 1 } else { exit 0 }

    [ ] 24. Share the CSP nonce reader through @ncode/ui

    • Files: FE/packages/ui/src/cspNonce.ts (new; the content of FE/apps/auth/src/cspNonce.ts),
      FE/packages/ui/src/cspNonce.test.ts (new; the content of FE/apps/auth/src/cspNonce.test.ts), FE/packages/ui/src/index.ts,
      FE/apps/auth/src/mount.tsx, FE/apps/auth/src/cspNonce.ts and FE/apps/auth/src/cspNonce.test.ts (both deleted)
    • Change: move the two files with git mv; export CSP_NONCE_SELECTOR and readCspNonce from @ncode/ui's index;
      mount.tsx imports readCspNonce from @ncode/ui. The doc comment says the host that serves a bundle writes the
      nonce, so it holds for the OP and the console host.
    • Done when: Push-Location src/frontend; npx -y pnpm@10.34.7 exec turbo run typecheck lint --filter=@ncode/ui --filter=@ncode/app-auth; $a = $LASTEXITCODE; npx -y pnpm@10.34.7 --filter @ncode/ui --filter @ncode/app-auth exec vitest run --coverage; $b = $LASTEXITCODE; Pop-Location; if ($a -or $b) { exit 1 } else { exit 0 }
  2. polewskm commented on Nov 6, 2026

    @polewskm
    ContributorAuthor

    Plan snapshot, part 2 of 2 (steps 25–48, the review checklist and the follow-ups). The working copy is PLAN.md in the plan's checkout; this comment is not updated as steps run.

    Plan: feat(mgmt): serve the management console through its own BFF host (48 steps) — part 2 of 2

    [ ] 25. Hold the BFF session in @ncode/store

    • Files: FE/packages/store/src/sessionSlice.ts, FE/packages/store/src/sessionSlice.test.ts,
      FE/packages/store/src/bffSession.ts, FE/packages/store/src/bffSession.test.ts (all new)
    • Change: sessionSlice.ts: BffSessionLifetime = { readonly expiresAt: number; readonly idleExpiresAt: number }
      (epoch seconds); BffUser = { readonly sub: string; readonly name?: string; readonly claims: Readonly<Record<string, unknown>>; readonly session: BffSessionLifetime; readonly logoutUrl: string; readonly logoutSid: string };
      SessionStatus = 'unknown' | 'signed-in' | 'signed-out'; SessionState = { readonly status: SessionStatus; readonly user: BffUser | null };
      sessionSlice (name session, initial { status: 'unknown', user: null }) with sessionStarted(user) →
      { status: 'signed-in', user } and sessionEnded() → { status: 'signed-out', user: null } (return fresh state, as
      configSlice does). bffSession.ts: parseBffUser(raw: unknown): BffUser — throws
      Error('The session probe answered with a malformed user.') unless sub, logoutUrl, logoutSid are non-empty
      strings, session.expiresAt and session.idleExpiresAt are numbers and claims is an object (name kept only when
      a non-empty string); fetchBffUser(runtime: BffRuntimeConfig, fetchFn: typeof fetch = fetch): Promise<BffUser | null>
      — GET ${runtime.bff.basePath}/user with { credentials: 'same-origin', cache: 'no-store', headers: { accept: 'application/json', [name]: value } }
      from runtime.bff.antiforgeryHeader; 401 → null; ok → parseBffUser(await response.json()); otherwise throw
      Error(\The session probe failed (HTTP ${status}).`)`. Document that the probe never slides the session's idle window (ADR-1434).
    • Tests: the reducer transitions; every malformed-user branch; the probe's URL, init and header; 200, 401 and 500.
    • Done when: Push-Location src/frontend/packages/store; npx -y pnpm@10.34.7 exec vitest run src/sessionSlice.test.ts src/bffSession.test.ts; $c = $LASTEXITCODE; Pop-Location; exit $c

    [ ] 26. Call the management API with the session cookie, never a token

    • Files: FE/packages/store/src/baseQuery.ts, FE/packages/store/src/store.ts, FE/packages/store/src/index.ts,
      FE/packages/store/src/baseQuery.test.ts, FE/packages/store/src/store.test.ts, FE/packages/store/src/configSlice.test.ts,
      FE/packages/store/src/authSlice.ts and FE/packages/store/src/authSlice.test.ts (both deleted)
    • Change: ApiRootState = { readonly config: { readonly runtime: RuntimeConfig | null } }. dynamicBaseQuery: null
      runtime → the existing CUSTOM_ERROR; runtime.kind !== 'bff' → CUSTOM_ERROR 'The management API is called only from a surface its BFF serves.';
      otherwise fetchBaseQuery({ baseUrl: \${runtime.apiBaseUrl}/`, credentials: 'same-origin', prepareHeaders: (headers) => { headers.set(name, value); return headers; } })with the anti-forgery header, and after the callif (result.error?.status === 401) api.dispatch(sessionEnded());. Rewrite its doc comments (the cookie and the header authenticate the call; the browser holds no token). The root reducer holds session: sessionSlice.reducerin place of the auth slice;index.tsexportssessionSlice, sessionStarted, sessionEnded, SessionState, SessionStatus, BffUser, BffSessionLifetime, parseBffUser, fetchBffUser and no auth symbol. Delete the auth slice files (git rm`).
    • Tests: base query — no fetch without runtime, CUSTOM_ERROR for an OpenID runtime, base URL resolved at request
      time, the anti-forgery header sent and no authorization header, credentials same-origin, a 401 ends the session,
      a 500 does not; store — initial session state and preloaded session; configSlice.test.ts literals gain kind.
    • Done when: Push-Location src/frontend/packages/store; npx -y pnpm@10.34.7 exec vitest run src/baseQuery.test.ts src/store.test.ts src/configSlice.test.ts src/sessionSlice.test.ts src/bffSession.test.ts; $c = $LASTEXITCODE; Pop-Location; exit $c

    [ ] 27. Rebase the management API's tests on the BFF runtime

    • Files: FE/packages/store/src/managementApi.test.ts, FE/packages/store/src/apiError.test.ts
    • Change: the runtime literal becomes
      const runtime: BffRuntimeConfig = { kind: 'bff', apiBaseUrl: 'https://api.test', bff: { basePath: '/bff', antiforgeryHeader: { name: 'X-CSRF', value: '1' } }, features: {} };;
      createStore() preloads only config; CapturedRequest.authorization becomes antiforgery read from
      request.headers.get('x-csrf'), and every expectation authorization: 'Bearer token-123' becomes antiforgery: '1'.
      apiError.test.ts changes only where it builds a runtime or an auth state.
    • Done when: Push-Location src/frontend; npx -y pnpm@10.34.7 exec turbo run typecheck lint --filter=@ncode/store; $a = $LASTEXITCODE; npx -y pnpm@10.34.7 --filter @ncode/store exec vitest run --coverage; $b = $LASTEXITCODE; Pop-Location; if ($a -or $b) { exit 1 } else { exit 0 }

    [ ] 28. Keep only the Playground's protocol helpers in @ncode/auth

    • Files: FE/packages/auth/src/client.ts, FE/packages/auth/src/client.test.ts, FE/packages/auth/src/renew.ts,
      FE/packages/auth/src/renew.test.ts (all deleted), FE/packages/auth/src/jwt.ts, FE/packages/auth/src/jwt.test.ts,
      FE/packages/auth/src/index.ts, FE/packages/auth/package.json, FE/packages/auth/src/flow.test.ts
    • Change: git rm the client and renewal modules and their tests; delete getSubject and its tests from jwt.ts;
      in flow.test.ts the sample client id mgmt-spa becomes playground-spa;
      index.ts exports only pkce.js, flow.js and decodeJwtClaims; package.json description: "Framework-free OAuth
      2.0 authorization-code + PKCE protocol helpers for the Playground's public client." Only @ncode/app-mgmt imports
      the deleted symbols, and steps 29–31 remove those imports.
    • Done when: Push-Location src/frontend; npx -y pnpm@10.34.7 exec turbo run typecheck lint --filter=@ncode/auth --filter=@ncode/app-playground; $a = $LASTEXITCODE; npx -y pnpm@10.34.7 --filter @ncode/auth exec vitest run --coverage; $b = $LASTEXITCODE; Pop-Location; if ($a -or $b) { exit 1 } else { exit 0 }

    [ ] 29. Add the console's session helpers

    • Files: MG/src/session.ts, MG/src/session.test.ts (new)
    • Change: export SESSION_POLL_INTERVAL_MS = 60_000, SESSION_CHANNEL_NAME = 'ncode.mgmt.session',
      SESSION_ENDED_MESSAGE = 'ended', SESSION_ENDED_NOTICE = 'Your session ended. Sign in to continue.', and:
      loginUrl(runtime: BffRuntimeConfig, returnPath: string): string → ${runtime.bff.basePath}/login?returnUrl=${encodeURIComponent(returnPath)};
      submitLogout(user: BffUser, doc: Document = document): void — appends to doc.body a hidden
      <form method="post" action={user.logoutUrl}> with hidden inputs sid = user.logoutSid and returnUrl = /, and
      calls form.submit() (a form post is a top-level navigation that follows the redirect to the OP);
      readSignInError(search: string): string | undefined → the error query parameter, if any;
      signInErrorMessage(code: string): string → Sign-in failed (${code}).;
      nextProbeDelayMs(user: BffUser, now: number = Date.now()): number →
      Math.max(0, Math.min(user.session.idleExpiresAt, user.session.expiresAt) * 1000 - now) + 1000;
      openSessionChannel(onEnded: () => void): { close(): void } — when globalThis.BroadcastChannel exists, a channel
      named SESSION_CHANNEL_NAME that calls onEnded for SESSION_ENDED_MESSAGE; otherwise a no-op;
      announceSessionEnded(): void — opens a channel, posts SESSION_ENDED_MESSAGE, closes it (no-op without the API).
      A header comment cites ADR-1434 and ADR-1438.
    • Tests: each function, the form's method/action/inputs (spy on HTMLFormElement.prototype.submit), the channel with
      a fake BroadcastChannel installed by vi.stubGlobal (message delivered, other message ignored, close) and without one.
    • Done when: Push-Location src/frontend/apps/mgmt; npx -y pnpm@10.34.7 exec vitest run src/session.test.ts; $c = $LASTEXITCODE; Pop-Location; exit $c

    [ ] 30. Gate the console on its BFF session

    • Files: MG/src/SessionGate.tsx, MG/src/SessionGate.test.tsx (new), MG/src/AuthGate.tsx, MG/src/AuthGate.test.tsx,
      MG/src/auth.ts, MG/src/auth.test.ts (four deleted with git rm)
    • Change: export function SessionGate({ children }: { children: ReactNode }). Reads config.runtime and session.
      A runtime whose kind is not 'bff' renders <BootstrapScreen error="The management console must be served by its BFF host." />.
      On mount it probes (fetchBffUser(runtime) → sessionStarted(user) or sessionEnded(); a thrown probe sets the
      local error 'Unable to check your session.'); results after unmount are ignored. The ?error= code is read once
      (useState initializer with readSignInError(window.location.search)) and removed from the address bar with
      window.history.replaceState(null, '', window.location.pathname). While signed-in: an interval probe every
      SESSION_POLL_INTERVAL_MS, a timeout probe after nextProbeDelayMs(user), a visibilitychange listener that probes
      when document.visibilityState === 'visible', and openSessionChannel(...) whose callback marks a ref
      endedByBroadcast and dispatches sessionEnded(); all cleaned up. A transition from signed-in to signed-out
      that the channel did not cause calls announceSessionEnded(); either way the gate remembers that a session ended.
      Render: signed-in → children; unknown → <SignInScreen isBusy …/>; signed-out → <SignInScreen …/> with
      error = the sign-in error message, else SESSION_ENDED_NOTICE after an ended session, else the probe error; its
      onSignIn sets busy and calls window.location.assign(loginUrl(runtime, window.location.pathname)). Pass
      productName from the runtime's branding as AuthGate does.
    • Tests (fake timers where needed, fetch stubbed with vi.stubGlobal, window.location replaced as App.test.tsx
      does): busy while probing; children after a user; sign-in after a 401 and the login navigation with returnUrl;
      the ?error= message and its removal; the probe-failure message; the BFF-host error for an OpenID runtime; the poll,
      the expiry probe and the visibility probe; a broadcast ends the session with the notice and no re-announce; a
      sessionEnded() dispatched by the store announces once.
    • Pattern: MG/src/AuthGate.tsx (structure, SignInScreen props) and MG/src/AuthGate.test.tsx (setup).
    • Done when: Push-Location src/frontend/apps/mgmt; npx -y pnpm@10.34.7 exec vitest run src/SessionGate.test.tsx src/session.test.ts; $c = $LASTEXITCODE; Pop-Location; exit $c

    [ ] 31. Build the console as a BFF-served bundle

    • Files: MG/package.json, MG/vite.config.ts, MG/src/main.tsx, FE/pnpm-lock.yaml
    • Change: package.json: "dev": "vite build --watch", delete the preview script, delete the @ncode/auth
      dependency, add "@emotion/cache": "^11.14.0" (the range @ncode/app-auth uses); description "NCode.Identity
      Management console (served by its BFF host)." vite.config.ts: defineBffServedSurfaceConfig({ coverage: { statements: 97, branches: 95, functions: 100, lines: 97 } })
      with a comment that the console host (NCode.Identity.ManagementConsole.Host) serves the bundle at the root of its
      origin under each response's CSP nonce. main.tsx: const nonce = readCspNonce(document);
      const styleCache = createCache({ key: 'mgmt', container: document.head, ...(nonce ? { nonce } : {}) });
      const loadRuntimeConfig = createBffRuntimeConfigLoader(); and render
      StrictMode > CacheProvider(styleCache) > Provider(store) > BootstrapGate(load) > SessionGate > App. Then run
      npx -y pnpm@10.34.7 install --prefer-offline in src/frontend to update the lockfile.
    • Pattern: FE/apps/auth/src/mount.tsx (emotion cache with the nonce).
    • Done when: Push-Location src/frontend; npx -y pnpm@10.34.7 install --frozen-lockfile; $a = $LASTEXITCODE; Pop-Location; $b = [int](-not (Select-String -Path src/frontend/apps/mgmt/vite.config.ts -Pattern 'defineBffServedSurfaceConfig' -Quiet)); $c = [int](Select-String -Path src/frontend/apps/mgmt/package.json -Pattern '@ncode/auth' -Quiet); if ($a -or $b -or $c) { exit 1 } else { exit 0 }

    [ ] 32. Show the BFF session in the console shell

    • Files: MG/src/App.tsx, MG/src/App.test.tsx
    • Change: App reads const user = useAppSelector((state) => state.session.user) and
      const isSignedIn = useAppSelector((state) => state.session.status === 'signed-in'); the subject label is
      user.name, else the preferred_username claim when it is a string, else user.sub; handleSignOut calls
      announceSessionEnded() then submitLogout(user) when a user is present. Delete the runtime/ID-token reads, the
      signedOut dispatch and endSessionUrlFromRuntime, and rewrite the sign-out comment (the form post ends the BFF
      session, revokes its refresh token and runs RP-initiated logout at the OP).
    • Tests: the runtime literal becomes the BFF literal of step 27 with apiBaseUrl: 'https://api.test'; stores preload
      session: { status: 'signed-in', user } with a BffUser fixture (logoutUrl: '/bff/logout', logoutSid: 'guard')
      in place of auth. Replace the session tests: the label shows the name, the preferred_username claim, or the
      subject; sign-out submits a form to /bff/logout carrying sid=guard and returnUrl=/ (spy on
      HTMLFormElement.prototype.submit); no session controls when signed out.
    • Done when: Push-Location src/frontend/apps/mgmt; npx -y pnpm@10.34.7 exec vitest run src/App.test.tsx; $c = $LASTEXITCODE; Pop-Location; exit $c

    [ ] 33. Rebase the console's tenant-surface tests on the BFF runtime (1/3)

    • Files: MG/src/containers/ClientDetailPage.test.tsx, MG/src/containers/ClientsPage.test.tsx,
      MG/src/containers/GrantsPage.test.tsx, MG/src/containers/ResourceServerDetailPage.test.tsx,
      MG/src/containers/ResourceServerScopesPage.test.tsx, MG/src/containers/ResourceServersPage.test.tsx
    • Change (the same mechanical edit in each file): replace the RuntimeConfig literal (apiBaseUrl, issuer,
      authorizationEndpoint, tokenEndpoint, scopes, features) with
      const runtime: BffRuntimeConfig = { kind: 'bff', apiBaseUrl: 'https://api.test', bff: { basePath: '/bff', antiforgeryHeader: { name: 'X-CSRF', value: '1' } }, features: {} };
      keeping the file's own apiBaseUrl value; import BffRuntimeConfig in place of RuntimeConfig; delete the auth: { … }
      member of every createAppStore({ … }) call. Change nothing else.
    • Done when: Push-Location src/frontend/apps/mgmt; npx -y pnpm@10.34.7 exec vitest run src/containers/ClientDetailPage.test.tsx src/containers/ClientsPage.test.tsx src/containers/GrantsPage.test.tsx src/containers/ResourceServerDetailPage.test.tsx src/containers/ResourceServerScopesPage.test.tsx src/containers/ResourceServersPage.test.tsx; $c = $LASTEXITCODE; Pop-Location; exit $c

    [ ] 34. Rebase the console's owner-surface tests on the BFF runtime (2/3)

    • Files: MG/src/containers/OwnerEncryptionKeysTab.test.tsx, MG/src/containers/OwnerSecretsTab.test.tsx,
      MG/src/containers/SecretsManager.test.tsx, MG/src/containers/PlatformEncryptionKeysPage.test.tsx,
      MG/src/containers/PlatformSettingsPage.test.tsx, MG/src/containers/TenantDetailPage.test.tsx
    • Change: the edit of step 33.
    • Done when: Push-Location src/frontend/apps/mgmt; npx -y pnpm@10.34.7 exec vitest run src/containers/OwnerEncryptionKeysTab.test.tsx src/containers/OwnerSecretsTab.test.tsx src/containers/SecretsManager.test.tsx src/containers/PlatformEncryptionKeysPage.test.tsx src/containers/PlatformSettingsPage.test.tsx src/containers/TenantDetailPage.test.tsx; $c = $LASTEXITCODE; Pop-Location; exit $c

    [ ] 35. Rebase the console's team and home tests on the BFF runtime (3/3)

    • Files: MG/src/containers/HomePage.test.tsx, MG/src/containers/TeamDetailPage.test.tsx, MG/src/containers/TeamsPage.test.tsx,
      MG/src/containers/TenantsPage.test.tsx, MG/src/useCallerAccess.test.tsx
    • Change: the edit of step 33; in useCallerAccess.test.tsx the store preloads only config (the hook takes
      isSignedIn as its argument).
    • Done when: Push-Location src/frontend/apps/mgmt; npx -y pnpm@10.34.7 exec vitest run src/containers/HomePage.test.tsx src/containers/TeamDetailPage.test.tsx src/containers/TeamsPage.test.tsx src/containers/TenantsPage.test.tsx src/useCallerAccess.test.tsx; $c = $LASTEXITCODE; Pop-Location; exit $c

    [ ] 36. Pass the whole frontend gate

    • Files: any frontend file the commands below reformat or report (fix each report in the file it names, within the
      plan's design; escalate when a fix needs a decision)
    • Change: from src/frontend run npx -y pnpm@10.34.7 run format (Prettier writes), make sure no file under
      src/frontend/apps/mgmt/src mentions accessToken, Bearer, @ncode/auth or AuthGate, then run the gate
      below; the @ncode/app-mgmt coverage floors hold (97/95/100/97).
    • Done when: Push-Location src/frontend; npx -y pnpm@10.34.7 run format:check; $a = $LASTEXITCODE; npx -y pnpm@10.34.7 run lint; $b = $LASTEXITCODE; npx -y pnpm@10.34.7 run typecheck; $c = $LASTEXITCODE; npx -y pnpm@10.34.7 run test:report; $d = $LASTEXITCODE; npx -y pnpm@10.34.7 run build; $e = $LASTEXITCODE; Pop-Location; $f = [int](Select-String -Path src/frontend/apps/mgmt/src/*.ts*, src/frontend/apps/mgmt/src/*/*.ts* -Pattern 'accessToken|Bearer|@ncode/auth|AuthGate' -Quiet); if ($a -or $b -or $c -or $d -or $e -or $f) { exit 1 } else { exit 0 }

    [ ] 37. Test the console host against the built console bundle in CI

    • Files: MCT/BuiltConsoleBundleFactAttribute.cs (new), MCT/ManagementConsoleBundleReaderTests.cs, .github/workflows/frontend.yml
    • Change: BuiltConsoleBundleFactAttribute like tests/NCode.Identity.OpenId.Authentication.Interactive.Tests/BuiltAuthBundleFactAttribute.cs
      with variable NCODE_MGMT_BUNDLE_INDEX and the apps/mgmt wording. Add
      [BuiltConsoleBundleFact] public async Task RenderIndex_WhenGivenTheBuiltBundlesIndex_LeavesNoPlaceholderAndNoncesEveryScript():
      read the file, render with nonce n0nce, assert no __NCODE_CSP_NONCE__ remains, the csp-nonce meta carries
      nonce="n0nce", and every <script tag carries nonce="n0nce". In frontend.yml, after "Test the host against
      the built login bundle", add the step "Test the console host against the built console bundle" with
      NCODE_MGMT_BUNDLE_INDEX: ${{ github.workspace }}/src/frontend/apps/mgmt/dist/index.html running
      dotnet test tests/NCode.Identity.ManagementConsole.Tests/NCode.Identity.ManagementConsole.Tests.csproj --nologo --filter "FullyQualifiedName~ManagementConsoleBundleReaderTests.RenderIndex_WhenGivenTheBuiltBundlesIndex" -- RunConfiguration.TreatNoTestsAsError=true
      (same if, shell and working directory as the login-bundle step).
    • Done when: $env:MSBUILDDISABLENODEREUSE = '1'; $env:NCODE_MGMT_BUNDLE_INDEX = (Resolve-Path src/frontend/apps/mgmt/dist/index.html).Path; dotnet test tests/NCode.Identity.ManagementConsole.Tests/NCode.Identity.ManagementConsole.Tests.csproj -m:2 --filter "FullyQualifiedName~ManagementConsoleBundleReaderTests.RenderIndex_WhenGivenTheBuiltBundlesIndex" -- RunConfiguration.TreatNoTestsAsError=true

    [ ] 38. Format and build the whole solution

    • Files: any .cs file the formatter rewrites; any file a build error names (fix within the plan's design)
    • Change: dotnet csharpier format ., then build the solution and fix every warning-as-error.
    • Done when: $env:MSBUILDDISABLENODEREUSE = '1'; dotnet csharpier check .; $a = $LASTEXITCODE; dotnet build NCode.Identity.slnx -m:2; $b = $LASTEXITCODE; if ($a -or $b) { exit 1 } else { exit 0 }

    [ ] 39. Record the hosting decision (ADR-1437)

    • Files: docs/decisions/1437-the-management-console-is-a-confidential-client-served-by-a-bff-host-of-its-own.md (new)
    • Change: from docs/decisions/0000-template.md. Title # 1437. The management console is a confidential client served by a BFF host of its own. Status Accepted (amends [ADR-1064](…), [ADR-1071](…), [ADR-1131](…), [ADR-1146](…), [ADR-1294](…)),
      Date 2026-11-05, Deciders NCode Group. Context (present tense): the console is the family's most privileged surface;
      a browser public client holds its tokens where injected script reads them (RFC 10017 §5); the BFF library serves
      several frontends (ADR-1432); the design's §11 question 6 asks whether the console's BFF runs in the OP host or a
      separate host. Decision: D1 (with its reasons as a list), D2, D3 (including the manage.<apex> routing: /api to the
      OP for machine callers, every other path to the console host), D4 and D6. Options considered: (a) the BFF in the OP
      host with the console under /mgmt/ — rejected: the console's script would share an origin with the OP's login,
      consent and account pages, and the OP would carry a second composition and a client's keys; (b) the BFF in the OP
      process on a host name of its own — rejected: origin isolation without process isolation, and AddIdentityServer and
      AddIdentityBff in one service collection; (c) a separate host (chosen). Consequences: one more deployable (its image
      and chart are follow-up work), the console reaches the OP over HTTP like any client, local development runs three
      processes for the console (api, console, mgmt build-watcher), back-channel logout is not delivered on a
      developer machine. References: docs/backend-for-frontend.md §5.14 and §11, RFC 10017, the package and host paths,
      issue #158.
    • Done when: $f = 'docs/decisions/1437-the-management-console-is-a-confidential-client-served-by-a-bff-host-of-its-own.md'; if ((Test-Path $f) -and (Select-String -Path $f -Pattern '^# 1437\. ' -Quiet) -and (Select-String -Path $f -Pattern '^## Options considered' -Quiet) -and (Select-String -Path $f -Pattern 'manage\.<apex>' -Quiet)) { exit 0 } else { exit 1 }

    [ ] 40. Record the console's browser contract (ADR-1438)

    • Files: docs/decisions/1438-the-management-console-holds-no-token-and-reads-its-session-from-its-bff.md (new)
    • Change: Title # 1438. The management console holds no token and reads its session from its BFF. Status
      Accepted (amends [ADR-1312](…)). Context: what a BFF-served bundle needs at boot (its API base, its BFF's prefix
      and anti-forgery header) and does not (an issuer, a client id, scopes); ADR-1434's open-tabs pattern; a logout in one
      tab racing a login in another. Decision: D7, D8 and D9 as numbered points (the bootstrap document shape, the
      RuntimeConfig union and loadBffRuntimeConfig, the session slice and fetchBffUser, the base query's header and
      401, the deleted @ncode/auth modules, SessionGate's probes and channel, the sign-in screen as the end state,
      the logout form, the page CSP and defineBffServedSurfaceConfig); state that standalone dev servers proxy only the
      bootstrap document (the ADR-1312 amendment). Options considered: navigating to /bff/login on every end (rejected:
      the race, and a failing login would loop); keeping @ncode/auth's custody with a token-mediating backend (rejected:
      RFC 10017 §6.2 leaves tokens in the browser); one RuntimeConfig with optional OpenID and BFF members (rejected:
      every consumer would test for members its surface always has). Consequences, References (ADR-1434, ADR-1437, RFC 10017).
    • Done when: $f = 'docs/decisions/1438-the-management-console-holds-no-token-and-reads-its-session-from-its-bff.md'; if ((Test-Path $f) -and (Select-String -Path $f -Pattern '^# 1438\. ' -Quiet) -and (Select-String -Path $f -Pattern 'ncode\.mgmt\.session' -Quiet)) { exit 0 } else { exit 1 }

    [ ] 41. Record the BFF's key rings from PEM files (ADR-1439)

    • Files: docs/decisions/1439-a-bff-loads-the-key-rings-its-configuration-names-from-pem-files.md (new)
    • Change: Title # 1439. A BFF loads the key rings its configuration names from PEM files. Status Accepted.
      Context: a frontend names its client and DPoP key rings (ClientAuthentication:KeyRing, DPoP:KeyRing), and a BFF
      host had to register each keyed ISecretKeyCollectionProvider in code; a stateless BFF replica takes its keys from a
      mounted secret. Decision: D5 in full (section, formats, EC-then-RSA reading, kid = file stem, sensitive buffers,
      content-root resolution, startup validation messages, code registration as the seam for other sources). Options:
      keys in the configuration's text (rejected: a private key in configuration values ends up in logs and dumps of the
      configuration); a JWK set file (rejected: PEM is what tooling and secret stores emit); a Source vocabulary for key
      rings (rejected for now as unneeded: keyed DI already is the open seam). Consequences, References (ADR-1290, ADR-1382,
      src/NCode.Identity.Bff/Secrets/BffPemKeyRing.cs).
    • Done when: $f = 'docs/decisions/1439-a-bff-loads-the-key-rings-its-configuration-names-from-pem-files.md'; if ((Test-Path $f) -and (Select-String -Path $f -Pattern '^# 1439\. ' -Quiet) -and (Select-String -Path $f -Pattern 'Bff:KeyRings' -Quiet)) { exit 0 } else { exit 1 }

    [ ] 42. Index the records and mark the ones they amend

    • Files: docs/decisions/README.md, docs/decisions/1064-frontend-presentation-platform.md,
      docs/decisions/1071-aspire-local-dev-orchestrator.md,
      docs/decisions/1131-hosted-platform-environment-model-and-hostname-scheme.md,
      docs/decisions/1146-a-development-seeder-registers-only-the-clients-its-configuration-declares.md,
      docs/decisions/1294-a-bff-client-is-registered-with-explicit-metadata-not-a-bff-security-profile.md,
      docs/decisions/1312-the-frontend-bootstrap-api-base-is-root-relative-unless-configured.md
    • Change: append index rows for 1437 (Accepted (amends [1064](…), [1071](…), [1131](…), [1146](…), [1294](…))), 1438
      (Accepted (amends [1312](…))) and 1439 (Accepted) after 1434, in the existing row format. Extend each amended
      record's Status line (and its index row) with one clause, and change nothing else in the record: 1064 — "and by
      ADR-1437: the management console is served by a BFF host of its own"; 1071 — "amended by ADR-1437: the AppHost runs
      the management console's BFF host on https://localhost:5005 and build-watches apps/mgmt" (read its Status line
      first and join the clause in its existing style); 1131 — "Accepted (amended by ADR-1437: manage.<apex> serves the
      management console's BFF host, and routes /api to the management API)"; 1146 — "and by ADR-1437: the seeder also
      registers the confidential BFF clients its configuration declares"; 1294 — "and by ADR-1437: a client whose provider
      cannot reach its jwks_uri registers its keys by value"; 1312 — "Accepted (amended by ADR-1438: the standalone dev
      servers proxy only the bootstrap document)".
    • Done when: pwsh -NoProfile -File ./.template/Test-AdrIndex.ps1; if ($LASTEXITCODE) { exit 1 }; pwsh -NoProfile -File ./build/adr-index.ps1

    [ ] 43. Describe the console in the BFF design (status, §1.2, §4.1, §5.12)

    • Files: docs/backend-for-frontend.md
    • Change: Status bullet → "In progress. SharedState, the protocol client, the NCode.Identity.Bff core, remote APIs,
      browser hardening, multiple frontends and the management console are implemented (§9).
      The library is tracked as issue [Backend for Frontend (BFF) library: cookie sessions with server-side token custody #123]…; each implemented area's decisions are ADRs." (keep the Backend for Frontend (BFF) library: cookie sessions with server-side token custody #123 link; delete the
      sentence that calls the console pending and its Serve the management console through the BFF instead of as a browser public client #158 link). §1.2's last paragraph → present tense: the family's relying party is NCode.Identity.Bff,
      and its own management console is a BFF frontend served by NCode.Identity.ManagementConsole.Host, so the most
      privileged surface holds no token in the browser (§5.14). §4.1 table: add rows
      NCode.Identity.ManagementConsole (depends on NCode.Identity.Bff.Server, NCode.AspNetCore; holds
      AddManagementConsole, MapManagementConsole, the console's bootstrap document, page and assets — ADR-1437) and
      NCode.Identity.ManagementConsole.Host (the console's reference host). §5.12: after the composition sketch, a
      paragraph and a jsonc snippet for Bff:KeyRings:{name}:PemFiles (D5, ADR-1439), and change the sketch's
      AddKeyedSingleton<ISecretKeyCollectionProvider>(…) comment to say it is the seam for a ring held elsewhere, such as
      a KMS; add the key-ring failures to the startup-validation list.
    • Done when: $f = 'docs/backend-for-frontend.md'; if ((Select-String -Path $f -Pattern 'Bff:KeyRings' -Quiet) -and (Select-String -Path $f -Pattern 'NCode\.Identity\.ManagementConsole\.Host' -Quiet) -and -not (Select-String -Path $f -Pattern 'is pending' -Quiet)) { exit 0 } else { exit 1 }

    [ ] 44. Describe the console in the BFF design (§5.14, §9, §11)

    • Files: docs/backend-for-frontend.md
    • Change: add ### 5.14 The management console after §5.13: the topology (D1, D3) as a short list, a table of the
      console host's paths (/ and every non-file path → the bundle page under a nonce CSP; /assets/* → immutable
      files; /config/bootstrap.json → the console's bootstrap document; /bff/* → the mgmt frontend's endpoints;
      /management/api/* → the OP's management API with the user's DPoP-bound urn:ncode:management token), the client
      registration (D4), local development (D6), and the browser side (D7, D8), citing ADR-1437, ADR-1438, ADR-1439. §9:
      replace the "Pending" row with a "Management console" row ("NCode.Identity.ManagementConsole.Host serves apps/mgmt
      as the confidential client mgmt-console of the control-plane tenant (§5.14,
      [ADR-1437]…, [ADR-1438]…). The Playground stays a public client on purpose.") and add a "Key rings" row (ADR-1439).
      §11 question 6: append "Decided: a separate host, NCode.Identity.ManagementConsole.Host, on an origin of its own
      ([ADR-1437]…)." and drop "(simplest)" / "(cleaner separation …)" from the question.
    • Done when: $f = 'docs/backend-for-frontend.md'; if ((Select-String -Path $f -Pattern '^### 5\.14 The management console' -Quiet) -and (Select-String -Path $f -Pattern '1437-the-management-console' -Quiet) -and -not (Select-String -Path $f -Pattern '^\| Pending' -Quiet)) { exit 0 } else { exit 1 }

    [ ] 45. Describe the console in the frontend README

    • Files: src/frontend/README.md
    • Change: layout line for mgmt/ → "Management console (served by its BFF host, NCode.Identity.ManagementConsole.Host)";
      the comment of the pnpm --filter @ncode/app-mgmt dev line becomes "build-watch the Management console's dist (its
      BFF host serves it)". Serving models: add a BFF-served SPAs bullet (apps/mgmt, vite build --watch,
      defineBffServedSurfaceConfig({ coverage }), base /, nonce placeholder, served by the console host) and make the
      standalone bullet's example apps/playground. Runtime configuration: show the BFF document beside the OpenID one,
      createBffRuntimeConfigLoader(), the kind union; the standalone dev servers proxy only /config/bootstrap.json;
      the default document (no ?surface=) is the OP's default, not the console's. Replace "Authentication
      (authorization-code + PKCE)" with "Sessions": the console's SessionGate, /bff/user, the anti-forgery header, the
      401 → sessionEnded(), the logout form, the polling and BroadcastChannel (ADR-1434, ADR-1438), and one paragraph
      that the Playground keeps @ncode/auth as a public client on purpose. Full stack: the console resource on
      https://localhost:5005 and the mgmt build-watcher; drop 5173 and the CORS sentence about it. Gotchas: the run-once
      ref example becomes the Playground's FlowExplorer callback completion (apps/playground/src/containers/FlowExplorer.tsx)
      and StrictMode's example apps/playground/src/main.tsx.
    • Done when: $f = 'src/frontend/README.md'; if ((Select-String -Path $f -Pattern 'defineBffServedSurfaceConfig' -Quiet) -and -not (Select-String -Path $f -Pattern '5173|AuthGate' -Quiet)) { exit 0 } else { exit 1 }

    [ ] 46. Describe the console host in the repository's guides

    • Files: README.md, src/NCode.Identity.OpenId.Host/README.md, AGENTS.md
    • Change: README.md local-run paragraph: the AppHost brings up the provider at https://localhost:5001, the
      management console at https://localhost:5005, the flow explorer and the conformance dashboard. Host README's
      development-credentials paragraph: the AppHost also writes mgmt-client.pem and mgmt-dpop.pem (the console's
      client and DPoP keys) and hands the client key's public key set to the OP's developer seed; sign in at
      https://localhost:5005 as admin. AGENTS.md Layout: under Backend for frontend add the console:
      "NCode.Identity.ManagementConsole composes the management console's BFF host, and
      NCode.Identity.ManagementConsole.Host is its reference host (ADR-1437)"; and mention
      Bff:KeyRings ([ADR-1439]…) beside AddIdentityBff(configuration).
    • Done when: if ((Select-String -Path AGENTS.md -Pattern 'NCode\.Identity\.ManagementConsole\.Host' -Quiet) -and (Select-String -Path README.md -Pattern 'localhost:5005' -Quiet) -and (Select-String -Path src/NCode.Identity.OpenId.Host/README.md -Pattern 'mgmt-client\.pem' -Quiet)) { exit 0 } else { exit 1 }

    [ ] 47. Rewrite the changelog entries the console's hosting reshapes

    • Files: CHANGELOG.md
    • Change: edit these ## [Unreleased] entries in place (find each by its quoted text):
      • "Development seeding signs in end to end": "provisions public (PKCE-only) single-page-application clients and a
        deterministic administrator user" → "provisions public (PKCE-only) single-page-application clients, confidential
        BFF clients and a deterministic administrator user"; add after the DeveloperSpaClientSeed parenthesis: "and
        exactly the BFF clients under DeveloperInteractiveSeed:BffClients (DeveloperBffClientSeed: ClientId, Jwks
        registered by value, RedirectUris, PostLogoutRedirectUris, BackchannelLogoutUri; private_key_jwt, PAR and
        DPoP-bound tokens)"; "The reference host declares mgmt-spa and playground-spa (which requires consent)" → "The
        reference host declares playground-spa (which requires consent) and the management console's BFF client
        mgmt-console, whose key set the AppHost supplies".
      • "IIdentityServerBuilder.AddFrontendBootstrap(configuration)": "publishes mgmt-spa with the dcr feature flag
        and developer branding, playground-spa" → "publishes developer branding, playground-spa".
      • The AppHost entry: "and the mgmt, playground and conformance Vite dev servers" → "the management console host
        (console) and its build-watcher (mgmt), and the playground and conformance Vite dev servers"; "and mgmt,
        playground and conformance on https://localhost:5173, 5174 and 5175, un-proxied" → "console on
        https://localhost:5005, and playground and conformance on https://localhost:5174 and 5175, un-proxied".
      • "The standalone Vite surfaces serve HTTPS outside Aspire too": "mgmt, playground or conformance" →
        "playground or conformance"; "ports 5173–5175" → "ports 5174–5175".
      • "The management console signs in with authorization code + PKCE." → retitle "@ncode/auth holds the Playground's
        public-client protocol helpers.
        " and keep only the package sentence (PKCE S256 verifier and challenge, the
        authorize URL, state validation, code exchange, refresh, the end-session URL and JWT claim decoding, injectable and
        unit-tested, including the RFC 7636 test vector). Delete the entry "The management console renews its access token
        silently and signs out through RP-Initiated Logout." in full.
      • "Every standalone frontend surface boots through one shared gate.": title → "Every frontend surface boots through
        one shared gate."; "through createRuntimeConfigLoader({ surface })" → "through createRuntimeConfigLoader({ surface }), or the console through createBffRuntimeConfigLoader()".
      • "Every frontend workspace builds and tests from one Vite preset.": "the two serving models" → "the three serving
        models (standalone, backend-served and BFF-served)"; "and the Management console's dev server pins port 5173,
        which its redirect URI and CORS origin assume" → "and each standalone dev server pins the port its redirect URI and
        CORS origin assume".
      • "The frontend bootstrap document never takes its issuer or API base from the request.": delete ", and the
        standalone Vite dev servers proxy /api/ to the OP host".
    • Done when: $c = Get-Content -Raw CHANGELOG.md; if ($c -match 'mgmt-spa|5173|renews its access token silently|signs in with authorization code \+ PKCE') { exit 1 } else { exit 0 }

    [ ] 48. Catalog the console's BFF host in the changelog

    • Files: CHANGELOG.md
    • Change: under ## [Unreleased] → ### Added, directly after the entry "One BFF serves several browser frontends.",
      add three entries, each ending with its links:
      • "The management console is served by a BFF host of its own." — NCode.Identity.ManagementConsole
        (AddManagementConsole(configuration), MapManagementConsole()) and NCode.Identity.ManagementConsole.Host: the
        built apps/mgmt bundle under a per-response CSP nonce, /config/bootstrap.json, the mgmt frontend's /bff
        endpoints, and /management/api/* forwarded to ManagementConsole:ManagementApiUrl with the user's DPoP-bound
        urn:ncode:management token; the confidential client mgmt-console of the control-plane tenant (private_key_jwt,
        PAR, DPoP, back-channel logout); https://localhost:5005 under the AppHost, which generates the console's keys per
        machine (issue [Serve the management console through the BFF instead of as a browser public client #158]…, [ADR-1437]…).
      • "A BFF loads its key rings from PEM files." — Bff:KeyRings:{name}:PemFiles, EC or RSA, kid = file name,
        validated at startup ([ADR-1439]…).
      • "The management console reads its session from its BFF." — RuntimeConfig union and loadBffRuntimeConfig,
        the session slice and fetchBffUser, SessionGate (poll, expiry and visibility probes, BroadcastChannel),
        sign-out by the logout form, defineBffServedSurfaceConfig, readCspNonce in @ncode/ui ([ADR-1438]…).
        Under ### Security add "The management console holds no token in the browser." — no access, refresh or ID
        token reaches the console's script; its calls carry the __Host-Http-bff.mgmt cookie and the anti-forgery header,
        and the console's origin is separate from the OP's login, consent and account pages (RFC 10017, [ADR-1437]…,
        [ADR-1438]…). Every link uses the full https://github.com/NCodeGroup/Identity/issues/158 form and
        docs/decisions/<file>.md paths, as neighboring entries do.
    • Done when: if ((Select-String -Path CHANGELOG.md -Pattern 'served by a BFF host of its own' -Quiet) -and (Select-String -Path CHANGELOG.md -Pattern 'holds no token in the browser' -Quiet) -and (Select-String -Path CHANGELOG.md -Pattern 'Bff:KeyRings' -Quiet)) { exit 0 } else { exit 1 }

    Review checklist

    • No file under src/frontend/apps/mgmt, packages/store or packages/auth holds or sends a token; @ncode/auth
      exports only what apps/playground imports.
    • The console host's Program.cs is composition-only; every behavior lives in NCode.Identity.ManagementConsole and
      is tested (tested-or-excluded).
    • The page CSP admits nothing but the response's nonce for scripts and styles, and form-action names exactly 'self'
      and the authority's origin; the bootstrap document and the page are no-store; assets are immutable.
    • /management/api/* is limited to the mgmt frontend, needs the session and the anti-forgery header, and forwards
      only to ManagementConsole:ManagementApiUrl; /api is not mapped on the console host.
    • The developer seed registers mgmt-console with PAR, DPoP-bound tokens, private_key_jwt, back-channel logout and the
      key set by value, and the OP's Development overlay has no mgmt-spa, no 5173 origin and no default client id.
    • Bff:KeyRings reads key material through sensitive buffers, never logs a path's content, and fails startup for a
      missing file.
    • SessionGate never navigates by itself; an end of session shows the sign-in screen in every tab.
    • No text in the change uses delta phrasing; ADR statuses, the index and the design doc agree; issue Backend for Frontend (BFF) library: cookie sessions with server-side token custody #123 is only
      referenced.
    • Manual check after merge: run the AppHost, sign in at https://localhost:5005 as admin, open a tenant's clients,
      sign out, and confirm the browser's storage and the network log carry no token.

    Follow-ups

    • Publish and deploy the management console host: add a Dockerfile for src/NCode.Identity.ManagementConsole.Host that builds apps/mgmt into the image (as the OP's Dockerfile builds apps/auth), a CI image workflow, and a Helm chart component that mounts the mgmt-client/mgmt-dpop PEM keys from secrets and routes manage.<apex> (/api to the OP, the rest to the console), so the console has a production deployment.
    • Give BFF hosts the reverse-proxy trust and HSTS the OP host has: AddReverseProxy/UseReverseProxy and AddStrictTransportSecurity live in NCode.Identity.Server, so the console host behind an ingress has no trusted forwarded-headers list or HSTS; move them to a package a BFF host can reference and compose them in NCode.Identity.ManagementConsole.Host.
    • Share one single-page bundle reader between the interactive surface and the console: InteractiveBundleReader and InteractiveBundleAssetEndpointHandler (NCode.Identity.OpenId.Authentication.Interactive) and ManagementConsoleBundleReader/ManagementConsoleAssetHandler (NCode.Identity.ManagementConsole) duplicate the cached index read and the immutable asset serving; one general helper in NCode.AspNetCore removes the duplicate.
  3. polewskm commented on Nov 6, 2026

    @polewskm
    ContributorAuthor

    Plan update (step 15, D4): the console logs in without resource, so its login access token carries the OpenID audience that UserInfo requires (ADR-1027). The /management/api remote API declares Resource = urn:ncode:management, so the BFF obtains a separate, audience-restricted, DPoP-bound management token through the refresh-token grant with RFC 8707 resource, and no token is usable at both UserInfo and the management API. Step 15 drops Resources from the console host's configuration and proves a management call end to end.

  4. polewskm commented on Nov 6, 2026

    @polewskm
    ContributorAuthor

    Landed in #606 (900071a): the management console is served by its own BFF host as the confidential client mgmt-console and holds no token in the browser (ADR-1437, ADR-1438, ADR-1439). Follow-ups: #609, #610, #611, #612.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    frontendFrontend presentation platform

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions