Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@
<PackageVersion Include="NexusKit.GameData" Version="[0.5.0,)" />
<PackageVersion Include="NexusKit.Persistence" Version="[0.5.0,)" />
<PackageVersion Include="NexusKit.Ui" Version="[0.5.0,)" />
<!-- 0.5.1 rather than 0.5.0, because that is the release the library first appeared in.
A floor naming a version that never contained it restores fine but raises NU1603 for
the substitution, and TreatWarningsAsErrors turns that into a failed build.

The sync norm, consumed by NexusKit.Modules.Sync. Same open-ended range rule: the
workspace swaps it for a ProjectReference and CI takes the newest published package.
Do not let a bump turn it into an exact pin — a pinned protocol package is how a
client ends up unable to follow a server that has moved on. -->
<PackageVersion Include="NexusKit.Sync" Version="[0.5.1,)" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// <para>
/// Full IPC names (assuming plugin "PlayerNexusTracker"):
/// Full IPC names (assuming plugin "MyPlugin"):
/// <list type="bullet">
/// <item><c>PlayerNexusTracker.FfxivCollect.GetCharacterJson</c></item>
/// <item><c>PlayerNexusTracker.FfxivCollect.GetMountsJson</c></item>
/// <item><c>PlayerNexusTracker.FfxivCollect.GetMinionsJson</c></item>
/// <item><c>PlayerNexusTracker.FfxivCollect.GetAchievementsJson</c></item>
/// <item><c>MyPlugin.FfxivCollect.GetCharacterJson</c></item>
/// <item><c>MyPlugin.FfxivCollect.GetMountsJson</c></item>
/// <item><c>MyPlugin.FfxivCollect.GetMinionsJson</c></item>
/// <item><c>MyPlugin.FfxivCollect.GetAchievementsJson</c></item>
/// </list>
/// </para>
/// </summary>
Expand Down
12 changes: 6 additions & 6 deletions External/NexusKit.Modules.FfxivCollect/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<ulong, Task<string?>>` | JSON of `Character` |
| `PlayerNexusTracker.FfxivCollect.GetMountsJson` | `Func<ulong, Task<string?>>` | JSON of `ListResponse<Mount>` |
| `PlayerNexusTracker.FfxivCollect.GetMinionsJson` | `Func<ulong, Task<string?>>` | JSON of `ListResponse<Minion>` |
| `PlayerNexusTracker.FfxivCollect.GetAchievementsJson` | `Func<ulong, Task<string?>>` | JSON of `ListResponse<Achievement>` |
| `MyPlugin.FfxivCollect.GetCharacterJson` | `Func<ulong, Task<string?>>` | JSON of `Character` |
| `MyPlugin.FfxivCollect.GetMountsJson` | `Func<ulong, Task<string?>>` | JSON of `ListResponse<Mount>` |
| `MyPlugin.FfxivCollect.GetMinionsJson` | `Func<ulong, Task<string?>>` | JSON of `ListResponse<Minion>` |
| `MyPlugin.FfxivCollect.GetAchievementsJson` | `Func<ulong, Task<string?>>` | JSON of `ListResponse<Achievement>` |

Foreign plugins consume via:

```csharp
var func = pi.GetIpcSubscriber<ulong, Task<string?>>(
"PlayerNexusTracker.FfxivCollect.GetCharacterJson");
"MyPlugin.FfxivCollect.GetCharacterJson");
var json = await func.InvokeFunc(lodestoneId);
```

Expand Down
10 changes: 5 additions & 5 deletions External/NexusKit.Modules.FfxivCollect/docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<ulong, Task<string?>>` | JSON of `Character` |
| `PlayerNexusTracker.FfxivCollect.GetMountsJson` | `Func<ulong, Task<string?>>` | JSON of `ListResponse<Mount>` |
| `PlayerNexusTracker.FfxivCollect.GetMinionsJson` | `Func<ulong, Task<string?>>` | JSON of `ListResponse<Minion>` |
| `PlayerNexusTracker.FfxivCollect.GetAchievementsJson` | `Func<ulong, Task<string?>>` | JSON of `ListResponse<Achievement>` |
| `MyPlugin.FfxivCollect.GetCharacterJson` | `Func<ulong, Task<string?>>` | JSON of `Character` |
| `MyPlugin.FfxivCollect.GetMountsJson` | `Func<ulong, Task<string?>>` | JSON of `ListResponse<Mount>` |
| `MyPlugin.FfxivCollect.GetMinionsJson` | `Func<ulong, Task<string?>>` | JSON of `ListResponse<Minion>` |
| `MyPlugin.FfxivCollect.GetAchievementsJson` | `Func<ulong, Task<string?>>` | JSON of `ListResponse<Achievement>` |

All four pipe through `IFfxivCollectClient`, so they respect the same
`ModuleEnabled` / `CacheEnabled` matrix above. A foreign plugin invoking an
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ namespace NexusKit.Modules.Lodestone.Ipc;
/// <summary>
/// Publishes Lodestone endpoints as IPCs (JSON-serialised responses).
/// <para>
/// Full IPC names (assuming plugin "PlayerNexusTracker"):
/// Full IPC names (assuming plugin "MyPlugin"):
/// <list type="bullet">
/// <item><c>PlayerNexusTracker.Lodestone.GetCharacterJson</c></item>
/// <item><c>PlayerNexusTracker.Lodestone.SearchCharacterJson</c></item>
/// <item><c>MyPlugin.Lodestone.GetCharacterJson</c></item>
/// <item><c>MyPlugin.Lodestone.SearchCharacterJson</c></item>
/// </list>
/// </para>
/// </summary>
Expand Down
70 changes: 70 additions & 0 deletions External/NexusKit.Modules.Sync/ContractResolution.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using NexusKit.Sync.Contracts;
using NexusKit.Sync.Protocol;

namespace NexusKit.Modules.Sync;

/// <summary>
/// Decides which contract document a client works from: the server's, or its own.
/// </summary>
public static class ContractResolution
{
/// <summary>
/// Fetches the server's contract when the key may read it, and falls back to the local
/// document when it may not.
/// <para><b>The server's copy wins.</b> 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.</para>
/// <para>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.</para>
/// </summary>
/// <param name="protocol">The connection to ask.</param>
/// <param name="local">
/// 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.
/// </param>
/// <param name="contractId">Which contract, when <paramref name="local"/> is null.</param>
/// <param name="version">Which version to ask for.</param>
/// <param name="ct">Cancels the lookup.</param>
/// <exception cref="SyncProtocolException">
/// The server refused and there is no local document to fall back on.
/// </exception>
public static async Task<ResolvedContract> 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);
}
}
}

/// <summary>
/// The contract to work from, and where it came from.
/// </summary>
/// <param name="Contract">The document both sides will be held to.</param>
/// <param name="FromServer">
/// 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".
/// </param>
public sealed record ResolvedContract(SyncContract Contract, bool FromServer);
33 changes: 33 additions & 0 deletions External/NexusKit.Modules.Sync/NexusKit.Modules.Sync.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>NexusKit.Modules.Sync</RootNamespace>
<AssemblyName>NexusKit.Modules.Sync</AssemblyName>
<Description>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.</Description>
<IsPackable>true</IsPackable>
<Deterministic>true</Deterministic>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>CS1591</NoWarn>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Platforms>x64</Platforms>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>

<ItemGroup>
<!-- The protocol norm, from the NexusKit framework. Deliberately the only sync package
here: a client needs the contract model and ISyncProtocol, nothing from the server. -->
<PackageReference Include="NexusKit.Sync" />
</ItemGroup>

<ItemGroup>
<None Include="README.md" />
<None Include="docs\**\*.md" />
</ItemGroup>
</Project>
95 changes: 95 additions & 0 deletions External/NexusKit.Modules.Sync/ProblemDetailsReader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
using NexusKit.Sync.Protocol;

namespace NexusKit.Modules.Sync;

/// <summary>
/// Turns a failed HTTP response into a <see cref="SyncProblem"/>.
/// <para>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.</para>
/// </summary>
internal static class ProblemDetailsReader
{
public static async Task<SyncProblem> 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<string, string>? 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<string, string>(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();
}
67 changes: 67 additions & 0 deletions External/NexusKit.Modules.Sync/README.md
Original file line number Diff line number Diff line change
@@ -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.**
Loading