Initial commit: Nexus Mission Control Platform
- ASP.NET Core 10 Backend (JWT Auth, Agent config API) - Vue 3 Frontend (Dashboard, Team, Agents, Config Editor) - PostgreSQL Database - Docker Compose setup - Mission Control Dashboard redesign
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Nexus.Api.Domain;
|
||||
using Nexus.Api.Integrations;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed record AgentConfig
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("workspace")]
|
||||
public string? Workspace { get; init; }
|
||||
|
||||
[JsonPropertyName("agentDir")]
|
||||
public string? AgentDir { get; init; }
|
||||
|
||||
[JsonPropertyName("model")]
|
||||
public string? Model { get; init; }
|
||||
|
||||
[JsonPropertyName("identity")]
|
||||
public AgentIdentityConfig? Identity { get; init; }
|
||||
|
||||
[JsonPropertyName("subagents")]
|
||||
public SubAgentConfig? Subagents { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SubAgentConfig
|
||||
{
|
||||
[JsonPropertyName("allowAgents")]
|
||||
public IReadOnlyList<string>? AllowAgents { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AgentIdentityConfig
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("theme")]
|
||||
public string Theme { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed record AgentInfo(
|
||||
string Id,
|
||||
string Name,
|
||||
string Role,
|
||||
string Model,
|
||||
OperationalStatus Status,
|
||||
DateTimeOffset? LastSeen,
|
||||
string? Workspace,
|
||||
string? Description
|
||||
);
|
||||
|
||||
public sealed record AgentDetail(
|
||||
string Id,
|
||||
string Name,
|
||||
string Role,
|
||||
string Model,
|
||||
OperationalStatus Status,
|
||||
DateTimeOffset? LastSeen,
|
||||
string? Workspace,
|
||||
string? AgentDir,
|
||||
string? Description,
|
||||
IReadOnlyList<string>? SubAgents,
|
||||
string? IdentityName
|
||||
);
|
||||
|
||||
public interface IAgentService
|
||||
{
|
||||
Task<IReadOnlyCollection<AgentInfo>> GetAgentsAsync(CancellationToken cancellationToken);
|
||||
Task<AgentDetail?> GetAgentAsync(string id, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class AgentService(IConfiguration configuration, IAgentRuntime runtime) : IAgentService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public async Task<IReadOnlyCollection<AgentInfo>> GetAgentsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var configs = await LoadAgentConfigsAsync(cancellationToken);
|
||||
var runtimeStatus = await runtime.GetStatusAsync(cancellationToken);
|
||||
var overallOperational = runtimeStatus.Status;
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var agents = new List<AgentInfo>(configs.Count);
|
||||
foreach (var config in configs)
|
||||
{
|
||||
var model = config.Model ?? "deepseek/deepseek-v4-flash";
|
||||
var role = DeriveRole(config.Id);
|
||||
var description = config.Identity?.Theme ?? string.Empty;
|
||||
|
||||
// main agent doesn't have a separate identity; set a generic description
|
||||
if (string.IsNullOrEmpty(description))
|
||||
{
|
||||
description = config.Id switch
|
||||
{
|
||||
"main" => "Primary conversational agent — routing and general-purpose chat",
|
||||
_ => description
|
||||
};
|
||||
}
|
||||
|
||||
agents.Add(new AgentInfo(
|
||||
Id: config.Id,
|
||||
Name: config.Identity?.Name ?? config.Name ?? config.Id,
|
||||
Role: role,
|
||||
Model: model,
|
||||
Status: overallOperational,
|
||||
LastSeen: now,
|
||||
Workspace: config.Workspace,
|
||||
Description: description
|
||||
));
|
||||
}
|
||||
|
||||
return agents.AsReadOnly();
|
||||
}
|
||||
|
||||
public async Task<AgentDetail?> GetAgentAsync(string id, CancellationToken cancellationToken)
|
||||
{
|
||||
var configs = await LoadAgentConfigsAsync(cancellationToken);
|
||||
var config = configs.FirstOrDefault(a =>
|
||||
a.Id.Equals(id, StringComparison.OrdinalIgnoreCase));
|
||||
if (config is null) return null;
|
||||
|
||||
var runtimeStatus = await runtime.GetStatusAsync(cancellationToken);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var role = DeriveRole(config.Id);
|
||||
var description = config.Identity?.Theme ?? string.Empty;
|
||||
|
||||
if (string.IsNullOrEmpty(description) && config.Id == "main")
|
||||
description = "Primary conversational agent — routing and general-purpose chat";
|
||||
|
||||
return new AgentDetail(
|
||||
Id: config.Id,
|
||||
Name: config.Identity?.Name ?? config.Name ?? config.Id,
|
||||
Role: role,
|
||||
Model: config.Model ?? "deepseek/deepseek-v4-flash",
|
||||
Status: runtimeStatus.Status,
|
||||
LastSeen: now,
|
||||
Workspace: config.Workspace,
|
||||
AgentDir: config.AgentDir,
|
||||
Description: description,
|
||||
SubAgents: config.Subagents?.AllowAgents,
|
||||
IdentityName: config.Identity?.Name
|
||||
);
|
||||
}
|
||||
|
||||
private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch
|
||||
{
|
||||
"iris" => "Orchestrator",
|
||||
"programmer" => "Developer",
|
||||
"reviewer" => "Reviewer",
|
||||
"architekt" => "Architect",
|
||||
"main" => "Assistant",
|
||||
_ => "Custom"
|
||||
};
|
||||
|
||||
private async Task<IReadOnlyList<AgentConfig>> LoadAgentConfigsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var path = configuration.GetValue<string>("AgentConfigPath")
|
||||
?? "/home/node/.openclaw/openclaw.json";
|
||||
|
||||
if (!File.Exists(path))
|
||||
return Array.Empty<AgentConfig>();
|
||||
|
||||
var json = await File.ReadAllTextAsync(path, cancellationToken);
|
||||
using var document = JsonDocument.Parse(json, new JsonDocumentOptions { AllowTrailingCommas = true });
|
||||
var root = document.RootElement;
|
||||
|
||||
if (!root.TryGetProperty("agents", out var agentsElement))
|
||||
return Array.Empty<AgentConfig>();
|
||||
|
||||
if (!agentsElement.TryGetProperty("list", out var listElement))
|
||||
return Array.Empty<AgentConfig>();
|
||||
|
||||
var defaults = agentsElement.TryGetProperty("defaults", out var defaultsElement)
|
||||
? JsonSerializer.Deserialize<AgentDefaults>(defaultsElement.GetRawText(), JsonOptions)
|
||||
: null;
|
||||
|
||||
var configs = new List<AgentConfig>();
|
||||
foreach (var agentElement in listElement.EnumerateArray())
|
||||
{
|
||||
var config = JsonSerializer.Deserialize<AgentConfig>(agentElement.GetRawText(), JsonOptions);
|
||||
if (config is null || string.IsNullOrWhiteSpace(config.Id))
|
||||
continue;
|
||||
|
||||
// Inherit defaults for missing fields
|
||||
if (string.IsNullOrWhiteSpace(config.Name))
|
||||
config = config with { Name = config.Id };
|
||||
if (string.IsNullOrWhiteSpace(config.Model) && defaults?.Model?.Primary is not null)
|
||||
config = config with { Model = defaults.Model.Primary };
|
||||
if (string.IsNullOrWhiteSpace(config.Workspace) && defaults?.Workspace is not null)
|
||||
config = config with { Workspace = defaults.Workspace };
|
||||
|
||||
configs.Add(config);
|
||||
}
|
||||
|
||||
return configs.AsReadOnly();
|
||||
}
|
||||
|
||||
private sealed record AgentDefaults
|
||||
{
|
||||
[JsonPropertyName("workspace")]
|
||||
public string? Workspace { get; init; }
|
||||
|
||||
[JsonPropertyName("model")]
|
||||
public AgentDefaultModel? Model { get; init; }
|
||||
}
|
||||
|
||||
private sealed record AgentDefaultModel
|
||||
{
|
||||
[JsonPropertyName("primary")]
|
||||
public string? Primary { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Nexus.Api.Contracts;
|
||||
using Nexus.Api.Domain;
|
||||
using Nexus.Api.Infrastructure;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public interface IAuthService
|
||||
{
|
||||
Task<AuthSession?> LoginAsync(LoginRequest request, CancellationToken ct = default);
|
||||
Task<AuthSession?> RefreshAsync(string refreshToken, CancellationToken ct = default);
|
||||
Task RevokeAsync(string refreshToken, CancellationToken ct = default);
|
||||
Task<NexusUser?> GetUserAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<NexusUser?> UpdateProfileAsync(Guid userId, UpdateProfileRequest request, CancellationToken ct = default);
|
||||
Task<bool> ChangePasswordAsync(Guid userId, ChangePasswordRequest request, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed record AuthSession(
|
||||
string AccessToken,
|
||||
string RefreshToken,
|
||||
DateTimeOffset ExpiresAt,
|
||||
UserInfo User);
|
||||
|
||||
public sealed class AuthService : IAuthService
|
||||
{
|
||||
private readonly NexusDbContext _db;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<AuthService> _logger;
|
||||
|
||||
public AuthService(NexusDbContext db, IConfiguration config, ILogger<AuthService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<AuthSession?> LoginAsync(LoginRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var normalizedEmail = NormalizeEmail(request.Email);
|
||||
var user = await _db.Users.FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail, ct);
|
||||
|
||||
if (user is null || !PasswordSecurity.Verify(request.Password, user.PasswordHash, out var needsUpgrade))
|
||||
{
|
||||
_logger.LogWarning("Rejected login attempt");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (needsUpgrade) user.PasswordHash = PasswordSecurity.Hash(request.Password);
|
||||
user.LastLoginAt = DateTimeOffset.UtcNow;
|
||||
user.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
await RemoveExpiredTokensAsync(user.Id, ct);
|
||||
return await CreateSessionAsync(user, Guid.NewGuid(), null, ct);
|
||||
}
|
||||
|
||||
public async Task<AuthSession?> RefreshAsync(string refreshToken, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(refreshToken)) return null;
|
||||
|
||||
var tokenHash = HashToken(refreshToken);
|
||||
var token = await _db.RefreshTokens
|
||||
.Include(r => r.User)
|
||||
.FirstOrDefaultAsync(r => r.TokenHash == tokenHash, ct);
|
||||
|
||||
if (token is null) return null;
|
||||
|
||||
if (token.RevokedAt is not null)
|
||||
{
|
||||
await RevokeFamilyAsync(token.FamilyId, ct);
|
||||
_logger.LogWarning("Refresh token reuse detected for family {FamilyId}", token.FamilyId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (token.ExpiresAt <= DateTimeOffset.UtcNow) return null;
|
||||
|
||||
return await CreateSessionAsync(token.User, token.FamilyId, token, ct);
|
||||
}
|
||||
|
||||
public async Task RevokeAsync(string refreshToken, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(refreshToken)) return;
|
||||
|
||||
var tokenHash = HashToken(refreshToken);
|
||||
var token = await _db.RefreshTokens.FirstOrDefaultAsync(r => r.TokenHash == tokenHash, ct);
|
||||
if (token is null || token.RevokedAt is not null) return;
|
||||
|
||||
token.RevokedAt = DateTimeOffset.UtcNow;
|
||||
token.ConcurrencyStamp = Guid.NewGuid();
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public Task<NexusUser?> GetUserAsync(Guid userId, CancellationToken ct = default)
|
||||
=> _db.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == userId, ct);
|
||||
|
||||
public async Task<NexusUser?> UpdateProfileAsync(Guid userId, UpdateProfileRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == userId, ct);
|
||||
if (user is null) return null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.DisplayName))
|
||||
{
|
||||
user.DisplayName = request.DisplayName.Trim();
|
||||
}
|
||||
|
||||
user.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return user;
|
||||
}
|
||||
|
||||
public async Task<bool> ChangePasswordAsync(Guid userId, ChangePasswordRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == userId, ct);
|
||||
if (user is null) return false;
|
||||
|
||||
if (!PasswordSecurity.Verify(request.CurrentPassword, user.PasswordHash, out _))
|
||||
return false;
|
||||
|
||||
user.PasswordHash = PasswordSecurity.Hash(request.NewPassword);
|
||||
user.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<AuthSession?> CreateSessionAsync(
|
||||
NexusUser user,
|
||||
Guid familyId,
|
||||
RefreshToken? replacedToken,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var accessExpiresAt = DateTimeOffset.UtcNow.AddMinutes(GetAccessTokenExpirationMinutes());
|
||||
var rawRefreshToken = GenerateRefreshToken();
|
||||
var refreshTokenHash = HashToken(rawRefreshToken);
|
||||
|
||||
if (replacedToken is not null)
|
||||
{
|
||||
replacedToken.RevokedAt = DateTimeOffset.UtcNow;
|
||||
replacedToken.ReplacedByTokenHash = refreshTokenHash;
|
||||
replacedToken.ConcurrencyStamp = Guid.NewGuid();
|
||||
}
|
||||
|
||||
_db.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
UserId = user.Id,
|
||||
TokenHash = refreshTokenHash,
|
||||
FamilyId = familyId,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(GetRefreshTokenExpirationDays())
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
_logger.LogWarning("Concurrent refresh token rotation rejected");
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AuthSession(
|
||||
GenerateAccessToken(user, accessExpiresAt),
|
||||
rawRefreshToken,
|
||||
accessExpiresAt,
|
||||
ToUserInfo(user));
|
||||
}
|
||||
|
||||
private string GenerateAccessToken(NexusUser user, DateTimeOffset expiresAt)
|
||||
{
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(GetRequiredConfig("Jwt:Key")));
|
||||
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Email, user.Email),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
new Claim(ClaimTypes.Role, user.Role),
|
||||
new Claim("display_name", user.DisplayName)
|
||||
};
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: GetRequiredConfig("Jwt:Issuer"),
|
||||
audience: GetRequiredConfig("Jwt:Audience"),
|
||||
claims: claims,
|
||||
notBefore: DateTime.UtcNow,
|
||||
expires: expiresAt.UtcDateTime,
|
||||
signingCredentials: credentials);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
|
||||
private async Task RevokeFamilyAsync(Guid familyId, CancellationToken ct)
|
||||
{
|
||||
var activeTokens = await _db.RefreshTokens
|
||||
.Where(r => r.FamilyId == familyId && r.RevokedAt == null)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
foreach (var token in activeTokens)
|
||||
{
|
||||
token.RevokedAt = now;
|
||||
token.ConcurrencyStamp = Guid.NewGuid();
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private async Task RemoveExpiredTokensAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var cutoff = DateTimeOffset.UtcNow.AddDays(-30);
|
||||
var oldTokens = await _db.RefreshTokens
|
||||
.Where(r => r.UserId == userId && (r.ExpiresAt < DateTimeOffset.UtcNow || r.RevokedAt < cutoff))
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (oldTokens.Count > 0) _db.RefreshTokens.RemoveRange(oldTokens);
|
||||
}
|
||||
|
||||
private static string GenerateRefreshToken()
|
||||
{
|
||||
var value = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
|
||||
return value.TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
}
|
||||
|
||||
private static string HashToken(string token)
|
||||
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
|
||||
|
||||
public static string NormalizeEmail(string email) => email.Trim().ToUpperInvariant();
|
||||
|
||||
private static UserInfo ToUserInfo(NexusUser user) => new()
|
||||
{
|
||||
Id = user.Id,
|
||||
Email = user.Email,
|
||||
DisplayName = user.DisplayName,
|
||||
Role = user.Role
|
||||
};
|
||||
|
||||
private string GetRequiredConfig(string key)
|
||||
=> _config[key] ?? throw new InvalidOperationException($"Missing required configuration: {key}");
|
||||
|
||||
private int GetAccessTokenExpirationMinutes()
|
||||
=> _config.GetValue<int?>("Jwt:AccessTokenExpirationMinutes") ?? 15;
|
||||
|
||||
private int GetRefreshTokenExpirationDays()
|
||||
=> _config.GetValue<int?>("Jwt:RefreshTokenExpirationDays") ?? 7;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public static class PasswordSecurity
|
||||
{
|
||||
private const int Iterations = 210_000;
|
||||
private const int SaltSize = 16;
|
||||
private const int HashSize = 32;
|
||||
private const string Version = "v1";
|
||||
|
||||
public static string Hash(string password)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(password);
|
||||
|
||||
var salt = RandomNumberGenerator.GetBytes(SaltSize);
|
||||
var hash = Rfc2898DeriveBytes.Pbkdf2(
|
||||
password,
|
||||
salt,
|
||||
Iterations,
|
||||
HashAlgorithmName.SHA256,
|
||||
HashSize);
|
||||
|
||||
return string.Join('.', Version, Iterations, Convert.ToBase64String(salt), Convert.ToBase64String(hash));
|
||||
}
|
||||
|
||||
public static bool Verify(string password, string encodedHash, out bool needsUpgrade)
|
||||
{
|
||||
needsUpgrade = false;
|
||||
if (string.IsNullOrEmpty(password) || string.IsNullOrEmpty(encodedHash)) return false;
|
||||
|
||||
var parts = encodedHash.Split('.');
|
||||
if (parts.Length == 4 && parts[0] == Version && int.TryParse(parts[1], out var iterations))
|
||||
{
|
||||
try
|
||||
{
|
||||
var salt = Convert.FromBase64String(parts[2]);
|
||||
var expected = Convert.FromBase64String(parts[3]);
|
||||
var actual = Rfc2898DeriveBytes.Pbkdf2(
|
||||
password,
|
||||
salt,
|
||||
iterations,
|
||||
HashAlgorithmName.SHA256,
|
||||
expected.Length);
|
||||
|
||||
needsUpgrade = iterations < Iterations;
|
||||
return CryptographicOperations.FixedTimeEquals(actual, expected);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (encodedHash.Length == 64 && encodedHash.All(Uri.IsHexDigit))
|
||||
{
|
||||
var legacy = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(password)));
|
||||
needsUpgrade = true;
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.ASCII.GetBytes(legacy),
|
||||
Encoding.ASCII.GetBytes(encodedHash.ToUpperInvariant()));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user