Choosing which logins an instance offers

Maintainer directive, 2026-09-01, given twice:

"i should now get setup dialog imo where i can choose which logins to enable" "i would like to get a setup dialog where i can choose any login"

A fresh instance sent the operator straight to Microsoft sign-in without ever asking whether that is the login it should offer. This page is the answer: what the setup surface does, where an enabled login is actually written, and — the part that is easy to get wrong and expensive to discover — what it deliberately does not do.

The design of record for the whole instance-setup story is core's Instance Identity and Setup (get Doc/Architecture/InstanceIdentityAndSetup), whose slice 3 is the first-run wizard. That design's five steps — Database · Where · Instance id · Modules · Review — carry no sign-in step at all; this is that missing step, built first because it is the one an operator hits before anything else can be reached.

The surface

Settings → Administration → Sign-in providers (/GlobalSettings/SignInProviders), on the instance you are on. Not the Fleet Console's Set up a new instance dialog, which provisions a DIFFERENT instance and files a Hosting/InstanceRequest.

It renders, in this order:

  1. How it is configured now. Every login route this image can offer, whether it is on, which configuration key said so, whether its secret is present (by NAME), and which call registers it. Six rows: Microsoft, Google, LinkedIn, Apple, GitHub, Developer login.
  2. The chooser. One group per route — a checkbox, a client-id field, and the tenant field for the one provider that has one. Framework controls bound to layout data; no hand-built markup and no /data replica.
  3. The composed answer. The exact Authentication__* entries to set, what changes against the instance's current state, and every problem that would follow — a selected provider with no client id, or one whose secret is missing.
  4. The restart notice and the secret guidance, verbatim, below.

Reading the current state comes FIRST, and that is the point

A surface that shows only its own stored answer is precisely the defect MeshWeaver.Plugins#980 describes: Provider/Anthropic sat keyless on memex.systemorph.com for a week while Anthropic__ApiKey was configured in the deployment, and the platform believed it could not serve a model it was serving. The provenance column — which key decided this row — is what makes that state visible instead of discoverable a week later.

The table is also honest about the two states that look alike and are not:

Where an enabled provider is actually persisted

Today: in the host's IConfiguration, and nowhere else. Every login route is registered while the host is being built, from these keys:

Route Key that turns it on Secret Registered by
Microsoft Authentication__Microsoft__ClientId (+ __TenantId) Authentication__Microsoft__ClientSecret AddOpenIdConnect("Microsoft")
Google Authentication__Google__ClientId Authentication__Google__ClientSecret AddOAuth("Google")
LinkedIn Authentication__LinkedIn__ClientId Authentication__LinkedIn__ClientSecret AddOAuth("LinkedIn")
Apple Authentication__Apple__ClientId Authentication__Apple__PrivateKey AddApple()
GitHub GitHub__OAuth__ClientId GitHub__OAuth__ClientSecret MapGitHubLogin() — minimal-API OAuth, not a scheme
Developer login Authentication__EnableDevLogin (none) DevAuthController — self-provisioning

Each handler is a no-op when its client id is unset, so "which logins does this instance offer" is decided entirely by which of those keys carry a value at boot.

Those keys reach the process from one of two places, and both are the SAME names:

That is why the surface composes ENTRIES rather than writing a node: one projection serves both homes, and it cannot drift from the key names the host actually reads.

🚨 There is no second home, and none should be added. A Admin/SignIn node holding the same facts would be exactly the "three places that cannot see each other" problem #980 opens with.

Enabling a login requires a restart. There is no way around it today.

Measured, not assumed, across both repositories:

So a client id appearing after boot registers no handler, and /auth/login?provider=X would answer 400 Unknown provider with every value correct — the shape of the production outage ExternalProvidersReachBothAuthenticationOptionsTest was written for.

The surface therefore says so where the operator decides, rather than letting them discover it:

These answers take effect at the next start. Sign-in providers are registered while the host is being built, so a running process cannot gain or lose one. Apply the entries, then restart the instance — through its deployment record on a managed instance, or by restarting the container on a self-managed one. Never by hand-editing a live pod.

The follow-up that would remove the restart is dynamic scheme registration — IAuthenticationSchemeProvider.AddScheme plus a named-options factory that runs the full PostConfigure pipeline. It is genuinely possible and genuinely not free: an OIDC handler's options are finished by post-configuration, so a scheme added by hand around that pipeline half-works in ways a unit test does not see. It is not in this slice.

Secrets: collected nowhere, named everywhere

🚨 A client secret is never collected by this surface and never stored on a mesh node. A live provider key sat in cleartext on a node — readable by anyone with read on the partition — until 2026-08-24 (Providers/Skill/providers.md). A client id is not a secret: it is public in every redirect URL, which is why the record holds it.

So the surface renders, verbatim:

Secrets are not collected here. A client id is public — it appears in every redirect URL — so this surface records it. A client secret is not, and a mesh node is the wrong place for one: it is readable by everyone with read on the partition. Set each secret in this instance's secret store under the name shown, and this record will name it rather than hold it.

Two tests keep that from decaying into a habit: no state the catalog produces may carry a secret's value under any property, and no composed entry may carry one — or even NAME a secret key.

Who may use it

The platform-admin grant, and nothing else. The AccessAssignment at the Admin scope, which is what hub.IsGlobalAdmin() reads. Not ownership of the node whose settings page it is, not Permission.All on some partition, not merely being signed in.

Two doors, one rule (SignInSetupAccess.Decide): the settings MENU asks before contributing the entry, and the AREA asks again before rendering a byte — a settings area is addressable by URL, so hiding a menu item hides nothing. Every uncertainty resolves to REFUSED: no viewer, somebody else's settings page, an unresolved check, a timeout, an exception.

🚨 Note what the grant does not confer: a global admin still has no read, write or list on any partition's content. Admin/_Access gates platform actions, and choosing an instance's logins is one; it opens this surface and nothing else.

Awaiting first-run setup

MeshBuilder.IsAwaitingSetup — an instance with no storage and no completed instance.json — is now read in production for the first time, through InstanceSetupStatus. Its own doc comment has demanded since MeshWeaver#2550 that "a host that reads this true must serve the SETUP surface and nothing else", and until now nothing read it at all.

The surface reports the state honestly: an awaiting-setup instance is told that nothing can be persisted yet, because it has no storage to persist into, and that storage is the first question. The verdict is read LAZILY — the two hosts call ConfigureMemexMesh (which sets the flag) on opposite sides of ConfigureMemexPortal, so a value captured at registration time would be correct on one host and silently false on the other.

What is still to land

This slice is the surface and the composition. Named, so the gap is visible rather than assumed:

Still to land Where it belongs
The instance's own record holds the answer. InstanceManifest (instance.json) gains a sign-in section, so a self-managed instance can persist the choice instead of the operator copying entries. Core — MeshWeaver.Mesh.Contract, the design's slice 3
The host serves the setup surface and nothing else when IsAwaitingSetup. A terminal branch after UseForwardedHeaders() and the /healthz short-circuit, before UseRouting(). Today an awaiting-setup mesh has no hubs, so a layout area cannot render there at all. The portal hosts, once the manifest can hold an answer
Writing the Hosting/Deployment record's signIn section from the surface on a fleet-managed instance, instead of composing entries to copy. This repo, once the record is reachable from the portal's composition
Dynamic scheme registration, removing the restart. MeshWeaver.Blazor.Portal.Authentication
The storage, instance-id and module steps of the first-run wizard. Core, the design's slice 3

Where the code is

Piece Path
The image's capability set — one list, also feeding the two legacy helpers src/MeshWeaver.Blazor.Portal/Authentication/SignInProviderCatalog.cs
The pure composition — entries, changes, problems, the two notices src/Memex.Portal.Gui/Setup/SignInSetupPlan.cs
The access rule, both doors src/Memex.Portal.Gui/Setup/SignInSetupAccess.cs
The first production reader of IsAwaitingSetup src/Memex.Portal.Gui/Setup/InstanceSetupStatus.cs
The settings tab and its rendering src/Memex.Portal.Gui/Setup/SignInSetupTab.cs
The suites src/Memex.Portal.Gui.Test/SignIn*.cs
Reconnecting…
The server was updated. Reloading the page to pick up the latest version.