feat(info): report session seats as used/limit with real failure reasons

`cloakbrowser info` printed a bare seat count with no denominator, so a
customer could not tell whether they were at capacity (#513). It also
collapsed six distinct outcomes into one "unavailable": unreachable,
timeout, invalid key, inactive licence, rate limited, and the server
reporting the count as unknown while degraded.

Adds SessionSeats (active, limit, state, reason) and getSessionSeats to
all three wrappers; get_active_session_count keeps its signature and
delegates. The limit is read from the server, never derived from the plan
name, and falls back to the old bare count when absent.

Python, JS and .NET renderers verified byte-identical.
This commit is contained in:
CloakHQ
2026-08-21 20:14:40 +02:00
parent be75a9b9e4
commit c2421da06d
13 changed files with 1001 additions and 86 deletions
+39 -7
View File
@@ -265,11 +265,17 @@ def _collect_diagnostics(quick: bool, proxy: str | None = None) -> dict:
# latest-version check below: --quick keeps `info` network-free, and a free
# tier holds no seats. Never cached (a cached count is a wrong count).
if entitled_pro and not quick:
from .license import get_active_session_count, resolve_license_key
from .license import get_session_seats, resolve_license_key
key = resolve_license_key(None)
if key:
license_info["sessions"] = {"active": get_active_session_count(key)}
seats = get_session_seats(key)
license_info["sessions"] = {
"active": seats.active,
"limit": seats.limit,
"state": seats.state,
"reason": seats.reason,
}
from .config import get_platform_tag
@@ -353,6 +359,36 @@ def _collect_diagnostics(quick: bool, proxy: str | None = None) -> dict:
return diag
# Server error codes → the words a customer can act on. Anything unrecognised is
# printed verbatim rather than swallowed, so a new server code still says something.
_SEAT_DENIAL_REASONS = {
"invalid_key": "invalid key",
"license_inactive": "license inactive",
"rate_limited": "rate limited",
}
def _format_seats(sessions: dict) -> str:
"""Render the seat lookup. Kept identical in the JS and .NET wrappers."""
state = sessions.get("state", "ok")
if state == "unreachable":
return "unavailable (cannot reach cloakbrowser.dev)"
if state == "denied":
reason = sessions.get("reason") or "refused"
return f"unavailable ({_SEAT_DENIAL_REASONS.get(reason, reason)})"
if state != "ok" or sessions.get("active") is None:
# The server is up and the key is fine — it just cannot count right now.
return "unavailable (server cannot report seats right now)"
active = sessions["active"]
limit = sessions.get("limit")
if limit is None:
# No cap to show (unlimited, unrecognised plan, or a server predating the
# field). Fall back to the bare count — never print "N/unknown".
return f"{active} seat{'' if active == 1 else 's'} in use"
return f"{active}/{limit} in use"
def _print_diagnostics(diag: dict) -> None:
"""Render the diagnostics dict as a human-readable report."""
env = diag["environment"]
@@ -459,11 +495,7 @@ def _print_diagnostics(diag: dict) -> None:
print(f"License: {tier}")
if "sessions" in lic:
active = lic["sessions"]["active"]
if active is None:
print("Sessions: unavailable")
else:
print(f"Sessions: {active} seat{'' if active == 1 else 's'} in use")
print(f"Sessions: {_format_seats(lic['sessions'])}")
geoip = diag["geoip"]
db_line = "present" if geoip["db_present"] else "not downloaded (optional)"
+76 -10
View File
@@ -31,6 +31,29 @@ LICENSE_CACHE_TTL = 86400 # 24 hours
PRO_VERSION_CHECK_INTERVAL = 3600 # 1 hour
@dataclass
class SessionSeats:
"""Result of a seat lookup: the count, the cap it counts against, and the reason
either is missing.
state:
"ok" active is a real number (0 is a real answer, not an error)
"unreachable" never got an answer — DNS, refused, timeout, TLS
"denied" the server refused; `reason` carries its error code
"unknown" the server is up and the key is fine, but it cannot count
right now (leaseless mode, or its seat store is unreachable)
limit is None whenever the server declined to state a cap: unlimited, an
unrecognised plan, or a server too old to send the field. Callers must fall back
to the bare count, never invent a denominator.
"""
active: int | None = None
limit: int | None = None
state: str = "ok"
reason: str | None = None
@dataclass
class LicenseInfo:
valid: bool
@@ -491,13 +514,16 @@ def get_pro_latest_version(release_channel: str | None = None) -> str | None:
return release.version if release else None
def get_active_session_count(license_key: str) -> int | None:
"""How many concurrent sessions (seats) this license is holding right now.
def get_session_seats(license_key: str) -> SessionSeats:
"""Seats held right now, the cap they count against, and why either is missing.
Deliberately NOT cached: a cached seat count is a wrong seat count. Returns
None when the number is unknown — the server couldn't be reached, or it
reported the count as unavailable (it does that instead of a false 0 while
running in leaseless mode). Callers render None as "unavailable".
Deliberately NOT cached: a cached seat count is a wrong seat count.
Six different things can stop us answering — no route to the host, a timeout, a
403 for a dead key, a 429, the server reporting the count as unknown in leaseless
mode, and its seat store being unreachable. They used to collapse into one bare
None, so `info` printed the same "unavailable" for "your key is dead" and "our
backend is degraded, you are fine". `state` keeps them apart.
"""
try:
resp = httpx.post(
@@ -505,11 +531,51 @@ def get_active_session_count(license_key: str) -> int | None:
json={"license_key": license_key},
timeout=10.0,
)
resp.raise_for_status()
return resp.json().get("active")
except Exception as e:
logger.debug("Session count lookup failed: %s", e)
return None
# Never reached the server: DNS, refused, timed out, TLS.
logger.debug("Session count lookup unreachable: %s", e)
return SessionSeats(state="unreachable")
if resp.status_code >= 400:
# The server answered, and the answer was a refusal. Its `error` field is the
# actionable part (invalid_key / license_inactive / rate_limited); fall back to
# the status when the body is missing or not JSON.
reason = None
try:
reason = resp.json().get("error")
except Exception:
pass
reason = reason or f"HTTP {resp.status_code}"
logger.debug("Session count denied: %s", reason)
return SessionSeats(state="denied", reason=reason)
try:
body = resp.json()
except Exception as e:
logger.debug("Session count body unparseable: %s", e)
return SessionSeats(state="unknown")
active = body.get("active")
if not isinstance(active, int) or isinstance(active, bool):
# 200 with active=null is the server saying "up, your key is fine, but I
# genuinely cannot count right now" — deliberate, so it never reports a false 0.
return SessionSeats(state="unknown")
limit = body.get("limit")
if not isinstance(limit, int) or isinstance(limit, bool):
# Absent (older server) or null (unlimited / unknown plan). Callers fall back
# to the bare count rather than printing a made-up denominator.
limit = None
return SessionSeats(active=active, limit=limit, state="ok")
def get_active_session_count(license_key: str) -> int | None:
"""How many concurrent sessions (seats) this license is holding right now.
Kept for callers outside `info` that only want the number. Prefer
get_session_seats(), which also carries the cap and the reason a lookup failed.
"""
return get_session_seats(license_key).active
def _read_cache(
+1 -4
View File
@@ -235,10 +235,7 @@ static void PrintDiagnostics(Dictionary<string, object?> diag)
if (lic.TryGetValue("sessions", out var sessionsObj) && sessionsObj is Dictionary<string, object?> sessions)
{
var active = sessions["active"] as int?;
Console.WriteLine(active is null
? "Sessions: unavailable"
: $"Sessions: {active} seat{(active == 1 ? "" : "s")} in use");
Console.WriteLine($"Sessions: {Diagnostics.FormatSeats(sessions)}");
}
var geoip = (Dictionary<string, object?>)diag["geoip"]!;
+44 -1
View File
@@ -35,9 +35,13 @@ internal static class Diagnostics
string? sessionKey = License.ResolveLicenseKey();
if (!string.IsNullOrEmpty(sessionKey))
{
var seats = License.GetSessionSeats(sessionKey!);
license["sessions"] = new Dictionary<string, object?>
{
["active"] = License.GetActiveSessionCount(sessionKey!),
["active"] = seats.Active,
["limit"] = seats.Limit,
["state"] = seats.State,
["reason"] = seats.Reason,
};
}
}
@@ -316,4 +320,43 @@ internal static class Diagnostics
catch { /* best-effort */ }
return missing;
}
// Server error codes -> the words a customer can act on. Anything unrecognised is
// printed verbatim rather than swallowed, so a new server code still says something.
private static string HumaniseSeatDenial(string reason) => reason switch
{
"invalid_key" => "invalid key",
"license_inactive" => "license inactive",
"rate_limited" => "rate limited",
_ => reason,
};
/// <summary>
/// Render the seat lookup for the CLI's "Sessions:" line. Kept byte-identical to
/// the Python (_format_seats) and JS (formatSeats) renderers.
/// </summary>
/// <remarks>
/// Lives here rather than in the CLI because Program.cs uses top-level statements,
/// whose local functions cannot be reached from the test assembly.
/// </remarks>
internal static string FormatSeats(Dictionary<string, object?> sessions)
{
var state = sessions.GetValueOrDefault("state") as string ?? "ok";
if (state == "unreachable")
return "unavailable (cannot reach cloakbrowser.dev)";
if (state == "denied")
{
var reason = sessions.GetValueOrDefault("reason") as string;
return $"unavailable ({HumaniseSeatDenial(string.IsNullOrEmpty(reason) ? "refused" : reason)})";
}
if (state != "ok" || sessions.GetValueOrDefault("active") is not int active)
// The server is up and the key is fine - it just cannot count right now.
return "unavailable (server cannot report seats right now)";
if (sessions.GetValueOrDefault("limit") is not int limit)
// No cap to show (unlimited, unrecognised plan, or a server predating the
// field). Fall back to the bare count - never print "N/unknown".
return $"{active} seat{(active == 1 ? "" : "s")} in use";
return $"{active}/{limit} in use";
}
}
+132 -15
View File
@@ -11,6 +11,32 @@ namespace CloakBrowser;
/// </summary>
public sealed record LicenseInfo(bool Valid, string Plan, string? Expires);
/// <summary>
/// Result of a seat lookup: the count, the cap it counts against, and the reason
/// either is missing. Mirrors the Python <c>SessionSeats</c> dataclass / JS
/// <c>SessionSeats</c> interface.
/// </summary>
/// <remarks>
/// <see cref="State"/> is one of:
/// <list type="bullet">
/// <item><c>"ok"</c> — Active is a real number (0 is a real answer, not an error)</item>
/// <item><c>"unreachable"</c> — never got an answer: DNS, refused, timeout, TLS</item>
/// <item><c>"denied"</c> — the server refused; <see cref="Reason"/> carries its error code</item>
/// <item><c>"unknown"</c> — the server is up and the key is fine, but it cannot count
/// right now (leaseless mode, or its seat store is unreachable)</item>
/// </list>
/// <see cref="Limit"/> is null whenever the server declined to state a cap: unlimited,
/// an unrecognised plan, or a server too old to send the field. Callers must fall back
/// to the bare count, never invent a denominator.
/// </remarks>
public sealed record SessionSeats
{
public int? Active { get; init; }
public int? Limit { get; init; }
public string State { get; init; } = "ok";
public string? Reason { get; init; }
}
/// <summary>Server-resolved Pro release for the requested channel and platform.</summary>
public sealed record ProReleaseInfo(
string Version, string RequestedChannel, string ResolvedChannel, bool Fallback);
@@ -257,6 +283,9 @@ public static class License
/// <summary>Overrides the live seat-count lookup for tests. Null -> real HTTP.</summary>
internal static Func<string, int?>? ActiveSessionCountOverride;
/// <summary>Overrides the full seat lookup (count + cap + state) for tests. Null -> real HTTP.</summary>
internal static Func<string, SessionSeats>? SessionSeatsOverride;
/// <summary>
/// Resolves the user home directory used to detect the default
/// <c>~/.cloakbrowser</c> cache path. A test seam mirroring the Python
@@ -614,38 +643,126 @@ public static class License
GetProLatestRelease(releaseChannel)?.Version;
/// <summary>
/// How many concurrent sessions (seats) this license is holding right now.
/// Seats held right now, the cap they count against, and why either is missing.
/// </summary>
/// <remarks>
/// Deliberately NOT cached: a cached seat count is a wrong seat count. Returns
/// null when the number is unknown — the server couldn't be reached, or it
/// reported the count as unavailable (it does that instead of a false 0 while
/// running in leaseless mode). Callers render null as "unavailable".
/// Deliberately NOT cached: a cached seat count is a wrong seat count.
/// <para>
/// Six different things can stop us answering — no route to the host, a timeout,
/// a 403 for a dead key, a 429, the server reporting the count as unknown in
/// leaseless mode, and its seat store being unreachable. They used to collapse
/// into one bare null, so <c>info</c> printed the same "unavailable" for "your
/// key is dead" and "our backend is degraded, you are fine". <see
/// cref="SessionSeats.State"/> keeps them apart.
/// </para>
/// </remarks>
public static int? GetActiveSessionCount(string licenseKey)
public static SessionSeats GetSessionSeats(string licenseKey)
{
if (SessionSeatsOverride != null)
return SessionSeatsOverride(licenseKey);
if (ActiveSessionCountOverride != null)
return ActiveSessionCountOverride(licenseKey);
{
// Older tests (and any external caller) seam in only the number.
var only = ActiveSessionCountOverride(licenseKey);
return only is null
? new SessionSeats { State = "unknown" }
: new SessionSeats { Active = only, State = "ok" };
}
HttpResponseMessage resp;
try
{
var body = new StringContent(
JsonSerializer.Serialize(new Dictionary<string, string> { ["license_key"] = licenseKey }),
Encoding.UTF8, "application/json");
using var resp = Http.PostAsync(SessionCountUrl, body).GetAwaiter().GetResult();
resp.EnsureSuccessStatusCode();
var json = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult();
using var doc = JsonDocument.Parse(json);
return doc.RootElement.TryGetProperty("active", out var a) && a.ValueKind == JsonValueKind.Number
? a.GetInt32() : null;
resp = Http.PostAsync(SessionCountUrl, body).GetAwaiter().GetResult();
}
catch (Exception ex)
{
CloakLog.Debug("Session count lookup failed: {0}", ex.Message);
return null;
// Never reached the server: DNS, refused, timed out, TLS.
CloakLog.Debug("Session count lookup unreachable: {0}", ex.Message);
return new SessionSeats { State = "unreachable" };
}
using (resp)
{
string json;
try
{
json = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult();
}
catch (Exception ex)
{
CloakLog.Debug("Session count body unreadable: {0}", ex.Message);
return new SessionSeats { State = resp.IsSuccessStatusCode ? "unknown" : "denied",
Reason = resp.IsSuccessStatusCode ? null : $"HTTP {(int)resp.StatusCode}" };
}
if (!resp.IsSuccessStatusCode)
{
// The server answered, and the answer was a refusal. Its `error` field is
// the actionable part (invalid_key / license_inactive / rate_limited);
// fall back to the status when the body is missing or not JSON.
string? reason = null;
try
{
using var errDoc = JsonDocument.Parse(json);
if (errDoc.RootElement.TryGetProperty("error", out var e) && e.ValueKind == JsonValueKind.String)
reason = e.GetString();
}
catch (JsonException)
{
// fall through to the status
}
reason ??= $"HTTP {(int)resp.StatusCode}";
CloakLog.Debug("Session count denied: {0}", reason);
return new SessionSeats { State = "denied", Reason = reason };
}
JsonDocument doc;
try
{
doc = JsonDocument.Parse(json);
}
catch (JsonException ex)
{
CloakLog.Debug("Session count body unparseable: {0}", ex.Message);
return new SessionSeats { State = "unknown" };
}
using (doc)
{
if (!doc.RootElement.TryGetProperty("active", out var a) || a.ValueKind != JsonValueKind.Number)
{
// 200 with active=null is the server saying "up, your key is fine, but
// I genuinely cannot count right now" — deliberate, so it never
// reports a false 0.
return new SessionSeats { State = "unknown" };
}
// limit absent (older server) or null (unlimited / unknown plan): callers
// fall back to the bare count rather than printing a made-up denominator.
int? limit = doc.RootElement.TryGetProperty("limit", out var l) && l.ValueKind == JsonValueKind.Number
? l.GetInt32() : null;
return new SessionSeats { Active = a.GetInt32(), Limit = limit, State = "ok" };
}
}
}
/// <summary>
/// How many concurrent sessions (seats) this license is holding right now.
/// </summary>
/// <remarks>
/// Kept for callers outside <c>info</c> that only want the number. Prefer
/// <see cref="GetSessionSeats"/>, which also carries the cap and the reason a
/// lookup failed.
/// </remarks>
public static int? GetActiveSessionCount(string licenseKey) =>
// Straight through GetSessionSeats, which honours both override seams itself.
// Re-checking ActiveSessionCountOverride here would give the two methods
// different precedence if a test ever set both, and disagree about the count.
GetSessionSeats(licenseKey).Active;
// -----------------------------------------------------------------------
// Cache helpers (atomic write via tmp+rename, like Python/JS).
// -----------------------------------------------------------------------
@@ -119,4 +119,88 @@ public class DiagnosticsTests
try { Directory.Delete(tmp, recursive: true); } catch { /* best-effort */ }
}
}
// =======================================================================
// FormatSeats — the CLI's "Sessions:" line
//
// Kept byte-identical to the Python (_format_seats) and JS (formatSeats)
// renderers: the three wrappers must print the same line for the same state.
// =======================================================================
private static Dictionary<string, object?> Seats(
int? active = null, int? limit = null, string state = "ok", string? reason = null)
{
var d = new Dictionary<string, object?> { ["state"] = state, ["reason"] = reason };
if (active is not null) d["active"] = active.Value;
if (limit is not null) d["limit"] = limit.Value;
return d;
}
[Fact]
public void FormatSeats_shows_used_over_limit()
{
// The point of the change: a scale-plan customer can see they are nowhere
// near the ceiling (or right on it) instead of reading a bare number.
Assert.Equal("8/2000 in use", Diagnostics.FormatSeats(Seats(active: 8, limit: 2000)));
}
[Fact]
public void FormatSeats_falls_back_when_the_server_sends_no_limit()
{
// Older server, unlimited licence, or an unrecognised plan. Never "8/unknown".
Assert.Equal("8 seats in use", Diagnostics.FormatSeats(Seats(active: 8)));
}
[Fact]
public void FormatSeats_uses_the_singular_in_the_fallback()
{
Assert.Equal("1 seat in use", Diagnostics.FormatSeats(Seats(active: 1)));
}
[Fact]
public void FormatSeats_shows_the_limit_for_a_single_seat()
{
// A free key holds exactly one seat - the cohort most likely to hit its cap.
Assert.Equal("1/1 in use", Diagnostics.FormatSeats(Seats(active: 1, limit: 1)));
}
[Fact]
public void FormatSeats_keeps_zero_as_a_real_answer()
{
Assert.Equal("0/5 in use", Diagnostics.FormatSeats(Seats(active: 0, limit: 5)));
}
[Fact]
public void FormatSeats_says_so_when_the_server_is_unreachable()
{
Assert.Equal("unavailable (cannot reach cloakbrowser.dev)",
Diagnostics.FormatSeats(Seats(state: "unreachable")));
}
[Theory]
[InlineData("license_inactive", "license inactive")]
[InlineData("invalid_key", "invalid key")]
[InlineData("rate_limited", "rate limited")]
public void FormatSeats_spells_out_the_denial(string code, string shown)
{
// These used to be one string. A dead key and a healthy key behind a
// degraded backend must not read identically.
Assert.Equal($"unavailable ({shown})",
Diagnostics.FormatSeats(Seats(state: "denied", reason: code)));
}
[Fact]
public void FormatSeats_passes_an_unrecognised_denial_reason_through()
{
Assert.Equal("unavailable (some_new_code)",
Diagnostics.FormatSeats(Seats(state: "denied", reason: "some_new_code")));
}
[Fact]
public void FormatSeats_degraded_backend_does_not_read_like_a_licence_problem()
{
// Leaseless mode / seat store down: the key is fine, nothing for them to do.
Assert.Equal("unavailable (server cannot report seats right now)",
Diagnostics.FormatSeats(Seats(state: "unknown")));
}
}
@@ -564,6 +564,137 @@ public class LicenseTests : IDisposable
Assert.Equal(2, handler.Calls);
}
// =======================================================================
// GetSessionSeats — the six failure paths that used to collapse into one
// bare null (count, cap, and the reason either is missing)
// =======================================================================
[Fact]
public void SessionSeats_reports_count_and_limit()
{
WithSessionCountHttp(
new SessionCountHandler("{\"valid\":true,\"active\":8,\"limit\":2000}"),
() =>
{
var seats = License.GetSessionSeats("cb_key");
Assert.Equal("ok", seats.State);
Assert.Equal(8, seats.Active);
Assert.Equal(2000, seats.Limit);
});
}
[Fact]
public void SessionSeats_missing_limit_is_null_not_an_error()
{
// A server predating the field still yields a usable count.
WithSessionCountHttp(
new SessionCountHandler("{\"valid\":true,\"active\":8}"),
() =>
{
var seats = License.GetSessionSeats("cb_key");
Assert.Equal("ok", seats.State);
Assert.Equal(8, seats.Active);
Assert.Null(seats.Limit);
});
}
[Fact]
public void SessionSeats_null_limit_is_null()
{
// Unlimited licence or unrecognised plan — the server says so explicitly.
WithSessionCountHttp(
new SessionCountHandler("{\"valid\":true,\"active\":3,\"limit\":null}"),
() => Assert.Null(License.GetSessionSeats("cb_key").Limit));
}
[Fact]
public void SessionSeats_zero_is_a_real_answer()
{
WithSessionCountHttp(
new SessionCountHandler("{\"valid\":true,\"active\":0,\"limit\":5}"),
() =>
{
var seats = License.GetSessionSeats("cb_key");
Assert.Equal("ok", seats.State);
Assert.Equal(0, seats.Active);
});
}
[Fact]
public void SessionSeats_network_failure_is_unreachable()
{
// info is a diagnostic — it degrades, it never throws out of the command.
var original = License.Http;
License.Http = new HttpClient(new ThrowingHandler());
try
{
var seats = License.GetSessionSeats("cb_key");
Assert.Equal("unreachable", seats.State);
Assert.Null(seats.Active);
}
finally
{
License.Http.Dispose();
License.Http = original;
}
}
[Theory]
[InlineData("license_inactive", HttpStatusCode.Forbidden)]
[InlineData("invalid_key", HttpStatusCode.Forbidden)]
[InlineData("rate_limited", HttpStatusCode.TooManyRequests)]
public void SessionSeats_denial_carries_the_server_reason(string code, HttpStatusCode status)
{
WithSessionCountHttp(
new SessionCountHandler($"{{\"valid\":false,\"error\":\"{code}\"}}", status),
() =>
{
var seats = License.GetSessionSeats("cb_key");
Assert.Equal("denied", seats.State);
Assert.Equal(code, seats.Reason);
});
}
[Fact]
public void SessionSeats_denial_without_a_body_falls_back_to_the_status()
{
WithSessionCountHttp(
new SessionCountHandler("not json", HttpStatusCode.InternalServerError),
() => Assert.Equal("HTTP 500", License.GetSessionSeats("cb_key").Reason));
}
[Fact]
public void SessionSeats_server_reported_unavailable_is_unknown_not_denied()
{
// Leaseless mode: 200, key is fine, the server just cannot count. This is
// the distinction the old single null destroyed.
WithSessionCountHttp(
new SessionCountHandler("{\"valid\":true,\"active\":null,\"limit\":null}"),
() =>
{
var seats = License.GetSessionSeats("cb_key");
Assert.Equal("unknown", seats.State);
Assert.Null(seats.Active);
});
}
[Fact]
public void SessionSeats_unparseable_body_is_unknown()
{
WithSessionCountHttp(
new SessionCountHandler("not json"),
() => Assert.Equal("unknown", License.GetSessionSeats("cb_key").State));
}
[Fact]
public void SessionSeats_old_helper_still_returns_the_bare_count()
{
// GetActiveSessionCount is shipped public API — it must keep behaving.
WithSessionCountHttp(
new SessionCountHandler("{\"valid\":true,\"active\":4,\"limit\":20}"),
() => Assert.Equal(4, License.GetActiveSessionCount("cb_key")));
}
// =======================================================================
// Config Pro paths
// =======================================================================
+46 -8
View File
@@ -26,7 +26,7 @@ import {
} from "./config.js";
import { countFontsPresent, WINDOWS_FONT_TELLS, OFFICE_FONT_TELLS } from "./fonts.js";
import { resolveProxyGeo } from "./geoip.js";
import { resolveLicenseKey, validateLicense, getProLatestRelease, getActiveSessionCount, type LicenseInfo } from "./license.js";
import { resolveLicenseKey, validateLicense, getProLatestRelease, getSessionSeats, type LicenseInfo } from "./license.js";
import { execFileSync, spawn } from "node:child_process";
import { createRequire } from "node:module";
import { pathToFileURL } from "node:url";
@@ -233,7 +233,13 @@ export async function collectDiagnostics(
if (entitledPro && !quick) {
const key = resolveLicenseKey();
if (key) {
license.sessions = { active: await getActiveSessionCount(key) };
const seats = await getSessionSeats(key);
license.sessions = {
active: seats.active,
limit: seats.limit,
state: seats.state,
reason: seats.reason,
};
}
}
@@ -302,6 +308,43 @@ export async function collectDiagnostics(
return diag;
}
interface SeatSection {
active?: number | null;
limit?: number | null;
state?: string;
reason?: string | null;
}
// Server error codes → the words a customer can act on. Anything unrecognised is
// printed verbatim rather than swallowed, so a new server code still says something.
const SEAT_DENIAL_REASONS: Record<string, string> = {
invalid_key: "invalid key",
license_inactive: "license inactive",
rate_limited: "rate limited",
};
/** Render the seat lookup. Kept identical in the Python and .NET wrappers. */
export function formatSeats(sessions: SeatSection): string {
const state = sessions.state ?? "ok";
if (state === "unreachable") return "unavailable (cannot reach cloakbrowser.dev)";
if (state === "denied") {
const reason = sessions.reason || "refused";
return `unavailable (${SEAT_DENIAL_REASONS[reason] ?? reason})`;
}
if (state !== "ok" || typeof sessions.active !== "number") {
// The server is up and the key is fine — it just cannot count right now.
return "unavailable (server cannot report seats right now)";
}
const active = sessions.active;
if (typeof sessions.limit !== "number") {
// No cap to show (unlimited, unrecognised plan, or a server predating the
// field). Fall back to the bare count — never print "N/unknown".
return `${active} seat${active === 1 ? "" : "s"} in use`;
}
return `${active}/${sessions.limit} in use`;
}
function printDiagnostics(diag: Record<string, any>): void {
const env = diag.environment;
console.log("CloakBrowser diagnostics");
@@ -412,12 +455,7 @@ function printDiagnostics(diag: Record<string, any>): void {
}
if (lic.sessions) {
const active = (lic.sessions as { active: number | null }).active;
console.log(
active === null
? "Sessions: unavailable"
: `Sessions: ${active} seat${active === 1 ? "" : "s"} in use`
);
console.log(`Sessions: ${formatSeats(lic.sessions as SeatSection)}`);
}
const resolved = diag.geoip.resolved;
+77 -15
View File
@@ -26,6 +26,28 @@ export interface LicenseInfo {
expires: string | null;
}
/**
* Result of a seat lookup: the count, the cap it counts against, and the reason
* either is missing.
*
* state:
* "ok" active is a real number (0 is a real answer, not an error)
* "unreachable" never got an answer — DNS, refused, timeout, TLS
* "denied" the server refused; `reason` carries its error code
* "unknown" the server is up and the key is fine, but it cannot count
* right now (leaseless mode, or its seat store is unreachable)
*
* limit is null whenever the server declined to state a cap: unlimited, an
* unrecognised plan, or a server too old to send the field. Callers must fall back
* to the bare count, never invent a denominator.
*/
export interface SessionSeats {
active: number | null;
limit: number | null;
state: "ok" | "unreachable" | "denied" | "unknown";
reason: string | null;
}
export interface ProReleaseInfo {
version: string;
requestedChannel: "stable" | "preview";
@@ -630,31 +652,71 @@ export async function getProLatestVersion(releaseChannel?: string): Promise<stri
}
/**
* How many concurrent sessions (seats) this license is holding right now.
* Seats held right now, the cap they count against, and why either is missing.
*
* Deliberately NOT cached: a cached seat count is a wrong seat count. Returns
* null when the number is unknown — the server couldn't be reached, or it
* reported the count as unavailable (it does that instead of a false 0 while
* running in leaseless mode). Callers render null as "unavailable".
* Deliberately NOT cached: a cached seat count is a wrong seat count.
*
* Six different things can stop us answering — no route to the host, a timeout, a
* 403 for a dead key, a 429, the server reporting the count as unknown in leaseless
* mode, and its seat store being unreachable. They used to collapse into one bare
* null, so `info` printed the same "unavailable" for "your key is dead" and "our
* backend is degraded, you are fine". `state` keeps them apart.
*/
export async function getActiveSessionCount(licenseKey: string): Promise<number | null> {
export async function getSessionSeats(licenseKey: string): Promise<SessionSeats> {
let resp: Response;
try {
const resp = await fetch(SESSION_COUNT_URL, {
resp = await fetch(SESSION_COUNT_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ license_key: licenseKey }),
signal: AbortSignal.timeout(10_000),
});
if (!resp.ok) {
throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
}
const data = (await resp.json()) as Record<string, unknown>;
return typeof data.active === "number" ? data.active : null;
} catch {
return null;
// Never reached the server: DNS, refused, timed out, TLS.
return { active: null, limit: null, state: "unreachable", reason: null };
}
if (!resp.ok) {
// The server answered, and the answer was a refusal. Its `error` field is the
// actionable part (invalid_key / license_inactive / rate_limited); fall back to
// the status when the body is missing or not JSON.
let reason: string | null = null;
try {
const body = (await resp.json()) as Record<string, unknown>;
if (typeof body.error === "string") reason = body.error;
} catch {
// fall through to the status
}
return { active: null, limit: null, state: "denied", reason: reason ?? `HTTP ${resp.status}` };
}
let data: Record<string, unknown>;
try {
data = (await resp.json()) as Record<string, unknown>;
} catch {
return { active: null, limit: null, state: "unknown", reason: null };
}
if (typeof data.active !== "number") {
// 200 with active=null is the server saying "up, your key is fine, but I
// genuinely cannot count right now" — deliberate, so it never reports a false 0.
return { active: null, limit: null, state: "unknown", reason: null };
}
// limit absent (older server) or null (unlimited / unknown plan): callers fall back
// to the bare count rather than printing a made-up denominator.
const limit = typeof data.limit === "number" ? data.limit : null;
return { active: data.active, limit, state: "ok", reason: null };
}
/**
* How many concurrent sessions (seats) this license is holding right now.
*
* Kept for callers outside `info` that only want the number. Prefer
* getSessionSeats(), which also carries the cap and the reason a lookup failed.
*/
export async function getActiveSessionCount(licenseKey: string): Promise<number | null> {
return (await getSessionSeats(licenseKey)).active;
}
// ---------------------------------------------------------------------------
+68 -1
View File
@@ -54,7 +54,9 @@ describe("collectDiagnostics", () => {
resolvedChannel: "stable",
fallback: true,
});
vi.spyOn(license, "getActiveSessionCount").mockResolvedValue(null);
vi.spyOn(license, "getSessionSeats").mockResolvedValue({
active: null, limit: null, state: "unknown", reason: null,
});
const { collectDiagnostics } = await import("../src/cli.js");
const diag = (await collectDiagnostics(false)) as Record<string, any>;
@@ -108,3 +110,68 @@ describe("collectDiagnostics", () => {
expect(diag.geoip.resolved.error).toContain("proxy refused");
});
});
// ── formatSeats ───────────────────────────────────────
//
// Kept byte-identical to the Python (_format_seats) and .NET (FormatSeats)
// renderers — the three wrappers must print the same line for the same state.
describe("formatSeats", () => {
const fmt = async (section: Record<string, unknown>) => {
const { formatSeats } = await import("../src/cli.js");
return formatSeats(section);
};
it("shows used over limit", async () => {
// The point of the change: a scale-plan customer can see they are nowhere
// near the ceiling (or right on it) instead of reading a bare number.
expect(await fmt({ active: 8, limit: 2000, state: "ok" })).toBe("8/2000 in use");
});
it("falls back to the bare count when the server sends no limit", async () => {
// Older server, unlimited licence, or an unrecognised plan. Never "8/unknown".
expect(await fmt({ active: 8, limit: null, state: "ok" })).toBe("8 seats in use");
});
it("uses the singular in the fallback", async () => {
expect(await fmt({ active: 1, limit: null, state: "ok" })).toBe("1 seat in use");
});
it("shows the limit for a single seat", async () => {
// A free key holds exactly one seat — the cohort most likely to hit its cap.
expect(await fmt({ active: 1, limit: 1, state: "ok" })).toBe("1/1 in use");
});
it("keeps zero as a real answer", async () => {
expect(await fmt({ active: 0, limit: 5, state: "ok" })).toBe("0/5 in use");
});
it("says so when the server is unreachable", async () => {
expect(await fmt({ state: "unreachable" })).toBe(
"unavailable (cannot reach cloakbrowser.dev)"
);
});
it.each([
["license_inactive", "license inactive"],
["invalid_key", "invalid key"],
["rate_limited", "rate limited"],
])("spells out the %s denial", async (code, shown) => {
// These used to be one string. A dead key and a healthy key behind a
// degraded backend must not read identically.
expect(await fmt({ state: "denied", reason: code })).toBe(`unavailable (${shown})`);
});
it("passes an unrecognised denial reason through", async () => {
expect(await fmt({ state: "denied", reason: "some_new_code" })).toBe(
"unavailable (some_new_code)"
);
});
it("does not make a degraded backend read like a licence problem", async () => {
// Leaseless mode / seat store down: the key is fine, nothing for them to do.
expect(await fmt({ state: "unknown" })).toBe(
"unavailable (server cannot report seats right now)"
);
});
});
+106
View File
@@ -10,6 +10,7 @@ import {
getProLatestRelease,
getProLatestVersion,
getActiveSessionCount,
getSessionSeats,
buildLaunchEnv,
licenseErrorMessage,
licenseErrorFrom,
@@ -911,3 +912,108 @@ describe("getActiveSessionCount", () => {
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
});
});
// ── getSessionSeats ───────────────────────────────────
describe("getSessionSeats", () => {
// The six failure paths that used to collapse into one bare null.
const ok = (payload: unknown) =>
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => payload,
} as Response);
const denied = (status: number, payload: unknown) =>
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: false,
status,
statusText: "Denied",
json: async () => payload,
} as Response);
it("reports the count and the limit", async () => {
ok({ valid: true, active: 8, limit: 2000 });
expect(await getSessionSeats("cb_key")).toEqual({
active: 8, limit: 2000, state: "ok", reason: null,
});
});
it("treats a missing limit as null, not an error", async () => {
// A server predating the field still yields a usable count.
ok({ valid: true, active: 8 });
const seats = await getSessionSeats("cb_key");
expect(seats.state).toBe("ok");
expect(seats.active).toBe(8);
expect(seats.limit).toBeNull();
});
it("treats an explicit null limit as null", async () => {
// Unlimited licence or unrecognised plan — the server says so explicitly.
ok({ valid: true, active: 3, limit: null });
expect((await getSessionSeats("cb_key")).limit).toBeNull();
});
it("keeps zero seats as a real answer", async () => {
ok({ valid: true, active: 0, limit: 5 });
const seats = await getSessionSeats("cb_key");
expect(seats.state).toBe("ok");
expect(seats.active).toBe(0);
});
it("reports a network failure as unreachable", async () => {
// info is a diagnostic — it degrades, it never throws out of the command.
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("timeout"));
const seats = await getSessionSeats("cb_key");
expect(seats.state).toBe("unreachable");
expect(seats.active).toBeNull();
});
it("carries the server's reason on a denial", async () => {
denied(403, { valid: false, error: "license_inactive" });
const seats = await getSessionSeats("cb_key");
expect(seats.state).toBe("denied");
expect(seats.reason).toBe("license_inactive");
});
it("treats a rate limit as a denial", async () => {
denied(429, { valid: false, error: "rate_limited" });
expect((await getSessionSeats("cb_key")).reason).toBe("rate_limited");
});
it("falls back to the status when a denial has no body", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: false,
status: 500,
statusText: "Server Error",
json: async () => {
throw new Error("not json");
},
} as unknown as Response);
expect((await getSessionSeats("cb_key")).reason).toBe("HTTP 500");
});
it("reports a server-side unknown as unknown, not denied", async () => {
// Leaseless mode: 200, key is fine, the server just cannot count. This is the
// distinction the old single null destroyed.
ok({ valid: true, active: null, limit: null });
const seats = await getSessionSeats("cb_key");
expect(seats.state).toBe("unknown");
expect(seats.active).toBeNull();
});
it("reports an unparseable body as unknown", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => {
throw new Error("not json");
},
} as unknown as Response);
expect((await getSessionSeats("cb_key")).state).toBe("unknown");
});
it("keeps getActiveSessionCount returning the bare count", async () => {
// It is shipped public API — it must keep behaving.
ok({ valid: true, active: 4, limit: 20 });
expect(await getActiveSessionCount("cb_key")).toBe(4);
});
});
+83 -24
View File
@@ -10,7 +10,7 @@ import pytest
import cloakbrowser.__main__
from cloakbrowser.__main__ import _binary_version, cmd_info
from cloakbrowser.license import LicenseInfo, ProReleaseInfo
from cloakbrowser.license import LicenseInfo, ProReleaseInfo, SessionSeats
def _run(args, *, key=None, license_info=None, sessions=None):
@@ -18,8 +18,9 @@ def _run(args, *, key=None, license_info=None, sessions=None):
key=None -> no license -> free binary.
key set -> validate_license returns license_info (entitled to Pro if valid).
sessions -> what the seat-count lookup reports (it is mocked out, so a
non-quick Pro run never reaches the network).
sessions -> the SessionSeats the seat lookup reports (it is mocked out, so a
non-quick Pro run never reaches the network). None defaults to a
server-cannot-count result, which keeps unrelated tests offline.
Returns (download_free_mock, download_pro_mock, session_count_mock) so callers
can assert the command never triggers a binary download or an unwanted lookup.
@@ -28,7 +29,8 @@ def _run(args, *, key=None, license_info=None, sessions=None):
patch("cloakbrowser.license.resolve_license_key", return_value=key),
patch("cloakbrowser.license.validate_license", return_value=license_info),
patch(
"cloakbrowser.license.get_active_session_count", return_value=sessions
"cloakbrowser.license.get_session_seats",
return_value=sessions if sessions is not None else SessionSeats(state="unknown"),
) as mock_sessions,
patch("cloakbrowser.download._download_and_extract") as mock_dl_free,
patch("cloakbrowser.download._download_pro_binary") as mock_dl_pro,
@@ -170,35 +172,92 @@ def test_invalid_key_falls_back_to_free(capsys):
_PRO = LicenseInfo(valid=True, plan="business", expires=None)
def test_pro_reports_seats_in_use(capsys):
_run(Namespace(quick=False, json=True), key="cb_test", license_info=_PRO, sessions=3)
def _seats(args_json=False, **seat_kwargs):
"""Render the Sessions line for one SessionSeats result."""
return _run(
Namespace(quick=False, json=args_json),
key="cb_test",
license_info=_PRO,
sessions=SessionSeats(**seat_kwargs),
)
def test_pro_reports_seats_and_limit_in_json(capsys):
_seats(args_json=True, active=8, limit=2000, state="ok")
data = json.loads(capsys.readouterr().out)
assert data["license"]["sessions"] == {"active": 3}
assert data["license"]["sessions"] == {
"active": 8, "limit": 2000, "state": "ok", "reason": None,
}
def test_seat_line_printed_in_text_mode(capsys):
_run(Namespace(quick=False, json=False), key="cb_test", license_info=_PRO, sessions=3)
assert "Sessions: 3 seats in use" in capsys.readouterr().out
def test_seat_line_shows_used_over_limit(capsys):
"""The point of the change: a scale-plan customer can see they are nowhere near
the ceiling (or right on it) instead of reading a bare number."""
_seats(active=8, limit=2000, state="ok")
assert "Sessions: 8/2000 in use" in capsys.readouterr().out
def test_seat_line_is_singular_for_one(capsys):
_run(Namespace(quick=False, json=False), key="cb_test", license_info=_PRO, sessions=1)
assert "Sessions: 1 seat in use" in capsys.readouterr().out
def test_zero_seats_reads_as_none_in_use_not_unavailable(capsys):
"""0 is a real answer ("nothing running"); only an unknown prints unavailable."""
_run(Namespace(quick=False, json=False), key="cb_test", license_info=_PRO, sessions=0)
def test_seat_line_falls_back_when_the_server_sends_no_limit(capsys):
"""Older server, unlimited licence, or an unrecognised plan. Print the count we do
have rather than "8/unknown"."""
_seats(active=8, limit=None, state="ok")
out = capsys.readouterr().out
assert "Sessions: 0 seats in use" in out
assert "Sessions: 8 seats in use" in out
assert "unavailable" not in out
def test_unknown_count_prints_unavailable(capsys):
"""Server unreachable, or the server itself reported the count as unknown
(leaseless mode) -> "unavailable", never a made-up number."""
_run(Namespace(quick=False, json=False), key="cb_test", license_info=_PRO, sessions=None)
assert "Sessions: unavailable" in capsys.readouterr().out
def test_seat_fallback_is_singular_for_one(capsys):
_seats(active=1, limit=None, state="ok")
assert "Sessions: 1 seat in use" in capsys.readouterr().out
def test_one_of_one_seat_shows_the_limit(capsys):
"""A free key holds exactly one seat — the cohort most likely to hit its cap."""
_seats(active=1, limit=1, state="ok")
assert "Sessions: 1/1 in use" in capsys.readouterr().out
def test_zero_seats_reads_as_a_real_answer_not_unavailable(capsys):
"""0 is a real answer ("nothing running"); only an unknown prints unavailable."""
_seats(active=0, limit=5, state="ok")
out = capsys.readouterr().out
assert "Sessions: 0/5 in use" in out
assert "unavailable" not in out
def test_unreachable_server_says_so(capsys):
_seats(state="unreachable")
assert "Sessions: unavailable (cannot reach cloakbrowser.dev)" in capsys.readouterr().out
@pytest.mark.parametrize(
"code,shown",
[
("license_inactive", "license inactive"),
("invalid_key", "invalid key"),
("rate_limited", "rate limited"),
],
)
def test_denial_reasons_are_spelled_out(capsys, code, shown):
"""These four used to be one string. A dead key and a healthy key behind a
degraded backend must not read identically."""
_seats(state="denied", reason=code)
assert f"Sessions: unavailable ({shown})" in capsys.readouterr().out
def test_unrecognised_denial_reason_is_passed_through(capsys):
"""A server code we have no wording for still says something actionable."""
_seats(state="denied", reason="some_new_code")
assert "Sessions: unavailable (some_new_code)" in capsys.readouterr().out
def test_server_cannot_count_is_not_an_error(capsys):
"""Leaseless mode / seat store down: the customer's key is fine and there is
nothing for them to do. Must not read like a licence problem."""
_seats(state="unknown")
out = capsys.readouterr().out
assert "Sessions: unavailable (server cannot report seats right now)" in out
assert "invalid" not in out
def test_quick_skips_the_seat_lookup(capsys):
+114 -1
View File
@@ -16,6 +16,7 @@ from cloakbrowser.license import (
get_active_session_count,
get_pro_latest_release,
get_pro_latest_version,
get_session_seats,
resolve_license_key,
validate_license,
)
@@ -505,8 +506,9 @@ class TestGetProLatestVersion:
class TestGetActiveSessionCount:
def _resp(self, payload):
def _resp(self, payload, status=200):
mock_resp = MagicMock()
mock_resp.status_code = status
mock_resp.json.return_value = payload
mock_resp.raise_for_status = MagicMock()
return mock_resp
@@ -563,6 +565,117 @@ class TestGetActiveSessionCount:
assert mock_post.call_count == 2
# ── get_session_seats ─────────────────────────────────
class TestGetSessionSeats:
"""The six failure paths that used to collapse into one bare None."""
def _resp(self, payload, status=200):
mock_resp = MagicMock()
mock_resp.status_code = status
mock_resp.json.return_value = payload
return mock_resp
def test_reports_count_and_limit(self):
with patch(
"cloakbrowser.license.httpx.post",
return_value=self._resp({"valid": True, "active": 8, "limit": 2000}),
):
seats = get_session_seats("cb_key")
assert (seats.active, seats.limit, seats.state) == (8, 2000, "ok")
def test_missing_limit_is_none_not_an_error(self):
"""A server predating the field still yields a usable count."""
with patch(
"cloakbrowser.license.httpx.post",
return_value=self._resp({"valid": True, "active": 8}),
):
seats = get_session_seats("cb_key")
assert seats.state == "ok"
assert seats.active == 8
assert seats.limit is None
def test_null_limit_is_none(self):
"""Unlimited licence or unrecognised plan — the server says so explicitly."""
with patch(
"cloakbrowser.license.httpx.post",
return_value=self._resp({"valid": True, "active": 3, "limit": None}),
):
assert get_session_seats("cb_key").limit is None
def test_zero_seats_is_a_real_answer(self):
with patch(
"cloakbrowser.license.httpx.post",
return_value=self._resp({"valid": True, "active": 0, "limit": 5}),
):
seats = get_session_seats("cb_key")
assert seats.state == "ok"
assert seats.active == 0
def test_network_failure_is_unreachable(self):
"""info is a diagnostic — it degrades, it never raises out of the command."""
with patch("cloakbrowser.license.httpx.post", side_effect=Exception("network")):
seats = get_session_seats("cb_key")
assert seats.state == "unreachable"
assert seats.active is None
def test_denial_carries_the_server_reason(self):
with patch(
"cloakbrowser.license.httpx.post",
return_value=self._resp({"valid": False, "error": "license_inactive"}, status=403),
):
seats = get_session_seats("cb_key")
assert seats.state == "denied"
assert seats.reason == "license_inactive"
def test_rate_limit_is_a_denial(self):
with patch(
"cloakbrowser.license.httpx.post",
return_value=self._resp({"valid": False, "error": "rate_limited"}, status=429),
):
assert get_session_seats("cb_key").reason == "rate_limited"
def test_denial_without_a_body_falls_back_to_the_status(self):
resp = MagicMock()
resp.status_code = 500
resp.json.side_effect = ValueError("not json")
with patch("cloakbrowser.license.httpx.post", return_value=resp):
assert get_session_seats("cb_key").reason == "HTTP 500"
def test_server_reported_unavailable_is_unknown_not_denied(self):
"""Leaseless mode: 200, key is fine, the server just cannot count. This is the
distinction the old single None destroyed."""
with patch(
"cloakbrowser.license.httpx.post",
return_value=self._resp({"valid": True, "active": None, "limit": None}),
):
seats = get_session_seats("cb_key")
assert seats.state == "unknown"
assert seats.active is None
def test_unparseable_body_is_unknown(self):
resp = MagicMock()
resp.status_code = 200
resp.json.side_effect = ValueError("not json")
with patch("cloakbrowser.license.httpx.post", return_value=resp):
assert get_session_seats("cb_key").state == "unknown"
def test_old_helper_still_returns_the_bare_count(self):
"""get_active_session_count is shipped public API — it must keep behaving."""
with patch(
"cloakbrowser.license.httpx.post",
return_value=self._resp({"valid": True, "active": 4, "limit": 20}),
):
assert get_active_session_count("cb_key") == 4
# ── Config pro parameter ──────────────────────────────