From b500d425feae24d1ac6f74f8d7eec10c1c84dad3 Mon Sep 17 00:00:00 2001
From: nxships <2096086+nxships@users.noreply.github.com>
Date: Tue, 11 Aug 2026 13:36:54 +0200
Subject: [PATCH 1/2] feat(sync): add NexusKit.Modules.Sync, the client half of
the sync stack
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The REST transport against any server speaking NexusKit.Sync: RestSyncProtocol,
API-key handling, keyed connection registration, and defensive Problem Details
parsing.
Registration is keyed rather than singular because talking to several servers
is the normal case, not an exception: every author runs their own, so a plugin
consuming somebody's published client bindings talks to that author's server
and to its own. Retrofitting that later would mean partitioning the outbox,
the cursors, the settings section and the key storage after the fact.
ContractResolution decides which document a client works from. Where the key
carries the built-in contract-reading scope the server's copy wins — a local
file that has drifted then stops being a problem to diagnose. Where it does
not, the server publishes nothing and the client falls back on what it ships.
Plain HTTP is refused rather than upgraded. An API key is a bearer credential,
and silently rewriting an address hides a misconfiguration that matters.
No Dalamud reference: the transport has nothing to do with the game, and the
same assembly is what a headless test harness uses.
Also removes PlayerNexusTracker from the modules' API documentation, for the
same reason as NexusKit: these are meant to be usable without knowing that
plugin exists.
---
Directory.Packages.props | 5 +
.../Ipc/FfxivCollectIpcProvider.cs | 10 +-
.../NexusKit.Modules.FfxivCollect/README.md | 12 +-
.../docs/api-reference.md | 10 +-
.../Ipc/LodestoneIpcProvider.cs | 6 +-
.../ContractResolution.cs | 70 ++++++++
.../NexusKit.Modules.Sync.csproj | 33 ++++
.../ProblemDetailsReader.cs | 95 ++++++++++
External/NexusKit.Modules.Sync/README.md | 67 +++++++
.../NexusKit.Modules.Sync/RestSyncProtocol.cs | 165 ++++++++++++++++++
.../SyncConnectionOptions.cs | 79 +++++++++
.../SyncServiceCollectionExtensions.cs | 109 ++++++++++++
.../NexusKit.Modules.Sync/docs/connections.md | 91 ++++++++++
.../NexusKit.Modules.Sync/docs/transport.md | 84 +++++++++
NexusKit.Modules.sln | 69 +++++++-
15 files changed, 885 insertions(+), 20 deletions(-)
create mode 100644 External/NexusKit.Modules.Sync/ContractResolution.cs
create mode 100644 External/NexusKit.Modules.Sync/NexusKit.Modules.Sync.csproj
create mode 100644 External/NexusKit.Modules.Sync/ProblemDetailsReader.cs
create mode 100644 External/NexusKit.Modules.Sync/README.md
create mode 100644 External/NexusKit.Modules.Sync/RestSyncProtocol.cs
create mode 100644 External/NexusKit.Modules.Sync/SyncConnectionOptions.cs
create mode 100644 External/NexusKit.Modules.Sync/SyncServiceCollectionExtensions.cs
create mode 100644 External/NexusKit.Modules.Sync/docs/connections.md
create mode 100644 External/NexusKit.Modules.Sync/docs/transport.md
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 0a39d46..4c05285 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -32,6 +32,11 @@
+
+
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
From 2c47b05e8017e01a243690749389e399224c8fdf Mon Sep 17 00:00:00 2001
From: nxships <2096086+nxships@users.noreply.github.com>
Date: Tue, 11 Aug 2026 13:38:59 +0200
Subject: [PATCH 2/2] fix(deps): floor NexusKit.Sync at 0.5.1, the release it
first shipped in
A floor of 0.5.0 restores 0.5.1 and raises NU1603 for the substitution, which
TreatWarningsAsErrors turns into a failed build. The library did not exist in
0.5.0, so that was never a version anyone could resolve.
---
Directory.Packages.props | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 4c05285..24dbb38 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -32,11 +32,15 @@
-
-
+