From bb8fe7677ef05142a5f9eaaa20b7b2d9b8ce947a Mon Sep 17 00:00:00 2001 From: catsika Date: Mon, 26 Oct 2026 01:11:02 +0000 Subject: [PATCH] feat: expose expirationDate on activate and floating checkout ActivateKeyResponse and FloatingCheckoutResponse gain a nullable ExpirationDate (DateTimeOffset?) for the license expiry returned by POST /key/activate and POST /key/checkout; null means the license never expires. On checkout it is separate from ExpiresAt, the floating seat lease. Additive, no breaking changes. --- CHANGELOG.md | 6 +++ README.md | 25 +++++++++++ Services/Types.cs | 13 ++++++ Tests/ApiContractTests.cs | 87 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 131 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4e98d4..87f95a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Added + +- `ActivateKeyResponse.ExpirationDate` and `FloatingCheckoutResponse.ExpirationDate` (`DateTimeOffset?`): the license expiry returned by `POST /key/activate` and `POST /key/checkout`, or `null` when the license never expires. On checkout this is separate from `ExpiresAt`, the floating seat lease. + ## 1.5.0 - 2026-09-09 ### Fixed diff --git a/README.md b/README.md index c8a6ce0..8c78509 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,31 @@ if (result.IsSuccess) { } ``` +## License Expiry + +`ActivateKey` and `FloatingCheckout` return `ExpirationDate`: when the license expires, as a UTC `DateTimeOffset?`, or `null` if it never expires. On `FloatingCheckout` this is separate from `ExpiresAt`, which is the floating seat lease. + +```csharp +var activation = await client.ActivateKey(new ActivateKeyParams { + ProductId = productId, + LicenseKey = licenseKey, + HostId = hostId +}); + +if (activation.IsSuccess) { + var expiry = activation.Data.ExpirationDate; + if (expiry is null) { + Console.WriteLine("License never expires."); + } else { + var daysLeft = Math.Max(0, (int)Math.Ceiling((expiry.Value - DateTimeOffset.UtcNow).TotalDays)); + Console.WriteLine($"License expires {expiry.Value:yyyy-MM-dd} ({daysLeft} days left)."); + } + // Persist the last value you received (e.g. in your app settings). +} +``` + +Store the last `ExpirationDate` you received. An expired license fails activation with the same generic error as an invalid key, so the stored date is what lets your app tell the user the license expired on that date instead of calling the key invalid. + ## Machine Identity Keymint provides utilities to uniquely identify machines for node-locking: diff --git a/Services/Types.cs b/Services/Types.cs index 01f8171..99dc6f6 100644 --- a/Services/Types.cs +++ b/Services/Types.cs @@ -195,6 +195,13 @@ public class ActivateKeyResponse public string? VersionId { get; set; } [JsonPropertyName("version")] public ActivationVersion? Version { get; set; } + /// + /// When the license expires (UTC), or null if it never expires. + /// An expired license fails activation with the generic invalid-key error, + /// so store the last value you received to tell the user when it expired. + /// + [JsonPropertyName("expirationDate")] + public DateTimeOffset? ExpirationDate { get; set; } } /// @@ -675,6 +682,12 @@ public class FloatingCheckoutResponse public required int HeartbeatInterval { get; set; } [JsonPropertyName("metadata")] public Dictionary? Metadata { get; set; } + /// + /// When the license expires (UTC), or null if it never expires. + /// Not the same as , which is the floating seat lease. + /// + [JsonPropertyName("expirationDate")] + public DateTimeOffset? ExpirationDate { get; set; } [JsonPropertyName("currentSessions")] public int? CurrentSessions { get; set; } [JsonPropertyName("maxSessions")] diff --git a/Tests/ApiContractTests.cs b/Tests/ApiContractTests.cs index 19c04e9..1b5ab95 100644 --- a/Tests/ApiContractTests.cs +++ b/Tests/ApiContractTests.cs @@ -92,6 +92,85 @@ public async Task ActivateKey_deserializes_metadata_and_version_fields() Assert.Equal("pro", ((JsonElement)result.Data.Metadata!["tier"]).GetString()); } + [Fact] + public async Task ActivateKey_deserializes_expiration_date() + { + var handler = new StubHandler(_ => RawJsonResponse( + "{\"code\":0,\"message\":\"License valid\",\"expirationDate\":\"2027-03-01T00:00:00.000Z\"}")); + var sdk = CreateSdk(handler); + + var result = await sdk.ActivateKey(new ActivateKeyParams + { + ProductId = "product_123", + LicenseKey = "license_123" + }); + + Assert.True(result.IsSuccess, result.Error?.Message); + Assert.Equal(new DateTimeOffset(2027, 3, 1, 0, 0, 0, TimeSpan.Zero), result.Data!.ExpirationDate); + Assert.Equal(TimeSpan.Zero, result.Data.ExpirationDate!.Value.Offset); + } + + [Theory] + [InlineData("{\"code\":0,\"message\":\"License valid\",\"expirationDate\":null}")] + [InlineData("{\"code\":0,\"message\":\"License valid\"}")] + public async Task ActivateKey_treats_null_or_missing_expiration_date_as_never_expires(string json) + { + var sdk = CreateSdk(new StubHandler(_ => RawJsonResponse(json))); + + var result = await sdk.ActivateKey(new ActivateKeyParams + { + ProductId = "product_123", + LicenseKey = "license_123" + }); + + Assert.True(result.IsSuccess, result.Error?.Message); + Assert.Null(result.Data!.ExpirationDate); + } + + [Theory] + [InlineData("\"2027-03-01T00:00:00.000Z\"", true)] + [InlineData("null", false)] + public async Task FloatingCheckout_deserializes_license_expiration_separately_from_seat_lease(string expirationJson, bool hasExpiry) + { + var json = "{\"code\":0,\"message\":\"Seat checked out\",\"sessionId\":\"session_123\"," + + "\"sessionSecret\":\"secret_123\",\"nextNonce\":\"nonce_1\",\"expiresAt\":\"2026-09-08T01:00:00Z\"," + + "\"heartbeatInterval\":60,\"expirationDate\":" + expirationJson + "}"; + var sdk = CreateSdk(new StubHandler(_ => RawJsonResponse(json))); + + var result = await sdk.FloatingCheckout(new FloatingCheckoutParams + { + ProductId = "product_123", + LicenseKey = "license_123", + HostId = "host_123" + }); + + Assert.True(result.IsSuccess, result.Error?.Message); + Assert.Equal("2026-09-08T01:00:00Z", result.Data!.ExpiresAt); + if (hasExpiry) + { + Assert.Equal(new DateTimeOffset(2027, 3, 1, 0, 0, 0, TimeSpan.Zero), result.Data.ExpirationDate); + } + else + { + Assert.Null(result.Data.ExpirationDate); + } + } + + [Fact] + public void ExpirationDate_round_trips_through_System_Text_Json() + { + var original = new ActivateKeyResponse + { + Code = 0, + Message = "License valid", + ExpirationDate = new DateTimeOffset(2027, 3, 1, 0, 0, 0, TimeSpan.Zero) + }; + + var roundTripped = JsonSerializer.Deserialize(JsonSerializer.Serialize(original)); + + Assert.Equal(original.ExpirationDate, roundTripped!.ExpirationDate); + } + [Fact] public async Task CreateKey_deserializes_bulk_keys() { @@ -184,6 +263,14 @@ private static HttpResponseMessage JsonResponse(object value) }; } + private static HttpResponseMessage RawJsonResponse(string json) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + } + private sealed class StubHandler(Func responder) : HttpMessageHandler { protected override Task SendAsync(