diff --git a/Directory.Packages.props b/Directory.Packages.props index 0a39d46..24dbb38 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -32,6 +32,15 @@ + + diff --git a/External/NexusKit.Modules.FfxivCollect/Ipc/FfxivCollectIpcProvider.cs b/External/NexusKit.Modules.FfxivCollect/Ipc/FfxivCollectIpcProvider.cs index 68576cc..547141e 100644 --- a/External/NexusKit.Modules.FfxivCollect/Ipc/FfxivCollectIpcProvider.cs +++ b/External/NexusKit.Modules.FfxivCollect/Ipc/FfxivCollectIpcProvider.cs @@ -9,12 +9,12 @@ namespace NexusKit.Modules.FfxivCollect.Ipc; /// without depending on our types. Each function returns the response as a /// JSON string; consumers deserialize against their own model. /// -/// Full IPC names (assuming plugin "PlayerNexusTracker"): +/// Full IPC names (assuming plugin "MyPlugin"): /// -/// PlayerNexusTracker.FfxivCollect.GetCharacterJson -/// PlayerNexusTracker.FfxivCollect.GetMountsJson -/// PlayerNexusTracker.FfxivCollect.GetMinionsJson -/// PlayerNexusTracker.FfxivCollect.GetAchievementsJson +/// MyPlugin.FfxivCollect.GetCharacterJson +/// MyPlugin.FfxivCollect.GetMountsJson +/// MyPlugin.FfxivCollect.GetMinionsJson +/// MyPlugin.FfxivCollect.GetAchievementsJson /// /// /// diff --git a/External/NexusKit.Modules.FfxivCollect/README.md b/External/NexusKit.Modules.FfxivCollect/README.md index 4838995..24beac7 100644 --- a/External/NexusKit.Modules.FfxivCollect/README.md +++ b/External/NexusKit.Modules.FfxivCollect/README.md @@ -53,21 +53,21 @@ short-circuits to `null` — no HTTP, no cache read. ## Published IPCs -Full names assume the plugin's name is `PlayerNexusTracker`; the actual +Full names assume the plugin's name is `MyPlugin`; the actual prefix is `IPluginContext.PluginName`. | IPC name | Signature | Returns | |---|---|---| -| `PlayerNexusTracker.FfxivCollect.GetCharacterJson` | `Func>` | JSON of `Character` | -| `PlayerNexusTracker.FfxivCollect.GetMountsJson` | `Func>` | JSON of `ListResponse` | -| `PlayerNexusTracker.FfxivCollect.GetMinionsJson` | `Func>` | JSON of `ListResponse` | -| `PlayerNexusTracker.FfxivCollect.GetAchievementsJson` | `Func>` | JSON of `ListResponse` | +| `MyPlugin.FfxivCollect.GetCharacterJson` | `Func>` | JSON of `Character` | +| `MyPlugin.FfxivCollect.GetMountsJson` | `Func>` | JSON of `ListResponse` | +| `MyPlugin.FfxivCollect.GetMinionsJson` | `Func>` | JSON of `ListResponse` | +| `MyPlugin.FfxivCollect.GetAchievementsJson` | `Func>` | JSON of `ListResponse` | Foreign plugins consume via: ```csharp var func = pi.GetIpcSubscriber>( - "PlayerNexusTracker.FfxivCollect.GetCharacterJson"); + "MyPlugin.FfxivCollect.GetCharacterJson"); var json = await func.InvokeFunc(lodestoneId); ``` diff --git a/External/NexusKit.Modules.FfxivCollect/docs/api-reference.md b/External/NexusKit.Modules.FfxivCollect/docs/api-reference.md index 0b997c2..12ba372 100644 --- a/External/NexusKit.Modules.FfxivCollect/docs/api-reference.md +++ b/External/NexusKit.Modules.FfxivCollect/docs/api-reference.md @@ -125,15 +125,15 @@ Rows are upserted on every successful fetch. ## Published IPCs -Names assume the plugin is `PlayerNexusTracker`; replace with your plugin +Names assume the plugin is `MyPlugin`; replace with your plugin name otherwise. | Full name | Signature | Returns | |---|---|---| -| `PlayerNexusTracker.FfxivCollect.GetCharacterJson` | `Func>` | JSON of `Character` | -| `PlayerNexusTracker.FfxivCollect.GetMountsJson` | `Func>` | JSON of `ListResponse` | -| `PlayerNexusTracker.FfxivCollect.GetMinionsJson` | `Func>` | JSON of `ListResponse` | -| `PlayerNexusTracker.FfxivCollect.GetAchievementsJson` | `Func>` | JSON of `ListResponse` | +| `MyPlugin.FfxivCollect.GetCharacterJson` | `Func>` | JSON of `Character` | +| `MyPlugin.FfxivCollect.GetMountsJson` | `Func>` | JSON of `ListResponse` | +| `MyPlugin.FfxivCollect.GetMinionsJson` | `Func>` | JSON of `ListResponse` | +| `MyPlugin.FfxivCollect.GetAchievementsJson` | `Func>` | JSON of `ListResponse` | All four pipe through `IFfxivCollectClient`, so they respect the same `ModuleEnabled` / `CacheEnabled` matrix above. A foreign plugin invoking an diff --git a/External/NexusKit.Modules.Lodestone/Ipc/LodestoneIpcProvider.cs b/External/NexusKit.Modules.Lodestone/Ipc/LodestoneIpcProvider.cs index 2e1e8c6..40dac5e 100644 --- a/External/NexusKit.Modules.Lodestone/Ipc/LodestoneIpcProvider.cs +++ b/External/NexusKit.Modules.Lodestone/Ipc/LodestoneIpcProvider.cs @@ -7,10 +7,10 @@ namespace NexusKit.Modules.Lodestone.Ipc; /// /// Publishes Lodestone endpoints as IPCs (JSON-serialised responses). /// -/// Full IPC names (assuming plugin "PlayerNexusTracker"): +/// Full IPC names (assuming plugin "MyPlugin"): /// -/// PlayerNexusTracker.Lodestone.GetCharacterJson -/// PlayerNexusTracker.Lodestone.SearchCharacterJson +/// MyPlugin.Lodestone.GetCharacterJson +/// MyPlugin.Lodestone.SearchCharacterJson /// /// /// diff --git a/External/NexusKit.Modules.Sync/ContractResolution.cs b/External/NexusKit.Modules.Sync/ContractResolution.cs new file mode 100644 index 0000000..b54c27f --- /dev/null +++ b/External/NexusKit.Modules.Sync/ContractResolution.cs @@ -0,0 +1,70 @@ +using NexusKit.Sync.Contracts; +using NexusKit.Sync.Protocol; + +namespace NexusKit.Modules.Sync; + +/// +/// Decides which contract document a client works from: the server's, or its own. +/// +public static class ContractResolution +{ + /// + /// Fetches the server's contract when the key may read it, and falls back to the local + /// document when it may not. + /// The server's copy wins. A key carrying the built-in contract-reading scope + /// is a statement that this client is allowed to follow the server's schema, and the + /// server is where a contract is registered — so its version is authoritative, and a + /// local copy that has drifted stops being a problem to diagnose. + /// Without that scope the server does not hand out documents at all, and the client + /// must already know the contract. That is the deliberate posture: a server should not + /// describe what it holds to anyone who asks. + /// + /// The connection to ask. + /// + /// What the client shipped with. Used when the server refuses; may be null, in which case + /// a refusal is fatal — there would be nothing left to talk about. + /// + /// Which contract, when is null. + /// Which version to ask for. + /// Cancels the lookup. + /// + /// The server refused and there is no local document to fall back on. + /// + public static async Task ResolveAsync( + ISyncProtocol protocol, + SyncContract? local, + string contractId, + ContractVersion version, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(protocol); + + try + { + var descriptor = await protocol + .DescribeAsync(new ContractRef(local?.ContractId ?? contractId, version), ct) + .ConfigureAwait(false); + + return new ResolvedContract(ContractJson.Parse(descriptor.CanonicalJson), FromServer: true); + } + catch (SyncProtocolException ex) + when (ex.Problem.Type is SyncProblemType.ScopeMissing or SyncProblemType.Unauthenticated) + { + // Not an error: this key is not permitted to read documents, which is the mode + // where the client is expected to carry its own. + if (local is null) throw; + + return new ResolvedContract(local, FromServer: false); + } + } +} + +/// +/// The contract to work from, and where it came from. +/// +/// The document both sides will be held to. +/// +/// True when the server supplied it. Worth surfacing: it is the difference between "we agree +/// because I checked" and "we agree as far as I know". +/// +public sealed record ResolvedContract(SyncContract Contract, bool FromServer); diff --git a/External/NexusKit.Modules.Sync/NexusKit.Modules.Sync.csproj b/External/NexusKit.Modules.Sync/NexusKit.Modules.Sync.csproj new file mode 100644 index 0000000..f399597 --- /dev/null +++ b/External/NexusKit.Modules.Sync/NexusKit.Modules.Sync.csproj @@ -0,0 +1,33 @@ + + + net10.0 + latest + enable + enable + NexusKit.Modules.Sync + NexusKit.Modules.Sync + Client side of the NexusKit.Sync protocol: REST transport, API-key handling and connection registration. Talks to any server speaking it; several connections at once are the normal case. + true + true + true + CS1591 + true + x64 + + + + + + + + + + + + + + + + + diff --git a/External/NexusKit.Modules.Sync/ProblemDetailsReader.cs b/External/NexusKit.Modules.Sync/ProblemDetailsReader.cs new file mode 100644 index 0000000..2e15911 --- /dev/null +++ b/External/NexusKit.Modules.Sync/ProblemDetailsReader.cs @@ -0,0 +1,95 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; +using NexusKit.Sync.Protocol; + +namespace NexusKit.Modules.Sync; + +/// +/// Turns a failed HTTP response into a . +/// Written defensively on purpose. A failure response is exactly the moment when the +/// thing on the other end might not be a sync server at all — a reverse proxy returning +/// its own 502 page, a captive portal, a misconfigured host serving HTML. Throwing a JSON +/// parse error there would replace a useful message with a misleading one. +/// +internal static class ProblemDetailsReader +{ + public static async Task ReadAsync(HttpResponseMessage response, CancellationToken ct) + { + var status = (int)response.StatusCode; + + if (LooksLikeProblemDetails(response.Content.Headers.ContentType)) + { + try + { + await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + using var document = await JsonDocument.ParseAsync(stream, cancellationToken: ct).ConfigureAwait(false); + + return FromDocument(document.RootElement, status); + } + catch (JsonException) + { + // Content-Type claimed problem+json and the body was not. Fall through to the + // generic problem rather than surfacing a parse error the caller cannot act on. + } + } + + return Fallback(response.StatusCode, status); + } + + private static bool LooksLikeProblemDetails(MediaTypeHeaderValue? contentType) => + contentType?.MediaType is "application/problem+json" or "application/json"; + + private static SyncProblem FromDocument(JsonElement root, int status) + { + if (root.ValueKind != JsonValueKind.Object) return Fallback((HttpStatusCode)status, status); + + string? type = null; + string? title = null; + string? detail = null; + var reportedStatus = status; + Dictionary? extensions = null; + + foreach (var property in root.EnumerateObject()) + { + switch (property.Name) + { + case "type": + type = property.Value.GetString(); + break; + case "title": + title = property.Value.GetString(); + break; + case "detail": + detail = property.Value.GetString(); + break; + case "status": + if (property.Value.TryGetInt32(out var parsed)) reportedStatus = parsed; + break; + case "instance": + break; // defined by RFC 9457 but carries nothing this client acts on + default: + // Everything else is a type-specific extension. Flattened to strings so a + // client can read "the server knows 1.0 and 1.1" without this layer having + // to model every problem type. + extensions ??= new Dictionary(StringComparer.Ordinal); + extensions[property.Name] = property.Value.ValueKind == JsonValueKind.String + ? property.Value.GetString() ?? string.Empty + : property.Value.GetRawText(); + break; + } + } + + return new SyncProblem( + type ?? "about:blank", + title ?? ReasonFor((HttpStatusCode)reportedStatus), + reportedStatus, + detail, + extensions); + } + + private static SyncProblem Fallback(HttpStatusCode statusCode, int status) => + new("about:blank", ReasonFor(statusCode), status); + + private static string ReasonFor(HttpStatusCode statusCode) => statusCode.ToString(); +} diff --git a/External/NexusKit.Modules.Sync/README.md b/External/NexusKit.Modules.Sync/README.md new file mode 100644 index 0000000..12982e7 --- /dev/null +++ b/External/NexusKit.Modules.Sync/README.md @@ -0,0 +1,67 @@ +# NexusKit.Modules.Sync + +The client half of the sync stack: the REST transport, API-key handling, and connection +registration. Talks to any server speaking the protocol. + +**No Dalamud reference.** The protocol itself — contract model, canonical form, `ISyncProtocol` +— lives in [`NexusKit.Sync`](../../../NexusKit/NexusKit.Sync/README.md), which the server +references too. + +## Public API + +| Type | File | Purpose | +|---|---|---| +| `RestSyncProtocol` | `RestSyncProtocol.cs` | `ISyncProtocol` over `HttpClient`. Stateless beyond its configuration, safe to share concurrently. Presents the API key on every call; sends `DescribeAsync` unauthenticated by design. | +| `SyncConnectionOptions` | `SyncConnectionOptions.cs` | One connection: `ServerUrl`, `ApiKey`, `ClientAgent`, `Timeout`, `AllowInsecureTransport`. `Validate()` runs eagerly and names the connection in its failures. | +| `SyncServiceCollectionExtensions` | `SyncServiceCollectionExtensions.cs` | `AddNexusKitSync(key, configure)` for a keyed connection, `AddNexusKitSync(configure)` for a single unkeyed one. | +| `ProblemDetailsReader` | `ProblemDetailsReader.cs` | *(internal)* Maps a failure response onto `SyncProblem`, defensively — the responder might be a proxy, not a server. | + +## Registration + +```csharp +services.AddNexusKitSync("acme.myplugin", o => +{ + o.ServerUrl = new Uri("https://sync.example.org/"); + o.ApiKey = settings.ApiKey; // nxs_… , pasted by the user + o.ClientAgent = "MyPlugin/1.0"; +}); +``` + +Registration is **keyed**, because talking to several servers is the normal case rather than an +exception: each author runs their own, so a plugin consuming somebody's published client +binding talks to that author's server and to its own. Each connection has its own address, key +and `HttpClient`, so one unreachable server does not affect the others. + +```csharp +public sealed class ItemService( + [FromKeyedServices("acme.myplugin")] ISyncProtocol sync) { … } +``` + +An unkeyed overload registers `ISyncProtocol` directly for the single-server case. + +## What it does and does not do + +| | | +|---|---| +| **Does** | Speaks the four protocol operations, presents the API key, maps Problem Details onto `SyncProtocolException`, refuses plain HTTP | +| **Not yet** | Outbox, downlink mirror, cursors, background draining, settings UI — what turns `PushAsync` into fire-and-forget and `GetAsync` into a local, offline-capable read | + +Until then, callers hold `ISyncProtocol` and drive it themselves. + +## Plain HTTP is refused, not upgraded + +An API key is a bearer credential: over plain HTTP, everyone on the path has it. A `http://` +address throws at configuration time rather than being silently rewritten, because a silent +rewrite hides a misconfiguration that matters. `AllowInsecureTransport` exists so a developer +can talk to a container on localhost, and for no other reason. + +## Further reading + +| Document | What it covers | +|---|---| +| [docs/connections.md](docs/connections.md) | Multi-connection registration, the options, and where the API key belongs | +| [docs/transport.md](docs/transport.md) | HTTP mapping, defensive Problem Details parsing, and the robustness details behind it | + +## License + +**AGPL-3.0-only.** diff --git a/External/NexusKit.Modules.Sync/RestSyncProtocol.cs b/External/NexusKit.Modules.Sync/RestSyncProtocol.cs new file mode 100644 index 0000000..4539f76 --- /dev/null +++ b/External/NexusKit.Modules.Sync/RestSyncProtocol.cs @@ -0,0 +1,165 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using NexusKit.Sync.Protocol; + +namespace NexusKit.Modules.Sync; + +/// +/// The REST implementation of . +/// Stateless beyond its configuration, so it is safe to share across a plugin that +/// drains an outbox and refreshes a mirror at the same time. It holds no session: every call +/// presents the API key. The handshake may return a session token and this client ignores it — +/// caching one would buy a little bandwidth and cost invalidation logic, and the protocol is +/// explicitly written so that a client which ignores it stays correct. +/// +public sealed class RestSyncProtocol : ISyncProtocol +{ + private readonly HttpClient mHttp; + private readonly SyncConnectionOptions mOptions; + private readonly ILogger mLog; + + /// Creates the client. Validates the options eagerly. + /// The options cannot produce a working connection. + public RestSyncProtocol(HttpClient http, SyncConnectionOptions options, ILogger? log = null) + { + ArgumentNullException.ThrowIfNull(http); + ArgumentNullException.ThrowIfNull(options); + + options.Validate(); + + mHttp = http; + mOptions = options; + mLog = log ?? NullLogger.Instance; + + // Only set what the caller has not already configured — an HttpClient handed in by + // IHttpClientFactory may legitimately arrive pre-configured, and stomping on that + // would make the factory registration a lie. + mHttp.BaseAddress ??= options.ServerUrl; + if (mHttp.Timeout == TimeSpan.FromSeconds(100)) mHttp.Timeout = options.Timeout; + } + + /// + public async Task HandshakeAsync(HandshakeRequest request, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(request); + + using var message = Authenticated(HttpMethod.Post, SyncRoutes.Handshake()); + message.Content = JsonContent.Create(request, options: SyncJson.Options); + + var result = await SendAsync(message, ct).ConfigureAwait(false); + + if (result.ServerMessage is { Length: > 0 } motd) + mLog.LogInformation("Server notice for {Contract}: {Message}", request.ContractId, motd); + + if (!string.Equals(result.ServerContractHash, request.ContractHash, StringComparison.Ordinal)) + { + // Not an error — the negotiated version is authoritative and a differing hash is + // normal when the server runs a newer minor. Logged because when something *does* + // go wrong later, this line is the difference between a diff and a mystery. + mLog.LogDebug( + "Contract {Contract} hashes differ (client {ClientHash}, server {ServerHash}); negotiated {Version}.", + request.ContractId, request.ContractHash, result.ServerContractHash, result.NegotiatedVersion); + } + + return result; + } + + /// + public async Task PushAsync(PushRequest request, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(request); + + using var message = Authenticated(HttpMethod.Post, SyncRoutes.Push(request.ContractId, request.Collection)); + message.Content = JsonContent.Create(request, options: SyncJson.Options); + + return await SendAsync(message, ct).ConfigureAwait(false); + } + + /// + public async Task PullAsync(PullRequest request, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(request); + + var route = SyncRoutes.Pull( + request.ContractId, request.Version, request.Collection, request.Since, request.Limit); + using var message = Authenticated(HttpMethod.Get, route); + + return await SendAsync(message, ct).ConfigureAwait(false); + } + + /// + public async Task DescribeAsync(ContractRef reference, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(reference); + + // Authenticated, unlike every other read-only description you might expect. A contract + // document is the shape of everything a server holds, and servers gate it behind the + // built-in contract:pull scope. A key without that scope gets a scope-missing problem, + // which is the signal to fall back on the contract the client already carries. + using var message = Authenticated(HttpMethod.Get, SyncRoutes.Contract(reference.ContractId, reference.Version)); + + return await SendAsync(message, ct).ConfigureAwait(false); + } + + private HttpRequestMessage Authenticated(HttpMethod method, string route) + { + var message = new HttpRequestMessage(method, route); + + if (mOptions.ApiKey is { Length: > 0 } key) + message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", key); + + message.Headers.UserAgent.ParseAdd(SanitizeAgent(mOptions.ClientAgent)); + return message; + } + + private async Task SendAsync(HttpRequestMessage message, CancellationToken ct) + { + using var response = await mHttp.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, ct) + .ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + var problem = await ProblemDetailsReader.ReadAsync(response, ct).ConfigureAwait(false); + + mLog.LogDebug( + "{Method} {Route} failed: {Problem}", + message.Method, message.RequestUri, problem); + + throw new SyncProtocolException(problem); + } + + var payload = await response.Content.ReadFromJsonAsync(SyncJson.Options, ct).ConfigureAwait(false); + + if (payload is null) + { + // A conforming server never does this; a proxy returning 204 for something it did + // not understand does. Surfacing it as a protocol problem keeps the caller's + // error handling in one place instead of adding a null check at every call site. + throw new SyncProtocolException(new SyncProblem( + "about:blank", + "Empty response", + (int)response.StatusCode, + $"{message.Method} {message.RequestUri} succeeded but returned no body.")); + } + + return payload; + } + + private static string SanitizeAgent(string agent) + { + // User-Agent has to parse as a product token; a stray space from a caller-supplied + // string would throw inside ParseAdd rather than at configuration time, which is a + // confusing place to discover a typo. + Span buffer = stackalloc char[agent.Length]; + var length = 0; + + foreach (var c in agent) + { + buffer[length++] = char.IsWhiteSpace(c) ? '-' : c; + } + + return new string(buffer[..length]); + } +} diff --git a/External/NexusKit.Modules.Sync/SyncConnectionOptions.cs b/External/NexusKit.Modules.Sync/SyncConnectionOptions.cs new file mode 100644 index 0000000..5c91ecc --- /dev/null +++ b/External/NexusKit.Modules.Sync/SyncConnectionOptions.cs @@ -0,0 +1,79 @@ +using NexusKit.Sync.Protocol; + +namespace NexusKit.Modules.Sync; + +/// +/// Everything one connection needs to reach one server. +/// One instance describes one server. A plugin talking to several — its own, plus those +/// of authors who publish their client bindings as packages — holds several of these, each +/// with its own address and its own key. That separation is what keeps one unreachable server +/// from affecting the others. +/// +public sealed class SyncConnectionOptions +{ + /// Root address of the server, e.g. https://sync.example.org/. + public Uri? ServerUrl { get; set; } + + /// + /// The API key, in nxs_… form. Null means unauthenticated, which is enough for + /// and nothing else. + /// + public string? ApiKey { get; set; } + + /// + /// How this client identifies itself, e.g. MyPlugin/0.3.0. Lands in the + /// server's audit log, which is what makes "one build is hammering the API" answerable. + /// + public string ClientAgent { get; set; } = "NexusKit.Modules.Sync"; + + /// Per-request timeout. + public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// Permits a plain http:// address. + /// Off by default and deliberately awkward to turn on. An API key is a bearer + /// credential: over plain HTTP, everyone on the path gets it. This exists so a developer + /// can talk to a container on localhost, and for no other reason. + /// + public bool AllowInsecureTransport { get; set; } + + /// Throws when the options cannot produce a working connection. + /// A required value is missing or unusable. + public void Validate() + { + if (ServerUrl is null) + throw new InvalidOperationException($"{nameof(ServerUrl)} is required."); + + if (!ServerUrl.IsAbsoluteUri) + throw new InvalidOperationException($"{nameof(ServerUrl)} must be an absolute URI, got '{ServerUrl}'."); + + var https = string.Equals(ServerUrl.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase); + var http = string.Equals(ServerUrl.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase); + + if (!https && !http) + throw new InvalidOperationException($"{nameof(ServerUrl)} must be http or https, got '{ServerUrl.Scheme}'."); + + if (http && !AllowInsecureTransport) + { + // Refused rather than upgraded. Silently rewriting the address would hide a + // misconfiguration that matters, and an upgrade that fails leaves the caller + // guessing why. + throw new InvalidOperationException( + $"{nameof(ServerUrl)} is plain HTTP ('{ServerUrl}'), which would put the API key on the wire " + + $"in the clear. Use https, or set {nameof(AllowInsecureTransport)} for local development."); + } + + if (ApiKey is not null && !ApiKeyFormat.IsWellFormed(ApiKey)) + { + throw new InvalidOperationException( + $"{nameof(ApiKey)} '{ApiKeyFormat.Redact(ApiKey)}' is not shaped like a sync API key " + + $"({ApiKeyFormat.Prefix} followed by {ApiKeyFormat.BodyLength} characters)."); + } + + if (string.IsNullOrWhiteSpace(ClientAgent)) + throw new InvalidOperationException($"{nameof(ClientAgent)} is required."); + + if (Timeout <= TimeSpan.Zero) + throw new InvalidOperationException($"{nameof(Timeout)} must be positive."); + } +} diff --git a/External/NexusKit.Modules.Sync/SyncServiceCollectionExtensions.cs b/External/NexusKit.Modules.Sync/SyncServiceCollectionExtensions.cs new file mode 100644 index 0000000..a6f3bf6 --- /dev/null +++ b/External/NexusKit.Modules.Sync/SyncServiceCollectionExtensions.cs @@ -0,0 +1,109 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NexusKit.Sync.Protocol; + +namespace NexusKit.Modules.Sync; + +/// +/// DI registration for sync connections. +/// +public static class SyncServiceCollectionExtensions +{ + /// Prefix for the named instances this registers. + public const string HttpClientPrefix = "nexussync:"; + + /// + /// Registers one server connection under a key. + /// Keyed rather than plain, because talking to several servers is the normal case + /// once authors start publishing their client bindings as packages — a plugin may speak to + /// its own server and to somebody else's at the same time. Resolve with + /// [FromKeyedServices("acme.venuetracker")] ISyncProtocol or + /// GetRequiredKeyedService<ISyncProtocol>(key). + /// + /// The service collection. + /// + /// Connection key. Using the contract id is the obvious choice and keeps registration and + /// resolution obviously in step. + /// + /// Configures the connection's address, key and agent. + public static IServiceCollection AddNexusKitSync( + this IServiceCollection services, + string key, + Action configure) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrWhiteSpace(key); + ArgumentNullException.ThrowIfNull(configure); + + var httpClientName = HttpClientPrefix + key; + + services.AddHttpClient(httpClientName, http => + { + var options = Resolve(key, configure); + http.BaseAddress = options.ServerUrl; + http.Timeout = options.Timeout; + }); + + services.AddKeyedSingleton(key, (provider, _) => + { + var options = Resolve(key, configure); + var http = provider.GetRequiredService().CreateClient(httpClientName); + var log = provider.GetService()?.CreateLogger(); + + return new RestSyncProtocol(http, options, log); + }); + + return services; + } + + /// + /// Registers a single, unkeyed connection — the simple case of one plugin, one server. + /// + public static IServiceCollection AddNexusKitSync( + this IServiceCollection services, + Action configure) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configure); + + const string key = "default"; + const string httpClientName = HttpClientPrefix + key; + + services.AddHttpClient(httpClientName, http => + { + var options = Resolve(key, configure); + http.BaseAddress = options.ServerUrl; + http.Timeout = options.Timeout; + }); + + services.AddSingleton(provider => + { + var options = Resolve(key, configure); + var http = provider.GetRequiredService().CreateClient(httpClientName); + var log = provider.GetService()?.CreateLogger(); + + return new RestSyncProtocol(http, options, log); + }); + + return services; + } + + private static SyncConnectionOptions Resolve(string key, Action configure) + { + var options = new SyncConnectionOptions(); + configure(options); + + try + { + options.Validate(); + } + catch (InvalidOperationException ex) + { + // Name the connection in the message. With several registered, "ServerUrl is + // required" on its own does not say which one. + throw new InvalidOperationException($"Sync connection '{key}' is misconfigured: {ex.Message}", ex); + } + + return options; + } +} diff --git a/External/NexusKit.Modules.Sync/docs/connections.md b/External/NexusKit.Modules.Sync/docs/connections.md new file mode 100644 index 0000000..9f7c110 --- /dev/null +++ b/External/NexusKit.Modules.Sync/docs/connections.md @@ -0,0 +1,91 @@ +# Connections (NexusKit.Modules.Sync) + +How a plugin reaches one server — or several. + +## Several is the normal case + +Registration is **keyed**, not singular: + +```csharp +// your own server +services.AddNexusKitSync("myplugin.tracker", o => +{ + o.ServerUrl = new Uri("https://sync.myplugin.dev/"); + o.ApiKey = settings.OwnApiKey; + o.ClientAgent = "MyPlugin/0.3.0"; +}); + +// somebody else's, reached through the client bindings they published +services.AddNexusKitSync("acme.venues", o => +{ + o.ServerUrl = new Uri("https://sync.acme.dev/"); + o.ApiKey = settings.AcmeApiKey; + o.ClientAgent = "MyPlugin/0.3.0"; +}); +``` + +```csharp +public sealed class VenueService( + [FromKeyedServices("acme.venues")] ISyncProtocol sync) { … } +``` + +Using the contract id as the key keeps registration and resolution obviously in step. + +This is not speculative generality. Each author runs their own server, so the moment anyone +publishes their client binding as a package, a plugin consuming it talks to that author's +server *and* to its own. Retrofitting multi-connection later would mean partitioning the +outbox, the cursors, the settings section and the key storage after the fact — cheap now, +expensive then. + +Every connection is fully independent: its own `HttpClient`, address, key, agent and timeout. +An unreachable server degrades exactly one feature. + +For the genuinely single-server case there is an unkeyed overload that registers +`ISyncProtocol` directly. + +## Options + +| Option | Notes | +|---|---| +| `ServerUrl` | Required, absolute. | +| `ApiKey` | `nxs_…`. Null is legal and means unauthenticated — enough for `DescribeAsync` and nothing else. | +| `ClientAgent` | Ends up in the server's audit log, which is what makes "one build is hammering the API" answerable. | +| `Timeout` | Per request. | +| `AllowInsecureTransport` | See below. | + +`Validate()` runs eagerly at construction, and its failures name the connection key — with +several registered, "ServerUrl is required" on its own does not say which one. + +## Plain HTTP is refused, not upgraded + +An `http://` address throws unless `AllowInsecureTransport` is set. + +An API key is a bearer credential: over plain HTTP, everyone on the path has it. Refusing is +deliberate in both directions — silently rewriting the address to `https://` would hide a +misconfiguration that matters, and an upgrade that then fails leaves the caller guessing why. + +The escape hatch exists so a developer can talk to a container on localhost. That is its whole +purpose. + +## Where the key belongs + +**Not in the ordinary settings table.** A key sitting next to harmless options ends up in every +config export and every support screenshot. The intended handling, which the full module will +implement: + +- a separate store, excluded from settings export +- a password field with a reveal toggle in the UI +- DPAPI encryption (`ProtectedData`, CurrentUser scope) +- validation on entry via a probe handshake, with the result shown in the settings + +Anywhere a key might be written down — a log line, an exception, a diagnostic dump — use +`ApiKeyFormat.Redact`. + +## What is not here yet + +Today this package is transport only: it speaks the four operations and hands the results back. +The full module adds the outbox, the downlink mirror, cursor persistence and the background +drainer — which is what turns `PushAsync` into fire-and-forget and `GetAsync` into a local, +offline-capable read. + +Until then a caller holds `ISyncProtocol` and drives it directly. diff --git a/External/NexusKit.Modules.Sync/docs/transport.md b/External/NexusKit.Modules.Sync/docs/transport.md new file mode 100644 index 0000000..9bd0fd4 --- /dev/null +++ b/External/NexusKit.Modules.Sync/docs/transport.md @@ -0,0 +1,84 @@ +# Transport (NexusKit.Modules.Sync) + +How `RestSyncProtocol` turns the four operations into HTTP, and how failures come back. + +## Shape + +`RestSyncProtocol` implements `ISyncProtocol` over `HttpClient`. It is **stateless beyond its +configuration**, so a single instance is safe to share across a plugin that drains an outbox +and refreshes a mirror at the same time. + +It holds no session. Every call presents the API key; the optional `SessionToken` from the +handshake is ignored. Caching one would buy a little bandwidth and cost invalidation logic, and +the protocol is explicitly written so that a client which ignores it stays correct. + +Routes are never built here — they come from `SyncRoutes` in `NexusKit.Sync`, so client and +server derive them from the same code rather than from two string literals that agree until one +is edited. + +## Authentication per request, not per client + +The bearer header is attached when the request is built, not baked into +`HttpClient.DefaultRequestHeaders`. That matters because the key can change at runtime: a user +pastes a new one into the settings after rotating it, and the next request has to use it +without anything being re-created. + +`DescribeAsync` is sent deliberately **without** a key. A contract document describes shapes, +not data, and requiring authentication to read one would stop an author checking compatibility +against a server they have not signed up to yet. + +## Errors + +A non-success response becomes a `SyncProtocolException` carrying a `SyncProblem`. Transport +faults — DNS, TLS, a dropped connection — surface as their own exception types. + +That distinction is for the caller's benefit: a transport fault is worth retrying, whereas most +protocol problems will produce the identical answer next time and retrying only burns the rate +limit. `SyncProtocolException.IsTransient` marks the exceptions to that rule. + +### Reading Problem Details defensively + +`ProblemDetailsReader` assumes the body might not be from a NexusSyncServer at all. + +A failure response is exactly the moment when something else may be answering: a reverse proxy +returning its own 502 page, a captive portal, a misconfigured host serving HTML. So the reader +only attempts JSON when the content type claims it, and falls back to a status-derived problem +when parsing fails. Throwing a JSON parse error there would replace a useful message — +"502 BadGateway" — with a misleading one about malformed JSON. + +Known RFC 9457 members are read into `SyncProblem`; everything else is flattened into +`Extensions` as strings. That is what lets a client surface "the server speaks 1.0 and 1.1" +without this layer having to model every problem type. + +## Two small robustness details + +**`User-Agent` is sanitised.** The header must parse as a product token, and a stray space in a +caller-supplied `ClientAgent` would throw inside `ParseAdd` — at request time, which is a +confusing place to discover a configuration typo. Whitespace becomes `-`. + +**An empty success body is treated as a protocol problem.** A conforming server never returns +one; a proxy answering 204 for something it did not understand does. Surfacing it as +`SyncProtocolException` keeps the caller's error handling in one place instead of adding a null +check at every call site. + +## Injected `HttpClient` + +The constructor only fills in `BaseAddress` and `Timeout` if they are still at their defaults. +An `HttpClient` handed over by `IHttpClientFactory` may legitimately arrive pre-configured — +with a handler chain, a proxy, a policy — and overwriting that would make the factory +registration a lie. + +## Testing against it + +`ISyncProtocol` is an interface, so a plugin test substitutes a fake and needs no server at +all. For testing the transport itself, `RestSyncProtocolTests` in +`localTools/tests/NexusKit.Modules.Sync.Tests` drives it through a stub `HttpMessageHandler` — +covering the key header, the unauthenticated describe, per-record push outcomes, cursor and +tombstone handling, typed problems, and the non-JSON error page. + +Those tests run in no CI (`NexusKit.Modules` builds but does not test), so run them by hand +when touching this package: + +```powershell +dotnet test localTools\tests\NexusKit.Modules.Sync.Tests -c Debug -p:Platform=x64 +``` diff --git a/NexusKit.Modules.sln b/NexusKit.Modules.sln index 0b4d67f..4c028a8 100644 --- a/NexusKit.Modules.sln +++ b/NexusKit.Modules.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 @@ -17,36 +17,102 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NexusKit.Modules.Lodestone" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NexusKit.Modules.PluginBridge", "External\NexusKit.Modules.PluginBridge\NexusKit.Modules.PluginBridge.csproj", "{2A100000-0000-0000-0000-000000000006}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusKit.Modules.Sync", "External\NexusKit.Modules.Sync\NexusKit.Modules.Sync.csproj", "{05FB52A9-4B24-43CF-AD44-D6194AF60D8A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|x64 = Debug|x64 + Debug|Any CPU = Debug|Any CPU + Debug|x86 = Debug|x86 Release|x64 = Release|x64 + Release|Any CPU = Release|Any CPU + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {2A100000-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|x64 {2A100000-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|x64 + {2A100000-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2A100000-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2A100000-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {2A100000-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU {2A100000-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|x64 {2A100000-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|x64 + {2A100000-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2A100000-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {2A100000-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {2A100000-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU {2A100000-0000-0000-0000-000000000002}.Debug|x64.ActiveCfg = Debug|x64 {2A100000-0000-0000-0000-000000000002}.Debug|x64.Build.0 = Debug|x64 + {2A100000-0000-0000-0000-000000000002}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2A100000-0000-0000-0000-000000000002}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2A100000-0000-0000-0000-000000000002}.Debug|x86.ActiveCfg = Debug|Any CPU + {2A100000-0000-0000-0000-000000000002}.Debug|x86.Build.0 = Debug|Any CPU {2A100000-0000-0000-0000-000000000002}.Release|x64.ActiveCfg = Release|x64 {2A100000-0000-0000-0000-000000000002}.Release|x64.Build.0 = Release|x64 + {2A100000-0000-0000-0000-000000000002}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2A100000-0000-0000-0000-000000000002}.Release|Any CPU.Build.0 = Release|Any CPU + {2A100000-0000-0000-0000-000000000002}.Release|x86.ActiveCfg = Release|Any CPU + {2A100000-0000-0000-0000-000000000002}.Release|x86.Build.0 = Release|Any CPU {2A100000-0000-0000-0000-000000000003}.Debug|x64.ActiveCfg = Debug|x64 {2A100000-0000-0000-0000-000000000003}.Debug|x64.Build.0 = Debug|x64 + {2A100000-0000-0000-0000-000000000003}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2A100000-0000-0000-0000-000000000003}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2A100000-0000-0000-0000-000000000003}.Debug|x86.ActiveCfg = Debug|Any CPU + {2A100000-0000-0000-0000-000000000003}.Debug|x86.Build.0 = Debug|Any CPU {2A100000-0000-0000-0000-000000000003}.Release|x64.ActiveCfg = Release|x64 {2A100000-0000-0000-0000-000000000003}.Release|x64.Build.0 = Release|x64 + {2A100000-0000-0000-0000-000000000003}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2A100000-0000-0000-0000-000000000003}.Release|Any CPU.Build.0 = Release|Any CPU + {2A100000-0000-0000-0000-000000000003}.Release|x86.ActiveCfg = Release|Any CPU + {2A100000-0000-0000-0000-000000000003}.Release|x86.Build.0 = Release|Any CPU {2A100000-0000-0000-0000-000000000004}.Debug|x64.ActiveCfg = Debug|x64 {2A100000-0000-0000-0000-000000000004}.Debug|x64.Build.0 = Debug|x64 + {2A100000-0000-0000-0000-000000000004}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2A100000-0000-0000-0000-000000000004}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2A100000-0000-0000-0000-000000000004}.Debug|x86.ActiveCfg = Debug|Any CPU + {2A100000-0000-0000-0000-000000000004}.Debug|x86.Build.0 = Debug|Any CPU {2A100000-0000-0000-0000-000000000004}.Release|x64.ActiveCfg = Release|x64 {2A100000-0000-0000-0000-000000000004}.Release|x64.Build.0 = Release|x64 + {2A100000-0000-0000-0000-000000000004}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2A100000-0000-0000-0000-000000000004}.Release|Any CPU.Build.0 = Release|Any CPU + {2A100000-0000-0000-0000-000000000004}.Release|x86.ActiveCfg = Release|Any CPU + {2A100000-0000-0000-0000-000000000004}.Release|x86.Build.0 = Release|Any CPU {2A100000-0000-0000-0000-000000000005}.Debug|x64.ActiveCfg = Debug|x64 {2A100000-0000-0000-0000-000000000005}.Debug|x64.Build.0 = Debug|x64 + {2A100000-0000-0000-0000-000000000005}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2A100000-0000-0000-0000-000000000005}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2A100000-0000-0000-0000-000000000005}.Debug|x86.ActiveCfg = Debug|Any CPU + {2A100000-0000-0000-0000-000000000005}.Debug|x86.Build.0 = Debug|Any CPU {2A100000-0000-0000-0000-000000000005}.Release|x64.ActiveCfg = Release|x64 {2A100000-0000-0000-0000-000000000005}.Release|x64.Build.0 = Release|x64 + {2A100000-0000-0000-0000-000000000005}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2A100000-0000-0000-0000-000000000005}.Release|Any CPU.Build.0 = Release|Any CPU + {2A100000-0000-0000-0000-000000000005}.Release|x86.ActiveCfg = Release|Any CPU + {2A100000-0000-0000-0000-000000000005}.Release|x86.Build.0 = Release|Any CPU {2A100000-0000-0000-0000-000000000006}.Debug|x64.ActiveCfg = Debug|x64 {2A100000-0000-0000-0000-000000000006}.Debug|x64.Build.0 = Debug|x64 + {2A100000-0000-0000-0000-000000000006}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2A100000-0000-0000-0000-000000000006}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2A100000-0000-0000-0000-000000000006}.Debug|x86.ActiveCfg = Debug|Any CPU + {2A100000-0000-0000-0000-000000000006}.Debug|x86.Build.0 = Debug|Any CPU {2A100000-0000-0000-0000-000000000006}.Release|x64.ActiveCfg = Release|x64 {2A100000-0000-0000-0000-000000000006}.Release|x64.Build.0 = Release|x64 + {2A100000-0000-0000-0000-000000000006}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2A100000-0000-0000-0000-000000000006}.Release|Any CPU.Build.0 = Release|Any CPU + {2A100000-0000-0000-0000-000000000006}.Release|x86.ActiveCfg = Release|Any CPU + {2A100000-0000-0000-0000-000000000006}.Release|x86.Build.0 = Release|Any CPU + {05FB52A9-4B24-43CF-AD44-D6194AF60D8A}.Debug|x64.ActiveCfg = Debug|x64 + {05FB52A9-4B24-43CF-AD44-D6194AF60D8A}.Debug|x64.Build.0 = Debug|x64 + {05FB52A9-4B24-43CF-AD44-D6194AF60D8A}.Debug|Any CPU.ActiveCfg = Debug|x64 + {05FB52A9-4B24-43CF-AD44-D6194AF60D8A}.Debug|Any CPU.Build.0 = Debug|x64 + {05FB52A9-4B24-43CF-AD44-D6194AF60D8A}.Debug|x86.ActiveCfg = Debug|x64 + {05FB52A9-4B24-43CF-AD44-D6194AF60D8A}.Debug|x86.Build.0 = Debug|x64 + {05FB52A9-4B24-43CF-AD44-D6194AF60D8A}.Release|x64.ActiveCfg = Release|x64 + {05FB52A9-4B24-43CF-AD44-D6194AF60D8A}.Release|x64.Build.0 = Release|x64 + {05FB52A9-4B24-43CF-AD44-D6194AF60D8A}.Release|Any CPU.ActiveCfg = Release|x64 + {05FB52A9-4B24-43CF-AD44-D6194AF60D8A}.Release|Any CPU.Build.0 = Release|x64 + {05FB52A9-4B24-43CF-AD44-D6194AF60D8A}.Release|x86.ActiveCfg = Release|x64 + {05FB52A9-4B24-43CF-AD44-D6194AF60D8A}.Release|x86.Build.0 = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -55,5 +121,6 @@ Global {2A100000-0000-0000-0000-000000000004} = {2A100000-EE00-0000-0000-000000000000} {2A100000-0000-0000-0000-000000000005} = {2A100000-EE00-0000-0000-000000000000} {2A100000-0000-0000-0000-000000000006} = {2A100000-EE00-0000-0000-000000000000} + {05FB52A9-4B24-43CF-AD44-D6194AF60D8A} = {2A100000-EE00-0000-0000-000000000000} EndGlobalSection EndGlobal