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
3 changes: 2 additions & 1 deletion memex/Memex.Portal.Monolith/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@
"MeshWeaver.Markdown.Export.dll",
"MeshWeaver.Observability.dll",
"MeshWeaver.OgCard.dll",
"MeshWeaver.Approvals.dll"
"MeshWeaver.Approvals.dll",
"MeshWeaver.Social.dll"
]
}
}
59 changes: 10 additions & 49 deletions memex/Memex.Portal.Shared/MemexConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<MeshWeaver.Social.LinkedInPublisher>();
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);
Expand Down Expand Up @@ -1073,7 +1045,7 @@ public static void StartMemexApplication<TApp>(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 →
Expand Down Expand Up @@ -1163,19 +1135,8 @@ public static void StartMemexApplication<TApp>(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
Expand Down
5 changes: 3 additions & 2 deletions memex/Memex.Portal.Shared/Social/GitHubConnectEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ namespace Memex.Portal.Shared.Social;
/// <summary>
/// GitHub OAuth2 authorization-code flow for connecting a user's GitHub identity for
/// <see cref="MeshWeaver.GitSync">GitHub Sync</see>. Mirrors
/// <see cref="LinkedInConnectEndpoints"/> — the deployed surface is just the parts that
/// need browser cookies + a whitelisted callback URL:
/// <see cref="MeshWeaver.Social.LinkedInConnectEndpoints"/> (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)
Expand Down
1 change: 1 addition & 0 deletions memex/MeshModulesPublish.targets
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
<MeshModule Include="$(MSBuildThisFileDirectory)../src/MeshWeaver.Speech/MeshWeaver.Speech.csproj" />
<MeshModule Include="$(MSBuildThisFileDirectory)../src/MeshWeaver.Observability/MeshWeaver.Observability.csproj" />
<MeshModule Include="$(MSBuildThisFileDirectory)../src/MeshWeaver.OgCard/MeshWeaver.OgCard.csproj" />
<MeshModule Include="$(MSBuildThisFileDirectory)../src/MeshWeaver.Social/MeshWeaver.Social.csproj" />

<!-- Flipped off the ships-the-bits ProjectReference (#1644 step 2, flip #1). -->
<MeshModuleClosure Include="$(MSBuildThisFileDirectory)../src/MeshWeaver.Markdown.Export/MeshWeaver.Markdown.Export.csproj" />
Expand Down
3 changes: 2 additions & 1 deletion memex/aspire/Memex.Portal.Distributed/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions src/MeshWeaver.Documentation/Data/Architecture/Modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>().BindConfiguration("Section")` — never `services.Configure(section)`:
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Icon: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect width="2

Publish LinkedIn posts — and pull their engagement — **directly from the mesh**. A [SocialMediaPost](/Doc/DataMesh/SocialMedia) node is drafted in the portal, published to LinkedIn as you, and the returned post URN plus like/comment counts are written back onto the node.

The code lives in the **`MeshWeaver.Social`** module (`LinkedInPostsApi`, `LinkedInPublishService`) with the HTTP surface in `memex/Memex.Portal.Shared/Social` (`LinkedInConnectEndpoints`, `LinkedInPublishEndpoints`, `SocialPostMenuProvider`).
The code lives in the **`MeshWeaver.Social`** [module](/Doc/Architecture/Modules) — the publish chain (`LinkedInPostsApi`, `LinkedInPublishService`), the HTTP surface (`LinkedInConnectEndpoints`, `LinkedInPublishEndpoints`, contributed through the module endpoint hook), and the node-menu actions (`SocialPostMenuProvider`). The deployment activates it by listing `MeshWeaver.Social.dll` under `Modules:Assemblies`.

> **Scope of this page:** publishing (writing posts) and reading engagement — **including impressions** — on posts you publish. It does not cover DMs; see **Limitations** at the end.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
Name: LinkedIn publishing rides the module lane
Category: Feature
Description: The LinkedIn integration (connect, publish, page sync, node-menu actions) is now the MeshWeaver.Social module — one Modules:Assemblies line turns it on or off per deployment.
Icon: Sparkle
Order: -20260816
---

# LinkedIn publishing rides the module lane

The LinkedIn integration — connecting your account, publishing posts, refreshing engagement, and
company-Page sync — now ships as the `MeshWeaver.Social` module instead of being hard-wired into
the portal. A deployment turns the whole feature on or off with one `Modules:Assemblies` line: its
HTTP routes and menu actions appear when the module is listed and disappear cleanly when it is not.
Nothing changes for users on deployments that keep it on — the routes, menus, and stored
credentials work exactly as before.
Original file line number Diff line number Diff line change
@@ -1,33 +1,26 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Reactive.Linq;
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.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace Memex.Portal.Shared.Social;
namespace MeshWeaver.Social;

/// <summary>
/// OAuth2 authorization-code flow for connecting a LinkedIn publishing identity
/// to a profile in the mesh. The deployed surface is intentionally tiny — just
/// the parts that need to live in the portal binary because they involve
/// browser cookies, HTTP routing, and a callback URL whitelisted on LinkedIn:
/// to a profile in the mesh. This is the Social module's endpoint contribution
/// (<see cref="SocialModuleAttribute"/>) — 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)
Expand All @@ -40,9 +33,11 @@ namespace Memex.Portal.Shared.Social;
/// </summary>
public static class LinkedInConnectEndpoints
{
/// <summary>Name of the CSRF state cookie set when starting the connect flow.</summary>
public const string StateCookieName = "lnkd_connect_state";
private const string CallbackPath = "/connect/linkedin/callback";

/// <summary>Registers the LinkedIn connect endpoints.</summary>
public static IEndpointRouteBuilder MapLinkedInConnect(this IEndpointRouteBuilder endpoints)
{
// Convenience: bind the credential to the authenticated user's own User node.
Expand All @@ -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 });
Expand Down Expand Up @@ -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");

Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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;
}
Expand Down
Loading
Loading