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:
Bao
2026-06-09 16:31:42 +02:00
commit eeb6174de0
248 changed files with 19706 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
using System.ComponentModel.DataAnnotations;
namespace Nexus.Api.Contracts;
public sealed record LoginRequest
{
[Required, EmailAddress, MaxLength(120)]
public string Email { get; init; } = string.Empty;
[Required, MinLength(10), MaxLength(200)]
public string Password { get; init; } = string.Empty;
}
public sealed record AuthResponse
{
public string AccessToken { get; init; } = string.Empty;
public DateTimeOffset ExpiresAt { get; init; }
public UserInfo User { get; init; } = new();
}
public sealed record UserInfo
{
public Guid Id { get; init; }
public string Email { get; init; } = string.Empty;
public string DisplayName { get; init; } = string.Empty;
public string Role { get; init; } = string.Empty;
}
public sealed record UpdateProfileRequest
{
[MaxLength(100)]
public string? DisplayName { get; init; }
}
public sealed record ChangePasswordRequest
{
[Required, MinLength(10), MaxLength(200)]
public string CurrentPassword { get; init; } = string.Empty;
[Required, MinLength(10), MaxLength(200)]
public string NewPassword { get; init; } = string.Empty;
}
+9
View File
@@ -0,0 +1,9 @@
namespace Nexus.Api.Contracts;
public sealed record CreateProjectRequest(string Name, string? Description);
public sealed record CreateTaskRequest(string Title, string? Priority, Guid? ProjectId);
public sealed record UpdateTaskStateRequest(string State);
public sealed record ChatRequest(string Message, string? ConversationId, string? AgentId);
public sealed record UpdateProjectRequest(string? Name, string? Description, string? Status);
public sealed record UpdateTaskRequest(string? Title, string? Priority, Guid? ProjectId);
+50
View File
@@ -0,0 +1,50 @@
using Nexus.Api.Domain;
namespace Nexus.Api.Contracts;
public sealed record AgentListResponse(
string Id,
string Name,
string Role,
string Model,
string Status,
DateTimeOffset? LastSeen,
string? Workspace,
string? Description
);
public sealed record AgentDetailResponse(
string Id,
string Name,
string Role,
string Model,
string Status,
DateTimeOffset? LastSeen,
string? Workspace,
string? AgentDir,
string? Description,
IReadOnlyList<string>? SubAgents,
string? IdentityName
);
public sealed record AgentCommandRequest(string Message);
public sealed record AgentCommandResponse(
string Runtime,
string AgentId,
string ConversationId,
string Content
);
public sealed record ProjectHealth(
int Online,
int Offline,
int Degraded,
int Unknown
);
public sealed record IncidentInfo(
Guid? TaskId,
string? Title,
DateTimeOffset? Since
);
+13
View File
@@ -0,0 +1,13 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
WORKDIR /src
COPY Nexus.Api.csproj .
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine
WORKDIR /app
COPY --from=build /app/publish .
USER $APP_UID
EXPOSE 8080
ENTRYPOINT ["dotnet", "Nexus.Api.dll"]
+93
View File
@@ -0,0 +1,93 @@
namespace Nexus.Api.Domain;
public enum OperationalStatus
{
Online,
Degraded,
Offline,
Unknown
}
/// <summary>
/// Strongly-typed task lifecycle states.
/// String values (e.g. "In progress") are preserved for API compatibility
/// via <see cref="TaskStateHelper"/>; the WorkTask entity continues to store
/// state as a string in the database.
/// </summary>
public enum TaskState
{
Backlog,
InProgress,
Blocked,
Done
}
public static class TaskStateHelper
{
private static readonly Dictionary<TaskState, string> StateToString = new()
{
[TaskState.Backlog] = "Backlog",
[TaskState.InProgress] = "In progress",
[TaskState.Blocked] = "Blocked",
[TaskState.Done] = "Done"
};
private static readonly Dictionary<string, TaskState> StringToState = new(StringComparer.OrdinalIgnoreCase)
{
["Backlog"] = TaskState.Backlog,
["In progress"] = TaskState.InProgress,
["Blocked"] = TaskState.Blocked,
["Done"] = TaskState.Done
};
/// <summary>Valid task-state string values for API validation.</summary>
public static readonly string[] AllStates = ["Backlog", "In progress", "Blocked", "Done"];
/// <summary>Convert a TaskState enum to its API string representation.</summary>
public static string ToStateString(this TaskState state) => StateToString[state];
/// <summary>Parse a string to TaskState; defaults to Backlog for unrecognized input.</summary>
public static TaskState ToTaskState(this string state) =>
StringToState.TryGetValue(state, out var result) ? result : TaskState.Backlog;
/// <summary>Returns true if the string is a recognized task state (case-insensitive).</summary>
public static bool IsValidState(string? state) =>
!string.IsNullOrWhiteSpace(state) && StringToState.ContainsKey(state);
public static bool IsInProgressOrBlocked(string? state) =>
string.Equals(state, "In progress", StringComparison.OrdinalIgnoreCase)
|| string.Equals(state, "Blocked", StringComparison.OrdinalIgnoreCase);
public static bool IsDoneOrBacklog(string? state) =>
string.Equals(state, "Done", StringComparison.OrdinalIgnoreCase)
|| string.Equals(state, "Backlog", StringComparison.OrdinalIgnoreCase);
}
public sealed class Project
{
public Guid Id { get; init; } = Guid.NewGuid();
public required string Name { get; set; }
public string Description { get; set; } = string.Empty;
public int Progress { get; set; }
public OperationalStatus Status { get; set; } = OperationalStatus.Unknown;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class WorkTask
{
public Guid Id { get; init; } = Guid.NewGuid();
public required string Title { get; set; }
public string State { get; set; } = "Backlog";
public string Priority { get; set; } = "Normal";
public Guid? ProjectId { get; set; }
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class ActivityEvent
{
public long Id { get; init; }
public required string Type { get; set; }
public required string Message { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
+45
View File
@@ -0,0 +1,45 @@
using System.ComponentModel.DataAnnotations;
namespace Nexus.Api.Domain;
public class NexusUser
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(120)]
public string Email { get; set; } = string.Empty;
[MaxLength(120)]
public string NormalizedEmail { get; set; } = string.Empty;
[MaxLength(100)]
public string DisplayName { get; set; } = string.Empty;
public string PasswordHash { get; set; } = string.Empty;
public string Role { get; set; } = "owner";
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? LastLoginAt { get; set; }
public ICollection<RefreshToken> RefreshTokens { get; set; } = new List<RefreshToken>();
}
public class RefreshToken
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
[MaxLength(64)]
public string TokenHash { get; set; } = string.Empty;
public Guid FamilyId { get; set; } = Guid.NewGuid();
public DateTimeOffset ExpiresAt { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? RevokedAt { get; set; }
[MaxLength(64)]
public string? ReplacedByTokenHash { get; set; }
public Guid ConcurrencyStamp { get; set; } = Guid.NewGuid();
public NexusUser User { get; set; } = null!;
}
@@ -0,0 +1,220 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Nexus.Api.Infrastructure;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Nexus.Api.Migrations
{
[DbContext(typeof(NexusDbContext))]
[Migration("20260609064750_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Nexus.Api.Domain.ActivityEvent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Activity");
});
modelBuilder.Entity("Nexus.Api.Domain.NexusUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<DateTimeOffset?>("LastLoginAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("Nexus.Api.Domain.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<int>("Progress")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Projects");
});
modelBuilder.Entity("Nexus.Api.Domain.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FamilyId")
.HasColumnType("uuid");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId", "FamilyId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Domain.WorkTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Priority")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("State")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Tasks");
});
modelBuilder.Entity("Nexus.Api.Domain.RefreshToken", b =>
{
b.HasOne("Nexus.Api.Domain.NexusUser", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Nexus.Api.Domain.NexusUser", b =>
{
b.Navigation("RefreshTokens");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,143 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Nexus.Api.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Activity",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Type = table.Column<string>(type: "text", nullable: false),
Message = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Activity", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Projects",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: false),
Description = table.Column<string>(type: "text", nullable: false),
Progress = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Projects", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Tasks",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Title = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: false),
State = table.Column<string>(type: "text", nullable: false),
Priority = table.Column<string>(type: "text", nullable: false),
ProjectId = table.Column<Guid>(type: "uuid", nullable: true),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Tasks", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Email = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
NormalizedEmail = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
DisplayName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
PasswordHash = table.Column<string>(type: "text", nullable: false),
Role = table.Column<string>(type: "text", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
LastLoginAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
});
migrationBuilder.CreateTable(
name: "RefreshTokens",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
TokenHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
FamilyId = table.Column<Guid>(type: "uuid", nullable: false),
ExpiresAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
RevokedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
ReplacedByTokenHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
ConcurrencyStamp = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RefreshTokens", x => x.Id);
table.ForeignKey(
name: "FK_RefreshTokens_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_RefreshTokens_TokenHash",
table: "RefreshTokens",
column: "TokenHash",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_RefreshTokens_UserId_FamilyId",
table: "RefreshTokens",
columns: new[] { "UserId", "FamilyId" });
migrationBuilder.CreateIndex(
name: "IX_Users_NormalizedEmail",
table: "Users",
column: "NormalizedEmail",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Activity");
migrationBuilder.DropTable(
name: "Projects");
migrationBuilder.DropTable(
name: "RefreshTokens");
migrationBuilder.DropTable(
name: "Tasks");
migrationBuilder.DropTable(
name: "Users");
}
}
}
@@ -0,0 +1,217 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Nexus.Api.Infrastructure;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Nexus.Api.Migrations
{
[DbContext(typeof(NexusDbContext))]
partial class NexusDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Nexus.Api.Domain.ActivityEvent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Activity");
});
modelBuilder.Entity("Nexus.Api.Domain.NexusUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<DateTimeOffset?>("LastLoginAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("Nexus.Api.Domain.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<int>("Progress")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Projects");
});
modelBuilder.Entity("Nexus.Api.Domain.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FamilyId")
.HasColumnType("uuid");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId", "FamilyId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Domain.WorkTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Priority")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("State")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Tasks");
});
modelBuilder.Entity("Nexus.Api.Domain.RefreshToken", b =>
{
b.HasOne("Nexus.Api.Domain.NexusUser", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Nexus.Api.Domain.NexusUser", b =>
{
b.Navigation("RefreshTokens");
});
#pragma warning restore 612, 618
}
}
}
+31
View File
@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore;
using Nexus.Api.Domain;
namespace Nexus.Api.Infrastructure;
public sealed class NexusDbContext(DbContextOptions<NexusDbContext> options) : DbContext(options)
{
public DbSet<Project> Projects => Set<Project>();
public DbSet<WorkTask> Tasks => Set<WorkTask>();
public DbSet<ActivityEvent> Activity => Set<ActivityEvent>();
public DbSet<NexusUser> Users => Set<NexusUser>();
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Project>().Property(x => x.Name).HasMaxLength(160);
modelBuilder.Entity<WorkTask>().Property(x => x.Title).HasMaxLength(240);
modelBuilder.Entity<ActivityEvent>().Property(x => x.Message).HasMaxLength(1000);
modelBuilder.Entity<NexusUser>().HasIndex(u => u.NormalizedEmail).IsUnique();
modelBuilder.Entity<RefreshToken>().HasIndex(r => r.TokenHash).IsUnique();
modelBuilder.Entity<RefreshToken>().HasIndex(r => new { r.UserId, r.FamilyId });
modelBuilder.Entity<RefreshToken>().Property(r => r.ConcurrencyStamp).IsConcurrencyToken();
modelBuilder.Entity<RefreshToken>()
.HasOne(r => r.User)
.WithMany(u => u.RefreshTokens)
.HasForeignKey(r => r.UserId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<ActivityEvent>().HasIndex(x => x.CreatedAt);
}
}
+40
View File
@@ -0,0 +1,40 @@
using Nexus.Api.Domain;
namespace Nexus.Api.Integrations;
public sealed record AgentRuntimeStatus(
string Runtime,
OperationalStatus Status,
TimeSpan? Latency,
string? Detail);
public sealed record ModelProviderStatus(
string Provider,
string Model,
OperationalStatus Status,
bool IsLocal,
string? Detail);
public sealed record AgentChatResult(
string Runtime,
string AgentId,
string ConversationId,
string Content);
public interface IAgentRuntime
{
string Name { get; }
Task<AgentRuntimeStatus> GetStatusAsync(CancellationToken cancellationToken);
Task<AgentChatResult> ChatAsync(
string message,
string conversationId,
string agentId,
CancellationToken cancellationToken);
}
public interface IModelProvider
{
string Name { get; }
Task<IReadOnlyCollection<ModelProviderStatus>> GetModelsAsync(CancellationToken cancellationToken);
}
+25
View File
@@ -0,0 +1,25 @@
using Nexus.Api.Domain;
namespace Nexus.Api.Integrations;
public sealed class NvidiaProvider(IConfiguration configuration) : IModelProvider
{
public string Name => "NVIDIA";
public Task<IReadOnlyCollection<ModelProviderStatus>> GetModelsAsync(
CancellationToken cancellationToken)
{
var configured = !string.IsNullOrWhiteSpace(
configuration["Integrations:Nvidia:ApiKey"]);
IReadOnlyCollection<ModelProviderStatus> models =
[
new(
Name,
"moonshotai/kimi-k2.6",
configured ? OperationalStatus.Online : OperationalStatus.Unknown,
false,
configured ? "Credential configured" : "Credential required")
];
return Task.FromResult(models);
}
}
+37
View File
@@ -0,0 +1,37 @@
using System.Net.Http.Json;
using Nexus.Api.Domain;
namespace Nexus.Api.Integrations;
public sealed class OllamaProvider(HttpClient client) : IModelProvider
{
private sealed record OllamaTag(string Name);
private sealed record OllamaTags(IReadOnlyCollection<OllamaTag>? Models);
public string Name => "Ollama";
public async Task<IReadOnlyCollection<ModelProviderStatus>> GetModelsAsync(
CancellationToken cancellationToken)
{
try
{
var response = await client.GetFromJsonAsync<OllamaTags>("/api/tags", cancellationToken);
return response?.Models?
.Select(model => new ModelProviderStatus(
Name,
model.Name,
OperationalStatus.Online,
true,
"Local"))
.ToArray() ?? [];
}
catch (Exception exception)
{
return
[
new(Name, "qwen3:4b", OperationalStatus.Offline, true, exception.Message)
];
}
}
}
+75
View File
@@ -0,0 +1,75 @@
using System.Diagnostics;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Nexus.Api.Domain;
namespace Nexus.Api.Integrations;
public sealed class OpenClawRuntime(HttpClient client, IConfiguration configuration) : IAgentRuntime
{
public string Name => "OpenClaw";
public async Task<AgentRuntimeStatus> GetStatusAsync(CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, "/");
ApplyAuthorization(request);
using var response = await client.SendAsync(request, cancellationToken);
stopwatch.Stop();
var status = response.IsSuccessStatusCode
? OperationalStatus.Online
: OperationalStatus.Degraded;
return new(Name, status, stopwatch.Elapsed, $"HTTP {(int)response.StatusCode}");
}
catch (Exception exception)
{
stopwatch.Stop();
return new(Name, OperationalStatus.Offline, stopwatch.Elapsed, exception.Message);
}
}
public async Task<AgentChatResult> ChatAsync(
string message,
string conversationId,
string agentId,
CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Post, "/v1/chat/completions");
ApplyAuthorization(request);
request.Content = JsonContent.Create(new
{
model = $"openclaw/{agentId}",
messages = new[] { new { role = "user", content = message } },
user = conversationId,
stream = false
});
using var response = await client.SendAsync(request, cancellationToken);
var body = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
throw new HttpRequestException($"OpenClaw chat returned HTTP {(int)response.StatusCode}: {body}");
using var document = JsonDocument.Parse(body);
var content = document.RootElement
.GetProperty("choices")[0]
.GetProperty("message")
.GetProperty("content")
.GetString();
if (string.IsNullOrWhiteSpace(content))
throw new InvalidOperationException("OpenClaw returned an empty assistant response.");
return new(Name, agentId, conversationId, content);
}
private void ApplyAuthorization(HttpRequestMessage request)
{
var credential = configuration["Integrations:OpenClaw:Password"]
?? configuration["Integrations:OpenClaw:Token"];
if (!string.IsNullOrWhiteSpace(credential))
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", credential);
}
}
+18
View File
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
</ItemGroup>
</Project>
+1229
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
using Nexus.Api.Domain;
using Nexus.Api.Integrations;
namespace Nexus.Api.Routing;
public sealed record RoutingTarget(
int Priority,
string Provider,
string Model,
string Purpose,
OperationalStatus Status,
string Detail);
public sealed class ModelRoutingService(
IAgentRuntime runtime)
{
public async Task<IReadOnlyCollection<RoutingTarget>> GetStatusAsync(
CancellationToken cancellationToken)
{
var runtimeStatus = await runtime.GetStatusAsync(cancellationToken);
return
[
new(1, "OpenClaw", "deepseek/deepseek-v4-flash", "Programmer agent",
runtimeStatus.Status,
"Routed through OpenClaw policy"),
new(2, "OpenClaw", "deepseek/deepseek-v4-pro", "Reviewer agent",
runtimeStatus.Status,
"Routed through OpenClaw policy"),
new(3, "OpenClaw", "openai/gpt-5.3-chat-latest", "Iris orchestrator",
runtimeStatus.Status,
"Routed through OpenClaw policy")
];
}
}
+222
View File
@@ -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; }
}
}
+248
View File
@@ -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;
}
+67
View File
@@ -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;
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"ConnectionStrings": {
"Nexus": "Host=localhost;Port=5432;Database=nexus;Username=nexus;Password=nexus"
},
"Integrations": {
"OpenClaw": {
"BaseUrl": "http://127.0.0.1:18789",
"Token": "",
"Password": ""
},
"Ollama": {
"BaseUrl": "http://127.0.0.1:11434"
},
"Nvidia": {
"ApiKey": ""
}
},
"Jwt": {
"Issuer": "nexus",
"Audience": "nexus-web",
"AccessTokenExpirationMinutes": 15,
"RefreshTokenExpirationDays": 7
},
"AllowedHosts": "*"
}
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Vendored Executable
BIN
View File
Binary file not shown.
+356
View File
@@ -0,0 +1,356 @@
{
"runtimeTarget": {
"name": ".NETCoreApp,Version=v10.0",
"signature": ""
},
"compilationOptions": {},
"targets": {
".NETCoreApp,Version=v10.0": {
"Nexus.Api/1.0.0": {
"dependencies": {
"AspNetCore.HealthChecks.NpgSql": "9.0.0",
"Microsoft.AspNetCore.Authentication.JwtBearer": "10.0.8",
"Npgsql.EntityFrameworkCore.PostgreSQL": "10.0.2",
"Swashbuckle.AspNetCore": "10.2.1"
},
"runtime": {
"Nexus.Api.dll": {}
}
},
"AspNetCore.HealthChecks.NpgSql/9.0.0": {
"dependencies": {
"Npgsql": "10.0.3"
},
"runtime": {
"lib/net8.0/HealthChecks.NpgSql.dll": {
"assemblyVersion": "9.0.0.0",
"fileVersion": "9.0.0.0"
}
}
},
"Microsoft.AspNetCore.Authentication.JwtBearer/10.0.8": {
"dependencies": {
"Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.0.1"
},
"runtime": {
"lib/net10.0/Microsoft.AspNetCore.Authentication.JwtBearer.dll": {
"assemblyVersion": "10.0.8.0",
"fileVersion": "10.0.826.23019"
}
}
},
"Microsoft.EntityFrameworkCore/10.0.8": {
"dependencies": {
"Microsoft.EntityFrameworkCore.Abstractions": "10.0.8"
},
"runtime": {
"lib/net10.0/Microsoft.EntityFrameworkCore.dll": {
"assemblyVersion": "10.0.8.0",
"fileVersion": "10.0.826.23019"
}
}
},
"Microsoft.EntityFrameworkCore.Abstractions/10.0.8": {
"runtime": {
"lib/net10.0/Microsoft.EntityFrameworkCore.Abstractions.dll": {
"assemblyVersion": "10.0.8.0",
"fileVersion": "10.0.826.23019"
}
}
},
"Microsoft.EntityFrameworkCore.Relational/10.0.8": {
"dependencies": {
"Microsoft.EntityFrameworkCore": "10.0.8"
},
"runtime": {
"lib/net10.0/Microsoft.EntityFrameworkCore.Relational.dll": {
"assemblyVersion": "10.0.8.0",
"fileVersion": "10.0.826.23019"
}
}
},
"Microsoft.IdentityModel.Abstractions/8.0.1": {
"runtime": {
"lib/net9.0/Microsoft.IdentityModel.Abstractions.dll": {
"assemblyVersion": "8.0.1.0",
"fileVersion": "8.0.1.50722"
}
}
},
"Microsoft.IdentityModel.JsonWebTokens/8.0.1": {
"dependencies": {
"Microsoft.IdentityModel.Tokens": "8.0.1"
},
"runtime": {
"lib/net9.0/Microsoft.IdentityModel.JsonWebTokens.dll": {
"assemblyVersion": "8.0.1.0",
"fileVersion": "8.0.1.50722"
}
}
},
"Microsoft.IdentityModel.Logging/8.0.1": {
"dependencies": {
"Microsoft.IdentityModel.Abstractions": "8.0.1"
},
"runtime": {
"lib/net9.0/Microsoft.IdentityModel.Logging.dll": {
"assemblyVersion": "8.0.1.0",
"fileVersion": "8.0.1.50722"
}
}
},
"Microsoft.IdentityModel.Protocols/8.0.1": {
"dependencies": {
"Microsoft.IdentityModel.Tokens": "8.0.1"
},
"runtime": {
"lib/net9.0/Microsoft.IdentityModel.Protocols.dll": {
"assemblyVersion": "8.0.1.0",
"fileVersion": "8.0.1.50722"
}
}
},
"Microsoft.IdentityModel.Protocols.OpenIdConnect/8.0.1": {
"dependencies": {
"Microsoft.IdentityModel.Protocols": "8.0.1",
"System.IdentityModel.Tokens.Jwt": "8.0.1"
},
"runtime": {
"lib/net9.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll": {
"assemblyVersion": "8.0.1.0",
"fileVersion": "8.0.1.50722"
}
}
},
"Microsoft.IdentityModel.Tokens/8.0.1": {
"dependencies": {
"Microsoft.IdentityModel.Logging": "8.0.1"
},
"runtime": {
"lib/net9.0/Microsoft.IdentityModel.Tokens.dll": {
"assemblyVersion": "8.0.1.0",
"fileVersion": "8.0.1.50722"
}
}
},
"Microsoft.OpenApi/2.7.5": {
"runtime": {
"lib/net8.0/Microsoft.OpenApi.dll": {
"assemblyVersion": "2.7.5.0",
"fileVersion": "2.7.5.0"
}
}
},
"Npgsql/10.0.3": {
"runtime": {
"lib/net10.0/Npgsql.dll": {
"assemblyVersion": "10.0.3.0",
"fileVersion": "10.0.3.0"
}
}
},
"Npgsql.EntityFrameworkCore.PostgreSQL/10.0.2": {
"dependencies": {
"Microsoft.EntityFrameworkCore": "10.0.8",
"Microsoft.EntityFrameworkCore.Relational": "10.0.8",
"Npgsql": "10.0.3"
},
"runtime": {
"lib/net10.0/Npgsql.EntityFrameworkCore.PostgreSQL.dll": {
"assemblyVersion": "10.0.2.0",
"fileVersion": "10.0.2.0"
}
}
},
"Swashbuckle.AspNetCore/10.2.1": {
"dependencies": {
"Swashbuckle.AspNetCore.Swagger": "10.2.1",
"Swashbuckle.AspNetCore.SwaggerGen": "10.2.1",
"Swashbuckle.AspNetCore.SwaggerUI": "10.2.1"
}
},
"Swashbuckle.AspNetCore.Swagger/10.2.1": {
"dependencies": {
"Microsoft.OpenApi": "2.7.5"
},
"runtime": {
"lib/net10.0/Swashbuckle.AspNetCore.Swagger.dll": {
"assemblyVersion": "10.2.1.0",
"fileVersion": "10.2.1.2634"
}
}
},
"Swashbuckle.AspNetCore.SwaggerGen/10.2.1": {
"dependencies": {
"Swashbuckle.AspNetCore.Swagger": "10.2.1"
},
"runtime": {
"lib/net10.0/Swashbuckle.AspNetCore.SwaggerGen.dll": {
"assemblyVersion": "10.2.1.0",
"fileVersion": "10.2.1.2634"
}
}
},
"Swashbuckle.AspNetCore.SwaggerUI/10.2.1": {
"runtime": {
"lib/net10.0/Swashbuckle.AspNetCore.SwaggerUI.dll": {
"assemblyVersion": "10.2.1.0",
"fileVersion": "10.2.1.2634"
}
}
},
"System.IdentityModel.Tokens.Jwt/8.0.1": {
"dependencies": {
"Microsoft.IdentityModel.JsonWebTokens": "8.0.1",
"Microsoft.IdentityModel.Tokens": "8.0.1"
},
"runtime": {
"lib/net9.0/System.IdentityModel.Tokens.Jwt.dll": {
"assemblyVersion": "8.0.1.0",
"fileVersion": "8.0.1.50722"
}
}
}
}
},
"libraries": {
"Nexus.Api/1.0.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"AspNetCore.HealthChecks.NpgSql/9.0.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-npc58/AD5zuVxERdhCl2Kb7WnL37mwX42SJcXIwvmEig0/dugOLg3SIwtfvvh3TnvTwR/sk5LYNkkPaBdks61A==",
"path": "aspnetcore.healthchecks.npgsql/9.0.0",
"hashPath": "aspnetcore.healthchecks.npgsql.9.0.0.nupkg.sha512"
},
"Microsoft.AspNetCore.Authentication.JwtBearer/10.0.8": {
"type": "package",
"serviceable": true,
"sha512": "sha512-oGnE+X/SN6jdqao9WOkOIfyZ5+a0AtluJWy1Mxndq+kcWG6sx5k6l6tucu8/wJ7o9fHfLgVCzm/c4v/KVgVk6w==",
"path": "microsoft.aspnetcore.authentication.jwtbearer/10.0.8",
"hashPath": "microsoft.aspnetcore.authentication.jwtbearer.10.0.8.nupkg.sha512"
},
"Microsoft.EntityFrameworkCore/10.0.8": {
"type": "package",
"serviceable": true,
"sha512": "sha512-EJx+fIBMgBlgD+ublKCn+GTOJkw3UqV7xOjYWBRVdUYyIm8UfvAsmSOPFiIInsWTHyMEYUJ9gCJY1jwX+6UB7w==",
"path": "microsoft.entityframeworkcore/10.0.8",
"hashPath": "microsoft.entityframeworkcore.10.0.8.nupkg.sha512"
},
"Microsoft.EntityFrameworkCore.Abstractions/10.0.8": {
"type": "package",
"serviceable": true,
"sha512": "sha512-jbKDXWPZQhuPHygMnwzNOqxBADVcpRVytcKYZsA++QqhPkpF93Ta8o5mbJQGrARSjlkr9WtOaADV97EDMOZ7DA==",
"path": "microsoft.entityframeworkcore.abstractions/10.0.8",
"hashPath": "microsoft.entityframeworkcore.abstractions.10.0.8.nupkg.sha512"
},
"Microsoft.EntityFrameworkCore.Relational/10.0.8": {
"type": "package",
"serviceable": true,
"sha512": "sha512-UU3diAD2wwZveye2rnrwaF/wvJ9tm5iL2fuY9TTap6/iGQK1OO29M1BzXZRlRPVH/dByt5w/pISBSFtyR7hTqw==",
"path": "microsoft.entityframeworkcore.relational/10.0.8",
"hashPath": "microsoft.entityframeworkcore.relational.10.0.8.nupkg.sha512"
},
"Microsoft.IdentityModel.Abstractions/8.0.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-OtlIWcyX01olfdevPKZdIPfBEvbcioDyBiE/Z2lHsopsMD7twcKtlN9kMevHmI5IIPhFpfwCIiR6qHQz1WHUIw==",
"path": "microsoft.identitymodel.abstractions/8.0.1",
"hashPath": "microsoft.identitymodel.abstractions.8.0.1.nupkg.sha512"
},
"Microsoft.IdentityModel.JsonWebTokens/8.0.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-s6++gF9x0rQApQzOBbSyp4jUaAlwm+DroKfL8gdOHxs83k8SJfUXhuc46rDB3rNXBQ1MVRxqKUrqFhO/M0E97g==",
"path": "microsoft.identitymodel.jsonwebtokens/8.0.1",
"hashPath": "microsoft.identitymodel.jsonwebtokens.8.0.1.nupkg.sha512"
},
"Microsoft.IdentityModel.Logging/8.0.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-UCPF2exZqBXe7v/6sGNiM6zCQOUXXQ9+v5VTb9gPB8ZSUPnX53BxlN78v2jsbIvK9Dq4GovQxo23x8JgWvm/Qg==",
"path": "microsoft.identitymodel.logging/8.0.1",
"hashPath": "microsoft.identitymodel.logging.8.0.1.nupkg.sha512"
},
"Microsoft.IdentityModel.Protocols/8.0.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-uA2vpKqU3I2mBBEaeJAWPTjT9v1TZrGWKdgK6G5qJd03CLx83kdiqO9cmiK8/n1erkHzFBwU/RphP83aAe3i3g==",
"path": "microsoft.identitymodel.protocols/8.0.1",
"hashPath": "microsoft.identitymodel.protocols.8.0.1.nupkg.sha512"
},
"Microsoft.IdentityModel.Protocols.OpenIdConnect/8.0.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-AQDbfpL+yzuuGhO/mQhKNsp44pm5Jv8/BI4KiFXR7beVGZoSH35zMV3PrmcfvSTsyI6qrcR898NzUauD6SRigg==",
"path": "microsoft.identitymodel.protocols.openidconnect/8.0.1",
"hashPath": "microsoft.identitymodel.protocols.openidconnect.8.0.1.nupkg.sha512"
},
"Microsoft.IdentityModel.Tokens/8.0.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-kDimB6Dkd3nkW2oZPDkMkVHfQt3IDqO5gL0oa8WVy3OP4uE8Ij+8TXnqg9TOd9ufjsY3IDiGz7pCUbnfL18tjg==",
"path": "microsoft.identitymodel.tokens/8.0.1",
"hashPath": "microsoft.identitymodel.tokens.8.0.1.nupkg.sha512"
},
"Microsoft.OpenApi/2.7.5": {
"type": "package",
"serviceable": true,
"sha512": "sha512-0FA67RSnRM4tcBKqiqVu/HPdZ9+QOKbmeRjxRUGTCjPU4C0bmUhd97Dso7Yild5P7nOV6GxJ2xrK0Kv/O9xp0w==",
"path": "microsoft.openapi/2.7.5",
"hashPath": "microsoft.openapi.2.7.5.nupkg.sha512"
},
"Npgsql/10.0.3": {
"type": "package",
"serviceable": true,
"sha512": "sha512-7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w==",
"path": "npgsql/10.0.3",
"hashPath": "npgsql.10.0.3.nupkg.sha512"
},
"Npgsql.EntityFrameworkCore.PostgreSQL/10.0.2": {
"type": "package",
"serviceable": true,
"sha512": "sha512-PsNYgPOSW41Xx19gin7y4EdZAPteWr9Cb01XkdObxOsPzi+mgBupBEN7J7+erXFsROPOILM7MlIoO9QzL8+LGQ==",
"path": "npgsql.entityframeworkcore.postgresql/10.0.2",
"hashPath": "npgsql.entityframeworkcore.postgresql.10.0.2.nupkg.sha512"
},
"Swashbuckle.AspNetCore/10.2.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-SDU6akgCV/H4jFMRfyJ0mgO5jWOuuAqekvEThXg8c/LjnfNz5Nkaz+RUpeTVJKWIRX4wDKC/6R3ogJ4AsRE32A==",
"path": "swashbuckle.aspnetcore/10.2.1",
"hashPath": "swashbuckle.aspnetcore.10.2.1.nupkg.sha512"
},
"Swashbuckle.AspNetCore.Swagger/10.2.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-ej4inPhiWCq+0utG8yaKhIhE8M3k3R/qRaGhpgDZB+O/s+o62/zRMO1Cn2CtQccsrqPE9PYnzCp6hQGYGpJOyQ==",
"path": "swashbuckle.aspnetcore.swagger/10.2.1",
"hashPath": "swashbuckle.aspnetcore.swagger.10.2.1.nupkg.sha512"
},
"Swashbuckle.AspNetCore.SwaggerGen/10.2.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-JYX6i/y0xEtQWH/hZyfcage1/ldwww83ueD/gBc34uSnMwyvRLUsOpYcxlliFFxFbZMrY6t+R9ENqolE7zTEOg==",
"path": "swashbuckle.aspnetcore.swaggergen/10.2.1",
"hashPath": "swashbuckle.aspnetcore.swaggergen.10.2.1.nupkg.sha512"
},
"Swashbuckle.AspNetCore.SwaggerUI/10.2.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-vzB8ZAGqXus3fdareJ9GHctaRP9ZL+wW9x8U7s1Y+BWprInFvSg6rpD9VhANNpwXA8fUHqu5Agjl/+hHG1BCQA==",
"path": "swashbuckle.aspnetcore.swaggerui/10.2.1",
"hashPath": "swashbuckle.aspnetcore.swaggerui.10.2.1.nupkg.sha512"
},
"System.IdentityModel.Tokens.Jwt/8.0.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-GJw3bYkWpOgvN3tJo5X4lYUeIFA2HD293FPUhKmp7qxS+g5ywAb34Dnd3cDAFLkcMohy5XTpoaZ4uAHuw0uSPQ==",
"path": "system.identitymodel.tokens.jwt/8.0.1",
"hashPath": "system.identitymodel.tokens.jwt.8.0.1.nupkg.sha512"
}
}
}
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+21
View File
@@ -0,0 +1,21 @@
{
"runtimeOptions": {
"tfm": "net10.0",
"frameworks": [
{
"name": "Microsoft.NETCore.App",
"version": "10.0.0"
},
{
"name": "Microsoft.AspNetCore.App",
"version": "10.0.0"
}
],
"configProperties": {
"System.GC.Server": true,
"System.Reflection.Metadata.MetadataUpdater.IsSupported": false,
"System.Reflection.NullabilityInfoContext.IsSupported": true,
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false
}
}
}
+1
View File
@@ -0,0 +1 @@
{"Version":1,"ManifestType":"Publish","Endpoints":[]}
Binary file not shown.
Vendored Executable
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+25
View File
@@ -0,0 +1,25 @@
{
"ConnectionStrings": {
"Nexus": "Host=localhost;Port=5432;Database=nexus;Username=nexus;Password=nexus"
},
"Integrations": {
"OpenClaw": {
"BaseUrl": "http://127.0.0.1:18789",
"Token": "",
"Password": ""
},
"Ollama": {
"BaseUrl": "http://127.0.0.1:11434"
},
"Nvidia": {
"ApiKey": ""
}
},
"Jwt": {
"Issuer": "nexus",
"Audience": "nexus-web",
"AccessTokenExpirationMinutes": 15,
"RefreshTokenExpirationDays": 7
},
"AllowedHosts": "*"
}
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<location path="." inheritInChildApplications="false">
<system.webServer>
<handlers>
<add name="aspNetCore" path="*" verb="*" modules="AspNetCoreModuleV2" resourceType="Unspecified" />
</handlers>
<aspNetCore processPath="dotnet" arguments=".\Nexus.Api.dll" stdoutLogEnabled="false" stdoutLogFile=".\logs\stdout" hostingModel="inprocess" />
</system.webServer>
</location>
</configuration>