Skip to content

DESIGN: endpoint contributions on the module lane — the sixth MeshNodeProviderAttribute hook #1655

Closed

Description

@rbuergi

The wall this removes (documented in UiExtensibility → "What cannot be extended today"): there is no endpoint-contribution seam, so every HTTP surface belonging to a MODULE'S concern stays compiled in the portal, resolving its module service optionally and answering an actionable 503 when the module is delisted. The live inventory of exactly this shape:

  • /api/speech/transcribe — compiled in Portal.Shared; resolves ISpeechTranscriber optionally (MeshWeaver.Speech module).
  • /api/log-incidents — compiled; resolves the ILogIncidentIngest contract seam optionally (MeshWeaver.Observability module).
  • /api/og/{**path} — compiled, while the OgCard AREA rides the module (feat(modules): the OgCard link-preview area becomes a module #1643): the module's own HTTP half could not follow it.
  • The course-asset endpoints — the Wave-2 falsification that kept education runtime code compiled.
  • The Social/*Endpoints families — candidates only if their concerns ever modularize.

Proposal: a sixth hook on MeshNodeProviderAttribute:

public virtual IEnumerable<Action<IEndpointRouteBuilder>> EndpointConfigurations => [];

The host applies every installed module's endpoint configurations at its endpoint-mapping stage (after auth middleware, before the Blazor catch-all — modules load at boot via InstallAssemblies, well before MapEndpoints, so ordering is structurally safe). Delisting a module then removes its routes wholesale — a 404 instead of today's compiled 503 — and the compiled optional-service endpoints migrate INTO their modules.

Design questions for review (no code before sign-off)

  1. Contract width: raw IEndpointRouteBuilder (maximum power, host-framework coupling) vs a narrower record (pattern + verb + handler) the host maps itself. Raw is simpler and modules are TRUSTED CODE (unlike UiContribution data — the security model difference is load-bearing and must be documented); the narrow form would ease a future non-ASP.NET host but has no consumer today.
  2. Auth defaults: contributed endpoints should default to the authenticated policy with explicit opt-out (AllowAnonymous stays a deliberate per-route act — the /api/og fetcher and webhook inboxes need it), so a module cannot accidentally publish an open route.
  3. Route collisions: fail LOUD at startup on duplicate patterns (the silent-config-mismatch class) — two modules mapping /api/x must refuse to boot, not last-write-win.
  4. Hosting layering: Mesh.Contract must not reference ASP.NET. Either the hook lives in a small MeshWeaver.Hosting.AspNetCore attribute subclass, or EndpointConfigurations is typed Action<object>-shaped… the former is honest; proposal: a derived MeshNodeProviderAttribute member discovered by the HOST package, keeping the contract assembly clean.
  5. Migration list (each its own PR after the hook lands): speech transcribe → Speech; log-incidents → Observability; og preview → OgCard; course assets → revisit the Wave-2 falsification.
  6. Gate parity: the plugin compile gate is unaffected (modules stay in the compile surface via feat(modules): the modules/ publish layout, step 1 — probing, compile surface, fingerprint (#1644) #1653's InstalledModuleAssembly); the e2e that exercises a migrated endpoint must run with the module LISTED — same as any module feature.

Review wanted before any code — the same gate #1644 carried. Related: #1644 (modules-folder publish), #1645 (UI contributions — the DATA lane this deliberately is not), #1630 (bundles).

🤖 Generated with Claude Code

Activity

  1. added a commit that references this issue on Aug 16, 2026
  2. rbuergi commented on Aug 21, 2026

    @rbuergi
    ContributorAuthor

    Verdict: DONE — closing with evidence

    Verified against origin/main, item by item. The design asked for review before code; the review happened and the code landed with two deliberate, documented deviations from the proposal, both of which I judge correct.

    The hook

    src/MeshWeaver.Hosting.AspNetCore/MeshEndpointProviderAttribute.cs — public abstract IEnumerable<Action<IEndpointRouteBuilder>> EndpointConfigurations { get; }, applied by MeshModuleEndpointExtensions.MapMeshModuleEndpoints over every InstalledModuleAssembly. Consumers: SocialModuleAttribute (#1667) and GrpcModuleAttribute (#1670).

    Deviation 1 — it is NOT a sixth hook on MeshNodeProviderAttribute. It is a separate assembly attribute, for the two reasons design question 4 raised, now stated in the code: layering (Mesh.Contract must never reference ASP.NET) and timing (endpoints map after the auth middleware, not at mesh build). A module needing both carries both attributes. This is the "former is honest" option the issue itself preferred.

    The four design questions

    # question answer in code
    1 contract width raw IEndpointRouteBuilder. The trusted-code-vs-UiContribution-data distinction is documented in the attribute's <summary>, as the issue required.
    2 auth defaults app.MapGroup(string.Empty).RequireAuthorization() wraps every contribution; AllowAnonymous() is a per-route act. "A module cannot accidentally publish an open route."
    3 route collisions RegisterCollisionCheck at ApplicationStarted, over the materialized EndpointDataSource: logs Critical, throws AND StopApplication(). Platform-only duplicates exempt (precompressed static assets legitimately share a route). ASP.NET alone surfaces ambiguity only at REQUEST time — precisely the silent-until-hit mode the hook must not add.
    4 hosting layering see deviation 1. Mesh.Contract stays ASP.NET-free.

    The migration list — deliberately REVISED, and that is the right outcome

    Deviation 2, and the more interesting one. Doc/Architecture/Modules → "Which routes ride the module, and which stay in the host" (#1731) answers the list with a rule instead of executing it:

    The dividing question is whose API is it.

    • Social OAuth callbacks, meshweaver.v1.Mesh gRPC → ride the module. They exist only because the module does; a 404 when delisted is honest. ✅ migrated.
    • POST /api/speech/transcribe, POST /api/log-incidents → stay in the host behind the 503 seam, reversing the proposal. Two concrete failures if pushed onto the hook: the caller loses the diagnosis (an actionable "module not listed" 503 becomes an indistinguishable 404), and the route loses the host's specific authorization policy — speech needs the portal's Bearer-only McpAuth, whose challenge forwarding is what makes an unauthenticated API call answer 401 + WWW-Authenticate instead of a 302 to an HTML login. Naming a policy by string across an assembly boundary throws at request time in any host that never registered it. "Both failures pass CI and surface as the mobile app logs me out."
    • /api/og/{**path} stays in Memex.Portal.Shared/Api/SeoEndpoints.cs; MeshWeaver.OgCard ships the layout area only. Same rule — it is the portal's public link-preview surface, not the module's own protocol.
    • Course assets left the repo entirely with the fourteen modules (0b3b4266f); the Wave-2 falsification no longer has a subject.

    Doc/Architecture/UiExtensibility → "What cannot be extended today" now strikes the entry through:

    HTTP endpoints. No longer a wall … Mesh DATA (plugins' nodes) still cannot contribute endpoints — trusted compiled code only.

    No compiled endpoint of the "module's concern, host's route" shape survives unexamined: the two that remain compiled are there by a stated rule, not by omission. The wall this issue set out to remove is gone.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions