KeyGrant.Licensing 0.8.1

dotnet add package KeyGrant.Licensing --version 0.8.1
                    
NuGet\Install-Package KeyGrant.Licensing -Version 0.8.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="KeyGrant.Licensing" Version="0.8.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="KeyGrant.Licensing" Version="0.8.1" />
                    
Directory.Packages.props
<PackageReference Include="KeyGrant.Licensing" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add KeyGrant.Licensing --version 0.8.1
                    
#r "nuget: KeyGrant.Licensing, 0.8.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package KeyGrant.Licensing@0.8.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=KeyGrant.Licensing&version=0.8.1
                    
Install as a Cake Addin
#tool nuget:?package=KeyGrant.Licensing&version=0.8.1
                    
Install as a Cake Tool

KeyGrant.Licensing (.NET)

KeyGrant licensing for .NET desktop apps (WPF, WinForms, Avalonia, MAUI, console): a licence is a signed lease (a JWT, EdDSA / Ed25519) the app verifies offline against the product's public key. The app only talks to KeyGrant to activate, to renew a lease near or past its end, to report a newer major version, and to report a running trial's launch (at most once a day, in the background).

The rule every line serves: licensing failure fails in favour of the paying customer. No network, an outage, a timeout, a 5xx, a 429, an unrecognised 4xx, an answer that cannot be used, or a licence file that cannot be read just now never takes a licence away while the device holds a valid lease. Only the server's explicit verdicts (revoked, upgrade or update required, another device) and the lease's own end do.

This is a port of the Electron SDK (@keygrant/electron), which is the reference: it behaves the same, reads and writes the same state file, computes the same fingerprints, and its test suite reproduces every answer in conformance/vectors.json. See Parity with the reference.

Install

dotnet add package KeyGrant.Licensing

Targets net8.0 (runs on .NET 8 and later, on Windows, macOS and Linux). One dependency: BouncyCastle.Cryptography (MIT), for Ed25519, which .NET 8 does not have.

Quick start

Create one License per app process and share it (calls on it run one at a time).

using KeyGrant.Licensing;

var license = new License(new LicenseConfig
{
    Product = "sluice",                         // the product slug in KeyGrant
    ApiBaseUrl = "https://api.keygrant.dev",
    PublicJwks = PublicJwk.ParseSet(KeyGrantKeys.Sluice), // the product's PUBLIC keys, embedded (below)
    Major = 3,                                  // this build's major version: set it (a 0.x build is 1)
    DeviceName = Environment.MachineName,       // optional: a label in the customer's device list
});

// At launch: offline-first, never throws for storage.
LicenseStatus status = await license.StatusAsync();

// The customer pastes a key:
ActivateResult result = await license.ActivateAsync(keyTextBox.Text);
if (!result.Ok) ShowError(result.Error);         // LicenseError.InvalidKey, Limit, Revoked, Network, ...

// "I've paid" / "Try again" on a lockout screen: ask the server now.
status = await license.RefreshAsync();

// Free this device's seat (the local licence is dropped either way):
DeactivateResult released = await license.DeactivateAsync();
if (!released.Released) ShowSeatMayStillBeHeld(); // KeyGrant did not confirm it: say so, never "the seat is free"

// A trial, begun once (StatusAsync reports a running trial's launch by itself):
status = await license.StartTrialAsync();       // State Trial, TrialDaysLeft, TrialEndsAt

Console

var status = await license.StatusAsync();
switch (status.State)
{
    case LicenseState.Licensed:
    case LicenseState.Trial:
    case LicenseState.Unknown:   // the state file is busy just now: keep running
        RunTheApp();
        break;
    default:
        Console.WriteLine($"Not licensed ({status.State}, {status.Reason}). Enter a key:");
        var result = await license.ActivateAsync(Console.ReadLine() ?? "");
        Console.WriteLine(result.Ok ? "Activated." : $"Could not activate: {result.Error}");
        break;
}

WPF

public partial class App : Application
{
    public static License License { get; } = new(new LicenseConfig
    {
        Product = "sluice",
        ApiBaseUrl = "https://api.keygrant.dev",
        PublicJwks = PublicJwk.ParseSet(KeyGrantKeys.Sluice),
        Major = 3,
        Adapters = new LicenseAdapters { Registry = new WindowsRegistryStash("sluice") }, // optional
    });

    protected override async void OnStartup(StartupEventArgs e)
    {
        base.OnStartup(e);
        var status = await License.StatusAsync();      // safe to await on the UI thread
        Window window = status.State is LicenseState.Licensed or LicenseState.Trial or LicenseState.Unknown
            ? new MainWindow()
            : new ActivationWindow(status);
        window.Show();
    }
}

WinForms

internal static class Program
{
    public static readonly License License = new(new LicenseConfig { /* as above */ });

    [STAThread]
    private static void Main()
    {
        ApplicationConfiguration.Initialize();
        Application.Run(new MainForm());
    }
}

public partial class MainForm : Form
{
    protected override async void OnLoad(EventArgs e)
    {
        base.OnLoad(e);
        var status = await Program.License.StatusAsync();
        if (status.State is not (LicenseState.Licensed or LicenseState.Trial or LicenseState.Unknown))
        {
            using var dialog = new ActivationDialog(Program.License, status);
            dialog.ShowDialog(this);
        }
    }
}

The SDK's own code never captures a synchronization context (ConfigureAwait(false) throughout), so it does not deadlock a UI thread that blocks on it; awaiting it is still the right way.

Configuration

LicenseConfig Meaning
Product (required) The product slug, matching the KeyGrant product.
ApiBaseUrl (required) The API base, e.g. https://api.keygrant.dev (one trailing slash is dropped).
PublicJwks The product's Ed25519 public keys as JWKs: the key that signs its leases, and the next one once the product has one staged (below). PublicJwk.ParseSet(json) reads a JWK set. Give this or PublicJwk.
PublicJwk One public key, a key set of one: a JSON string converts implicitly, or new PublicJwk { X = "..." }. Give this or PublicJwks.
Major This build's major version (default 1). Keys are bound to, and cover, majors: set it. Read as KeyGrant's server reads a version: a 0.x build (Major = 0) is major 1, and so is any value below 1.
DeviceName A label for this device in the customer's device list; cut to 120 characters.
DeviceHold Whether a licence is held to the device it was activated on (default true). See below.
HttpTimeout How long a request may take before it is abandoned (default 15 s; at most a day). The SDK keeps it whatever the HTTP adapter does. An adapter's token is cancelled only when the SDK gives up on its call (its bound passed, or the caller stopped waiting), never after it answered: an answer stands, whatever the token's callbacks do.
RevokedLicenseFiles The ids of licence files this build refuses (an offline file that leaked), matched exactly; copied when the License is made. A null among them throws ArgumentException.
Adapters Overrides for Storage, Registry, Clock, Fingerprint and Http; any left null uses the default.
TimeProvider What the SDK's timers run on (default TimeProvider.System); for tests.

The configuration is checked when the License is created: an empty product or API, a bound that is not positive, no public key, an empty key set, both PublicJwks and PublicJwk, or a public key that is not an Ed25519 public JWK throws ArgumentException.

Trials: what the app calls

var status = await license.StatusAsync();
if (status.State == LicenseState.Unlicensed) status = await license.StartTrialAsync(); // begins the trial, once

A running trial reports its launch to KeyGrant by itself: StatusAsync() sends it at most once a day, on a thread-pool thread, answering without waiting for the request and never failing offline (it saves the day's stamp first), so the dashboard's trial funnel sees the trial come back. The app does not need to call StartTrialAsync() on every launch for that. StartTrialAsync() still re-syncs the trial when called: a change to the trial's length or entitlements reaches a running trial there.

Licence files: drag and drop

A licence can come as a file (.kglicense) the customer drops onto the app or opens from its licence screen. A key file carries their key: importing it activates the key online, exactly as typing it would. An offline licence file licenses the app with no network at all, for good, on machines that never connect; it has no seat limit and cannot be revoked, so the only way to stop one that leaked is an app update that lists its id:

var license = new License(new LicenseConfig
{
    Product = "sluice",
    ApiBaseUrl = "https://api.keygrant.dev",
    PublicJwks = PublicJwk.ParseSet(KeygrantKeys),
    Major = 3,
    RevokedLicenseFiles = [], // ids of files that leaked: an update adds them here
});

In WPF (AllowDrop="True" on the window), a dropped file and an "Open licence file..." command:

private async void Window_Drop(object sender, DragEventArgs e)
{
    if (e.Data.GetData(DataFormats.FileDrop) is not string[] paths) return;
    var path = paths.FirstOrDefault(p => p.EndsWith(LicenseFileFormat.Extension, StringComparison.OrdinalIgnoreCase));
    if (path is not null) await ImportAsync(path);
}

private async void OpenLicenseFile_Click(object sender, RoutedEventArgs e)
{
    var dialog = new Microsoft.Win32.OpenFileDialog { Filter = $"License file (*{LicenseFileFormat.Extension})|*{LicenseFileFormat.Extension}" };
    if (dialog.ShowDialog(this) == true) await ImportAsync(dialog.FileName);
}

private async Task ImportAsync(string path)
{
    try
    {
        // Never more than a licence file can be: a larger one is refused unread.
        if (new FileInfo(path).Length > LicenseFileFormat.MaxBytes) { ShowError(LicenseFileError.Malformed); return; }
        var result = await license.ImportLicenseFileAsync(await File.ReadAllBytesAsync(path));
        if (result.Ok) ShowThanks(result.LicensedTo!.Name ?? result.LicensedTo.Email);
        else if (result.Error is { } refused) ShowError(refused);                 // the file was not taken
        else ShowActivationError(result.ActivateError!.Value);                    // a key file's key did not activate
    }
    catch (Exception)
    {
        // An async void handler must catch everything, or the app ends: the file unreadable, the
        // state not saved, or a licence call still running. Try again.
        ShowRetry();
    }
}

WinForms is the same with AllowDrop = true, DragEnter setting e.Effect = DragDropEffects.Copy, and DragDrop reading e.Data.GetData(DataFormats.FileDrop).

ImportLicenseFileAsync takes the file's content (bytes, or its text in the string overload), never its path, and verifies it offline: signed by a key of PublicJwks, for this product, and, for an offline file, covering this build's major. It answers an ImportLicenseFileResult: Ok with its Kind (Key or Offline) and LicensedTo; or Error, a LicenseFileError (Malformed, NotALicenseFile, BadSignature, UnknownKey, WrongProduct, RevokedFile, Upgrade, Outdated), nothing stored; or, for a key file whose key did not activate, ActivateError, what ActivateAsync answers. It throws InvalidOperationException when a licence call before it has not finished, and, as ActivateAsync does, when the state cannot be read or the file cannot be saved. UnknownKey means the file was signed by a key this build does not carry: a newer one, after the product rotated its key, which an update of the app carries. A key file's notes print its key, which can be typed meanwhile.

An offline file is kept and checked again on every StatusAsync(), which then answers Licensed with Source = LicenseSource.File, the Key, LicensedTo and the file's Entitlements, never asking the server, RefreshAsync() included. Show "Licensed to Ada Lovelace" from LicensedTo. A status from a file has no seat to deactivate: offer RemoveLicenseFileAsync() instead (DeactivateAsync() leaves the file alone). If the device also holds a key, a file that covers this build wins, and the key keeps working once the file is removed, revoked or outgrown. A file that does not cover this build answers only when no key and no running trial does.

Keep every key that signed a file in your builds. A file names the key that signed it and never expires, so a key that has signed one stays in the product's key set, and in PublicJwks, after it stops signing leases. A build without it reads such a file as Invalid / UnknownKey.

User-locked keys

A licence model can let only the person a key was sold to activate a NEW device on it: the activation names the address on the key and, for a key that mails a code to that address, the code too. Only a new device is ever asked; StatusAsync(), RefreshAsync(), a renewal, a seat the key already holds and everything offline are as they were, so an app that is activated keeps running through a KeyGrant or mail outage.

ActivateAsync(key, options) carries what the person typed (ActivateOptions; each member is optional, and neither is ever stored):

ActivateResult result = await license.ActivateAsync(key, new ActivateOptions { Email = emailBox.Text, Code = codeBox.Text });
switch (result.Error)
{
    case LicenseError.UserEmailRequired or LicenseError.UserEmailMismatch:
        AskForTheAddress();                    // then activate again with it
        break;
    case LicenseError.UserCodeRequired or LicenseError.UserCodeInvalid:
        AskForTheCode();                       // a code was just mailed to the address
        break;
    case LicenseError.TooManyCodes or LicenseError.TooManyAttempts:
        AskToTryLater();
        break;
    case LicenseError.TryAgain:
        AskToRetry();                          // nothing was activated
        break;
}
LicenseError Means The app
UserEmailRequired the key is locked and no address came ask for the address
UserEmailMismatch not the key's address (nothing is said of the real one) ask again
UserCodeRequired the address was right and a code was just mailed to it ask for the code
UserCodeInvalid the code is wrong, expired (10 minutes) or used, or the key's codes are void for the hour after five wrong ones ask again, or activate with the address alone for a new code
TooManyCodes three codes were mailed for the key this hour wait, or use the code sent
TooManyAttempts ten wrong addresses this hour, or the key's codes void try again later
TryAgain the code could not be mailed: nothing was activated retry; the one error here that is nobody's mistake

The address is trimmed (it is compared without regard to case) and a blank is none. The code has its ends trimmed and its spaces dropped (123 456 is 123456), and must be exactly six ASCII digits. An address longer than 254 UTF-16 units, or a code that is not six digits, answers UserEmailMismatch or UserCodeInvalid at once, for any key, asking the server nothing and so costing the key none of its wrong tries; the key is judged first, then the address, then the code. A key that is not locked ignores both once they reach the server. ImportLicenseFileAsync(content, options) passes them on to a key file's key. DeactivateAsync(), RefreshAsync() and StartTrialAsync() take neither.

Metered uses: counting what a key may do

A licence model can count uses of what it sells: 1,000 exports for good, or 50 renders each billing period. The app spends them with UseAsync(meter, n), before the work they count, and does the work only when the spend is Ok:

UseResult spent = await license.UseAsync("exports"); // UseAsync("exports", 40) spends forty at once, whole or none
if (spent.Ok)
{
    RunExport(); // spent.Left: what is left now, net of this device's unsynced spends
}
else
{
    switch (spent.Error)
    {
        case UseError.UsedUp:
            OfferMoreExports(spent.Left);   // the server's word: fewer left than asked (null when it gave none)
            break;
        case UseError.OfflineLimit:
            AskToConnect();                 // KeyGrant could not be reached, and this is past what this device may spend without it
            break;
        default:
            ShowMeterProblem(spent.Error);  // NoMeter, NotLicensed, Revoked, Device, Upgrade, Outdated, Storage, InvalidAmount
            break;
    }
}

LicenseStatus status = await license.StatusAsync(); // offline, from the lease
status.Meters["exports"]; // MeterStatus { Left = 950, Total = 1000, Offline = 100, Unsynced = 0, Resets = false }
  • Online it is exact. The server takes n only while the meter has room for all of it. Two calls are two spends; the SDK's own retries and reports never count one twice.
  • With no answer it spends from an allowance. No network, a timeout, a 5xx, a 429 or an answer the SDK cannot use: the device spends from its offline allowance for that meter (set on the licence model; a tenth of the meter by default, 0 for online only), never past what it last heard is left, and reports the spend with its next check-in (UseResult.Offline is true). Past either, OfflineLimit. A spend the allowance would refuse asks the server first, whatever the last outage left behind, so a customer who is back online is not refused for it (once a minute while KeyGrant stays out of reach); one the allowance covers is not asked again for a minute after an outage, growing to an hour. The unsynced spends are kept with the licence (StoredState.Spends), so they survive a restart and a second instance of the app, and a spend the SDK cannot save yet is kept in memory: the customer always gets the use they were told they had, and it is reported once it has been saved (a spend in doubt, and the take-back of one refused, are reported with the next request).
  • It needs a key's lease that licenses this build on this device (NotLicensed otherwise, without asking). A device trial and an offline licence file have no meters.
  • DeactivateAsync() clears the unsynced spends with the licence. It sends them with the release, but clears them locally even when KeyGrant could not be reached (Released is then false), so what the server did not hear is lost: at most the offline allowance of each meter.
  • It does not throw for an outcome: every one is in the UseResult, and what no adapter can tell it (one threw where there is no word for it) is Storage. A use request waits at most 5 seconds for the server (less when HttpTimeout is shorter). The one exception is a CancellationToken cancelled while the call waits its turn, or before its request is sent: it throws OperationCanceledException and nothing is spent. Cancelled while the request is out, the call stops waiting and spends as for no answer (the spend is in doubt, so it is reported), never an exception: it RETURNS its result, so read it even after cancelling. No wait is recorded for the ask it cut short, but its journal entry is, and the journal's wait (for a spend the allowance refuses, its void's minute) runs from that entry.
  • n is a long: anything outside 1 to 1,000,000,000 is InvalidAmount, and a meter code that is not 1 to 32 UTF-16 units is NoMeter, both without asking anything. A code is matched exactly, case included.
  • LicenseStatus.Meters is there on a Licensed status from a key's lease that names meters, and empty on every other status: Left is net of this device's unsynced spends, Total is what the key holds (for good, or this period), Offline is this device's allowance, Unsynced what it has spent and not reported, and Resets is true for a meter that comes back each billing period.
  • A meter never takes the licence away. Nothing reads one to decide whether the app is licensed: a meter that is used up refuses spends, never the app.
  • An app built with an SDK from before meters licenses a metered key as any other and cannot spend.

Restoring a licence without the key (App Store builds)

A build that may not take a typed licence key (an App Store build) restores a web buyer's licence by the address the key was sold to. The product's Key pickup must be on. The app asks for a code, the customer types the code that comes to that address, and the SDK activates the key that fits this build:

// 1. Mail a code to the address the key was sold to (the answer is the same whether or not the address holds a key).
PickUpSentResult sent = await license.PickUpAsync(emailBox.Text);

// 2. The customer types the code; restore.
RestoreResult restored = await license.RestoreByEmailAsync(emailBox.Text, codeBox.Text);
if (!restored.Ok)
{
    switch (restored.PickupError)
    {
        case PickupError.WrongCode: AskAgain(); break;
        case PickupError.CodeExpired: await license.PickUpAsync(emailBox.Text); break;  // a new code
        case PickupError.TooManyAttempts or PickupError.TryAgain: WaitSeconds(restored.RetryAfter); break;
    }
    if (restored.NoKey) ShowNoKeyUnderThatAddress();
    if (restored.Error is LicenseError.Upgrade) OfferAnUpgrade();      // the keys are for an older major
    if (restored.Error is LicenseError.Outdated) AskToUpdateTheApp();  // the keys start at a later major
}

PickUpAsync(email, code) hands back the address's keys instead (PickUpKeysResult.Keys: show them by ModelName, never the key) for an app that lets the customer choose; activate one with ActivateAsync(picked.Key, new ActivateOptions { Email = address, Code = picked.ActivationCode }). RestoreByEmailAsync chooses for the customer, among the keys that cover this build's major: the key this device already holds a seat on, else one with a free seat, else the first.

PickupError Means The app
InvalidEmail the address is blank or longer than any ask again
WrongCode not six digits, or not the address's live code ask again
CodeExpired no live code (expired after 10 minutes, used, burnt by its fifth wrong try, or never asked for) PickUpAsync(email) again
TooManyAttempts thirty wrong codes for the address this hour, or too many asks or confirms from this network wait RetryAfter seconds
PickupDisabled the product's Key pickup is off offer another way (support)
TryAgain a count could not be made retry
Network no answer, or an answer that is not a pickup's retry

RestoreResult holds a refusal in one of three members: PickupError (the code was not taken), NoKey (the address holds no key of the product) or Error, a LicenseError: Upgrade (the keys are for an older major than this build) or Outdated (their lowest version is newer), activating nothing, then ActivateAsync's own errors (Limit, Revoked, ...) as they come, and Network behind a call that has not finished. The address and the code are prepared as an activation's are (trimmed, a code's spaces dropped) and refused at once, asking nothing, when the address is blank or the code is not six digits, so a mistyped code costs the address none of its wrong tries. RetryAfter is read from the server's answer. The first confirm spends the code, so the keys it answered are kept in memory for 10 minutes (a code's life) by the address and code as sent: a confirm or a restore with the same pair answers from them and asks nothing, so a restore whose activation got no answer can be retried with the same code, and a restore after PickUpAsync(email, code) needs no new one. A restore that activates drops them. Nothing about a pickup is stored (not the address, the code, the keys or a refusal): the two PickUpAsync calls write nothing and run beside any call, and RestoreByEmailAsync stores only the activation, as ActivateAsync does, and runs one at a time with it. A pickup never takes a licence away. The rules are in the SDK contract's section on key pickup, which the conformance vectors pickupAsks, pickupRefusals, pickedKeys and pickupChoices hold this SDK to.

Buying from the app

Give a customer who holds no key a Buy button that opens your Stripe Payment Link. The checkout emails them their key, and they type it into the app:

using System.Diagnostics;

Process.Start(new ProcessStartInfo("https://buy.stripe.com/your-link") { UseShellExecute = true });
// Once they have the key from the email:
ActivateResult result = await license.ActivateAsync(keyTextBox.Text);

For a customer who already holds a key, UpgradeUrlAsync(link) adds this install's activation to the link, so an upgrade sale finds the key it upgrades (below).

What each status means

StatusAsync() answers a LicenseStatus (State, Reason, Key, TrialDaysLeft, TrialEndsAt, Test, Entitlements, Meters, Stalled, and Source and LicensedTo on a status from a licence file):

State Reason Meaning What to show
Licensed A valid lease licenses this build on this device; with Source File, a stored offline licence file does. The app, in the edition its Entitlements name (below). Check Test (below). From a file, "Licensed to" its LicensedTo.
Trial A trial is running on its signed trial lease: TrialEndsAt (ms) is the end that lease was signed to, TrialDaysLeft the days to it, rounded up. The app, with a trial banner; its Entitlements are the trial lease's.
Unknown Storage The state file could not be read just now (an antivirus scan holding it). Nothing is known either way. Keep running as before; never the activation screen for it. Ask again shortly.
Expired Offline The lease ran out and the server could not be reached to renew it. "Connect to the internet to renew your licence", and RefreshAsync() on a retry.
Expired Trial The trial has ended, or its trial lease is not valid here. A trial end stored in the state file is unsigned and never runs a trial on its own. Buy / enter a key.
Invalid Revoked The key was revoked, refunded or expired, or this device was released. Buy / enter a key; RefreshAsync() once they say they renewed.
Invalid Upgrade This build is NEWER than the key covers (with Source File: than the file covers). Offer the upgrade (UpgradeUrlAsync), or the older version; from a file, a file (or a key) for this version.
Invalid Outdated This build is OLDER than the key's (or the file's) lowest version. "Update the app": the key is for a later version.
Invalid Device The licence was activated on another device (this machine changed, or the state file was copied). "Activate again on this device" (ActivateAsync(key)).
Invalid Tampered The stored lease is not for this licence at all (another key, activation or product, a bad signature); with Source File, the stored file is not one (the state file was edited). Enter a key; from a file, import it again or remove it.
Invalid UnknownKey The stored lease is signed by a key this build does not carry. Online, the server renews it with the key it signs with now. With Source File, no renewal comes. "Update the app" when it stays so online: this build does not carry the product's signing key. From a file: update the app, or ask for the same licence's file signed again.
Invalid RevokedFile The stored offline licence file is one this build lists as revoked (Source File). Enter a key, or contact the vendor.
Unlicensed No licence and no trial on this device. Enter a key or start a trial.

Stalled is true when an earlier call on this License has not finished (a storage adapter stuck on a dead disk, say): the answer then comes from what is stored, with no ask and no write.

ActivateAsync answers ActivateResult (Ok, Error): InvalidKey (the server knows no such key, or what was entered cannot be one: nothing, or over 120 characters), Limit (activated on as many devices as it allows), Revoked, Upgrade, Outdated, Network (no answer, or an answer it could not use, or the device could not be identified in time, or an earlier call has not finished: try again), InvalidLease (the server's lease failed verification: a wrong public key, usually), and the seven errors of a user-locked key (above). The key is trimmed and upper-cased before it is sent and stored. ActivateAsync and DeactivateAsync THROW when the state file cannot be read or saved (the customer asked, and can try again); DeactivateAsync also throws InvalidOperationException behind a call that has not finished.

DeactivateAsync drops the local licence (keeping only trial evidence) and answers DeactivateResult: Released is true when KeyGrant confirmed the release (a 200), or when the device held no activation; false when it could not be asked, or answered anything else (a 5xx, a 429, a proxy's page). Then the seat may still be held: tell the customer so (the developer's dashboard, or support, can release it), never that it is free. The local licence is dropped either way.

It never releases another device's seat. A state file copied or restored from another PC (a roaming profile, a backup, a cloned disk) carries that PC's activation: its lease, valid or run out, is held to that machine's id, or a device refusal stands for this one. Then DeactivateAsync sends nothing, only drops it here, and answers Released true, as this device holds no seat. Releasing it would lock the other PC out at its next check-in.

When it goes online

StatusAsync() answers from what the device holds, and asks the server only:

  1. on a valid lease, when this build is a newer major than the server last heard (on an open-ended lease), or the lease is near its end (less than 7 days left, or less than half its life): bounded to 5 s, and the valid lease is the answer when that gets no verdict;
  2. on a lease past its end or not valid here, so the server can issue a fresh one;
  3. on a stored refusal that still stands for this build, so a renewed or reinstated key heals;
  4. on a trial whose lease does not verify here while its stored end is still ahead (a lease signed before the product's key was rotated, or one signed to end short of the trial's end), so the server can sign it again: asked as StartTrialAsync() asks, bounded to 5 s, and its answer saved as that saves one. Until a trial lease this build can verify comes back, it is Expired/Trial;

and in each case only once the wait after the last ask is over: 1 minute after an ask nothing answered (offline), 1 hour after one the server answered without a lease (a refusal, a 5xx, a 429) or without a trial this build can verify. RefreshAsync() asks at once. A new lease ends the wait.

Besides those asks, a running trial (its trial lease valid, no key held) reports its launch at most once a day: its stamp saved first, then the report sent on a thread-pool thread, bounded to 5 s, never awaited and its answer not read. Since it runs beside the calls, a custom IHttpAdapter or IFingerprinter may be called from more than one thread at once (the defaults may).

Calls on one License run one at a time. A call waits for the one before it, but at most its HttpTimeout + 15 s from when that one started; then it runs read-only (Stalled).

Every call but UpgradeUrlAsync takes an optional CancellationToken. A call cancelled while it waits its turn throws OperationCanceledException and steps out of the queue. Once it runs, cancelling StatusAsync, RefreshAsync or StartTrialAsync only stops it waiting on the server or the device: it answers from what the device holds, as when nothing answers, and records nothing of the ask it cut short. A licensed answer never becomes an exception. ActivateAsync and DeactivateAsync throw OperationCanceledException until the server has answered, and save nothing.

Embedding the public keys

The product's public keys are the keys of GET https://api.keygrant.dev/v1/products/<slug>/pubkey, which is public and needs no API key, and the dashboard's Signing key tab copies the same set (Copy key set). Fetch them when you build and ship them with the app, e.g.

curl -s https://api.keygrant.dev/v1/products/sluice/pubkey
# {"publicJwk":{"kty":"OKP","crv":"Ed25519","x":"..."},"keys":[{"kty":"OKP","crv":"Ed25519","x":"...","kid":"..."}]}

and embed that response, or the copied set, at build time:

internal static class KeyGrantKeys
{
    public const string Sluice = """{"keys":[{"kty":"OKP","crv":"Ed25519","x":"Ohg4h8SrYQ0m1su_zVacJLLwf_6rNsvOtV5oWY6CzJs"}]}""";
}

var license = new License(new LicenseConfig
{
    Product = "sluice",
    ApiBaseUrl = "https://api.keygrant.dev",
    PublicJwks = PublicJwk.ParseSet(KeyGrantKeys.Sluice),
    Major = 2,
});

(that x is the conformance vectors' TEST key: use your product's own). An embedded resource works as well. Never ship the private key: a JWK carrying d is refused. The public keys only verify; holding them lets nobody sign a lease.

Why a set. A lease names the key that signed it (kid in its header, the key's RFC 7638 thumbprint, which the SDK computes from the key itself). It is verified by that key; a lease that names none (every lease signed before key sets) by each key in turn; and one naming a key the build does not carry is refused (UnknownKey). When the product rotates its signing key, it first stages the next key, published beside the current one while every lease is still signed with the current key. Ship builds carrying both; once they are out, the product switches, and those builds verify every lease signed after it. Embed the whole set each time you build, so a build always carries the key that signs now and the one staged next. A PublicJwk alone is a set of one, as before.

Each activation and validate reports the ids of the keys the build carries (kids), so the dashboard can count the devices still on builds without the staged key before you switch.

DeviceHold: holding a licence to its device

By default a lease whose seat is keyed by the operating system's machine id is held to that device: a state file copied to another machine reads Invalid/Device there, and the server refuses its renewal. An honest device is never refused for how it is identified: a host-name seat, a seat of unknown kind, and a run that cannot read the machine id in time are not judged.

Set DeviceHold = false for a product deployed on non-persistent, pooled VDI (or a home folder shared between machines), where every session may be another machine with the same state file: no fingerprint kind is reported, no lease is judged by its device, and no fingerprints are sent to the server to check. The device limit still counts seats.

The default fingerprint is the machine id, hashed so it never leaves the machine: MachineGuid (Windows, 64-bit registry view), IOPlatformUUID (macOS), /etc/machine-id or /var/lib/dbus/machine-id (Linux, unless on a tmpfs/ramfs or written at this boot). With none to read, the host name, platform and architecture, hashed. The same machine gives the same fingerprint under this SDK and the Electron one. A custom IFingerprinter with only GetAsync is never held to its device; implement IdentifyAsync and report DeviceKind.Machine for a stable machine id that should be.

Test licences

A test licence (made from the dashboard, a kg_test_ API key, or a Stripe test-mode checkout) is signed with the product's real key and licenses the app like any other; it lapses within 30 days and is never billed. LicenseStatus.Test is true for one. Refuse them in a release build if you want:

#if !DEBUG
if (status.Test) status = status with { State = LicenseState.Invalid, Reason = LicenseReason.Tampered };
#endif

Entitlements

A licence can grant more than the base product: an edition, a number of seats, an add-on. The lease signs them (its ent claim) and the status hands them to the app as Entitlements, read offline like everything else on the lease:

var status = await license.StatusAsync();
var pro = status.Entitlements.TryGetValue("edition", out var edition) && edition.Text == "pro";
var seats = status.Entitlements.TryGetValue("seats", out var granted) ? granted.Number ?? 1 : 1;
var beta = status.Entitlements.TryGetValue("beta", out var flag) && flag.Flag == true;
  • Type: IReadOnlyDictionary<string, Entitlement>, never null. An Entitlement is exactly one of a flag (Flag, a bool?), a number (Number, a double?) or a text (Text, a string?); the accessor of any other kind is null. Two are equal when they are the same kind with the same value (numbers as doubles, so 5 and 5.0 alike), and ToString() writes the value. The map is read-only, and each status holds its own copy: change a copy, not the status.
  • When it holds them: on a Licensed status (from the licence's lease: straight from the device, or after an activation, a renewal or RefreshAsync(), and on a Stalled answer from the lease stored) and on a Trial status (from the trial's lease), when that lease grants at least one.
  • When it is empty: on every other status (Expired, Invalid for any reason, a stored refusal included, Unlicensed, Unknown), and when the lease grants none. LicenseStatus compares its entitlements by content, not by reference, so two statuses from the same lease are equal.
  • A malformed claim never costs the licence: a member that is not a flag, a number or a text (an object, a list, null) is left out, and an ent that is not an object at all reads as none. The SDK enforces none of the server's caps on them (how many, how long); the signature is what it trusts.

To ask whether the licence grants an entitlement, name its code:

if (await license.HasAsync("cloud_sync")) EnableCloudSync();
await license.RequireAsync("export"); // throws MissingEntitlementException (its Entitlement is "export")

HasAsync(code) is true for a flag that is on, a number that is not zero, or a text that is not empty; false for anything else, and for a code the lease does not grant (matched exactly, case included). It is answered offline from the stored lease only, as StatusAsync() would answer from what is stored, with no request and no write: a licence that does not license this build, or none at all, grants nothing; a running trial grants what its trial lease does. RequireAsync(code) throws MissingEntitlementException, naming the code, when HasAsync would be false. EntitlementGrants.Grants(status.Entitlements, code) answers the same of a status already in hand.

Where the state is kept

Unless the app gives its own Storage adapter, one <product>.json per product, per user, on this machine (FileStorage.DefaultPath(product)):

  • Windows: %LOCALAPPDATA%\KeyGrant\ (else %USERPROFILE%\AppData\Local\KeyGrant\); never the roaming AppData.
  • macOS: ~/Library/Application Support/KeyGrant/
  • Linux and others: $XDG_CONFIG_HOME/keygrant/ (else ~/.config/keygrant/)

It is the Electron SDK's file, field for field, so an Electron and a .NET build of the same product on one machine share one licence. new FileStorage(path, new FileStorageOptions { MigrateFrom = old }) keeps it elsewhere (copying the state at old over the first time the new path is missing).

  • Written atomically: a temp file beside it, flushed to the disk, renamed over it (a rename Windows refuses as busy is tried again for about 1.5 s); the folder flushed after on macOS and Linux. A write that lands clears temp files a crash left (over a minute old).
  • Only a MISSING file is no state, and then the newest whole temp file beside it is read first. Any other read failure is tried again for about a second, then StatusAsync answers Unknown/Storage.
  • A file that does not parse is set aside as <product>.json.<ms>.corrupt (the newest three kept) and an empty state written in its place.
  • Two processes (two instances of the app) never lose each other's saves: each save stamps a revision (rev), checks it again just before the rename and merges onto a newer state (up to 5 times, then StateConflictException); answers are ordered by when they were asked for, so an older answer never replaces a newer one.
  • A call running read-only writes nothing at all.

The trial start also goes to the Registry stash when one is given: WindowsRegistryStash (opt-in, HKCU\Software\KeyGrant\<product>, value TrialStart) survives an uninstall, so a reinstall does not reset the trial. It keeps the format every KeyGrant SDK keeps, so a trial another SDK stashed for the product counts: milliseconds since the epoch, written as a REG_QWORD; read from a REG_QWORD or a REG_SZ of decimal digits. Anything else is no evidence: another type (a REG_DWORD read as a time is 1970), 0 or less, or 2^53 or more. The stash can cost the licence nothing: one that cannot be read is no evidence, and one that cannot be written is ignored (the server's trial is still saved).

A custom IStorageAdapter must return null only for a state that is truly missing, write atomically, and write nothing on a readOnly read; implementing UpdateAsync (merge onto the state as stored at the moment of saving) makes saves from several processes safe.

Upgrades

await license.UpgradeUrlAsync(paymentLink) returns the upgrade offer's payment link with this device's ACTIVATION id as client_reference_id (never the key), or null without an activation; a checkout through it extends the key the customer holds instead of minting another. The link's other parameters and its fragment are kept as written, and any client_reference_id already on it is replaced.

Selling through Paddle? Pass your own checkout page's address instead of a payment link: the page reads client_reference_id from its address and gives it to Paddle.js as custom data (customData: { client_reference_id }), which is where KeyGrant reads it. KeyGrant's docs page "Selling through Paddle" has the page.

Building and testing

dotnet build sdk-dotnet/KeyGrant.Licensing.sln -c Release
dotnet test sdk-dotnet/KeyGrant.Licensing.sln

The suite loads conformance/vectors.json from the repo root (or the file KEYGRANT_VECTORS names) and answers every group from the vector's own JSON as the reference does, through the same functions the engine itself calls: lease verification (canonical base64url, the claims as parsed) and checkLease, trial leases, fingerprints, claims, machine and host fingerprints, kept majors, early renewal, asks, standing refusals, trial statuses, refusal and lease patches, keyedBy, merges, the clock guard, its correction and the time a save records as seen, the wire (error words, refusals, keys, device names, no-answer statuses, lease and trial bodies), mountinfo, the machine-id reads, what a renewal's answer saves and says, when a status asks on a valid lease, who a device is (install memory, DeviceHold), what the default fingerprinter answers for each machine-id reading, the status a stored lease gives with no verdict, a trial's status from its start and its trial lease (entitlements included in every status), the bits of the double each entitlement number reads as, the major a build runs as, the link UpgradeUrlAsync builds, when a running trial reports its launch, a key's id and a lease against a key set, what entitlements grant, and licence files (the token a file's content holds, a file verified, judged and given as a status, and a file given where a lease goes), user-locked keys (what an activation sends for the address and code a person typed, and the error each word of a refusal gives), and key pickup (what a pickup sends for what a person typed, the error of each refusal, the keys a confirm hands back as read, and the key a restore activates). That is every group and every vector but the Windows machineIds cases, which script reg.exe's output, which this SDK does not parse (it reads the registry), and the majors cases holding a fraction, which an int major cannot hold. A group the file holds that the harness does not answer fails the suite.

Its behaviour tests cover activation, offline status, renewal, refusals and their waits, outages that never lock out, the clock guard, trials, deactivation, DeviceHold, storage that cannot be read, calls stuck behind one another, entitlements on every status that carries them, the launch report, a 0.x major, licence files (import, refusals, a stored file beside a key and a trial, key files), user-locked keys (the address and code sent, each refusal, nothing stored, a key file's key asked too), key pickup (the requests, every refusal, nothing written by PickUpAsync, a restore's activation with the address and the activation code, NoKey, Upgrade and Outdated, the keys kept in memory, a pickup behind an unfinished call), metered uses (online and with no answer, the waits, what is asked, a spend held in memory after a failed save, two processes over one state file, the allowance judged again where a spend lands, what a validate and a deactivate report and what clears the report, the journal's limits and its horizon), the file storage (atomic writes, temp recovery, corrupt files, revision conflicts between two processes) and the machine-id readers.

A property test (UsePropertyTests) runs seeded random sequences of spends, check-ins, releases and restarts from two processes over one state file against a server that loses, delays and refuses answers (or loses them after recording the spend), a disk that refuses its writes and a clock that jumps, and holds the contract's invariants at every step and at the end. PROPERTY_RUNS and PROPERTY_SEED choose how many sequences run and from which seed (1500 from seed 1 by default); a case it finds is shrunk and printed as data, to be kept with the cases beside it.

SystemTests drive the real readers and writers on the system the suite runs on: the registry, a file another process holds and a rename over it (Windows); /etc/machine-id, /proc and the folder fsync (Linux); ioreg (macOS); symbolic and hard links everywhere. A test for another system, or for something this machine cannot do (symbolic links on Windows need Developer Mode or an elevated shell; the host-name check needs a node of this architecture), is reported skipped with its reason. github-workflow-example.yml runs the suite on Linux, macOS and Windows (copy it to .github/workflows/ to enable it). On a Windows machine, the Linux half runs in Docker, from the repo root:

docker run --rm -v "$PWD:/repo:ro" mcr.microsoft.com/dotnet/sdk:8.0 bash -c \
  "mkdir /work && cp -r /repo/conformance /repo/sdk-dotnet /work && cd /work/sdk-dotnet && rm -rf */bin */obj && dotnet test KeyGrant.Licensing.sln"

Parity with the reference

Behaviour is the Electron SDK's; these are the differences, each on purpose:

Difference Why
Windows' MachineGuid is read with the registry API (RegistryView.Registry64), not by running reg.exe. Same value and view; only a REG_SZ counts and the first word is taken, as reg query's output gives it. Access denied is "no id" (as reg exiting 1); any other registry error is "cannot tell this time", where the reference read any non-zero reg exit as "no id". No child process, no %SystemRoot% question. "Cannot tell" never flips a device's fingerprint; "no id" switches it to the host name for good.
The host name is GetHostNameW on Windows and gethostname elsewhere (what Node's os.hostname() calls), the platform and architecture are Node's names (win32/darwin/linux, x64/arm64/ia32/arm) for this PROCESS. So the host-name fingerprint is the Electron SDK's on the same machine (checked against node in the tests when it is installed). An app built for another architecture than its Electron twin hashes another arch, as two Electron builds would.
The public key is checked when the License is created (ArgumentException); a JWK holding d is refused. The reference imports it lazily and a bad key makes every status() reject. A configuration error belongs at startup.
The default storage does not migrate <working directory>/.keygrant/<product>.json. That path is the legacy location older Electron SDK versions used. FileStorage.LegacyPath(product) + MigrateFrom reads it if you need to.
Every call (but UpgradeUrlAsync) takes a CancellationToken. Cancelled while it waits its turn, a call throws OperationCanceledException; once running, StatusAsync, RefreshAsync and StartTrialAsync stop waiting and answer what the device holds (never an exception, and no wait recorded for the ask they cut short), while ActivateAsync and DeactivateAsync throw until the server has answered. .NET convention; the reference cannot be cancelled. A status is awaited at launch, so a cancellation never turns a licensed answer into an exception.
UseAsync(meter, long n = 1, cancellationToken) answers a UseResult (Ok, Left, Offline, Error), where the reference's use(meter, n) answers one object: Left is a nullable long (null where the reference gives none), Offline a flag, Error a UseError. Cancelled while it waits its turn or before its request is sent, it throws OperationCanceledException with nothing spent; cancelled while the request is out, it stops waiting and spends as for no answer and returns its result (read it even after cancelling), never an exception. No wait is recorded for the ask it cut short, but its journal entry is, and the journal's wait runs from it. .NET convention: a typed result for the reference's union, and a token every call takes. A spend whose request was sent may be held by the server, so cutting the wait short leaves it in doubt (reported with the next request), as a timeout does.
StoredState.Spends and MeterFigures are typed lists and maps, read leniently as the reference reads them: a journal that is not a list, an entry or a figure of another shape, is read as empty or left out, and so are the unknown members of a valid entry, when the state is read, so any save writes the state without them (the reference drops a malformed entry or figure, and a figure's unknown members, only when a journal change lands, and keeps a valid entry's unknown members). The journal is the device's own, never the server's word.
HttpTimeout must be positive and at most a day. .NET timers take no more; an unbounded request would defeat the bound.
Major is an int: a fractional major cannot be configured (the reference cuts one to its whole number), so the majors vectors holding a fraction are skipped. A negative one runs as 1, as in the reference. Idiomatic C#; a version's major is a whole number.
A payment link is read by System.Uri, whose normal form differs from the WHATWG parser's in two ways that keep the URL the same: a percent-encoded letter, digit or -._~ in the query is written as that character, and ' in the query is left as it is (WHATWG writes %27). The link is read as the platform's URL parser reads it; the query is then edited as text as the reference edits it.
UpgradeUrlAsync throws ArgumentException for a link that is not an absolute URL (while the device holds an activation), where the reference rejects with a TypeError. .NET convention.
The launch report runs on a thread-pool thread (Task.Run), so the HTTP adapter and the fingerprinter may be called from two threads at once; the tests wait for it through an internal LaunchReportSettled. An SDK with blocking I/O sends it on a thread of its own, never on the caller's.
State files are read leniently: a known field of the wrong type (a refusal word from a later SDK, a number written as text, a string holding an escaped lone surrogate, a licenseFile that is not text, which the reference reads as Invalid/Tampered) reads as absent, a byte-order mark is forgiven, and fractional milliseconds are floored. What such a field held is written back as it was, unless a save sets or clears it; unknown fields are kept and written back, as the reference does. A field NAME holding a lone surrogate is dropped. The reference keeps whatever JSON.parse gives, so a damaged field misbehaves later; reading it as absent keeps the rest of the licence, and writing it back keeps what an Electron build of the same product stored. The reference sets a file with a byte-order mark aside as corrupt.
JSON is read to any depth JSON.parse reads, in one pass, but a member nested more than 64 levels deep is never held as a JsonElement: a state file's is kept as its text and written back (it is not in StoredState.AdditionalFields), a server answer's is left out of the body, and a lease's unknown claim is never read, nor a member of ent that is not a flag, a number or a text (dropped, however deep; any other claim the schema checks cannot nest, so one that deep is bad-claims there too). System.Text.Json's JsonDocument takes time that grows with the square of the nesting: a state file with a member 100 000 levels deep would take seconds to read, and a million minutes, which would hang a status at launch. Nothing the SDK reads nests.
A trial body's trialStart and trialEndsAt are floored to whole milliseconds, and one that is not finite (1e400) is no trial body: the trial evidence the device holds is answered. Times are long here. JSON.parse reads 1e400 as Infinity, which the reference would save as null. The server sends whole milliseconds.
On macOS and Linux, ioreg exiting with a code above 128 is read as killed by a signal ("cannot tell this time"), where execFile tells an exit code ("no id") from a signal. .NET reports a signal as exit code 128 + the signal and cannot tell the two apart. Reading it as "cannot tell" keeps the device's fingerprint (it is read again next time); "no id" would switch it to the host name for good.
WindowsRegistryStash is this SDK's own (the Electron SDK ships only a no-op stash), in the format every KeyGrant SDK keeps: REG_QWORD milliseconds written; a REG_QWORD or a decimal REG_SZ read; another type, 0 or less, or 2^53 or more is no evidence. So a reinstall cannot reset a trial on Windows, whichever SDK stashed it. A value that cannot be a time would start the trial in 1970 on the server, or have it refused.
A string in a server body, or in a lease claim the SDK reads (sub, aid, ...), holding an escaped lone surrogate is no string: no verdict, or bad-claims. Anywhere else in a body (a member the SDK does not read) it costs nothing else in the answer; unknown claims are fine. An entitlement whose name or text holds one is dropped, as a malformed member is: the licence and the other entitlements stay, where the reference would keep it. The default HTTP adapter reads a body as UTF-8 whatever charset the response names, as fetch does. System.Text.Json cannot hold one, and an exception there would fail a status. The server never signs an entitlement name or text holding one.
ActivateAsync(key, options) and ImportLicenseFileAsync(content, options) are overloads, with the options before the CancellationToken, where the reference takes an optional second argument; a code is text here, where the reference also reads a code kept as a number. .NET convention: the existing calls keep their shape. A typed port takes text.
PickUpAsync(email) and PickUpAsync(email, code) are overloads, as the reference's pickUp is, and a pickup's refusal is PickupError (with RetryAfter) where the reference's one error holds it. RestoreByEmailAsync holds its refusal in three members (PickupError, NoKey, Error), where the reference's one error holds any of the words. Cancelled before its request is answered, a PickUpAsync call throws OperationCanceledException. .NET convention: a typed result for the reference's union, and a token every call takes.
ImportLicenseFileAsync answers a refusal (Error) and a key file's failed activation (ActivateError) in two members, where the reference's one error holds either word; a whole number in a licence file's claims past a long is held at its end. Idiomatic C#: each typed. Majors are compared as the reference compares doubles, so every build is judged alike.
A key is upper-cased with ToUpperInvariant (simple case mapping); JavaScript's toUpperCase maps fully (ß to SS). Trimming uses JavaScript's white space exactly. The key alphabet is ASCII, where they agree.
Test and Stalled are false rather than absent, and Entitlements empty rather than absent, with LicenseStatus comparing it by content; times are long milliseconds; HttpResult.Json is a JsonNode; IStorageAdapter.UpdateAsync and IFingerprinter.IdentifyAsync are default interface methods (returning false / null for "not implemented"). Idiomatic C# for the reference's optional members.
The default storage opens files sharing read, write and delete, as libuv opens every file on Windows. A reader of ours never refuses another process its own reads and writes. It does not let a rename over the file through: Windows refuses a rename over a file anyone holds open, whatever its sharing (access denied), and that is tried again as busy for about 1.5 s, as the reference tries EPERM again.

Dependencies

BouncyCastle.Cryptography (MIT) for Ed25519 verification; everything else is in .NET 8.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.8.1 0 10/21/2026
0.8.0 57 10/20/2026
0.7.0 71 10/16/2026
0.6.0 76 10/15/2026
0.5.0 66 10/14/2026
0.4.0 85 10/10/2026
0.3.0 83 10/9/2026
0.2.1 82 10/9/2026
0.1.0 89 10/6/2026