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

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.

Reconnecting…
The connection to the server was interrupted. Trying to restore it…
Trying again…
The connection could not be restored. Reloading the page…
The server was updated. Reloading the page to pick up the latest version.