Add users, roles, and per-novel permissions

Introduces accounts (ASP.NET Identity + cookie auth), four global
roles (Admin/Writer/Editor/Reviewer), per-novel ownership and grants
via ProjectMember, and a service-API-key principal for the MCP server
and background import jobs. Enforcement lives in the application
services (not endpoint filters) so the embedded agent and MCP tools,
which call the same services directly, can't bypass it. Web client
gets a login page, session-aware routing, and a People section for
managing per-novel access.

Also includes prior in-flight changes from this branch (CLAUDE.md
compliance pass, dev-deploy docker-compose setup) that were
uncommitted when this feature work started.
This commit is contained in:
James Wampler
2026-08-15 22:29:33 -07:00
parent 7d8dd0c4fd
commit e598c18d67
111 changed files with 6562 additions and 797 deletions
+10 -20
View File
@@ -4,31 +4,23 @@ using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
using Novelly.Api.Projects;
using Novelly.Api.Users;
namespace Novelly.Api.Imports;
/// <summary>
/// Read-only inspection and job creation for outline imports. The actual import — reading
/// source files, calling the model, writing project data — runs in <see cref="ImportAgentService"/>,
/// driven off the request thread by <see cref="ImportJobRunner"/>; this service only ever
/// touches the filesystem to peek at a ledger, never to import anything itself.
/// </summary>
public class ImportService(
INovelDbContext db,
ProjectService projects,
Channel<Guid> queue,
INovelUserContext userContext,
ILogger<ImportService> logger,
IModelValidator<InspectImportRequest> inspectValidator,
IModelValidator<StartImportRequest> startValidator)
{
/// <summary>
/// Reports whether a folder is a fresh import, one to resume, or already complete —
/// so the UI can offer the right action before committing to anything.
/// </summary>
public Task<ImportInspectionResponse> InspectAsync(InspectImportRequest request, CancellationToken ct = default)
{
Guard.Null(request, nameof(request));
inspectValidator.Validate(request).ThrowIfInvalid();
inspectValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Inspecting import source {SourceRoot}", request.SourceRoot);
@@ -48,16 +40,10 @@ public class ImportService(
readiness, ledger.ProjectId, chaptersDone, total, ledger.CompletedPasses ?? []));
}
/// <summary>
/// Creates (or reuses) an <see cref="ImportJob"/> for this source root and enqueues it
/// for the background runner. <see cref="StartImportRequest.ForceRestart"/> deletes the
/// ledger and the project it points at first — the "complete, delete and reimport" path —
/// so make sure the caller has confirmed with the writer before setting it.
/// </summary>
public async Task<ImportJob> StartOrResumeAsync(StartImportRequest request, CancellationToken ct = default)
{
Guard.Null(request, nameof(request));
startValidator.Validate(request).ThrowIfInvalid();
startValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation(
"Starting import for {SourceRoot}, forceRestart {ForceRestart}", request.SourceRoot, request.ForceRestart);
@@ -88,7 +74,12 @@ public class ImportService(
return existing;
}
var job = new ImportJob { SourceRoot = root, ChaptersTotal = ImportPaths.CountChapterFiles(root) };
var job = new ImportJob
{
SourceRoot = root,
ChaptersTotal = ImportPaths.CountChapterFiles(root),
RequestedByUserId = userContext.UserId
};
db.ImportJobs.Add(job);
await db.SaveChangesAsync(ct);
@@ -97,7 +88,6 @@ public class ImportService(
return job;
}
/// <summary>Null when no job has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<ImportJob?> GetStatusAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));