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.
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.