Epsitec.Briefcases.Sdk 2.8.1.2629

Prefix Reserved
dotnet add package Epsitec.Briefcases.Sdk --version 2.8.1.2629
                    
NuGet\Install-Package Epsitec.Briefcases.Sdk -Version 2.8.1.2629
                    
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="Epsitec.Briefcases.Sdk" Version="2.8.1.2629" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Epsitec.Briefcases.Sdk" Version="2.8.1.2629" />
                    
Directory.Packages.props
<PackageReference Include="Epsitec.Briefcases.Sdk" />
                    
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 Epsitec.Briefcases.Sdk --version 2.8.1.2629
                    
#r "nuget: Epsitec.Briefcases.Sdk, 2.8.1.2629"
                    
#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 Epsitec.Briefcases.Sdk@2.8.1.2629
                    
#: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=Epsitec.Briefcases.Sdk&version=2.8.1.2629
                    
Install as a Cake Addin
#tool nuget:?package=Epsitec.Briefcases.Sdk&version=2.8.1.2629
                    
Install as a Cake Tool

Epsitec.Briefcases.Sdk

Mobile-facing client SDK for Crésus Briefcases. Online-only, backed by a transient in-memory store: the server is the authoritative store; the client rehydrates the workspace tree from the server ledger at the start of a session and discards everything on exit.

Usage — connect, discover, open, write

var options = new BriefcasesSessionOptions (
    ServerUrl: "https://briefcases.example.com",
    UserId: userId,
    UserKeys: userKeys,
    AccessTokenProvider: () => app.GetAccessTokenAsync ());

await using var session = new BriefcasesSession (options);

await session.ConnectAsync ();

// Discover the briefcases you can reach, then resolve the ones you can open.
var known      = await session.GetKnownBriefcaseIdsAsync ();
var workspaces = await session.ListWorkspacesAsync (known);
var workspace  = await session.OpenWorkspaceAsync (workspaces[0]);

// Push into a chosen folder. The parent is required: use workspace.RootPath
// for the tree root, or a folder path returned by CreateFolderAsync.
var folder = await workspace.CreateFolderAsync (
    workspace.RootPath, "Receipts", tags: ["year:2026"]);

var path = await workspace.PushDocumentAsync (
    folder,
    new DocumentPayload (
        "coop-receipt-2026-06-12.jpg",
        [
            new StreamPayload ("image/jpeg", imageBytes),
            new StreamPayload ("x-edm/fulltext", ocrTextUtf8Bytes),
            new StreamPayload ("application/json", receiptJsonUtf8Bytes),
        ],
        Tags: ["client:42", "archived"]));

Tree-aware writes and tag queries

PushDocumentAsync and CreateFolderAsync both take a parent NodePath already present in the workspace view (workspace.RootPath qualifies). The new node lands at {parent}/n…. All argument validation (parent, file/folder name, streams, tags, sibling-name collision) is side-effect-free and completes before any blob is staged or any network call is made.

CreateFolderAsync is idempotent on the folder name: if a child folder of that name already exists, its path is returned unchanged. The returned path is immediately usable as a parent for further pushes or folders.

Editing existing nodes — update, rename, retag

Three additive mutations edit a node already in the tree without changing its path. Each refreshes the view and advances Generation on a real change, but is a no-op (nothing committed, Generation unchanged) when the requested state already matches:

// Replace a document's content (full-version replace): streams, name, and tags
// all come from the payload. A different FileName renames the node; different
// Tags replace them; an EMPTY Tags array CLEARS them. Echo the current name and
// tags to edit content only. A fresh per-version node key is minted (this is not
// an access-key rotation).
await workspace.UpdateDocumentAsync (
    path,
    new DocumentPayload (
        "coop-receipt-2026-06-12.jpg",
        [new StreamPayload ("image/jpeg", editedImageBytes)],
        Tags: ["client:42", "archived"]));

// Rename a file or folder (metadata-only; content untouched).
await workspace.RenameAsync (path, "coop-receipt-final.jpg");

// Replace a node's entire tag set (metadata-only). There is no incremental
// add/remove: read the node's current Tags from the view, compute the full
// intended set, and pass it. An empty set clears all tags.
await workspace.SetTagsAsync (path, ["client:42", "reviewed"]);

Reindex vs refresh for an EDM consumer. All three commit a real Modified the reader's DiffAsync reports (never None). The etag (ContentHashes vs PreviousContentHashes) tells the two apart: UpdateDocumentAsync re-stages content, so the etag changes ⇒ reindex (OCR/embedding re-extraction); RenameAsync / SetTagsAsync leave the content untouched, so the etag is equal ⇒ a cheap metadata refresh, no re-extraction.

Find nodes by tag through the FindByTag / FindByTagPrefix extension methods (one-off lookups); for several queries against one generation, call BuildTagIndex () once and reuse it.

var sub = await workspace.CreateFolderAsync (folder, "Q4");
await workspace.PushDocumentAsync (sub, document);

ImmutableArray<NodePath> tagged   = workspace.FindByTag ("client:42");
ImmutableArray<NodePath> byPrefix = workspace.FindByTagPrefix ("client:");

Discovery — reachable briefcases and their home servers

ListWorkspacesAsync and ListWorkspaceCardsAsync take the candidate BriefcaseIds to resolve — the server confirms membership per id and never enumerates a user's briefcases. GetKnownBriefcaseIdsAsync supplies those candidates:

var known      = await session.GetKnownBriefcaseIdsAsync ();
var workspaces = await session.ListWorkspacesAsync (known);        // members only
var cards      = await session.ListWorkspaceCardsAsync (known);    // + decrypted cards

By default the candidate set is what this client already holds locally. Point the session at a directory and GetKnownBriefcaseIdsAsync additionally returns the briefcases the directory catalog lists for the user — including ones this device has never opened — and each briefcase's server work is then routed to that briefcase's home server:

var options = new BriefcasesSessionOptions (
    ServerUrl: "https://home.example.com",       // this user's primary home server
    UserId: userId,
    UserKeys: userKeys,
    AccessTokenProvider: () => app.GetAccessTokenAsync ())
{
    DirectoryUrl                 = "https://directory.example.com",
    DirectoryAccessTokenProvider = () => app.GetDirectoryTokenAsync (),  // optional; falls back to AccessTokenProvider
    DirectoryCacheRootPath       = localCachePath,                       // optional; caches home URLs for a fast/offline start
};

With a directory configured, the session opens one connection per distinct home server and sends each briefcase's reads, writes, blobs, and leases to that briefcase's home. Discovery is availability-tiered: a warm home-URL cache keeps an already-started session working while the directory is briefly unreachable, and GetKnownBriefcaseIdsAsync degrades to the locally-known set rather than throwing. Without a DirectoryUrl the session is single-server and every operation targets ServerUrl — the pre-federation behaviour.

Creating a briefcase and inviting a user

A session is not limited to briefcases that already exist. CreateBriefcaseAsync commits the genesis transaction for a brand-new briefcase and returns it already opened, so it slots in exactly where OpenWorkspaceAsync would — the whole write surface above (folders, PushDocumentAsync, updates, RefreshAsync) applies unchanged:

await using var session = new BriefcasesSession (options);
await session.ConnectAsync ();

// Returns the opened IWorkspace for the new briefcase (no List+Open round-trip).
var workspace = await session.CreateBriefcaseAsync (
    new CreateBriefcaseOptions (ProbationMinutes: 2880));   // 48 h successor window

var path = await workspace.PushDocumentAsync (workspace.RootPath, document);
//  workspace.Id is the fresh BriefcaseId, usable by Open/Reader/Watch.

InviteAsync is a method on the opened workspace (it reads that workspace's live ledger state and advances its Generation like any other write). The invitee's public keys are not derived here — the host supplies the UserPublicKeys, typically from the Identity package's discovery call BriefcasesIdentity.GetPublicKeysAsync, which replaces the manual profile.json exchange:

// inviteeKeys came from the host's discovery step (e.g. Identity.GetPublicKeysAsync).
Invitation invite = await workspace.InviteAsync (inviteeUid, inviteeKeys);

// …or restrict the grant to a subtree already present in workspace.Nodes:
Invitation partial = await workspace.InviteAsync (
    inviteeUid, inviteeKeys,
    new InviteOptions (FullAccess: false, Path: folder, Message: "Receipts only"));

// invite.Pin is produced ONCE and never persisted by the SDK — share it
// out-of-band (voice, secure messaging). invite.InviteId identifies the invitation.

Full-access invitations require the caller to be the briefcase owner; a non-owner attempt throws InvalidOperationException.

On the invitee's side (their own session, their own keys), AcceptInvitationAsync verifies the PIN and returns the BriefcaseId — exactly what OpenWorkspaceAsync / OpenReaderAsync / WatchAsync consume. Accept does not auto-open: the invitee decides when to open (which performs the download sync), mirroring the List → Open separation:

ImmutableArray<PendingInvitation> pending = await session.ListInvitationsAsync ();

BriefcaseId bid = await session.AcceptInvitationAsync (pending[0].InviteId, pin);

var workspace = await session.OpenWorkspaceAsync (bid);
await workspace.RefreshAsync ();   // pull the inviter's committed generations

A wrong PIN throws InvitationPinException, which exposes RemainingAttemptCount so the caller can re-prompt within the server's rate-limited budget. The inviter withdraws a pending invitation with CancelInvitationAsync; the invitee turns one down with DeclineInvitationAsync — both leave it absent from a later ListInvitationsAsync.

The invitee key authenticity is the caller's trust boundary: the SDK invites whatever UserPublicKeys it is handed. Binding those keys to the right person is the discovery step's job (Identity.GetPublicKeysAsync validates the ml-kem-768 profile and the keyset subject).

Succession time capsules (data continuity if the owner disappears)

A briefcase owner can designate a member as successor: the briefcase access key is sealed for the successor's keys and additionally timelocked to a public randomness beacon (drand quicknet), so nobody — the server included — can open the escrow before 2 × ProbationMinutes from the seal. The successor gains access only after filing a claim, surviving the owner-vetoable probation window, and waiting out the timelock.

Owner side — designate, inspect, veto:

// Designate (also the manual re-seal: re-designating refreshes the timelock).
var outcome = await workspace.DesignateSuccessorAsync (successorUid);
Console.WriteLine ($"escrow timelocked until {outcome.EscrowUnlockTime:O}");

// Inspect: phase, claim deadlines, and the stored escrow's condition.
var status = await workspace.GetSuccessionStatusAsync ();

foreach (var s in status.Successors)
{
    // s.Phase: Designated / Pending / Accepted.
    // s.Escrow (owner only): Timelocked / Unusable / Absent (designation-only).
    // s.EscrowNeedsRefresh: re-seal recommended (stale or unusable).
    Console.WriteLine ($"{s.UserId}: {s.Phase}, escrow {s.Escrow}");
}

// Veto a pending claim (the successor stays designated and may re-request),
// or remove the successor entirely (drops the escrow):
await workspace.CancelSuccessionAsync (successorUid);
await workspace.RevokeSuccessorAsync (successorUid);

The escrow's timelock decays: it protects fully for one probation window past the owner's last (re-)seal. With AutoRefreshSuccessionEscrows (default true on BriefcasesSessionOptions), OpenWorkspaceAsync quietly re-seals stale or unusable escrows whenever the owner opens the workspace; deliberately escrow-less designations (Absent) are never touched. RefreshSuccessionEscrowsAsync runs the same pass explicitly.

Successor side — claim, wait out probation, accept and open:

await workspace.RequestSuccessionAsync ();   // probation starts (owner can veto)

// … after the probation window …
var accept = await workspace.AcceptSuccessionAsync ();

switch (accept.Result)
{
    case SuccessionAcceptResult.Opened:
        // Both capsule layers opened; THIS workspace now reads every node
        // (owner-equivalent read), e.g. to copy content into a new briefcase.
        break;

    case SuccessionAcceptResult.Locked:
        // Accept committed; the timelock round is not due yet. Retry after:
        Console.WriteLine ($"retry after {accept.UnlockTime:O}");
        break;

    case SuccessionAcceptResult.NoEscrow:        // ask the owner to (re-)provision
    case SuccessionAcceptResult.EscrowUnusable:  // idem (see accept.Detail)
    case SuccessionAcceptResult.RelaysUnreachable:  // durable; retry later
        break;
}

AcceptSuccessionAsync is idempotent: a Locked or RelaysUnreachable outcome is finished by calling it again — the claim itself is durable ledger state. Opening the timelock fetches the unlocking drand beacon over the public relays (verified locally against the pinned quicknet chain); inject an IDrandClient into the BriefcasesSession constructor to control that transport.

Workspace cards (member-readable listing metadata)

ListWorkspacesAsync returns opaque BriefcaseId values. To show a human-meaningful listing — workspace name, owner, an optional description, colour, icon and tags — without downloading each ledger, call ListWorkspaceCardsAsync. It returns one decrypted WorkspaceCardInfo per accessible briefcase that has a card and for which the session user holds a capsule; each is decrypted with the user's own private keys, with no ledger replay.

var known = await session.GetKnownBriefcaseIdsAsync ();
var cards = await session.ListWorkspaceCardsAsync (known);

foreach (var card in cards)
{
    Console.WriteLine ($"{card.BriefcaseId}: {card.WorkspaceName}");
    //  card.OwnerUserId is opaque — resolve the display name via auth.
    //  card.WorkspaceKind / WorkspaceDescription / WorkspaceColor / Tags are optional.
    //  card.WorkspaceIcon (+ WorkspaceIconMediaType) is a small icon, or empty.
}

Briefcases without a card, or for which the user has no capsule yet, are omitted from ListWorkspaceCardsAsync; cross-reference ListWorkspacesAsync to show those by bid. A card that is present but the user cannot decrypt (a stale capsule, a corrupt blob) is not dropped — it surfaces as a degraded WorkspaceCardInfo carrying only its BriefcaseId (empty WorkspaceName, so IsEmpty is true), so a single unreadable card never fails the whole listing. The card is a low-churn projection of an in-ledger anchor: a card that lags the ledger head is accepted by design, and the blind server never sees any card field in cleartext (the blob is AEAD-bound to its briefcase id under a per-card random key distributed only via per-member capsules).

Optional signature verification (defense in depth). WorkspaceCardInfo.SignatureVerified is tri-state: null (not checked), true (the owner's envelope signature verified), or false (it failed). It stays null unless you inject an IWorkspaceCardSignerKeyResolver into the session — the resolver returns the signer's public keys (resolved from the trusted auth directory by signer id + keyset hkid, which the server serves with each card), and the SDK verifies the envelope itself. Verification is best-effort and never fails the listing; the SDK takes no auth dependency, so the consumer that already holds the auth wiring supplies the resolver.

Reading: point-in-time read, generation diff, stream open

IWorkspace is the current tree plus add-only writes. To read a node as it stood at a past generation, to obtain what changed between two generations, or to read the decrypted bytes of one stream, open a read-only IBriefcaseReader:

await using var reader = await session.OpenReaderAsync (bid);

// Read a node (or subtree) at a generation. `at` of `Empty`/`Latest` ⇒ Head.
NodeView? node = await reader.ReadNodeAsync (
    NodePath.From ("/n0/n43"), at: GenerationId.From (1200), depth: NodeDepth.All);

// Diff the inclusive window [from, to], ordered by NodePath.
await foreach (var change in reader.DiffAsync (GenerationId.From (1), reader.Head))
{
    // change.Kind is Added / Modified / Removed (never None).
    // change.ContentHashes vs change.PreviousContentHashes distinguishes a real
    // reindex (content changed) from a metadata-only refresh (equal etags).
}

// Open the decrypted plaintext of one stream (default streamId 0 = primary).
await using var stream = await reader.OpenStreamAsync (
    NodePath.From ("/n0/n43"), at: GenerationId.Latest, streamId: 1);

Generation arguments (per parameter)

A diff is directional, so resolution differs by parameter — there is no single global rule:

Parameter Role Empty (0) Latest (-1)
ReadNodeAsync.at point "as of" Head Head
DiffAsync.to inclusive upper bound Head Head
DiffAsync.from inclusive lower bound 1 (earliest real generation) invalid ⇒ ArgumentException

A positive value selects that generation; a value past Head (after resolution) throws ArgumentException. The window is inclusive [from, to]: from == to yields the changes at that single generation. Past generations are reconstructed by rebuilding from genesis, so a read or diff returns the tree as it stood — never a later snapshot's state.

Lifetime and removal semantics

  • OpenStreamAsync downloads lazily as the returned stream is read (no full pre-buffering) and decrypts with the key you already hold. You must dispose the returned stream, and it is valid only while the reader and its session are alive (reading after disposal throws ObjectDisposedException).
  • Removed covers deletion, crypto-erase, and access loss: a node visible at from but purged from the to snapshot by an ACL shrink is reported Removed so the consumer can drop its local index entry. A relocation surfaces as Removed at the old path + Added at the new path (the model has no move).

Change-data-capture loop (shell → local index)

// Catch up from the persisted watermark to Head, one resumable pass.
await foreach (var change in reader.DiffAsync (watermark, reader.Head, scope))
{
    switch (change.Kind)
    {
        case NodeChangeKind.Added:
        case NodeChangeKind.Modified:
            // reindex when change.ContentHashes != change.PreviousContentHashes,
            // else a cheap metadata refresh; fetch bytes via OpenStreamAsync.
            break;
        case NodeChangeKind.Removed:
            // drop the local index entry.
            break;
    }
}

// Persist the next watermark = to + 1 so the inclusive window never reprocesses
// the boundary generation. The CLI `diff` cursor line carries this `next`.
watermark = reader.Head.Next;

Staying in sync (refresh, watch, continuous CDC)

IWorkspace and IBriefcaseReader open as a snapshot at the current server head. To observe changes committed by other clients/devices without re-opening, use the pull (RefreshAsync), push (WatchAsync), and the high-level fusion (WatchChangesAsync).

Continuous change stream (fire-and-forget)

WatchChangesAsync drains the catch-up from your cursor, then streams every change as generations arrive — until cancelled. At-least-once and idempotent for the shell (Added/Modified upsert, Removed drop); it persists no watermark itself.

await using var reader = await session.OpenReaderAsync (bid);

await foreach (var change in reader.WatchChangesAsync (from: cursor.Next, ct: ct))
{
    await ApplyToIndex (reader, change, ct);   // index / reindex / refresh / drop
}

Manual composition for a precise, crash-resumable cursor

RefreshAsync returns the inclusive GenerationSpan that arrived (To is inclusive, like DiffAsync), so DiffAsync (span.From, span.To) reproduces the delta exactly. Persist span.To and resume at span.To.Next.

await foreach (var update in session.WatchAsync (bid, ct))   // push notification
{
    var span = await reader.RefreshAsync (ct);               // pull to the new head
    if (span.IsEmpty) { continue; }

    await foreach (var change in reader.DiffAsync (span.From, span.To, scope, ct))
    {
        await ApplyToIndex (reader, change, ct);
    }

    Persist (span.To);                                       // crash-resumable cursor
}

A poll-only variant skips WatchAsync and calls RefreshAsync on a timer. A failed/offline refresh throws InvalidOperationException (never a silent empty span); a transient connection drop does not fault WatchAsync — the next refresh recovers any missed notification.

Optimistic-concurrency writes (lost-update protection)

UpdateDocumentAsync, RenameAsync, SetTagsAsync, and DeleteAsync are optimistically concurrent: if the targeted node changed under you (another client committed a new version, or deleted it) between your view being taken and the commit, the write throws WorkspaceStaleException instead of silently clobbering. Recover by refreshing, re-resolving the node, and retrying against the new base.

try
{
    await workspace.UpdateDocumentAsync (path, payload, ct);
}
catch (WorkspaceStaleException ex)
{
    await workspace.RefreshAsync (ct);                        // pull the concurrent version
    var fresh = workspace.Nodes.First (n => n.Path == ex.Path);
    payload = Reconcile (payload, fresh);                     // re-apply intent on the fresh base
    await workspace.UpdateDocumentAsync (path, payload, ct);  // retry; ex.ActualBase == Empty ⇒ remote delete
}

PushDocumentAsync / CreateFolderAsync are unaffected — they allocate a fresh child path, so there is no shared target to race on.

Importing keys from a QR code

A desktop that already holds the user's keys can show a QR pairing code (briefcases export-keys in the CLI). The mobile app scans it and turns it into UserKeys: the SDK decrypts the embedded recovery phrase with the user's password and re-derives the key set locally. The secret never travels in cleartext, and uid and password are supplied by the app — never by the QR.

// uid is the JWT `sub` from your auth flow; password is entered by the user.
var credentials = BriefcasesKeyImport.FromQrPayload (scannedText, uid, password);

var options = BriefcasesSessionOptions.FromCredentials (
    credentials,
    () => app.GetAccessTokenAsync ());

await using var session = new BriefcasesSession (options);
await session.ConnectAsync ();

// credentials.Bid is an optional hint: open it directly when present.
IWorkspace workspace;
if (credentials.Bid is { } bid)
{
    workspace = await session.OpenWorkspaceAsync (bid);
}
else
{
    var known = await session.GetKnownBriefcaseIdsAsync ();
    workspace = await session.OpenWorkspaceAsync (
        (await session.ListWorkspacesAsync (known))[0]);
}

FromQrPayload throws a typed KeyImportException:

  • KeyImportFormatException — not a Briefcases pairing code, or malformed.
  • KeyImportIdentityMismatchException — the code was issued for another user (checked before any password work).
  • KeyImportPasswordException — wrong password, or a tampered code.

In a live camera loop, call KeyImportPayload.IsPairingPayload (text) first to skip foreign QR codes cheaply before attempting an import.

Integration seams the host app owns

  1. JWT token provider — the SDK never logs in. The app's login flow acquires the access token and hands the SDK a Func<Task<string?>>; the provider is consulted for every request (SignalR and blob HTTP), so token rotation is picked up automatically.
  2. User keys — the SDK never derives or persists key material. The app supplies the decrypted UserPublicPrivateKeys. The one exception is BriefcasesKeyImport.FromQrPayload (see above), a one-shot provisioning call that re-derives them from a scanned QR pairing code; the SDK still never persists them.
  3. Stream conventions — the SDK treats each stream as opaque bytes plus a media type; StreamId is the position in the payload. Which stream carries an image, full text, or structured JSON — and any wire format inside a stream (like the receipt JSON above) — is the app's contract, not the SDK's.

Lifecycle

BriefcasesSession is IAsyncDisposable. ConnectAsync is single-use; listing and opening require a connected session. Workspaces refresh after each successful write (PushDocumentAsync, CreateFolderAsync, UpdateDocumentAsync, RenameAsync, SetTagsAsync, DeleteAsync); changes made by other clients are observed by calling RefreshAsync (or watched live — see Staying in sync above), without re-opening. After disposal, session and workspace methods throw ObjectDisposedException.

Reclaiming memory over a long session

The default in-memory store keeps every uploaded (encrypted) blob in memory for the session's lifetime. A push-only client never reads those bytes back, so for a long-lived session you can reclaim that memory deterministically: inject your own InMemoryBriefcaseStorage and call ClearBlobs (bid) after a successful push. The ledger is preserved, so the workspace stays fully usable.

var storage = new InMemoryBriefcaseStorage ();
await using var session = new BriefcasesSession (options, storage);

await session.ConnectAsync ();
var workspace = await session.OpenWorkspaceAsync (bid);

await workspace.PushDocumentAsync (workspace.RootPath, document);
storage.ClearBlobs (bid);   // the upload succeeded; drop the local blob copies
Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  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 (1)

Showing the top 1 NuGet packages that depend on Epsitec.Briefcases.Sdk:

Package Downloads
Epsitec.Briefcases.Identity

User identity lifecycle for Crésus Briefcases: key provisioning, at-rest key store, login, and keyset registration

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
2.8.1.2629 37 7/15/2026
2.8.0.2629 43 7/15/2026
2.7.3.2629 39 7/14/2026
2.7.2.2629 42 7/14/2026
2.7.1.2629 48 7/14/2026
2.7.0.2629 40 7/14/2026
2.6.2.2629 42 7/14/2026
2.6.1.2629 40 7/14/2026
2.6.0.2629 40 7/14/2026
2.5.0.2628 52 7/12/2026
2.3.0.2628 60 7/10/2026
2.2.0.2628 59 7/8/2026
2.1.6.2627 63 7/3/2026
2.1.5.2627 65 7/2/2026
2.1.4.2627 59 7/2/2026
2.1.3.2627 65 6/30/2026
2.1.2.2627 61 6/29/2026
2.1.1.2627 66 6/29/2026
2.1.0.2627 63 6/29/2026
2.0.0.2626 67 6/28/2026
Loading failed