Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions MeshWeaver.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
<Project Path="src/MeshWeaver.Fixture/MeshWeaver.Fixture.csproj" />
<Project Path="src/MeshWeaver.Reactive.Assertions/MeshWeaver.Reactive.Assertions.csproj" />
<Project Path="src/MeshWeaver.Maps/MeshWeaver.Maps.csproj" />
<Project Path="src/MeshWeaver.Hosting.AspNetCore/MeshWeaver.Hosting.AspNetCore.csproj" />
<Project Path="src/MeshWeaver.OgCard/MeshWeaver.OgCard.csproj" />
<Project Path="src/MeshWeaver.Graph/MeshWeaver.Graph.csproj" />
<Project Path="src/MeshWeaver.GitSync/MeshWeaver.GitSync.csproj" />
Expand Down
1 change: 1 addition & 0 deletions memex/Memex.Portal.Shared/Memex.Portal.Shared.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
<ProjectReference Include="..\..\src\MeshWeaver.Hosting.AzureBlob\MeshWeaver.Hosting.AzureBlob.csproj" />
<ProjectReference Include="..\..\src\MeshWeaver.Hosting.Blazor\MeshWeaver.Hosting.Blazor.csproj" />
<ProjectReference Include="..\..\src\MeshWeaver.Hosting\MeshWeaver.Hosting.csproj" />
<ProjectReference Include="..\..\src\MeshWeaver.Hosting.AspNetCore\MeshWeaver.Hosting.AspNetCore.csproj" />
<ProjectReference Include="..\..\src\MeshWeaver.Hosting.SignalR\MeshWeaver.Hosting.SignalR.csproj" />
<ProjectReference Include="..\..\src\MeshWeaver.Hosting.Grpc\MeshWeaver.Hosting.Grpc.csproj" />
<ProjectReference Include="..\..\src\MeshWeaver.Graph\MeshWeaver.Graph.csproj" />
Expand Down
6 changes: 6 additions & 0 deletions memex/Memex.Portal.Shared/MemexConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
using MeshWeaver.Graph.Configuration;
using MeshWeaver.Hosting.AzureBlob;
using MeshWeaver.Hosting;
using MeshWeaver.Hosting.AspNetCore;
using MeshWeaver.Hosting.Blazor;
using MeshWeaver.Hosting.Persistence;
using MeshWeaver.Hosting.PostgreSql;
Expand Down Expand Up @@ -1127,6 +1128,11 @@ public static void StartMemexApplication<TApp>(this WebApplication app) where TA
// but with NO anonymous mode: it hands out compiled assemblies for paid modules.
app.MapPluginBundles();

// Module endpoint contributions (design #1655): every Modules:Assemblies DLL carrying a
// MeshEndpointProviderAttribute maps its routes here — authenticated by default, loud
// startup failure on route collisions. Delisting a module removes its routes wholesale.
app.MapMeshModuleEndpoints();

// First-startup auto-registration — POST /api/instances/register. A new deployment presents
// an admin-minted bootstrap key (mwr_) and receives its own instance key (mwi_) once;
// PluginCatalog:DefaultGrants seeding applies. The bootstrap key in the body IS the auth.
Expand Down
8 changes: 8 additions & 0 deletions src/MeshWeaver.Documentation/Data/Architecture/Modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ A module carries one assembly-level attribute deriving from `MeshNodeProviderAtt
| `DefaultNodeHubConfigurations` | Configuration applied to EVERY per-node hub (layout areas, type registrations) |
| `BuilderConfigurations` | The full-surface hook — a `MeshBuilder → MeshBuilder` fold, applied last |

HTTP endpoints ride a SEPARATE assembly attribute — `MeshEndpointProviderAttribute`
(`MeshWeaver.Hosting.AspNetCore`), applied by the host's `app.MapMeshModuleEndpoints()` at
endpoint-mapping time. The split is layering (the mesh contract never references ASP.NET) and
timing (endpoints map after the auth middleware). Every contribution maps inside an
authenticated-by-default group — a route is anonymous only where the module explicitly opts out —
and duplicate (verb, pattern) registrations refuse the app loudly at startup. Delisting the
module removes its routes wholesale: a 404, not a compiled optional-service 503.

Module DI options bind through the options pipeline —
`services.AddOptions<T>().BindConfiguration("Section")` — never `services.Configure(section)`:
there is no `IConfiguration` instance at install time. A module whose activation depends on
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,8 +208,10 @@ deliberately cannot express.

Honesty section — these are the known walls, so nobody burns a day rediscovering them:

- **HTTP endpoints.** There is no endpoint-contribution seam; `app.Map*()` calls live in the portal
composition. (This is what keeps the course-asset endpoint compiled in the portal.)
- ~~**HTTP endpoints.**~~ No longer a wall: a MODULE contributes routes via
`MeshEndpointProviderAttribute` (`MeshWeaver.Hosting.AspNetCore`) — authenticated by default,
loud startup refusal on route collisions; see [Modules](/Doc/Architecture/Modules). Mesh DATA
(plugins' nodes) still cannot contribute endpoints — trusted compiled code only.
- **Runtime `.razor` from mesh content.** The in-mesh compile pipeline is C#-only — there is no
Razor engine at runtime, and collectible recompiles would fight the Blazor renderer's
type-identity caching. Precompiled packs are the answer, not runtime Razor.
Expand Down
36 changes: 36 additions & 0 deletions src/MeshWeaver.Hosting.AspNetCore/MeshEndpointProviderAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using Microsoft.AspNetCore.Routing;

namespace MeshWeaver.Hosting.AspNetCore;

/// <summary>
/// The endpoint-contribution hook of the module lane (design #1655): a MODULE assembly carries one
/// attribute deriving from this, and the host applies its <see cref="EndpointConfigurations"/> at
/// endpoint-mapping time (<c>app.MapMeshModuleEndpoints()</c>). Delisting the module from
/// <c>Modules:Assemblies</c> removes its routes wholesale — a 404 instead of a compiled
/// optional-service 503.
///
/// <para>This is deliberately a SEPARATE attribute from <c>MeshNodeProviderAttribute</c>:
/// endpoint contributions are HOST-level (they need <see cref="IEndpointRouteBuilder"/>, an
/// ASP.NET surface the mesh contract must not reference), and they are applied at a different
/// time — endpoint mapping, after the auth middleware — than the mesh build. A module that
/// contributes both mesh registrations and endpoints carries both attributes.</para>
///
/// <para><b>Security model</b>: a module is TRUSTED CODE the deployment chose to list — unlike
/// <c>UiContribution</c> mesh DATA, whose closed vocabulary exists because data must never widen
/// anything. Two guardrails still apply, in the HOST: every contribution maps inside a group that
/// defaults to <c>RequireAuthorization()</c> — a route is only anonymous where the module
/// explicitly says <c>AllowAnonymous()</c> — and route collisions fail the app LOUDLY at startup
/// (never last-write-wins; a silent skip is the #683 trapdoor class).</para>
/// </summary>
[AttributeUsage(AttributeTargets.Assembly)]
public abstract class MeshEndpointProviderAttribute : Attribute
{
/// <summary>
/// The module's endpoint registrations. Each action receives a route-group builder that
/// already carries the authenticated-by-default policy; register routes exactly as in a host
/// (<c>MapGet</c>/<c>MapPost</c>/<c>MapGrpcService</c>/…), opting out per route via
/// <c>AllowAnonymous()</c> where a route is genuinely public (webhook inboxes, link
/// previews).
/// </summary>
public abstract IEnumerable<Action<IEndpointRouteBuilder>> EndpointConfigurations { get; }
}
105 changes: 105 additions & 0 deletions src/MeshWeaver.Hosting.AspNetCore/MeshModuleEndpointExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
using System.Reflection;
using MeshWeaver.Mesh;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace MeshWeaver.Hosting.AspNetCore;

/// <summary>
/// Applies every installed module's <see cref="MeshEndpointProviderAttribute"/> contributions —
/// the host half of the endpoint-contribution hook (design #1655).
/// </summary>
public static class MeshModuleEndpointExtensions
{
/// <summary>
/// Maps the endpoint contributions of every installed module (<c>Modules:Assemblies</c> →
/// <see cref="InstalledModuleAssembly"/>). Call it with the host's other <c>Map*</c> calls —
/// after authentication/authorization middleware, before any catch-all fallback.
///
/// <para>Each module's routes map inside a group defaulting to
/// <c>RequireAuthorization()</c>; a route is anonymous only where the module explicitly opts
/// out. On <c>ApplicationStarted</c> the whole endpoint table is checked for duplicate
/// (verb, pattern) registrations — a collision throws and takes the app down, because a
/// silently shadowed route is indistinguishable from a passing one (the #683 class).</para>
/// </summary>
public static WebApplication MapMeshModuleEndpoints(this WebApplication app)
{
var logger = app.Services.GetRequiredService<ILoggerFactory>()
.CreateLogger(typeof(MeshModuleEndpointExtensions));

var contributed = 0;
foreach (var module in app.Services.GetServices<InstalledModuleAssembly>())
foreach (var attribute in module.Assembly.GetCustomAttributes<MeshEndpointProviderAttribute>())
{
// Authenticated-by-default: the group policy applies to every route the module maps
// unless the route itself declares AllowAnonymous — a module cannot accidentally
// publish an open route.
var group = app.MapGroup(string.Empty).RequireAuthorization();
foreach (var configure in attribute.EndpointConfigurations)
{
configure(group);
contributed++;
}
logger.LogInformation(
"Mapped endpoint contributions from module {Module} ({Attribute})",
module.Assembly.GetName().Name, attribute.GetType().Name);
}

if (contributed > 0)
RegisterCollisionCheck(app, logger);
return app;
}

/// <summary>
/// LOUD duplicate-route detection, run once at <c>ApplicationStarted</c> when the endpoint
/// table is fully materialized: two endpoints on the same (verb, pattern) — module vs module
/// or module vs platform — throw with both display names. ASP.NET alone only surfaces the
/// ambiguity at REQUEST time, which is exactly the silent-until-hit failure the hook must not
/// introduce.
/// </summary>
private static void RegisterCollisionCheck(WebApplication app, ILogger logger)
{
app.Lifetime.ApplicationStarted.Register(() =>
{
var detail = FindRouteCollisions(
app.Services.GetRequiredService<EndpointDataSource>().Endpoints);
if (detail is null)
return;
logger.LogCritical(
"Endpoint route collision(s) after module contributions — refusing to serve: {Detail}", detail);
// Belt and braces: StopApplication() guarantees shutdown even where the runtime
// swallows a lifetime-callback exception; the throw makes the refusal visible in
// crash telemetry where it does propagate. Together: logged, stopped, loud.
app.Lifetime.StopApplication();
throw new InvalidOperationException(
$"Endpoint route collision(s) after module endpoint contributions: {detail}. "
+ "Two registrations on one (verb, pattern) mean one of them silently shadows the "
+ "other — remove or re-route the duplicate; never rely on registration order.");
});
}

/// <summary>
/// The pure collision predicate: duplicate (verb, pattern) pairs across the endpoint table,
/// or null when clean. Extracted so the refusal logic is unit-testable without a running host.
/// </summary>
internal static string? FindRouteCollisions(IEnumerable<Endpoint> endpoints)
{
var duplicates = endpoints
.OfType<RouteEndpoint>()
.SelectMany(endpoint =>
(endpoint.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods
?? (IReadOnlyList<string>)["*"])
.Select(verb => (Verb: verb, Pattern: endpoint.RoutePattern.RawText ?? "", Endpoint: endpoint)))
.Where(e => e.Pattern.Length > 0)
.GroupBy(e => (e.Verb, e.Pattern))
.Where(g => g.Count() > 1)
.ToList();
return duplicates.Count == 0
? null
: string.Join("; ", duplicates.Select(g =>
$"{g.Key.Verb} {g.Key.Pattern} ← [{string.Join(" | ", g.Select(e => e.Endpoint.DisplayName))}]"));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<Description>ASP.NET Core host integration for MeshWeaver modules: the endpoint-contribution hook — a module assembly declares HTTP endpoints the host maps at startup (design #1655).</Description>
</PropertyGroup>

<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="MeshWeaver.Hosting.Monolith.Test" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\MeshWeaver.Mesh.Contract\MeshWeaver.Mesh.Contract.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#pragma warning disable CS1591

using System;
using System.Collections.Generic;
using System.Linq;
using MeshWeaver.Hosting.AspNetCore;
using MeshWeaver.Hosting.Monolith.Test;
using MeshWeaver.Mesh;
Comment thread
rbuergi marked this conversation as resolved.
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Patterns;
using Microsoft.Extensions.DependencyInjection;
using Xunit;

[assembly: TestModuleEndpoints]

namespace MeshWeaver.Hosting.Monolith.Test;

/// <summary>
/// A test module-endpoint contribution living on THIS assembly — the discovery walks
/// <see cref="MeshEndpointProviderAttribute"/>s on installed module assemblies, so the test
/// assembly plays the module.
/// </summary>
[AttributeUsage(AttributeTargets.Assembly)]
public sealed class TestModuleEndpointsAttribute : MeshEndpointProviderAttribute
{
public const string SecuredRoute = "/api/test-module/secured";
public const string PublicRoute = "/api/test-module/public";

public override IEnumerable<Action<IEndpointRouteBuilder>> EndpointConfigurations =>
[
endpoints =>
{
endpoints.MapGet(SecuredRoute, () => Results.Ok("secured"));
endpoints.MapGet(PublicRoute, () => Results.Ok("public")).AllowAnonymous();
},
];
}

/// <summary>
/// Pins the endpoint-contribution hook (design #1655): discovery over
/// <see cref="InstalledModuleAssembly"/>, the authenticated-by-default group with per-route
/// anonymous opt-out, and the loud (verb, pattern) collision refusal.
/// </summary>
public class ModuleEndpointContributionTest
{
private static WebApplication BuildAppWithTestModule()
{
// The app is BUILT but never started — endpoint metadata is inspectable without Kestrel.
var builder = WebApplication.CreateBuilder();
builder.Services.AddAuthorization();
builder.Services.AddSingleton(
new InstalledModuleAssembly(typeof(ModuleEndpointContributionTest).Assembly));
var app = builder.Build();
app.MapMeshModuleEndpoints();
return app;
}

private static IReadOnlyList<RouteEndpoint> ContributedEndpoints(WebApplication app) =>
((IEndpointRouteBuilder)app).DataSources
.SelectMany(source => source.Endpoints)
.OfType<RouteEndpoint>()
.Where(e => e.RoutePattern.RawText?.Contains("test-module") == true)
.ToList();

[Fact]
public void ModuleEndpoints_AreDiscovered_AndAuthenticatedByDefault()
{
using var app = BuildAppWithTestModule();
var endpoints = ContributedEndpoints(app);
Assert.Equal(2, endpoints.Count);

var secured = Assert.Single(endpoints,
e => e.RoutePattern.RawText == TestModuleEndpointsAttribute.SecuredRoute);
// The group default: authorization metadata present, no anonymous escape.
Assert.NotNull(secured.Metadata.GetMetadata<IAuthorizeData>());
Assert.Null(secured.Metadata.GetMetadata<IAllowAnonymous>());

// The explicit per-route opt-out is the ONLY way a contributed route is anonymous.
var open = Assert.Single(endpoints,
e => e.RoutePattern.RawText == TestModuleEndpointsAttribute.PublicRoute);
Assert.NotNull(open.Metadata.GetMetadata<IAllowAnonymous>());
}

[Fact]
public void RouteCollisions_AreDetected_WithBothPartiesNamed()
{
static RouteEndpoint Endpoint(string pattern, string verb, string display) =>
new(
_ => System.Threading.Tasks.Task.CompletedTask,
RoutePatternFactory.Parse(pattern),
order: 0,
new EndpointMetadataCollection(new HttpMethodMetadata([verb])),
display);

// Same pattern, same verb — collision, both display names surfaced.
var detail = MeshModuleEndpointExtensions.FindRouteCollisions(
[
Endpoint("/api/x", "GET", "platform: X"),
Endpoint("/api/x", "GET", "module: X"),
]);
Assert.NotNull(detail);
Assert.Contains("platform: X", detail);
Assert.Contains("module: X", detail);

// Same pattern, DIFFERENT verb — legitimate, no collision.
Assert.Null(MeshModuleEndpointExtensions.FindRouteCollisions(
[
Endpoint("/api/x", "GET", "a"),
Endpoint("/api/x", "POST", "b"),
]));
}
}
Loading