A same-origin link is not the same thing as an in-app link. The portal serves a handful of paths from the ASP.NET Core pipeline — the mailbox consent flow, the content API, the instance-sync OAuth start — and those are not routes the Blazor router can resolve. Clicking one inside the app used to land on the router's catch-all and be reported as "does not match any registered address pattern".

The mechanism

Blazor installs a document-level click handler and claims any anchor that satisfies all of:

A claimed click becomes an in-circuit NavigateTo, and the router matches it against the @page routes. This portal's last route is @page "/{**Path}" (AreaPage), which resolves the path as a mesh address. So nothing 404s honestly: the navigation succeeds, the address lookup fails, and the reader is told their path is not a registered address pattern. No request ever leaves the browser, so the endpoint's own behaviour — the 302 to sign-in, the consent screen — is never reached.

That last point is what makes the bug hard to see from the server side. The endpoint is healthy; a curl against it answers correctly. Only the click is broken.

Where this bit

The Executive Assistant hands the user {BaseUrl}/auth/ea/connect as bare text inside a chat sentence — and that link is the just-in-time consent step: until it is followed, the EA cannot touch the mailbox at all. Chat text goes through the ordinary markdown path (Markdig with autolinks on, then MarkdownHtmlRenderer), so the URL became an ordinary anchor with no target, and the router ate every click. Asking the assistant to send mail produced a link that could not be used (Plugins#2036).

Four UI call sites had each met the same wall and hardened it privately, with a click action rather than an anchor:

Call site What it navigates to
SendDocumentLayoutArea (the send-document dialog) the mailbox connect endpoint
MailingLayoutAreas (the Connect button) the same endpoint
InstanceSyncLayoutArea /connect/instance
GettingStartedAreas (nudges) anything under /auth/

All four say ctx.NavigateTo(href, forceLoad: true), and three of them carry a comment explaining why. Chat has no button to hang a click action on, which is exactly why the one channel that tells people to connect was the one channel that could not.

The boundary, declared once

ServerEndpointPaths.IsServerEndpoint(href) (in MeshWeaver.Blazor) answers whether an href names a server endpoint rather than a route the app can resolve. It matches on the path, against six prefixes that each end in /:

Prefix Endpoint
/auth/ the EA consent flow — /auth/ea/connect, /auth/ea/callback — and /auth/logout
/api/ the server API surface, including the access-controlled content route
/connect/ /connect/instance, the instance-sync OAuth start
/static/ the static content route
/assets/ a course's assets (CourseAssetEndpoints) — and course bodies ARE markdown
/login/ the minimal-API sign-in flows, e.g. /login/github

Three properties are deliberate:

MarkdownHtmlRenderer applies it to every anchor it emits: a server endpoint gets target="_blank" plus a rel carrying noopener noreferrer, which is what makes the browser, not Blazor, own the click. An anchor that already carries a target is left alone — the pipeline enables generic attributes, so [text](url){target=_self} is a decision an author can already make, and the renderer does not overrule it.

🚨 The rel is the one attribute that is REPLACED rather than defaulted, and the reason is this change's own. Markdown here is untrusted and generic attributes are on, so [x](https://evil.example/auth/go){rel=opener} would otherwise be handed the automatic target="_blank" while keeping rel="opener" — and the opened cross-origin document could then drive the portal tab it came from. That vector does not exist without the automatic target, so it belongs to the code that adds one. Benign authored tokens (nofollow, …) are preserved; only the opener family is rewritten, and it is stated exactly once.

Because this lives in the renderer rather than at the producer, it holds for every producer that puts such a URL into markdown, not only the ones that remembered to.

What to write

A page that was told to leave owes no acknowledgement

NavigationManager.NavigateTo(uri, forceLoad: true) on a server circuit does two things: it sends the browser a JS call, and it awaits the browser's answer to that call. A page that has just been told to unload is the one caller that may never answer. The unload races the answer; when the unload wins and the circuit is not yet known to be gone, the pending call sits until the JS-interop call timeout cancels it a minute later, and the framework then logs the navigation as failed and raises it as an unhandled circuit exception (CircuitHost[111], TaskCanceledException from RemoteNavigationManager.PerformNavigationAsync).

Measured on the public instance (MeshWeaver#5362): about 150 occurrences in eight weeks, three a day, spread over every pod. The framework's own line names the destination, and the sampled destinations were the full-page leaves: /auth/login?provider=…, /login/github and /login?returnUrl=…. The navigation had happened each time; what failed was the wait for a page that no longer existed.

FullPageNavigation.LeaveTo is the one spelling for a leave:

NavigationService routes every forceLoad navigation through it, so a layout area's ctx.NavigateTo(href, forceLoad: true) needs no change. FullPageNavigationTest (in MeshWeaver.Hosting.Blazor.Test) pins each of the outcomes above, including the reported failure as the negative control.

What it does not change. An in-circuit navigation (forceLoad: false) to a browser that has gone quiet still ends in the framework's timeout and its circuit teardown. That one is correct: the page was meant to stay and answer, and it did not.

What this does not cover

The control

MarkdownLinkLeavesTheSpaTest (in MeshWeaver.Blazor.Views.Test) renders markdown exactly as a chat bubble does and reads the attributes off the emitted render-tree frames. It asserts both sides of the boundary, because the failure mode of a fix like this is over-marking — a documentation link that started reloading the whole SPA would be worse than the bug:

Measured against the unfixed renderer: 5 of 13 red, every one of them "expected dictionary to contain key target". The near-miss and rel cases were added after a review round and measured the same way — 6 of 24 red against the first fix (the two rel=opener cases, the benign-rel case, /assets/, /login/github, and the protocol-relative one) — and the left-alone cases were green on both sides of both rounds, which is the half that says the boundary did not widen.

Attribute values are emitted DECODED

Markdig writes HTML, so an & inside an href (or any attribute) arrives as &. The renderer reads the HtmlAgilityPack DOM, whose attribute Value is that SOURCE text, and hands it to RenderTreeBuilder.AddAttribute, which sets the DOM value — a value the browser never decodes again. Every attribute, the UCR link's href/display text and the layout-area data-* paths are therefore passed through HtmlEntity.DeEntitize first.

Without that, [Subscribe](/Store/Subscribe?plan=personal&accept=…) navigated to …?plan=personal&accept=…: the server read a parameter named amp;accept, the Plans card's Subscribe click lost the licence acceptance it carried, and every buyer looped back to the card (MeshWeaver.Plugins#3546). MarkdownLinkQueryKeepsItsAmpersandTest pins it for both anchor paths and for an authored title; against the unfixed renderer all three cases are red. The React clients set the rendered HTML as HTML and read getAttribute("href"), which is already decoded, so they were not affected.