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:
- the href is within the base URI space (same origin, under the app's
<base href>), and - the anchor has no
target, ortarget="_self", and - the anchor has no
downloadattribute.
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:
- Judged on the path, not the origin. A renderer does not know the deployment's base URI, and does not need one: a cross-origin URL under one of these prefixes is external anyway, so marking it cannot produce a wrong outcome.
- Every prefix ends in
/. The error that would actually hurt is the opposite one — claiming an ordinary mesh path — so a partition calledauthorsorapiarymust not match, and does not./login/is the sharpest case: the sign-in flow is a server endpoint while/loginitself is a Blazor@page, and the trailing slash is the whole of what keeps the page in the circuit. - A network-path reference is parsed before a rooted one.
//host/auth/ea/connectinherits the page's scheme, so on this host it is same-origin and Blazor claims it exactly as/auth/…would be claimed — but read as a rooted path its first segment is the host, and it would match nothing. The host is parsed off first.
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
- Markdown or chat text — nothing. Write the URL. The renderer decides.
- A button or other click action —
ctx.NavigateTo(href, forceLoad: true), as the four call sites above do. A click action is not an anchor, so the renderer never sees it. - A file the reader should download rather than navigate to —
downloadis the other attribute Blazor's interception declines on. - A Razor component or page that navigates in code —
Navigation.LeaveTo(JS, Logger, href)(FullPageNavigation, inMeshWeaver.Blazor), neverNavigation.NavigateTo(href, forceLoad: true). The next section says why.
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:
- It asks the browser itself (
location.assign,location.replace, orlocation.reloadwhen the destination is the page it is already on) and treats a cancelled or disconnected call as the expected end. Nothing is reported for it. - A failure the browser reports (a
JSException) is not the missing acknowledgement. It is logged at Error with the destination. - A statically rendered component has no browser to call. Its runtime refuses with
InvalidOperationException, andLeaveTothen makes the framework's navigation, synchronously: there it is an HTTP redirect, which involves no acknowledgement, and theNavigationExceptionthe framework signals it with reaches the caller as before.
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
- A hand-written
<a href>in a Razor component. The renderer only sees markdown. A component that emits its own anchor to a server endpoint must settargetitself —MeshNodeCardViewandChatCitationalready do. - The React and React Native clients. They render the same markdown but have no Blazor router, so this swallow does not exist there and nothing needed to change.
GettingStartedAreas' own inline/auth/test.MeshWeaver.AIhas no project edge toMeshWeaver.Blazor, so it cannot consumeServerEndpointPathswithout adding one. It is a click action and is correct as written; if that edge ever exists, it should consume the predicate.
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:
- the EA's real
NotConnectedsentence, and every prefix above — including the protocol-relative form — leave the app; - eleven in-app, page-route, near-miss (
/authors/…,/apiary/…,/assetsRegister/…,/statics/…), fragment,mailto:and cross-origin links are left exactly as they were; - an authored
targetsurvives, an authoredrel=openerdoes not, and a benign authoredrelis kept and stated once.
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.