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:
@@ -1,16 +1,12 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ModelContextProtocol.Protocol;
|
||||
|
||||
namespace Novelly.Mcp;
|
||||
|
||||
/// <summary>
|
||||
/// Thin wrapper over the Novelly REST API. The MCP server deliberately owns no
|
||||
/// domain logic of its own — it is a second front end onto the same API the web client
|
||||
/// uses, so an edit made from Claude Code and one made in the browser are the same edit.
|
||||
/// </summary>
|
||||
public class NovelApiClient(HttpClient http)
|
||||
public class NovelApiClient(HttpClient http, ILogger<NovelApiClient> logger)
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
@@ -33,11 +29,6 @@ public class NovelApiClient(HttpClient http)
|
||||
|
||||
public Task<CallToolResult> DeleteAsync(string path, CancellationToken ct = default) => SendAsync(new HttpRequestMessage(HttpMethod.Delete, path), ct);
|
||||
|
||||
/// <summary>
|
||||
/// Sends the request and shapes the outcome as a tool result. Failures come back as
|
||||
/// `isError` results carrying the API's own message, rather than as exceptions the
|
||||
/// SDK would flatten into "an error occurred" — the model can act on the former.
|
||||
/// </summary>
|
||||
private async Task<CallToolResult> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||
{
|
||||
HttpResponseMessage response;
|
||||
@@ -47,8 +38,7 @@ public class NovelApiClient(HttpClient http)
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
// The API not being up is the most common failure here, and a bare connection
|
||||
// exception tells the model nothing actionable.
|
||||
logger.LogError(ex, "Could not reach the Novelly API at {BaseAddress}", http.BaseAddress);
|
||||
return Error($"Could not reach the Novelly API at {http.BaseAddress}. Is it running? ({ex.Message})");
|
||||
}
|
||||
|
||||
@@ -62,6 +52,8 @@ public class NovelApiClient(HttpClient http)
|
||||
var detail = TryReadProblemDetail(body) ?? body;
|
||||
return Error(response.StatusCode switch
|
||||
{
|
||||
HttpStatusCode.Unauthorized => $"Not permitted: the Novelly API rejected the service api key. Set NOVELLY_API_KEY to match the API's Auth:ServiceApiKey. ({detail})",
|
||||
HttpStatusCode.Forbidden => $"Not permitted: {detail}",
|
||||
HttpStatusCode.NotFound => $"Not found: {detail}",
|
||||
HttpStatusCode.BadRequest => $"Rejected: {detail}",
|
||||
_ => $"API returned {(int)response.StatusCode}: {detail}"
|
||||
@@ -74,28 +66,29 @@ public class NovelApiClient(HttpClient http)
|
||||
private static CallToolResult Error(string message) =>
|
||||
new() { Content = [new TextContentBlock { Text = message }], IsError = true };
|
||||
|
||||
/// <summary>Reformats the API's compact JSON so tool output reads well in a transcript.</summary>
|
||||
private static string Prettify(string json)
|
||||
private string Prettify(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Serialize(JsonSerializer.Deserialize<JsonElement>(json), Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
catch (JsonException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Response body was not valid JSON; returning it unformatted");
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? TryReadProblemDetail(string body)
|
||||
private string? TryReadProblemDetail(string body)
|
||||
{
|
||||
try
|
||||
{
|
||||
var problem = JsonSerializer.Deserialize<JsonElement>(body);
|
||||
return problem.TryGetProperty("detail", out var detail) ? detail.GetString() : null;
|
||||
}
|
||||
catch (JsonException)
|
||||
catch (JsonException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Error response body was not valid JSON problem details");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user