diff --git a/memex/Memex.Portal.Monolith/appsettings.json b/memex/Memex.Portal.Monolith/appsettings.json index ab40f33362..8ad1902432 100644 --- a/memex/Memex.Portal.Monolith/appsettings.json +++ b/memex/Memex.Portal.Monolith/appsettings.json @@ -33,7 +33,8 @@ "MeshWeaver.Markdown.Export.dll", "MeshWeaver.Observability.dll", "MeshWeaver.OgCard.dll", - "MeshWeaver.Approvals.dll" + "MeshWeaver.Approvals.dll", + "MeshWeaver.Social.dll" ] } } \ No newline at end of file diff --git a/memex/Memex.Portal.Shared/MemexConfiguration.cs b/memex/Memex.Portal.Shared/MemexConfiguration.cs index 8eb81c936b..630ebfa086 100644 --- a/memex/Memex.Portal.Shared/MemexConfiguration.cs +++ b/memex/Memex.Portal.Shared/MemexConfiguration.cs @@ -319,41 +319,13 @@ public static void ConfigureMemexServices(this WebApplicationBuilder builder) } // CopilotConnectStrategy registers from the Copilot pack (Modules:Assemblies). - // Social publishing — the LinkedIn connect + pull endpoints. (The never-wired - // hosted-service pipeline around AddSocialPublishing was deleted as unreachable; - // if scheduling/ingest ever returns it will be built node-native in the - // SocialMedia repo, not compiled here.) - var linkedInClientId = builder.Configuration["Social:LinkedIn:ClientId"]; - if (!string.IsNullOrEmpty(linkedInClientId)) - { - services.AddHttpClient(); - services.AddSingleton(new MeshWeaver.Social.LinkedInOptions - { - ClientId = linkedInClientId!, - ClientSecret = builder.Configuration["Social:LinkedIn:ClientSecret"] ?? "" - }); - - // Add the menu provider so "Connect LinkedIn" + "Pull LinkedIn posts" - // appear on the viewer's own user page. - services.TryAddEnumerable( - Microsoft.Extensions.DependencyInjection.ServiceDescriptor.Scoped< - MeshWeaver.Mesh.INodeMenuProvider, - Memex.Portal.Shared.Social.LinkedInCredentialMenuProvider>()); - - // Add the Publish/Refresh-engagement actions to a social-media Post node's menu. - services.TryAddEnumerable( - Microsoft.Extensions.DependencyInjection.ServiceDescriptor.Scoped< - MeshWeaver.Mesh.INodeMenuProvider, - Memex.Portal.Shared.Social.SocialPostMenuProvider>()); - - // (Removed: SocialMediaUserMenuProvider — hardcoded a NodeType - // ("Systemorph/SocialMediaHub") that isn't registered anywhere in - // the codebase. NodeTypes belong in the database (NodeTypeDefinition - // MeshNodes), not as DLL-side string constants. The SocialMedia - // hub feature should be added back when its NodeType is defined - // through the regular mesh node creation flow rather than wired - // through a DLL-time CreateNode that fails on the receiver.) - } + // Social publishing (LinkedIn connect/publish/page-sync + node-menu providers) rides the + // MeshWeaver.Social MODULE (Modules:Assemblies): SocialMeshModuleAttribute registers the + // DI services + menu providers, SocialModuleAttribute contributes the endpoints via + // app.MapMeshModuleEndpoints() below. Only the ApiCredential NodeType registration + // (AddApiCredentialType) and the LinkedIn SIGN-IN scheme (AddLinkedInAuthentication) + // stay compiled here — existing credential nodes must deserialize and auth schemes + // configure before the host builds, module or no module. // Configure authentication var authSection = builder.Configuration.GetSection(PortalAuthOptions.SectionName); @@ -1073,7 +1045,7 @@ public static void StartMemexApplication(this WebApplication app) where TA app.UseCookiePolicy(); // User-context middleware MUST run BEFORE the terminal endpoint maps - // (MapMeshMcp / MapMeshWeaver / MapLinkedInConnect). Once a request + // (MapMeshMcp / MapMeshWeaver / MapGitHubConnect). Once a request // matches a terminal endpoint, no further `app.UseMiddleware<…>()` // registered AFTER the Map* call ever sees it. With UserContextMiddleware // after MapMeshMcp, MCP-Bearer requests skipped it entirely → @@ -1163,19 +1135,8 @@ public static void StartMemexApplication(this WebApplication app) where TA // override cookie and redirects — the reversible switch both shells link to. app.MapFrontendSelection(); - - // Social publishing — LinkedIn connect/pull endpoints. Must be AFTER - // UseAuthentication so HttpContext.User is populated. - app.MapLinkedInConnect(); - - // LinkedIn company-Page sync (Community Management API) — org-scope OAuth + - // posts/statistics pull. Same ordering requirement (needs HttpContext.User). - app.MapLinkedInPageSync(); - - // LinkedIn member publishing + engagement — POST /linkedin/publish (JSON API) plus - // GET /linkedin/publish and GET /linkedin/engagement triggers surfaced from the Post - // node menu (w_member_social scope). Same ordering requirement (needs HttpContext.User). - app.MapLinkedInPublish(); + // (LinkedIn connect/publish/page-sync endpoints ride the MeshWeaver.Social module — + // contributed through app.MapMeshModuleEndpoints() above via SocialModuleAttribute.) // GitHub Sync — OAuth authorization-code connect endpoints (same ordering // requirement: needs HttpContext.User). Stores the per-user token at diff --git a/memex/Memex.Portal.Shared/Social/GitHubConnectEndpoints.cs b/memex/Memex.Portal.Shared/Social/GitHubConnectEndpoints.cs index e63ec48fcc..f2e03d625f 100644 --- a/memex/Memex.Portal.Shared/Social/GitHubConnectEndpoints.cs +++ b/memex/Memex.Portal.Shared/Social/GitHubConnectEndpoints.cs @@ -22,8 +22,9 @@ namespace Memex.Portal.Shared.Social; /// /// GitHub OAuth2 authorization-code flow for connecting a user's GitHub identity for /// GitHub Sync. Mirrors -/// — the deployed surface is just the parts that -/// need browser cookies + a whitelisted callback URL: +/// (now contributed by the Social +/// module) — the deployed surface is just the parts that need browser cookies + a +/// whitelisted callback URL: /// /// GET /connect/github/me — redirect into the flow for the signed-in user /// GET /connect/github?returnPath={path} — start (CSRF cookie, redirect to GitHub authorize) diff --git a/memex/MeshModulesPublish.targets b/memex/MeshModulesPublish.targets index 140cb6679b..64c0909203 100644 --- a/memex/MeshModulesPublish.targets +++ b/memex/MeshModulesPublish.targets @@ -49,6 +49,7 @@ + diff --git a/memex/aspire/Memex.Portal.Distributed/appsettings.json b/memex/aspire/Memex.Portal.Distributed/appsettings.json index 4059202de4..a44bd970a5 100644 --- a/memex/aspire/Memex.Portal.Distributed/appsettings.json +++ b/memex/aspire/Memex.Portal.Distributed/appsettings.json @@ -15,7 +15,8 @@ "MeshWeaver.Markdown.Export.dll", "MeshWeaver.Observability.dll", "MeshWeaver.OgCard.dll", - "MeshWeaver.Approvals.dll" + "MeshWeaver.Approvals.dll", + "MeshWeaver.Social.dll" ] }, // Production logging. diff --git a/src/MeshWeaver.Documentation/Data/Architecture/Modules.md b/src/MeshWeaver.Documentation/Data/Architecture/Modules.md index 9fda6886e0..cf8f9a28ae 100644 --- a/src/MeshWeaver.Documentation/Data/Architecture/Modules.md +++ b/src/MeshWeaver.Documentation/Data/Architecture/Modules.md @@ -30,6 +30,9 @@ timing (endpoints map after the auth middleware). Every contribution maps inside 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. +`MeshWeaver.Social` is the first consumer — its LinkedIn connect/publish/page-sync routes ride +this hook, with the two OAuth callback routes opting out via `AllowAnonymous` (LinkedIn's +redirect must not bounce through a login challenge; the CSRF state cookie is the guard). Module DI options bind through the options pipeline — `services.AddOptions().BindConfiguration("Section")` — never `services.Configure(section)`: @@ -61,6 +64,7 @@ current first-party inventory and each module's configuration section: | `MeshWeaver.Markdown.Export.dll` | Document export (PDF/DOCX/HTML/email) | — | | `MeshWeaver.Observability.dll` | Red-log ticketing / log watch | `LogWatch` | | `MeshWeaver.OgCard.dll` | Link-preview (og-card) layout area | — | +| `MeshWeaver.Social.dll` | LinkedIn publishing: connect/publish/page-sync endpoints + node-menu actions | `Social:LinkedIn` | Boot packs select by OTHER configuration too: `Graph:Storage:Type` `Cosmos`/`Snowflake` requires the matching `MeshWeaver.Hosting.Cosmos`/`.Snowflake` DLL in this list — installation runs before diff --git a/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/LinkedInPublishing.md b/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/LinkedInPublishing.md index 177c72f1c7..a158a096c8 100644 --- a/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/LinkedInPublishing.md +++ b/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/LinkedInPublishing.md @@ -9,7 +9,7 @@ Icon: ) — just the parts that need to live in a +/// compiled binary because they involve browser cookies, HTTP routing, and a +/// callback URL whitelisted on LinkedIn: /// /// GET /connect/linkedin/me — convenience: redirect into the flow for the signed-in user /// GET /connect/linkedin?profile={path} — start the flow (sets CSRF cookie, redirects to LinkedIn) @@ -40,9 +33,11 @@ namespace Memex.Portal.Shared.Social; /// public static class LinkedInConnectEndpoints { + /// Name of the CSRF state cookie set when starting the connect flow. public const string StateCookieName = "lnkd_connect_state"; private const string CallbackPath = "/connect/linkedin/callback"; + /// Registers the LinkedIn connect endpoints. public static IEndpointRouteBuilder MapLinkedInConnect(this IEndpointRouteBuilder endpoints) { // Convenience: bind the credential to the authenticated user's own User node. @@ -57,7 +52,7 @@ public static IEndpointRouteBuilder MapLinkedInConnect(this IEndpointRouteBuilde endpoints.MapGet("/connect/linkedin", ( HttpContext http, [Microsoft.AspNetCore.Mvc.FromQuery] string profile, - IConfiguration config) => + [Microsoft.AspNetCore.Mvc.FromServices] IConfiguration config) => { if (!http.User.Identity?.IsAuthenticated ?? true) return Results.Challenge(new AuthenticationProperties { RedirectUri = http.Request.Path + http.Request.QueryString }); @@ -103,16 +98,21 @@ public static IEndpointRouteBuilder MapLinkedInConnect(this IEndpointRouteBuilde return Results.Redirect(url); }).RequireAuthorization(); + // DI parameters are EXPLICIT ([FromServices]) throughout these module-contributed + // endpoints: parameter inference classifies a service parameter by asking the host's + // container (IServiceProviderIsService) at endpoint-materialization time, and a module + // must not depend on what the host happens to have registered to keep its parameters + // from degrading into inferred-body binding. endpoints.MapGet(CallbackPath, async ( HttpContext http, [Microsoft.AspNetCore.Mvc.FromQuery] string? code, [Microsoft.AspNetCore.Mvc.FromQuery] string? state, [Microsoft.AspNetCore.Mvc.FromQuery] string? error, - IConfiguration config, - IHttpClientFactory httpFactory, - IMeshService mesh, - IMessageHub hub, - ILoggerFactory loggers) => + [Microsoft.AspNetCore.Mvc.FromServices] IConfiguration config, + [Microsoft.AspNetCore.Mvc.FromServices] IHttpClientFactory httpFactory, + [Microsoft.AspNetCore.Mvc.FromServices] IMeshService mesh, + [Microsoft.AspNetCore.Mvc.FromServices] IMessageHub hub, + [Microsoft.AspNetCore.Mvc.FromServices] ILoggerFactory loggers) => { var logger = loggers.CreateLogger("LinkedInConnect"); @@ -198,7 +198,7 @@ public static IEndpointRouteBuilder MapLinkedInConnect(this IEndpointRouteBuilde var credentialNode = new MeshNode("linkedin", profilePath + "/_ApiCredentials") { Name = "LinkedIn credential", - NodeType = ApiCredentialNodeType.NodeType, + NodeType = PlatformCredential.ApiCredentialNodeType, Content = credential, State = MeshNodeState.Active, }; @@ -290,7 +290,13 @@ public static IEndpointRouteBuilder MapLinkedInConnect(this IEndpointRouteBuilde }); return await tcs.Task; - }); + }) + // ANONYMOUS BY DESIGN, exactly as before the module move: this is LinkedIn's OAuth + // redirect target — routing it through the module group's authenticated-by-default + // policy could bounce the redirect into a login challenge and drop the code/state + // query. The CSRF state cookie (set by the authenticated /connect/linkedin start) + // is the guard; without it the request is rejected before any token exchange. + .AllowAnonymous(); return endpoints; } diff --git a/memex/Memex.Portal.Shared/Social/LinkedInCredentialMenuProvider.cs b/src/MeshWeaver.Social/LinkedInCredentialMenuProvider.cs similarity index 75% rename from memex/Memex.Portal.Shared/Social/LinkedInCredentialMenuProvider.cs rename to src/MeshWeaver.Social/LinkedInCredentialMenuProvider.cs index 1fb7d8c489..dbf2cc237f 100644 --- a/memex/Memex.Portal.Shared/Social/LinkedInCredentialMenuProvider.cs +++ b/src/MeshWeaver.Social/LinkedInCredentialMenuProvider.cs @@ -1,16 +1,13 @@ -using System.Linq; using System.Reactive.Linq; -using MeshWeaver.Data; using MeshWeaver.Graph; using MeshWeaver.Graph.Configuration; using MeshWeaver.Layout.Composition; using MeshWeaver.Mesh; using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; using MeshWeaver.Messaging; -using MeshWeaver.Social; +using Microsoft.Extensions.Options; -namespace Memex.Portal.Shared.Social; +namespace MeshWeaver.Social; /// /// Adds two contextual menu items for the LinkedIn publishing integration: @@ -26,9 +23,17 @@ namespace Memex.Portal.Shared.Social; /// /// Both items require Update permission on the target node, which the viewer /// has by definition on their own user + satellites. +/// +/// The credential-presence and node-shape predicates are beyond the closed +/// UiContribution vocabulary, so this stays a DI provider — registered by the +/// Social module's configure path. It emits +/// nothing while Social:LinkedIn:ClientId is unconfigured, preserving the +/// pre-module behaviour where the provider was only registered when configured. /// -public sealed class LinkedInCredentialMenuProvider : INodeMenuProvider +/// LinkedIn app options; an empty ClientId keeps the provider inert. +public sealed class LinkedInCredentialMenuProvider(IOptions options) : INodeMenuProvider { + /// public string Context => NodeMenuItemsExtensions.NodeMenuContext; /// @@ -40,6 +45,9 @@ public sealed class LinkedInCredentialMenuProvider : INodeMenuProvider public IObservable> GetItems( LayoutAreaHost host, RenderingContext ctx) { + if (string.IsNullOrEmpty(options.Value.ClientId)) + return Observable.Return>([]); + var hubPath = host.Hub.Address.ToString(); var accessService = host.Hub.ServiceProvider.GetService(typeof(AccessService)) as AccessService; var viewerId = accessService?.Context?.ObjectId @@ -67,8 +75,8 @@ private static IObservable> BuildIte return Observable.Return(empty); // Case 1: viewer's own User node. - if (hubPath.Equals($"User/{viewerId}", System.StringComparison.OrdinalIgnoreCase) - && string.Equals(node.NodeType, "User", System.StringComparison.OrdinalIgnoreCase)) + if (hubPath.Equals($"User/{viewerId}", StringComparison.OrdinalIgnoreCase) + && string.Equals(node.NodeType, "User", StringComparison.OrdinalIgnoreCase)) { // Only show "Link LinkedIn" when no credential exists yet. Synced query via // workspace.GetQuery — bypasses RLS, gated on Initial, deduped by path. Live, so the @@ -91,10 +99,12 @@ private static IObservable> BuildIte } // Case 2: an ApiCredential node for LinkedIn. - if (string.Equals(node.NodeType, ApiCredentialNodeType.NodeType, System.StringComparison.OrdinalIgnoreCase)) + if (string.Equals(node.NodeType, PlatformCredential.ApiCredentialNodeType, StringComparison.OrdinalIgnoreCase)) { - var platform = ExtractPlatform(node); - if (!string.Equals(platform, LinkedInPublisher.PlatformId, System.StringComparison.OrdinalIgnoreCase)) + // ContentAs, never a direct cast: on a hub without the type registered the content + // degrades to a JsonElement, which the accessor recovers (ObjectAsExtensions). + var platform = node.ContentAs(host.Hub.JsonSerializerOptions)?.Platform; + if (!string.Equals(platform, LinkedInPublisher.PlatformId, StringComparison.OrdinalIgnoreCase)) return Observable.Return(empty); // The credential node lives at {userPath}/_ApiCredentials/{platform} — the @@ -112,27 +122,17 @@ private static IObservable> BuildIte Icon: "ArrowDownload", RequiredPermission: Permission.Update, Order: 10, - Href: $"/connect/linkedin/pull?profile={System.Uri.EscapeDataString(userPath)}"), + Href: $"/connect/linkedin/pull?profile={Uri.EscapeDataString(userPath)}"), new NodeMenuItemDefinition( Label: "Re-authorize", Area: "ReAuthorizeLinkedIn", Icon: "ArrowSync", RequiredPermission: Permission.Update, Order: 20, - Href: $"/connect/linkedin?profile={System.Uri.EscapeDataString(userPath)}"), + Href: $"/connect/linkedin?profile={Uri.EscapeDataString(userPath)}"), ]); } return Observable.Return(empty); } - - private static string? ExtractPlatform(MeshNode node) - { - if (node.Content is PlatformCredential typed) return typed.Platform; - if (node.Content is System.Text.Json.JsonElement je - && je.TryGetProperty("platform", out var p) - && p.ValueKind == System.Text.Json.JsonValueKind.String) - return p.GetString(); - return null; - } } diff --git a/memex/Memex.Portal.Shared/Social/LinkedInPageSyncEndpoints.cs b/src/MeshWeaver.Social/LinkedInPageSyncEndpoints.cs similarity index 92% rename from memex/Memex.Portal.Shared/Social/LinkedInPageSyncEndpoints.cs rename to src/MeshWeaver.Social/LinkedInPageSyncEndpoints.cs index 3310076272..e2de04b252 100644 --- a/memex/Memex.Portal.Shared/Social/LinkedInPageSyncEndpoints.cs +++ b/src/MeshWeaver.Social/LinkedInPageSyncEndpoints.cs @@ -1,17 +1,11 @@ -using System; -using System.Collections.Generic; -using System.Net.Http; using System.Net.Http.Headers; using System.Reactive.Linq; using System.Reactive.Threading.Tasks; using System.Security.Cryptography; using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; using MeshWeaver.Mesh; using MeshWeaver.Mesh.Services; using MeshWeaver.Messaging; -using MeshWeaver.Social; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -20,7 +14,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; -namespace Memex.Portal.Shared.Social; +namespace MeshWeaver.Social; /// /// LinkedIn Community Management API sync for company Pages the signed-in user @@ -63,11 +57,13 @@ public static class LinkedInPageSyncEndpoints /// Registers the org-connect + Page-sync endpoints. Call alongside MapLinkedInConnect(). public static IEndpointRouteBuilder MapLinkedInPageSync(this IEndpointRouteBuilder endpoints) { + // DI parameters are EXPLICIT ([FromServices]) throughout — see LinkedInConnectEndpoints: + // a module's endpoints must not depend on host-container inference to classify them. // 1) Start the org OAuth flow for a specific Page node. endpoints.MapGet("/connect/linkedin/org", ( HttpContext http, [Microsoft.AspNetCore.Mvc.FromQuery] string page, - IConfiguration config) => + [Microsoft.AspNetCore.Mvc.FromServices] IConfiguration config) => { if (!http.User.Identity?.IsAuthenticated ?? true) return Results.Challenge(new AuthenticationProperties { RedirectUri = http.Request.Path + http.Request.QueryString }); @@ -105,10 +101,10 @@ public static IEndpointRouteBuilder MapLinkedInPageSync(this IEndpointRouteBuild [Microsoft.AspNetCore.Mvc.FromQuery] string? code, [Microsoft.AspNetCore.Mvc.FromQuery] string? state, [Microsoft.AspNetCore.Mvc.FromQuery] string? error, - IConfiguration config, - IHttpClientFactory httpFactory, - IMeshService mesh, - ILoggerFactory loggers) => + [Microsoft.AspNetCore.Mvc.FromServices] IConfiguration config, + [Microsoft.AspNetCore.Mvc.FromServices] IHttpClientFactory httpFactory, + [Microsoft.AspNetCore.Mvc.FromServices] IMeshService mesh, + [Microsoft.AspNetCore.Mvc.FromServices] ILoggerFactory loggers) => { var logger = loggers.CreateLogger("LinkedInPageSync"); @@ -187,7 +183,7 @@ public static IEndpointRouteBuilder MapLinkedInPageSync(this IEndpointRouteBuild var credentialNode = new MeshNode("linkedin", pagePath + "/_ApiCredentials") { Name = "LinkedIn credential", - NodeType = ApiCredentialNodeType.NodeType, + NodeType = PlatformCredential.ApiCredentialNodeType, Content = credential, State = MeshNodeState.Active, }; @@ -204,16 +200,22 @@ public static IEndpointRouteBuilder MapLinkedInPageSync(this IEndpointRouteBuild logger.LogInformation("Stored LinkedIn org credential for {Page} (org {Org})", pagePath, orgUrn ?? "(undiscovered)"); return result; - }); + }) + // ANONYMOUS BY DESIGN, exactly as before the module move: this is LinkedIn's OAuth + // redirect target — routing it through the module group's authenticated-by-default + // policy could bounce the redirect into a login challenge and drop the code/state + // query. The CSRF state cookie (set by the authenticated /connect/linkedin/org + // start) is the guard; without it the request is rejected before any token exchange. + .AllowAnonymous(); // 3) Sync — pull the Page's posts + share statistics into Post nodes under the Page. endpoints.MapGet("/connect/linkedin/sync", async ( HttpContext http, [Microsoft.AspNetCore.Mvc.FromQuery] string page, - IHttpClientFactory httpFactory, - IMessageHub hub, - IMeshService mesh, - ILoggerFactory loggers) => + [Microsoft.AspNetCore.Mvc.FromServices] IHttpClientFactory httpFactory, + [Microsoft.AspNetCore.Mvc.FromServices] IMessageHub hub, + [Microsoft.AspNetCore.Mvc.FromServices] IMeshService mesh, + [Microsoft.AspNetCore.Mvc.FromServices] ILoggerFactory loggers) => { var logger = loggers.CreateLogger("LinkedInPageSync"); if (!http.User.Identity?.IsAuthenticated ?? true) @@ -221,12 +223,14 @@ public static IEndpointRouteBuilder MapLinkedInPageSync(this IEndpointRouteBuild if (!IsSafePagePath(page)) return Results.BadRequest("page must be a mesh node path, e.g. LinkedIn/Systemorph."); - // Read the stored org credential (typed — ApiCredentialNodeType registers PlatformCredential). + // Read the stored org credential (typed — the host's ApiCredential NodeType registers + // PlatformCredential). ContentAs, never a direct cast: a hub whose registry misses the + // type hands back a degraded JsonElement, which the accessor recovers (ObjectAsExtensions). PlatformCredential? credential; try { credential = await hub.GetMeshNodeStream(page + "/_ApiCredentials/linkedin") - .Select(n => n?.Content as PlatformCredential) + .Select(n => n?.ContentAs(hub.JsonSerializerOptions, logger)) .Where(c => c is not null) .Take(1) .Timeout(TimeSpan.FromSeconds(10)) diff --git a/memex/Memex.Portal.Shared/Social/LinkedInPublishEndpoints.cs b/src/MeshWeaver.Social/LinkedInPublishEndpoints.cs similarity index 77% rename from memex/Memex.Portal.Shared/Social/LinkedInPublishEndpoints.cs rename to src/MeshWeaver.Social/LinkedInPublishEndpoints.cs index ee93a2e756..3f111e6446 100644 --- a/memex/Memex.Portal.Shared/Social/LinkedInPublishEndpoints.cs +++ b/src/MeshWeaver.Social/LinkedInPublishEndpoints.cs @@ -1,16 +1,12 @@ -using System; -using System.Net.Http; -using System.Threading.Tasks; using MeshWeaver.Mesh.Services; using MeshWeaver.Messaging; -using MeshWeaver.Social; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Logging; -namespace Memex.Portal.Shared.Social; +namespace MeshWeaver.Social; /// /// Member publishing + engagement endpoints for the LinkedIn integration — the @@ -23,14 +19,14 @@ namespace Memex.Portal.Shared.Social; /// GET /linkedin/engagement?postPath=… UI trigger — refresh like/comment counts for a published Post node /// /// -/// These are thin ASP.NET minimal-API adapters over (in -/// MeshWeaver.Social), which owns the credential-read → publish → node-write-back chain and its -/// access gates. async/HttpClient is fine here (endpoint handlers are NOT hub code); the -/// service carries the request's AccessContext through the mesh reads/writes — it NEVER runs as system, -/// so a caller who lacks access to the post (or to the credential) is denied by the normal mesh -/// permission checks before any LinkedIn call is made. The versioned /rest/* calls require the -/// app to be granted w_member_social and the member to have re-consented after that scope was -/// added; a missing scope surfaces as reason=missing-w_member_social-reconnect. +/// These are thin ASP.NET minimal-API adapters over , which owns +/// the credential-read → publish → node-write-back chain and its access gates. async/HttpClient +/// is fine here (endpoint handlers are NOT hub code); the service carries the request's AccessContext +/// through the mesh reads/writes — it NEVER runs as system, so a caller who lacks access to the post +/// (or to the credential) is denied by the normal mesh permission checks before any LinkedIn call is +/// made. The versioned /rest/* calls require the app to be granted w_member_social and +/// the member to have re-consented after that scope was added; a missing scope surfaces as +/// reason=missing-w_member_social-reconnect. /// public static class LinkedInPublishEndpoints { @@ -39,14 +35,17 @@ public static class LinkedInPublishEndpoints /// Registers the publish + engagement endpoints. Call alongside MapLinkedInConnect(). public static IEndpointRouteBuilder MapLinkedInPublish(this IEndpointRouteBuilder endpoints) { + // DI parameters are EXPLICIT ([FromServices]) throughout — see LinkedInConnectEndpoints: + // a module's endpoints must not depend on host-container inference to classify them + // (only the PublishRequest below is genuinely body-bound). // 1) JSON publish API — body { postPath?, profilePath?, text?, visibility? }. endpoints.MapPost("/linkedin/publish", async ( HttpContext http, - PublishRequest body, - IMessageHub hub, - IMeshService mesh, - IHttpClientFactory httpFactory, - ILoggerFactory loggers) => + [Microsoft.AspNetCore.Mvc.FromBody] PublishRequest body, + [Microsoft.AspNetCore.Mvc.FromServices] IMessageHub hub, + [Microsoft.AspNetCore.Mvc.FromServices] IMeshService mesh, + [Microsoft.AspNetCore.Mvc.FromServices] IHttpClientFactory httpFactory, + [Microsoft.AspNetCore.Mvc.FromServices] ILoggerFactory loggers) => { if (!http.User.Identity?.IsAuthenticated ?? true) return Results.Unauthorized(); @@ -81,10 +80,10 @@ public static IEndpointRouteBuilder MapLinkedInPublish(this IEndpointRouteBuilde endpoints.MapGet("/linkedin/publish", async ( HttpContext http, [Microsoft.AspNetCore.Mvc.FromQuery] string postPath, - IMessageHub hub, - IMeshService mesh, - IHttpClientFactory httpFactory, - ILoggerFactory loggers) => + [Microsoft.AspNetCore.Mvc.FromServices] IMessageHub hub, + [Microsoft.AspNetCore.Mvc.FromServices] IMeshService mesh, + [Microsoft.AspNetCore.Mvc.FromServices] IHttpClientFactory httpFactory, + [Microsoft.AspNetCore.Mvc.FromServices] ILoggerFactory loggers) => { if (!http.User.Identity?.IsAuthenticated ?? true) return Results.Challenge(new AuthenticationProperties { RedirectUri = http.Request.Path + http.Request.QueryString }); @@ -100,10 +99,10 @@ public static IEndpointRouteBuilder MapLinkedInPublish(this IEndpointRouteBuilde endpoints.MapGet("/linkedin/engagement", async ( HttpContext http, [Microsoft.AspNetCore.Mvc.FromQuery] string postPath, - IMessageHub hub, - IMeshService mesh, - IHttpClientFactory httpFactory, - ILoggerFactory loggers) => + [Microsoft.AspNetCore.Mvc.FromServices] IMessageHub hub, + [Microsoft.AspNetCore.Mvc.FromServices] IMeshService mesh, + [Microsoft.AspNetCore.Mvc.FromServices] IHttpClientFactory httpFactory, + [Microsoft.AspNetCore.Mvc.FromServices] ILoggerFactory loggers) => { if (!http.User.Identity?.IsAuthenticated ?? true) return Results.Challenge(new AuthenticationProperties { RedirectUri = http.Request.Path + http.Request.QueryString }); diff --git a/src/MeshWeaver.Social/LinkedInPublishService.cs b/src/MeshWeaver.Social/LinkedInPublishService.cs index 3d80801beb..283a791b3e 100644 --- a/src/MeshWeaver.Social/LinkedInPublishService.cs +++ b/src/MeshWeaver.Social/LinkedInPublishService.cs @@ -18,7 +18,7 @@ namespace MeshWeaver.Social; /// The mesh-facing LinkedIn member-publish chain: read the target SocialMediaPost node → check /// the caller's access → read the caller's stored LinkedIn credential → publish via /// → write Status/PublishedUrn back onto the post node. -/// The HTTP endpoints (Memex.Portal.Shared.Social.LinkedInPublishEndpoints) are thin adapters +/// The HTTP endpoints (, contributed by the Social module) are thin adapters /// that construct this service from the request's + /// and map its outcome to a redirect / JSON — so the whole credential-read → publish → write-back path /// is one testable unit (see LinkedInPublishServiceTest). diff --git a/src/MeshWeaver.Social/LinkedInPublisher.cs b/src/MeshWeaver.Social/LinkedInPublisher.cs index 21dcf55cda..b0f15ff073 100644 --- a/src/MeshWeaver.Social/LinkedInPublisher.cs +++ b/src/MeshWeaver.Social/LinkedInPublisher.cs @@ -548,6 +548,9 @@ private static string Truncate(string? s, int max) => /// public sealed record LinkedInOptions { + /// The configuration section these options bind from (Social:LinkedIn). + public const string SectionName = "Social:LinkedIn"; + /// The OAuth app client identifier. public required string ClientId { get; init; } diff --git a/src/MeshWeaver.Social/MeshWeaver.Social.csproj b/src/MeshWeaver.Social/MeshWeaver.Social.csproj index 6d958886c3..f79cf4ed73 100644 --- a/src/MeshWeaver.Social/MeshWeaver.Social.csproj +++ b/src/MeshWeaver.Social/MeshWeaver.Social.csproj @@ -1,9 +1,14 @@ - Social publishing for MeshWeaver: platform publisher abstraction with a LinkedIn implementation, approval-gated publishing, and analytics. + Social publishing module for MeshWeaver: LinkedIn connect/publish/page-sync endpoints and node-menu actions riding the module lane, platform publisher abstraction with a LinkedIn implementation, approval-gated publishing, and analytics. {d5c9f8a1-2b7e-4c9a-9f43-6d8f2a0e7b41} + + + + + @@ -11,6 +16,11 @@ + + + + diff --git a/src/MeshWeaver.Social/PlatformCredential.cs b/src/MeshWeaver.Social/PlatformCredential.cs index 10e4fdfc77..4c0010cda9 100644 --- a/src/MeshWeaver.Social/PlatformCredential.cs +++ b/src/MeshWeaver.Social/PlatformCredential.cs @@ -14,6 +14,14 @@ namespace MeshWeaver.Social; /// public sealed record PlatformCredential { + /// + /// The NodeType name credentials persist under ({profile}/_ApiCredentials/{platform}). + /// The NodeType REGISTRATION itself deliberately lives with the host (Memex + /// ApiCredentialNodeType), not this module — existing credential nodes must keep + /// deserializing even when the Social module is delisted. + /// + public const string ApiCredentialNodeType = "ApiCredential"; + /// Platform identifier matching . public required string Platform { get; init; } diff --git a/src/MeshWeaver.Social/SocialModuleAttribute.cs b/src/MeshWeaver.Social/SocialModuleAttribute.cs new file mode 100644 index 0000000000..7d7ed0a9bd --- /dev/null +++ b/src/MeshWeaver.Social/SocialModuleAttribute.cs @@ -0,0 +1,86 @@ +using MeshWeaver.Hosting.AspNetCore; +using MeshWeaver.Mesh; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +[assembly: MeshWeaver.Social.SocialMeshModule] +[assembly: MeshWeaver.Social.SocialModule] + +namespace MeshWeaver.Social; + +/// +/// The mesh half of the Social module: listing MeshWeaver.Social.dll under +/// Modules:Assemblies registers the LinkedIn publishing services and node-menu +/// providers via — the same configure path a +/// fixture or bespoke host calls explicitly (the two lanes must never drift). +/// +/// The ApiCredential NodeType registration deliberately does NOT ride this +/// module: it stays with the host (Memex ApiCredentialNodeType) so existing +/// credential nodes keep deserializing even when the module is delisted. +/// +[AttributeUsage(AttributeTargets.Assembly)] +public sealed class SocialMeshModuleAttribute : MeshNodeProviderAttribute +{ + /// + public override IEnumerable> BuilderConfigurations => + [SocialExtensions.AddSocial]; +} + +/// +/// The endpoint half of the Social module (design #1655 — the first consumer of the +/// endpoint-contribution hook): the LinkedIn connect, company-Page sync, and member +/// publish/engagement routes map through the host's app.MapMeshModuleEndpoints(). +/// Delisting the module removes the routes wholesale — a 404, not a compiled +/// optional-service 503. +/// +/// The LinkedIn sign-in scheme (AddLinkedInAuthentication) is NOT part +/// of this module: authentication schemes configure before the host builds, so that +/// registration stays platform. Only the endpoints ride the module lane. +/// +[AttributeUsage(AttributeTargets.Assembly)] +public sealed class SocialModuleAttribute : MeshEndpointProviderAttribute +{ + /// + public override IEnumerable> EndpointConfigurations => + [ + endpoints => endpoints.MapLinkedInConnect(), + endpoints => endpoints.MapLinkedInPageSync(), + endpoints => endpoints.MapLinkedInPublish(), + ]; +} + +/// +/// The module's registration surface. Production installs it via Modules:Assemblies +/// (); a mesh that composes it explicitly — a test +/// fixture, a bespoke host — calls for the identical registration. +/// +public static class SocialExtensions +{ + /// + /// Registers the Social module's DI surface: bound from + /// Social:LinkedIn through the options pipeline, the + /// typed HTTP client, and the LinkedIn node-menu providers. Everything stays inert while + /// Social:LinkedIn:ClientId is unconfigured — the menu providers emit no items and + /// the endpoints answer with a configuration problem. + /// + public static MeshBuilder AddSocial(this MeshBuilder builder) + => builder.ConfigureServices(services => + { + // Options pipeline, never services.Configure(section): there is no IConfiguration + // instance at install time (Doc/Architecture/Modules). The bare-instance bridge keeps + // LinkedInPublisher's ctor (and its tests) on the plain LinkedInOptions shape. + services.AddOptions().BindConfiguration(LinkedInOptions.SectionName); + services.TryAddSingleton(sp => sp.GetRequiredService>().Value); + services.AddHttpClient(); + + // "Link LinkedIn account" / "Download past posts" on the viewer's own user + + // credential nodes, and Publish/Refresh-engagement on social-media Post nodes. + services.TryAddEnumerable( + ServiceDescriptor.Scoped()); + services.TryAddEnumerable( + ServiceDescriptor.Scoped()); + return services; + }); +} diff --git a/memex/Memex.Portal.Shared/Social/SocialPostMenuProvider.cs b/src/MeshWeaver.Social/SocialPostMenuProvider.cs similarity index 85% rename from memex/Memex.Portal.Shared/Social/SocialPostMenuProvider.cs rename to src/MeshWeaver.Social/SocialPostMenuProvider.cs index 5c4f847d53..e3a0b6b097 100644 --- a/memex/Memex.Portal.Shared/Social/SocialPostMenuProvider.cs +++ b/src/MeshWeaver.Social/SocialPostMenuProvider.cs @@ -1,13 +1,11 @@ -using System; using System.Reactive.Linq; using System.Text.Json; -using MeshWeaver.Data; using MeshWeaver.Layout.Composition; using MeshWeaver.Mesh; using MeshWeaver.Mesh.Security; -using MeshWeaver.Messaging; +using Microsoft.Extensions.Options; -namespace Memex.Portal.Shared.Social; +namespace MeshWeaver.Social; /// /// Adds LinkedIn publish/engagement actions to the node menu of a social-media Post node @@ -23,9 +21,17 @@ namespace Memex.Portal.Shared.Social; /// "Download past posts") — no hand-rolled HTML, no async hub code. The item self-swaps as the node's /// publishedUrn lands because the provider composes the live own-node stream. Both require Update /// permission, which the post owner has by definition. +/// +/// The node-shape predicate ($type/platform/publishedUrn) is beyond the closed +/// UiContribution vocabulary, so this stays a DI provider — registered by the Social +/// module's configure path. It emits nothing while +/// Social:LinkedIn:ClientId is unconfigured, preserving the pre-module behaviour where +/// the provider was only registered when configured. /// -public sealed class SocialPostMenuProvider : INodeMenuProvider +/// LinkedIn app options; an empty ClientId keeps the provider inert. +public sealed class SocialPostMenuProvider(IOptions options) : INodeMenuProvider { + /// public string Context => "Node"; /// @@ -36,6 +42,9 @@ public sealed class SocialPostMenuProvider : INodeMenuProvider public IObservable> GetItems( LayoutAreaHost host, RenderingContext ctx) { + if (string.IsNullOrEmpty(options.Value.ClientId)) + return Observable.Return>([]); + var hubPath = host.Hub.Address.ToString(); return host.Workspace.GetMeshNodeStream() .Select(n => (MeshNode?)n) diff --git a/test/MeshWeaver.Hosting.Monolith.Test/MeshWeaver.Hosting.Monolith.Test.csproj b/test/MeshWeaver.Hosting.Monolith.Test/MeshWeaver.Hosting.Monolith.Test.csproj index 9bebc0e6df..80371402a5 100644 --- a/test/MeshWeaver.Hosting.Monolith.Test/MeshWeaver.Hosting.Monolith.Test.csproj +++ b/test/MeshWeaver.Hosting.Monolith.Test/MeshWeaver.Hosting.Monolith.Test.csproj @@ -60,6 +60,7 @@ + diff --git a/test/MeshWeaver.Hosting.Monolith.Test/SocialModuleContributionTest.cs b/test/MeshWeaver.Hosting.Monolith.Test/SocialModuleContributionTest.cs new file mode 100644 index 0000000000..b82c6d0021 --- /dev/null +++ b/test/MeshWeaver.Hosting.Monolith.Test/SocialModuleContributionTest.cs @@ -0,0 +1,121 @@ +#pragma warning disable CS1591 + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using MeshWeaver.Hosting.AspNetCore; +using MeshWeaver.Mesh; +using MeshWeaver.Messaging; +using MeshWeaver.Social; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace MeshWeaver.Hosting.Monolith.Test; + +/// +/// Pins the Social module's shape as the FIRST consumer of the endpoint-contribution hook +/// (design #1655): the assembly carries BOTH module attributes (mesh half + endpoint half), +/// installing it applies the same AddSocial() a fixture calls (the two lanes must not +/// drift), and its endpoint contributions map through MapMeshModuleEndpoints with the +/// exact auth semantics the compiled-in registration had — authenticated by default, the two +/// OAuth callbacks anonymous by explicit opt-out. +/// +public class SocialModuleContributionTest +{ + [Fact] + public void TheAssembly_CarriesBothModuleAttributes_WithNonEmptyContributions() + { + var assembly = typeof(SocialModuleAttribute).Assembly; + + // Mesh half: the Modules:Assemblies install path folds AddSocial over the builder. + var meshHalf = Assert.Single(assembly.GetCustomAttributes()); + Assert.NotEmpty(meshHalf.BuilderConfigurations); + + // Endpoint half: the host's MapMeshModuleEndpoints applies these. + var endpointHalf = Assert.Single(assembly.GetCustomAttributes()); + Assert.NotEmpty(endpointHalf.EndpointConfigurations); + } + + [Fact] + public void InstallingTheAssembly_AppliesAddSocial() + { + var serviceConfigs = new List>(); + var builder = new MeshBuilder(configure => serviceConfigs.Add(configure), AddressExtensions.CreateMeshAddress()); + + builder.InstallAssemblies(typeof(SocialModuleAttribute).Assembly.Location); + + var services = serviceConfigs.Aggregate( + (IServiceCollection)new ServiceCollection(), (collection, configure) => configure(collection)); + + // The two node-menu providers, scoped, on the INodeMenuProvider multi-registration. + var menuProviders = services + .Where(d => d.ServiceType == typeof(INodeMenuProvider)) + .ToList(); + Assert.Contains(menuProviders, d => + d.ImplementationType == typeof(LinkedInCredentialMenuProvider) && d.Lifetime == ServiceLifetime.Scoped); + Assert.Contains(menuProviders, d => + d.ImplementationType == typeof(SocialPostMenuProvider) && d.Lifetime == ServiceLifetime.Scoped); + + // Options ride the options pipeline (Social:LinkedIn) with the bare-instance bridge + // LinkedInPublisher's ctor consumes; the publisher itself is the typed HTTP client. + Assert.Contains(services, d => d.ServiceType == typeof(IConfigureOptions)); + Assert.Contains(services, d => d.ServiceType == typeof(LinkedInOptions)); + Assert.Contains(services, d => d.ServiceType == typeof(LinkedInPublisher)); + } + + [Fact] + public void SocialEndpoints_MapThroughTheHook_WithTheExactAuthSemantics() + { + // Built but never started — endpoint metadata is inspectable without Kestrel + // (same shape as ModuleEndpointContributionTest). + var builder = WebApplication.CreateBuilder(); + builder.Services.AddAuthorization(); + builder.Services.AddSingleton( + new InstalledModuleAssembly(typeof(SocialModuleAttribute).Assembly)); + using var app = builder.Build(); + app.MapMeshModuleEndpoints(); + + var endpoints = ((IEndpointRouteBuilder)app).DataSources + .SelectMany(source => source.Endpoints) + .OfType() + .Where(e => e.RoutePattern.RawText?.Contains("linkedin", StringComparison.OrdinalIgnoreCase) == true) + .ToList(); + + // The full compiled surface: connect (3) + page sync (3) + publish/engagement (3). + string[] expected = + [ + "/connect/linkedin/me", + "/connect/linkedin", + "/connect/linkedin/callback", + "/connect/linkedin/org", + "/connect/linkedin/org/callback", + "/connect/linkedin/sync", + "/linkedin/publish", + "/linkedin/engagement", + ]; + foreach (var pattern in expected) + Assert.Contains(endpoints, e => e.RoutePattern.RawText == pattern); + Assert.Equal(9, endpoints.Count); // /linkedin/publish maps twice (GET + POST) + + // The ONLY anonymous routes are the two OAuth redirect targets — LinkedIn's callback + // must not bounce through a login challenge (the CSRF state cookie is the guard). + string[] anonymous = ["/connect/linkedin/callback", "/connect/linkedin/org/callback"]; + foreach (var endpoint in endpoints) + { + if (anonymous.Contains(endpoint.RoutePattern.RawText)) + { + Assert.NotNull(endpoint.Metadata.GetMetadata()); + } + else + { + Assert.NotNull(endpoint.Metadata.GetMetadata()); + Assert.Null(endpoint.Metadata.GetMetadata()); + } + } + } +}