Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
93f27d3
fix: resolve relative paths against main node on satellite pages
rbuergi Apr 16, 2026
d85922d
test: lock in content slash-format + spaced-filename behavior
rbuergi Apr 16, 2026
13b890e
fix: strip embedded quotes from agent-emitted paths
rbuergi Apr 16, 2026
6c1a217
test: add PathUtils satellite partition tests and content access tole…
rbuergi Apr 16, 2026
6d5f13b
fix: also strip single quotes and trim whitespace in agent-emitted paths
rbuergi Apr 16, 2026
dbcf85a
fix: dedup duplicate chat submissions within a 500ms window
rbuergi Apr 16, 2026
d51db7c
fix: hold watcher guard until response cell is created
rbuergi Apr 18, 2026
77eed5a
feat: add OAuth discovery and authorization server for MCP endpoint
rbuergi Apr 18, 2026
6163916
fix: stabilize thread chat submission, embed sub-thread streams, add …
rbuergi Apr 18, 2026
5d4c3a8
chore: ignore .claude/ folder (per-user Claude Code state)
rbuergi Apr 18, 2026
490707a
fix: add launchSettings.json for Memex.Database.Migration
rbuergi Apr 18, 2026
39389bd
fix: suppress benign ObjectDisposedException in NamedAreaView area-st…
rbuergi Apr 18, 2026
1903966
fix: only add message ids to Thread.Messages after their satellites a…
rbuergi Apr 18, 2026
a6bfda4
fix: remove duplicate sub-thread embed, forward UsageContent, tighten…
rbuergi Apr 18, 2026
71a2999
refactor: delegation tool returns IAsyncEnumerable<string>; delta-bas…
rbuergi Apr 18, 2026
257db4b
refactor: git-like collapsible modified-nodes panel in thread header
rbuergi Apr 19, 2026
f362262
refactor: tabular modified-nodes layout with inline Diff/Restore + th…
rbuergi Apr 19, 2026
e95d97c
fixes around heart beat and infra
rbuergi Apr 19, 2026
fbe5e45
fixing creation
rbuergi Apr 19, 2026
6e09a3f
fix: emit UsageContent from Azure Claude streaming + format durations…
rbuergi Apr 19, 2026
4a95da9
fix: improve tool-call chip text + inline Diff/Revert links for node-…
rbuergi Apr 19, 2026
88b8ae2
fix: remove await from DeleteLayoutArea and reply before storage delete
rbuergi Apr 19, 2026
2cf3a5f
changing coder
rbuergi Apr 19, 2026
343aea1
returning error when laout not found.
rbuergi Apr 19, 2026
18a5603
feat: configurable Sources on NodeTypeDefinition
rbuergi Apr 19, 2026
df907a2
feat: surface NodeType compilation errors through MCP
rbuergi Apr 19, 2026
8691035
feat: Recycle menu item + markdown compilation-error overlay
rbuergi Apr 19, 2026
aee0ba9
introducing pinned areas.
rbuergi Apr 19, 2026
1ed30e7
refactor: IMeshStorage writes return IObservable
rbuergi Apr 19, 2026
bfe52a8
feat: compile progress + source-discovery diagnostics
rbuergi Apr 19, 2026
f38e7a6
fix: drop read-then-delete TOCTOU in PersistenceService.DeleteNode
rbuergi Apr 19, 2026
da06d23
feat: expose Recycle as an MCP tool
rbuergi Apr 19, 2026
42545b4
feat: expose GetDiagnostics + Recycle on the MCP server
rbuergi Apr 19, 2026
efb3ed6
fix: AccessControl graceful fallback + update menu-test counts
rbuergi Apr 19, 2026
32efc9e
fix: NodeTypeService.GatherInputsAsync honors Sources + includes sate…
rbuergi Apr 19, 2026
394b811
test: bump ThreadSubmission WaitForThreadAsync timeouts 10s → 30s
rbuergi Apr 19, 2026
2f725ac
test: bump xunit methodTimeout 30s → 60s (match CLAUDE.md, reduce CI …
rbuergi Apr 19, 2026
afd7a1a
fix: broadcast NodeType cache invalidation across silos via MeshChang…
rbuergi Apr 19, 2026
f9654da
feat: live 'Compiling <path> (Ns)...' progress during navigation
rbuergi Apr 19, 2026
ee6eb74
feat: silence HeartBeatEvent warnings on every Memex node hub
rbuergi Apr 19, 2026
17732f0
chore: raise MeshWeaver log level to Information in distributed dev
rbuergi Apr 19, 2026
d1679b5
fix: defensive MeshChangeFeed subscribe + optional NodeTypeService in…
rbuergi Apr 19, 2026
bba5239
fix: GatherInputsAsync uses IMeshQueryProvider — satellite-safe
rbuergi Apr 19, 2026
8f4df3f
improvig mesh compilation
rbuergi Apr 19, 2026
8dddc42
chore: bump Anthropic Opus to claude-opus-4-7
rbuergi Apr 19, 2026
be6a765
introducing docs for node types.
rbuergi Apr 19, 2026
9fa4b7c
fix: DeleteLayoutArea emits placeholder immediately + times out slow …
rbuergi Apr 19, 2026
0a3a666
test: split Autocomplete suite into MeshWeaver.Autocomplete.Test
rbuergi Apr 19, 2026
32a9407
fix: AddContentCollectionsInfrastructure idempotency guard
rbuergi Apr 19, 2026
71d6540
chore: batch pending edits (thread bubble + version area + sync stream)
rbuergi Apr 19, 2026
c2b9b05
Merge branch 'main' into bug_fix
rbuergi Apr 19, 2026
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
8 changes: 3 additions & 5 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ artifacts/
**/Properties/launchSettings.json
!**/*.Host/Properties/launchSettings.json
!**/*.AppHost/Properties/launchSettings.json
!memex/aspire/Memex.Database.Migration/Properties/launchSettings.json

# StyleCop
StyleCopReport.xml
Expand Down Expand Up @@ -367,8 +368,5 @@ samples/Graph/Data/VUser/
# User activity data
**/_useractivity/

# Claude Code personal settings
.claude/settings.local.json

# Claude Code scheduled tasks lock file
.claude/scheduled_tasks.lock
# Claude Code per-user local state (settings.local.json, plans/, scheduled_tasks.lock, etc.)
.claude/
1 change: 1 addition & 0 deletions MeshWeaver.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@
<Project Path="test/MeshWeaver.Todo.Test/MeshWeaver.Todo.Test.csproj" />
<Project Path="test/MeshWeaver.PathResolution.Test/MeshWeaver.PathResolution.Test.csproj" />
<Project Path="test/MeshWeaver.Persistence.Test/MeshWeaver.Persistence.Test.csproj" />
<Project Path="test/MeshWeaver.Autocomplete.Test/MeshWeaver.Autocomplete.Test.csproj" />
<Project Path="test/MeshWeaver.Query.Test/MeshWeaver.Query.Test.csproj" />
</Folder>
<Project Path="test/MeshWeaver.MemexTemplate.Test/MeshWeaver.MemexTemplate.Test.csproj" />
Expand Down
4 changes: 3 additions & 1 deletion memex/Memex.Portal.Monolith/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@
.AddFileSystemDataSource("Cornerstone", "Cornerstone",
Path.Combine(graphBasePath, "Cornerstone"), "Sample Cornerstone data")
.AddFileSystemDataSource("FutuRe", "FutuRe",
Path.Combine(graphBasePath, "FutuRe"), "Sample FutuRe reinsurance data");
Path.Combine(graphBasePath, "FutuRe"), "Sample FutuRe reinsurance data")
.AddFileSystemDataSource("SocialMedia", "Social Media",
Path.Combine(graphBasePath, "SocialMedia"), "Social media post planning demo");
}

return config.UseMonolithMesh();
Expand Down
121 changes: 121 additions & 0 deletions memex/Memex.Portal.Shared/Authentication/OAuthCodeStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;

namespace Memex.Portal.Shared.Authentication;

/// <summary>
/// In-memory store for OAuth authorization codes with PKCE support.
/// Codes expire after 5 minutes and are single-use (consumed on exchange).
/// Uses ConcurrentDictionary for thread-safe mutation (per CLAUDE.md exception).
/// </summary>
internal class OAuthCodeStore
{
private readonly ConcurrentDictionary<string, AuthorizationCode> _codes = new();
private static readonly TimeSpan CodeLifetime = TimeSpan.FromMinutes(5);

/// <summary>
/// Generates a new authorization code and stores it with the given parameters.
/// </summary>
public string GenerateCode(
string userId,
string userName,
string userEmail,
string clientId,
string redirectUri,
string? codeChallenge,
string? codeChallengeMethod)
{
// Clean up expired codes opportunistically
CleanupExpired();

var code = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
.Replace("+", "-").Replace("/", "_").TrimEnd('=');

var entry = new AuthorizationCode
{
Code = code,
UserId = userId,
UserName = userName,
UserEmail = userEmail,
ClientId = clientId,
RedirectUri = redirectUri,
CodeChallenge = codeChallenge,
CodeChallengeMethod = codeChallengeMethod,
CreatedAt = DateTimeOffset.UtcNow,
};

_codes[code] = entry;
return code;
}

/// <summary>
/// Exchanges an authorization code for the stored entry.
/// Returns null if the code is invalid, expired, or already consumed.
/// Validates PKCE code_verifier if a code_challenge was stored.
/// </summary>
public AuthorizationCode? ExchangeCode(string code, string clientId, string redirectUri, string? codeVerifier)
{
if (!_codes.TryRemove(code, out var entry))
return null;

// Check expiry
if (DateTimeOffset.UtcNow - entry.CreatedAt > CodeLifetime)
return null;

// Validate client_id and redirect_uri match
if (!string.Equals(entry.ClientId, clientId, StringComparison.Ordinal))
return null;
if (!string.Equals(entry.RedirectUri, redirectUri, StringComparison.Ordinal))
return null;

// Validate PKCE
if (!string.IsNullOrEmpty(entry.CodeChallenge))
{
if (string.IsNullOrEmpty(codeVerifier))
return null;

if (!VerifyPkce(codeVerifier, entry.CodeChallenge, entry.CodeChallengeMethod))
return null;
}

return entry;
}

private static bool VerifyPkce(string codeVerifier, string codeChallenge, string? method)
{
if (string.Equals(method, "S256", StringComparison.OrdinalIgnoreCase))
{
var hash = SHA256.HashData(Encoding.ASCII.GetBytes(codeVerifier));
var computed = Convert.ToBase64String(hash)
.Replace("+", "-").Replace("/", "_").TrimEnd('=');
return string.Equals(computed, codeChallenge, StringComparison.Ordinal);
}

// plain method (or no method specified)
return string.Equals(codeVerifier, codeChallenge, StringComparison.Ordinal);
}

private void CleanupExpired()
{
var cutoff = DateTimeOffset.UtcNow - CodeLifetime;
foreach (var kvp in _codes)
{
if (kvp.Value.CreatedAt < cutoff)
_codes.TryRemove(kvp.Key, out _);
}
}
}

internal record AuthorizationCode
{
public required string Code { get; init; }
public required string UserId { get; init; }
public required string UserName { get; init; }
public required string UserEmail { get; init; }
public required string ClientId { get; init; }
public required string RedirectUri { get; init; }
public string? CodeChallenge { get; init; }
public string? CodeChallengeMethod { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}
159 changes: 159 additions & 0 deletions memex/Memex.Portal.Shared/Authentication/OAuthConnectController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace Memex.Portal.Shared.Authentication;

/// <summary>
/// Minimal OAuth 2.0 authorization server for MCP clients (claude.ai Connectors, Claude Desktop).
/// Implements authorization code flow with PKCE. Issues mw_ API tokens as access tokens,
/// reusing the existing ApiTokenService infrastructure.
/// </summary>
[ApiController]
public class OAuthConnectController(
IServiceProvider serviceProvider,
ILogger<OAuthConnectController> logger) : ControllerBase
{
private OAuthCodeStore CodeStore => serviceProvider.GetRequiredService<OAuthCodeStore>();
private ApiTokenService TokenService => serviceProvider.GetRequiredService<ApiTokenService>();

/// <summary>
/// RFC 8414 — OAuth Authorization Server Metadata.
/// MCP clients discover this via the authorization_servers URL from the protected resource metadata.
/// </summary>
[HttpGet("/.well-known/oauth-authorization-server")]
[AllowAnonymous]
public IActionResult GetServerMetadata()
{
var origin = $"{Request.Scheme}://{Request.Host}";
return Ok(new
{
issuer = $"{origin}/connect",
authorization_endpoint = $"{origin}/connect/authorize",
token_endpoint = $"{origin}/connect/token",
response_types_supported = new[] { "code" },
grant_types_supported = new[] { "authorization_code" },
code_challenge_methods_supported = new[] { "S256" },
});
}

/// <summary>
/// OAuth Authorization Endpoint — redirects authenticated users to the client's redirect_uri
/// with an authorization code. Unauthenticated users are sent to /login first.
/// </summary>
[HttpGet("connect/authorize")]
public IActionResult Authorize(
[FromQuery] string response_type,
[FromQuery] string client_id,
[FromQuery] string redirect_uri,
[FromQuery] string? state,
[FromQuery] string? scope,
[FromQuery] string? code_challenge,
[FromQuery] string? code_challenge_method)
{
if (response_type != "code")
return BadRequest(new { error = "unsupported_response_type" });

if (string.IsNullOrEmpty(client_id) || string.IsNullOrEmpty(redirect_uri))
return BadRequest(new { error = "invalid_request", error_description = "client_id and redirect_uri are required" });

// If user is not authenticated, redirect to login with return URL
if (User?.Identity?.IsAuthenticated != true)
{
var authorizeUrl = $"{Request.Scheme}://{Request.Host}{Request.Path}{Request.QueryString}";
var loginUrl = $"/login?returnUrl={Uri.EscapeDataString(authorizeUrl)}";
return Redirect(loginUrl);
}

// Extract user identity from cookie claims
var email = User.FindFirstValue(ClaimTypes.Email)
?? User.FindFirstValue("email")
?? User.FindFirstValue("preferred_username")
?? "";
var name = User.FindFirstValue(ClaimTypes.Name)
?? User.FindFirstValue("name")
?? email;
var userId = User.FindFirstValue("preferred_username")
?? email;

if (string.IsNullOrEmpty(email))
return BadRequest(new { error = "invalid_request", error_description = "Unable to determine user identity" });

// Generate authorization code
var code = CodeStore.GenerateCode(
userId: userId,
userName: name,
userEmail: email,
clientId: client_id,
redirectUri: redirect_uri,
codeChallenge: code_challenge,
codeChallengeMethod: code_challenge_method);

logger.LogInformation("Issued OAuth authorization code for user {Email}, client {ClientId}", email, client_id);

// Redirect to client with code (and state if provided)
var callbackUrl = string.IsNullOrEmpty(state)
? $"{redirect_uri}?code={Uri.EscapeDataString(code)}"
: $"{redirect_uri}?code={Uri.EscapeDataString(code)}&state={Uri.EscapeDataString(state)}";

return Redirect(callbackUrl);
}

/// <summary>
/// OAuth Token Endpoint — exchanges an authorization code for an API token.
/// The issued token is a standard mw_ API token, indistinguishable from manually created ones.
/// </summary>
[HttpPost("connect/token")]
[AllowAnonymous]
public async Task<IActionResult> ExchangeToken([FromForm] TokenRequest request)
{
if (request.grant_type != "authorization_code")
return BadRequest(new { error = "unsupported_grant_type" });

if (string.IsNullOrEmpty(request.code) || string.IsNullOrEmpty(request.client_id) || string.IsNullOrEmpty(request.redirect_uri))
return BadRequest(new { error = "invalid_request" });

var entry = CodeStore.ExchangeCode(
request.code,
request.client_id,
request.redirect_uri,
request.code_verifier);

if (entry == null)
{
logger.LogWarning("OAuth token exchange failed: invalid or expired code for client {ClientId}", request.client_id);
return BadRequest(new { error = "invalid_grant" });
}

// Create an mw_ API token via the existing token service
var (rawToken, _) = await TokenService.CreateTokenAsync(
userId: entry.UserId,
userName: entry.UserName,
userEmail: entry.UserEmail,
label: $"OAuth: {request.client_id}",
expiresAt: DateTimeOffset.UtcNow.AddDays(30));

logger.LogInformation("Issued OAuth access token for user {Email}, client {ClientId}", entry.UserEmail, request.client_id);

return Ok(new
{
access_token = rawToken,
token_type = "Bearer",
expires_in = (int)TimeSpan.FromDays(30).TotalSeconds,
});
}
}

/// <summary>
/// Binds the form-encoded token request body.
/// </summary>
public class TokenRequest
{
public string grant_type { get; set; } = "";
public string? code { get; set; }
public string? client_id { get; set; }
public string? redirect_uri { get; set; }
public string? code_verifier { get; set; }
}
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 @@ -31,6 +31,7 @@

<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="ModelContextProtocol.AspNetCore" />
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components" />
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components.Icons" />
<PackageReference Include="Microsoft.Identity.Web" />
Expand Down
Loading
Loading