Skip to content

fix(#1140): token validation is issued off the ROUTER - #4227

Merged
meshweaver-cloud[bot] merged 1 commit into
mainfrom
fix/1140-token-validation-off-router
Sep 13, 2026
Merged

meshweaver-cloud[bot] merged 1 commit into
mainfrom
fix/1140-token-validation-off-router

Conversation

@rbuergi

@rbuergi rbuergi commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

The defect

UserContextMiddleware resolves its hub as

var hub = context.RequestServices.GetService<PortalApplication>()?.Hub
          ?? context.RequestServices.GetRequiredService<IMessageHub>();

The first is the portal hub and is fine. The fallback resolves the ROOT MESH HUB in the root container — which is what a portal running without the Blazor shell (Features:Gui:Blazor=false) gets, and what any request arriving outside a circuit scope gets. ValidateTokenViaHub then posts from it, so the request reaches the ApiToken node's hub stamped Sender = mesh/{id} and the response is addressed straight back at the router.

ValidateTokenRequest/Response is named among the observed types in #1140's own evidence from memex, and the new test captures the pair verbatim on main:

RouterTrafficRecord { MessageType = RawJson,               Role = sender, Sender = mesh/5rc-…,          Target = ApiToken/24eb4c912dad }
RouterTrafficRecord { MessageType = ValidateTokenResponse, Role = target, Sender = ApiToken/24eb4c912dad, Target = mesh/5rc-… }

This is the second call site of #1140 and the one this repo owns; the first — StartThread, which produced the 09-11/09-12 samples — is MeshWeaver.Plugins#1803.

Why ReadIssuingHub and not NodeOperationIssuingHub

This is a bounded read with an HTTP request waiting on it. portal/reads-{meshId} registers no handlers, so its block only ever dispatches replies to reads issued on it. The node-CRUD execution hub would put this reply behind every write in flight — #2901's ~10.3 s-then-503 shape, on the authentication path.

Both seams are the identity function for any hub that is not the router, so the portal-hub path is byte-for-byte what it was.

Negative control, and the thing it caught

The fixture carries a positive control — TheCapture_RecordsAViolation_WhenTheRouterGenuinelyPostsWork makes the router an end on purpose and requires the line, asserting on role and ends rather than the message type.

It also carries a non-vacuity assertion that earned its place on the first run: the validation faulted with a DeliveryFailure, because nothing writes the ApiToken/{hashPrefix} routing index automatically (ApiTokenService does, in production). Both ROUTER_TRAFFIC assertions passed — having observed no exchange at all. The fixture now creates the index too, and response.Success.Should().BeTrue(...) is what caught it. An assertion that cannot fail is not an assertion, and this one could have shipped as a green test over a path that never ran.

Verified locally

the test without the change FAILS, having captured the two lines above
the test with the change 2/2 pass
Memex.Portal.Shared.Test 1468/1468 passed
dotnet build -c Release -warnaserror clean on MeshWeaver.Hosting.AspNetCore and the test project

Relationship to the other #1140 PRs

Refs #1140; does not close it. That closes on a boot of memex with zero ROUTER_TRAFFIC lines, which needs all three merged and rolled.

🤖 Generated with Claude Code

UserContextMiddleware resolves its hub as
`RequestServices.GetService<PortalApplication>()?.Hub ?? GetRequiredService<IMessageHub>()`.
The first is the portal hub and is fine. The FALLBACK resolves the ROOT MESH HUB
in the root container — which is what a portal without the Blazor shell
(Features:Gui:Blazor=false) gets, and what any request outside a circuit scope
gets. A ValidateTokenRequest posted there reaches the ApiToken node's hub stamped
`Sender = mesh/{id}` and its response is addressed straight back at the router.

`ValidateTokenRequest/Response` is named among the observed types in #1140's own
evidence from memex, and the new test captures the pair verbatim on main:

  RawJson               sender=mesh/5rc-…  target=ApiToken/24eb4c912dad
  ValidateTokenResponse sender=ApiToken/…  target=mesh/5rc-…

ReadIssuingHub, not NodeOperationIssuingHub. This is a bounded READ with an HTTP
request waiting on it: portal/reads-{meshId} registers NO handlers, so its block
only ever dispatches replies to reads issued on it, whereas the node-CRUD
execution hub would put this reply behind every write in flight — #2901's
~10.3 s-then-503 shape. Both seams are the identity function for any hub that is
not the router, so the portal-hub path is byte-for-byte what it was.

The test's "the token is real" assertion is not decoration: on its first run the
validation faulted with a DeliveryFailure (nothing writes the ApiToken/{prefix}
routing index automatically — ApiTokenService does, in production), and both
ROUTER_TRAFFIC assertions passed having observed no exchange at all. The fixture
now creates the index too, and the Success assertion is what caught it.

Verified: the test fails without the change and passes with it;
Memex.Portal.Shared.Test 1468/1468; `dotnet build -c Release -warnaserror` clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 13, 2026 19:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The teardown fallback can recreate router traffic, and test-related review findings remain unresolved.

Pull request overview

Moves API-token validation off the root message router onto the dedicated read hub.

Changes:

  • Routes validation through ReadIssuingHub().
  • Adds regression and positive-control integration tests.
  • Documents the fix in the What's New entry.
File summaries
File Summary
test/Memex.Portal.Shared.Test/TokenValidationIsIssuedOffTheRouterTest.cs Adds validation and router-isolation coverage; documentation scope and capture collection require changes.
src/MeshWeaver.Hosting.AspNetCore/Portal/UserContextMiddleware.cs Uses the read hub, but teardown fallback can still post through the root router.
src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-13-api-token-checks-no-longer-run-through-the-message-router.md Adds documentation for the API-token validation fix.
Review details

Suppressed comments (4)

src/MeshWeaver.Hosting.AspNetCore/Portal/UserContextMiddleware.cs:556

  • ReadIssuingHub() is not guaranteed to be off-router: MeshReadHub() returns null once the mesh reaches DisposeHostedHubs, and the extension explicitly falls back to the original hub. If a request races teardown, this call therefore posts ValidateTokenRequest from the root mesh hub again, recreating the exact ROUTER_TRAFFIC path this fix claims to eliminate. Handle an unavailable read hub as a validation-unavailable result instead of posting through the router.
                () => hub.ReadIssuingHub().Observe(
                        new ValidateTokenRequest(rawToken),
                        o => o.WithTarget(tokenAddress))
                    .Select(d => (ValidateTokenResponse?)d.Message))

src/MeshWeaver.Hosting.AspNetCore/Portal/UserContextMiddleware.cs:540

  • The fallback is not what every out-of-circuit request gets: PortalApplication is registered as scoped, and its own contract says SSR/prerender/middleware scopes without a circuit use a portal/{userId} hub. Only scopes where PortalApplication is unavailable (such as a no-Blazor installation) reach the root IMessageHub; please narrow this comment so it does not misstate which requests this fix affects.
        // 🚨 ISSUED OFF THE ROUTER (#1140). `hub` above is the portal hub when there is one — but
        // the fallback, `RequestServices.GetRequiredService<IMessageHub>()`, resolves the ROOT MESH
        // HUB in the root container, which is what a portal without the Blazor shell
        // (Features:Gui:Blazor=false) and any request outside a circuit scope gets. Posting there
        // makes the router an END of the delivery in both directions: the request reaches the

test/Memex.Portal.Shared.Test/TokenValidationIsIssuedOffTheRouterTest.cs:32

  • This test documentation repeats the same overbroad claim: SSR/prerender/middleware requests can have no circuit while still resolving the scoped PortalApplication, so they use a portal/{userId} hub rather than this root fallback. Please describe the fallback as applying when PortalApplication is not registered; otherwise the test claims to cover a request shape that its setup does not establish.
/// <para><c>UserContextMiddleware</c> resolves its hub as
/// <c>RequestServices.GetService&lt;PortalApplication&gt;()?.Hub ?? RequestServices.GetRequiredService&lt;IMessageHub&gt;()</c>.
/// The first is the portal hub and is fine. The FALLBACK resolves the ROOT MESH HUB in the root
/// container — which is what a portal running without the Blazor shell
/// (<c>Features:Gui:Blazor=false</c>) gets, and what any request outside a circuit scope gets. A

test/Memex.Portal.Shared.Test/TokenValidationIsIssuedOffTheRouterTest.cs:212

  • This new capture stores records in a mutable ConcurrentQueue. The repository's collection policy prohibits mutable collections in test/ (the only concurrent-mutation exception is an instance ConcurrentDictionary); use an immutable snapshot updated atomically, or an approved capture abstraction, so concurrent logger callbacks remain safe without introducing a forbidden collection.
        private readonly ConcurrentQueue<RouterTrafficRecord> _records = new();

        internal RouterTrafficRecord[] Records => _records.ToArray();
  • Files reviewed: 3/3 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

262 tests  ±0   262 ✅ ±0   1m 46s ⏱️ -12s
  1 suites ±0     0 💤 ±0 
  1 files   ±0     0 ❌ ±0 

Results for commit c3b48b7. ± Comparison against base commit 7be4af5.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

1 425 tests  ±0   1 425 ✅ ±0   1m 27s ⏱️ +4s
    2 suites ±0       0 💤 ±0 
    2 files   ±0       0 ❌ ±0 

Results for commit c3b48b7. ± Comparison against base commit 7be4af5.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

883 tests  ±0   883 ✅ ±0   3m 37s ⏱️ +16s
  2 suites ±0     0 💤 ±0 
  2 files   ±0     0 ❌ ±0 

Results for commit c3b48b7. ± Comparison against base commit 7be4af5.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

    6 files  ±0      6 suites  ±0   3m 58s ⏱️ -53s
2 095 tests +2  2 095 ✅ +2  0 💤 ±0  0 ❌ ±0 
2 099 runs  +2  2 099 ✅ +2  0 💤 ±0  0 ❌ ±0 

Results for commit c3b48b7. ± Comparison against base commit 7be4af5.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 829 tests  ±0   1 637 ✅ ±0   4m 48s ⏱️ +4s
    4 suites ±0     192 💤 ±0 
    4 files   ±0       0 ❌ ±0 

Results for commit c3b48b7. ± Comparison against base commit 7be4af5.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

1 047 tests  ±0   1 045 ✅ ±0   6m 27s ⏱️ +21s
    3 suites ±0       2 💤 ±0 
    3 files   ±0       0 ❌ ±0 

Results for commit c3b48b7. ± Comparison against base commit 7be4af5.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results

   18 files  ±0     18 suites  ±0   22m 4s ⏱️ -23s
7 541 tests +2  7 347 ✅ +2  194 💤 ±0  0 ❌ ±0 
7 545 runs  +2  7 351 ✅ +2  194 💤 ±0  0 ❌ ±0 

Results for commit c3b48b7. ± Comparison against base commit 7be4af5.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants