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