diff --git a/engraphis/cloud_session.py b/engraphis/cloud_session.py
index b539afe..754955b 100644
--- a/engraphis/cloud_session.py
+++ b/engraphis/cloud_session.py
@@ -214,6 +214,14 @@ def _save(value: dict) -> None:
#: Plans that carry no paid cloud access, used only to default an absent activity flag.
_UNPAID_PLANS = ("free", "local")
+#: Entitlement status vocabulary the control plane can persist or compute
+#: (``engraphis_cloud/entitlements.py`` ``effective_status``, plus every provider status
+#: ``/internal/subscriptions/apply`` accepts). Kept as a bound, not an allow-list: an
+#: unrecognised value is stored verbatim so a future server release is not mistranslated,
+#: it is only length-capped like every other presentation string here.
+_MAX_STATUS_CHARS = 32
+#: An ISO-8601 timestamp is at most a few dozen characters; anything longer is not one.
+_MAX_TIMESTAMP_CHARS = 64
#: Bounds on the entitlement fields before they are written into the session record. The
#: record is read back under a 64 KiB private-state cap, so an unbounded provider value
#: could grow the file past that cap and make the whole session permanently unreadable.
@@ -221,17 +229,36 @@ def _save(value: dict) -> None:
_MAX_PLAN_CHARS = 64
_MAX_FEATURES = 32
_MAX_FEATURE_CHARS = 64
+#: Every entitlement key ``_declared_entitlement`` can put on the session record. The
+#: record is deliberately shaped like the wire response so one reader serves both, which
+#: is what keeps a saved answer and a fresh one from being parsed by different rules.
+_ENTITLEMENT_KEYS = (
+ "plan",
+ "cloud_access_active",
+ "cloud_features",
+ "status",
+ "is_trial",
+ "trial_consumed",
+ "trial_ends_at",
+)
def _declared_entitlement(response: object) -> dict:
"""Return the entitlement fields a control-plane response carried, or ``{}``.
``DeviceRegistrationResponse`` — the body both ``/internal/devices/register`` and
- ``POST /v1/tokens/refresh`` answer with — carries ``plan``, ``cloud_features`` and
- ``cloud_access_active``. They are read as *optional* on purpose: a control plane that
- has not deployed them yet returns exactly what it always did, and this client must keep
- working against it rather than requiring a newer server. An absent or unusable ``plan``
- therefore yields ``{}``, which leaves whatever the caller already knew in place.
+ ``POST /v1/tokens/refresh`` answer with — carries ``plan``, ``cloud_features``,
+ ``cloud_access_active``, and the trial facts (``status``, ``is_trial``,
+ ``trial_consumed``, ``trial_ends_at``). They are read as *optional* on purpose: a
+ control plane that has not deployed them yet returns exactly what it always did, and
+ this client must keep working against it rather than requiring a newer server. An
+ absent or unusable ``plan`` therefore yields ``{}``, which leaves whatever the caller
+ already knew in place.
+
+ The trial fields are each optional *individually* as well, because they shipped after
+ the plan fields did: a server that answers ``plan`` but not ``is_trial`` simply leaves
+ the key absent, and the caller keeps treating the customer as a non-trialist rather
+ than claiming a trial nobody declared.
Nothing here is authority. The cloud authorizes every paid call regardless of what
this record says; persisting it only saves the dashboard from guessing.
@@ -259,6 +286,23 @@ def _declared_entitlement(response: object) -> dict:
item.strip().lower()[:_MAX_FEATURE_CHARS]
for item in features if isinstance(item, str) and item.strip()
})[:_MAX_FEATURES]
+ # The trial half of the same answer. Absent stays absent so a field-less older server
+ # cannot erase what a newer one already recorded, exactly as ``cloud_features`` behaves.
+ status = response.get("status")
+ if isinstance(status, str) and status.strip():
+ declared["status"] = status.strip().lower()[:_MAX_STATUS_CHARS]
+ for key in ("is_trial", "trial_consumed"):
+ value = response.get(key)
+ if isinstance(value, bool):
+ declared[key] = value
+ ends_at = response.get("trial_ends_at")
+ if isinstance(ends_at, str) and ends_at.strip():
+ declared["trial_ends_at"] = ends_at.strip()[:_MAX_TIMESTAMP_CHARS]
+ elif ends_at is None and "is_trial" in declared and not declared["is_trial"]:
+ # An explicit ``null`` from a server that *did* answer the trial question is
+ # meaningful: it is how a converted paying customer is told they have no live trial
+ # boundary. Clear a stale one rather than letting a past trial end survive forever.
+ declared["trial_ends_at"] = ""
return declared
@@ -329,6 +373,13 @@ def record_billing_denial() -> bool:
)
saved["cloud_access_active"] = False
saved["cloud_features"] = []
+ # The last status the server named ("active", "trialing", …) is now known to
+ # contradict this denial, so it must not survive as renderable copy. The trial
+ # facts are *not* invalidated by a lapse -- whether this was a trial, when it
+ # ended, and whether one was consumed are all still true -- and they are what
+ # lets the dashboard say "your free trial ended" rather than the generic
+ # "your subscription lapsed".
+ saved.pop("status", None)
saved["entitlement_checked_at"] = time.time()
# Inside the lock: a save that lands after release is exactly the race above.
_save(saved)
@@ -543,17 +594,21 @@ def access_for_workspace(
# plan re-confirms itself on every token rotation. An older cloud omits them and
# the previously persisted answer (if any) is left untouched.
declared = _declared_entitlement(body)
- if declared and "cloud_features" not in declared:
- # A *plan change* may never inherit the previous plan's grants.
- # ``_declared_entitlement`` omits ``cloud_features`` whenever the body carried
- # no feature list, so merging it onto the saved record left a Team feature list
- # alive underneath a downgraded Pro plan and kept the Team tab unlocked
- # indefinitely. Dropping the stale key hands the answer back to this client's
- # own plan table, which is right for the plan the cloud just named. A refresh
- # that re-confirms the *same* plan still keeps the richer saved list.
+ if declared:
+ # A *plan change* may never inherit the previous plan's state.
+ # ``_declared_entitlement`` omits any field the body did not carry, so merging
+ # it onto the saved record left a Team feature list alive underneath a
+ # downgraded Pro plan and kept the Team tab unlocked indefinitely. Dropping
+ # every entitlement key the new answer did not restate hands those back to this
+ # client's own defaults, which are right for the plan the cloud just named --
+ # and stops a finished trial's ``is_trial``/``trial_ends_at`` surviving under
+ # the paid plan it converted into. A refresh that re-confirms the *same* plan
+ # still keeps the richer saved answer.
previous = str(saved.get("plan") or "").strip().lower()
if previous != declared["plan"]:
- updated.pop("cloud_features", None)
+ for key in _ENTITLEMENT_KEYS:
+ if key not in declared:
+ updated.pop(key, None)
updated.update(declared)
_save(updated)
return access, organization_id, compute
diff --git a/engraphis/hosted_client.py b/engraphis/hosted_client.py
index f889efe..4d4a894 100644
--- a/engraphis/hosted_client.py
+++ b/engraphis/hosted_client.py
@@ -89,6 +89,21 @@ def upgrade_url(plan: Optional[str] = None) -> str:
return value or DEFAULT_CLOUD_URL
+def account_url() -> str:
+ """Return the plan-neutral hosted account URL.
+
+ ``upgrade_url()`` is a *checkout* selector, not an account entry point: with no
+ argument it resolves ``plan="pro"`` and therefore prefers
+ ``ENGRAPHIS_PRO_UPGRADE_URL``. Wherever those are configured as distinct pages, a
+ lapsed customer sent to "the account portal" would land on the Pro checkout — the
+ wrong product offered to someone whose problem is a payment method on a subscription
+ they already hold. Resolve the generic value directly instead, and fall back to the
+ hosted account root rather than to either plan's page.
+ """
+
+ return os.environ.get("ENGRAPHIS_UPGRADE_URL", "").strip() or DEFAULT_CLOUD_URL
+
+
def _is_loopback_host(host: str) -> bool:
if host == "localhost":
return True
diff --git a/engraphis/licensing.py b/engraphis/licensing.py
index 4f7e82f..6b4ac02 100644
--- a/engraphis/licensing.py
+++ b/engraphis/licensing.py
@@ -13,6 +13,7 @@
TRIAL_DAYS,
TRIAL_SECONDS,
HostedFeatureError,
+ account_url,
required_plan,
upgrade_url,
)
@@ -29,6 +30,7 @@ def production_warnings() -> list:
__all__ = [
"LicenseError",
+ "account_url",
"MAX_LOCAL_WRITE_GRACE_SECONDS",
"TRIAL_DAYS",
"TRIAL_SECONDS",
diff --git a/engraphis/routes/v2_api.py b/engraphis/routes/v2_api.py
index cee65ef..4ae6531 100644
--- a/engraphis/routes/v2_api.py
+++ b/engraphis/routes/v2_api.py
@@ -7,9 +7,11 @@
"""
from __future__ import annotations
+import datetime as _datetime
import json
import hmac
import logging
+import math
import os
import threading
import time
@@ -1916,6 +1918,107 @@ def _normalized_plan(value: object) -> str:
return plan if plan in ("pro", "team") else "local"
+#: The control plane's entitlement status vocabulary, mirrored from (read-only)
+#: engraphis-cloud/engraphis_cloud/entitlements.py ``effective_status`` and the statuses
+#: ``/internal/subscriptions/apply`` accepts. Presentation only: an unrecognised value is
+#: reported as ``""`` rather than guessed at, which degrades the copy to the generic
+#: wording instead of asserting something the server never said.
+_ENTITLEMENT_STATUSES = frozenset({
+ "active", "trialing", "past_due", "canceled", "expired", "revoked", "scheduled",
+ "inactive",
+})
+
+
+def _normalized_status(value: object) -> str:
+ """Return the control plane's entitlement status, or ``""`` when it said nothing."""
+
+ status = str(value or "").strip().lower()
+ return status if status in _ENTITLEMENT_STATUSES else ""
+
+
+def _epoch_seconds(value: object) -> float:
+ """Return an ISO-8601 instant as epoch seconds, or ``0.0``. Never raises.
+
+ The control plane serializes ``trial_ends_at`` as an ISO-8601 UTC timestamp; the
+ dashboard renders instants as epoch seconds. Converting once, here, keeps the JS from
+ parsing dates the CSP-externalized asset cannot be unit-tested through.
+
+ ``datetime.fromisoformat`` on this package's Python 3.9 floor does not accept the
+ trailing ``Z`` that Pydantic emits for UTC, so it is rewritten to the explicit offset
+ first. Anything it still cannot parse is reported as "unknown" rather than raising on
+ the ``/api/bootstrap`` boot path.
+
+ Numeric input must additionally be *finite*. ``json.loads`` accepts the non-finite
+ literals ``Infinity``/``NaN`` and turns an out-of-range value such as ``1e309`` into
+ ``inf``, and either one would travel out through ``/api/license`` and ``/api/bootstrap``
+ into Starlette's ``JSONResponse``, which raises ``ValueError`` on non-JSON-compliant
+ floats — breaking the dashboard's boot path over a malformed cache entry. An
+ unrepresentable instant is not a boundary; report it as unknown like any other
+ unparseable value.
+ """
+
+ if isinstance(value, bool):
+ return 0.0
+ if isinstance(value, (int, float)):
+ try:
+ number = float(value)
+ except (OverflowError, ValueError): # an arbitrary-precision JSON integer
+ return 0.0
+ return number if math.isfinite(number) and number > 0 else 0.0
+ if not isinstance(value, str) or not value.strip():
+ return 0.0
+ text = value.strip()
+ if text[-1:] in ("Z", "z"):
+ text = text[:-1] + "+00:00"
+ try:
+ parsed = _datetime.datetime.fromisoformat(text)
+ except (ValueError, TypeError):
+ return 0.0
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=_datetime.timezone.utc)
+ try:
+ return float(parsed.timestamp())
+ except (OverflowError, OSError, ValueError):
+ return 0.0
+
+
+def _trial_facts(source: object) -> dict:
+ """Read the control plane's trial disclosure off any entitlement-shaped mapping.
+
+ ``DeviceRegistrationResponse`` and ``GET /v1/entitlements/{org}`` carry the same four
+ fields, so one reader serves both and the two answers cannot be parsed by different
+ rules. Every field is optional: a control plane that predates them simply omits them,
+ and the honest answer is then "not a trial, none consumed" rather than a claim.
+
+ ``trial_consumed`` is deliberately widened by ``is_trial``: an organization that is on
+ a trial has by definition consumed one, so a server that answers only ``is_trial``
+ still stops this client offering a second trial the server would refuse.
+ """
+
+ if not isinstance(source, dict):
+ return {"status": "", "is_trial": False, "trial_consumed": False,
+ "trial_ends_at": 0.0}
+ is_trial = source.get("is_trial")
+ is_trial = bool(is_trial) if isinstance(is_trial, bool) else False
+ consumed = source.get("trial_consumed")
+ consumed = bool(consumed) if isinstance(consumed, bool) else False
+ return {
+ "status": _normalized_status(source.get("status")),
+ "is_trial": is_trial,
+ "trial_consumed": consumed or is_trial,
+ # A trial boundary is only meaningful for a trial. Replaying a converted
+ # customer's long-past trial end would tell a paying subscriber their access
+ # expired last month, which is exactly what the server refuses to do.
+ "trial_ends_at": _epoch_seconds(source.get("trial_ends_at")) if is_trial else 0.0,
+ }
+
+
+def _unknown_trial_facts() -> dict:
+ """Return the "the control plane has told us nothing" trial answer."""
+
+ return {"status": "", "is_trial": False, "trial_consumed": False, "trial_ends_at": 0.0}
+
+
def _normalized_features(values: object, plan: str) -> list:
"""Keep the server's own grant, expanded to the names this dashboard renders.
@@ -1957,7 +2060,7 @@ def _session_entitlement() -> dict:
return {}
plan = _normalized_plan(declared.get("plan"))
active = bool(declared.get("cloud_access_active"))
- return {
+ resolved = {
"plan": plan,
# The server empties ``cloud_features`` the moment paid access stops being
# live; mirror that, exactly as the entitlements route's answer does below.
@@ -1969,6 +2072,8 @@ def _session_entitlement() -> dict:
"organization_id": str(declared.get("organization_id") or ""),
"fetched_at": float(declared.get("entitlement_checked_at") or 0.0),
}
+ resolved.update(_trial_facts(declared))
+ return resolved
except Exception: # noqa: BLE001 - a badge must never break /bootstrap
return {}
@@ -2029,13 +2134,18 @@ def _read_entitlement_cache() -> dict:
if not current or organization_id != current:
return {}
plan = _normalized_plan(stored_plan)
- return {
+ resolved = {
"plan": plan,
"features": _normalized_features(value.get("features"), plan),
"cloud_access_active": bool(value.get("cloud_access_active")),
"organization_id": organization_id,
"fetched_at": fetched_at,
}
+ # The trial disclosure is cached under the same wire names the entitlements route uses,
+ # so a cache written by an older build simply has none of them and reads back as "not a
+ # trial" rather than as a corrupt entry.
+ resolved.update(_trial_facts(value))
+ return resolved
def _write_entitlement_cache(entitlement: dict) -> None:
@@ -2059,6 +2169,13 @@ def _write_entitlement_cache(entitlement: dict) -> None:
"cloud_access_active": bool(entitlement["cloud_access_active"]),
"organization_id": str(entitlement.get("organization_id") or ""),
"fetched_at": float(entitlement.get("fetched_at") or time.time()),
+ # Persisted under the wire names ``_trial_facts`` reads, so the cache round
+ # trips through exactly one parser. ``trial_ends_at`` is already epoch seconds
+ # by this point, which that parser also accepts.
+ "status": _normalized_status(entitlement.get("status")),
+ "is_trial": bool(entitlement.get("is_trial")),
+ "trial_consumed": bool(entitlement.get("trial_consumed")),
+ "trial_ends_at": float(entitlement.get("trial_ends_at") or 0.0),
}, sort_keys=True, separators=(",", ":")))
except Exception: # noqa: BLE001 - losing the cache write must not surface anywhere
logger.debug("entitlement cache write skipped")
@@ -2092,6 +2209,11 @@ def _deny_entitlement_cache() -> None:
denied = dict(cached)
denied["cloud_access_active"] = False
denied["features"] = []
+ # The last status the server named now contradicts this denial, so it stops being
+ # renderable copy. The trial facts survive: a lapse does not un-consume a trial or
+ # move its boundary, and they are what distinguishes "your free trial ended" from
+ # "your subscription lapsed" in the panel this settles.
+ denied["status"] = ""
denied["fetched_at"] = time.time()
_write_entitlement_cache(denied)
except Exception: # noqa: BLE001 - a denial we cannot persist is still a denial
@@ -2216,7 +2338,7 @@ def _fetch_authoritative_entitlement() -> Optional[dict]:
if not isinstance(active, bool):
return None
plan = _normalized_plan(declared_plan)
- return {
+ resolved = {
"plan": plan,
# The server empties ``cloud_features`` the moment paid access stops being live.
# Mirroring that re-draws the locks on a lapsed subscription while the badge keeps
@@ -2227,6 +2349,11 @@ def _fetch_authoritative_entitlement() -> Optional[dict]:
"organization_id": organization_id,
"fetched_at": time.time(),
}
+ # ``is_trial``/``trial_consumed``/``trial_ends_at``/``status`` are optional here for the
+ # same reason the fields above the fallback are: a control plane that predates them
+ # omits them, and the honest answer is then "not a trial", not a refusal to cache.
+ resolved.update(_trial_facts(body))
+ return resolved
def _refresh_entitlement_in_background(known: dict) -> None:
@@ -2307,34 +2434,61 @@ def _plan_entitlement() -> dict:
declared = os.environ.get("ENGRAPHIS_CLOUD_PLAN", "").strip().lower()
if declared in ("pro", "team", "free", "local"):
plan = declared if declared in ("pro", "team") else "local"
- return {"plan": plan, "features": entitled_features(plan),
- "source": "environment", "cloud_access_active": plan != "local",
- "checked_at": 0.0}
+ # An operator override names a plan, never a trial: it exists for air-gapped and
+ # pinned-token deployments that have no control plane to ask. Reporting "no trial,
+ # none consumed" is the honest answer, and it is what keeps the trial CTA off a
+ # deployment that cannot start one.
+ entitlement = {"plan": plan, "features": entitled_features(plan),
+ "source": "environment", "cloud_access_active": plan != "local",
+ "checked_at": 0.0}
+ entitlement.update(_unknown_trial_facts())
+ return entitlement
try:
from engraphis import cloud_session
connected = cloud_session.configured(require_compute=False)
except Exception: # noqa: BLE001 - a badge must never break /bootstrap
connected = False
if not connected:
- return {"plan": "local", "features": [], "source": "local",
- "cloud_access_active": False, "checked_at": 0.0}
+ entitlement = {"plan": "local", "features": [], "source": "local",
+ "cloud_access_active": False, "checked_at": 0.0}
+ entitlement.update(_unknown_trial_facts())
+ return entitlement
session = _session_entitlement()
if session:
_refresh_entitlement_in_background(session)
- return {"plan": session["plan"], "features": list(session["features"]),
- "source": "session", "cloud_access_active": session["cloud_access_active"],
- "checked_at": session["fetched_at"]}
+ return _resolved_entitlement(session, source="session")
cached = _read_entitlement_cache()
_refresh_entitlement_in_background(cached)
if cached:
- return {"plan": cached["plan"], "features": list(cached["features"]),
- "source": "cloud", "cloud_access_active": cached["cloud_access_active"],
- "checked_at": cached["fetched_at"]}
+ return _resolved_entitlement(cached, source="cloud")
# Connected, but the control plane has never answered (first boot after onboarding, or
# offline since). ``pro`` unlocks what every paid plan includes and leaves only the Team
# upsell showing; the refresh scheduled above corrects it.
- return {"plan": "pro", "features": entitled_features("pro"), "source": "connected",
- "cloud_access_active": True, "checked_at": 0.0}
+ #
+ # It says nothing about a trial either way. Guessing "no trial consumed" here would
+ # re-offer the trial CTA to a connected organization the server will refuse -- a
+ # connected installation always has an organization, and that organization is on a
+ # trial, has spent one, or is paying -- so this reports the trial as consumed and lets
+ # the refresh replace the guess with the answer.
+ entitlement = {"plan": "pro", "features": entitled_features("pro"),
+ "source": "connected", "cloud_access_active": True, "checked_at": 0.0}
+ entitlement.update(_unknown_trial_facts())
+ entitlement["trial_consumed"] = True
+ return entitlement
+
+
+def _resolved_entitlement(known: dict, *, source: str) -> dict:
+ """Shape one persisted answer into the resolver's return value."""
+
+ resolved = {
+ "plan": known["plan"],
+ "features": list(known["features"]),
+ "source": source,
+ "cloud_access_active": known["cloud_access_active"],
+ "checked_at": known["fetched_at"],
+ }
+ resolved.update(_trial_facts(known))
+ return resolved
def _hosted_plan() -> str:
@@ -2375,15 +2529,76 @@ def hosted_plan_summary() -> dict:
entitlement = {"plan": plan, "features": entitled_features(plan),
"source": "override", "cloud_access_active": plan != "local",
"checked_at": 0.0}
+ entitlement.update(_unknown_trial_facts())
+ state = _access_state(entitlement)
+ # A grant this client cannot honour is not a grant. When the control plane itself
+ # denies access it empties ``cloud_features`` and this is a no-op; when the *disclosed
+ # trial boundary* is what ended the access, the last live answer's grants are still
+ # sitting in the session record and the cache, and echoing them would tick feature rows
+ # every hosted call is about to reject. One rule for both: the features are the ones
+ # that are live right now.
+ features = list(entitlement["features"]) if state in ("active", "trial") else []
return {
"plan": entitlement["plan"],
- "features": list(entitlement["features"]),
+ "features": features,
"plan_source": entitlement["source"],
"cloud_access_active": entitlement["cloud_access_active"],
"plan_checked_at": entitlement["checked_at"],
+ "entitlement_status": entitlement["status"],
+ "is_trial": entitlement["is_trial"],
+ "trial_consumed": entitlement["trial_consumed"],
+ "trial_ends_at": entitlement["trial_ends_at"],
+ "access_state": state,
}
+#: Why the customer's hosted features are, or are not, available right now. The dashboard
+#: renders one explanation per value; every one of them is a different thing to tell the
+#: customer and a different thing to ask them to do.
+#:
+#: * ``trial`` — a live trial. Say when it ends and offer to buy.
+#: * ``active`` — a live paid subscription. Nothing to explain.
+#: * ``trial_expired`` — the free trial ran out. Offer to buy; never offer another trial.
+#: * ``lapsed`` — a subscription that is no longer live (cancelled, expired, unpaid).
+#: Send the customer to billing, not to a trial they cannot start.
+#: * ``inactive`` — no hosted plan at all. This is the only state a trial is offerable in.
+#:
+#: Before this existed, the last three were indistinguishable on screen: the client kept
+#: ``plan="pro"`` with an emptied feature list, so a customer whose trial had ended and a
+#: customer whose card had failed both saw a confident PRO badge over rows of locks with
+#: no reason given.
+_ACCESS_STATES = ("active", "trial", "trial_expired", "lapsed", "inactive")
+
+
+def _trial_boundary_passed(ends_at: object, now: Optional[float] = None) -> bool:
+ """Has the disclosed trial boundary already gone by? Unknown boundaries say no."""
+
+ boundary = _epoch_seconds(ends_at)
+ if boundary <= 0:
+ return False
+ return (time.time() if now is None else float(now)) >= boundary
+
+
+def _access_state(entitlement: dict, now: Optional[float] = None) -> str:
+ """Classify why hosted features are available or locked. Never raises."""
+
+ if entitlement.get("plan") not in ("pro", "team"):
+ return "inactive"
+ is_trial = bool(entitlement.get("is_trial"))
+ if not entitlement.get("cloud_access_active"):
+ return "trial_expired" if is_trial else "lapsed"
+ # ``cloud_access_active`` is only ever as fresh as the last answer from the control
+ # plane, and a cached one can outlive the trial it describes: an installation that
+ # stays offline past ``trial_ends_at`` keeps reading back the ``true`` saved while the
+ # trial was live. Left alone this reported ``trial`` indefinitely — the dashboard
+ # calling a trial live under a printed end date already in the past, and
+ # ``/api/license`` advertising paid features every hosted call would be denied. The
+ # boundary the server itself disclosed settles it without another request.
+ if is_trial and _trial_boundary_passed(entitlement.get("trial_ends_at"), now):
+ return "trial_expired"
+ return "trial" if is_trial else "active"
+
+
@router.get("/license")
def get_license():
"""Hosted plan presentation for the dashboard; Cloud remains the authority.
@@ -2407,11 +2622,35 @@ def get_license():
"plan_source": summary["plan_source"],
"plan_checked_at": summary["plan_checked_at"],
"cloud_access_active": summary["cloud_access_active"],
- # The client cannot distinguish a trial from a paid subscription; the control
- # plane owns that. Report the honest default so the badge falls back to the plan
- # name instead of silently claiming a trial the customer may not be on.
- "is_trial": False,
- "trial": {"used": False, "trial_days": licensing.TRIAL_DAYS},
+ # Why the hosted features above are, or are not, live. The dashboard renders one
+ # explanation per value rather than a plan badge over unexplained locks.
+ "access_state": summary["access_state"],
+ # The control plane's own entitlement status, when it named one. ``""`` means it
+ # has not, or that its last answer was contradicted by a billing denial.
+ "entitlement_status": summary["entitlement_status"],
+ # The control plane owns the trial, and now says so on the calls this client
+ # already makes. Hardcoding these to ``False`` made the dashboard's TRIAL badge
+ # unreachable and, because ``used`` never became true, offered "Start your free
+ # trial" forever — to active subscribers and to customers whose trial was already
+ # spent, both of whom the server answers with ``TrialAlreadyConsumedError``.
+ "is_trial": summary["is_trial"],
+ "trial": {
+ "used": summary["trial_consumed"],
+ "active": summary["access_state"] == "trial",
+ # Epoch seconds, ``0`` when there is no live trial boundary to disclose. A
+ # converted paying customer is deliberately never told about a past one.
+ "ends_at": summary["trial_ends_at"],
+ # A trial is offerable only to an installation that belongs to no organization
+ # yet. ``start_trial`` refuses every organization that already holds an
+ # entitlement, so a connected customer — trialling, lapsed, or paying — can
+ # only ever be answered 409 by the button.
+ "available": (
+ not summary["trial_consumed"]
+ and summary["access_state"] == "inactive"
+ and summary["plan_source"] == "local"
+ ),
+ "trial_days": licensing.TRIAL_DAYS,
+ },
"cloud_managed": True,
"trial_seconds": 259_200,
"grace_seconds": 86_400,
@@ -2421,6 +2660,12 @@ def get_license():
# URL sent every Team upgrade click to the Pro page.
"pro_upgrade_url": licensing.upgrade_url("pro"),
"team_upgrade_url": licensing.upgrade_url("team"),
+ # The plan-neutral account entry point, for the actions that are not a purchase —
+ # "Open account portal" on a lapsed subscription above all. ``upgrade_url()``
+ # cannot serve that: with no argument it resolves ``plan="pro"`` and prefers
+ # ``ENGRAPHIS_PRO_UPGRADE_URL``, so wherever the checkout and the portal are
+ # configured as distinct pages it is the Pro checkout wearing a neutral name.
+ "account_url": licensing.account_url(),
}
diff --git a/engraphis/static/dashboard.css b/engraphis/static/dashboard.css
index fc2c328..8af1af2 100644
--- a/engraphis/static/dashboard.css
+++ b/engraphis/static/dashboard.css
@@ -255,6 +255,9 @@ body{
.diff-ins{border-radius:var(--radius-sm);background:var(--color-success-bg);color:var(--color-success)}
.diff-del{border-radius:var(--radius-sm);background:var(--color-danger-bg);color:var(--color-danger);text-decoration:line-through}
.trial-banner{margin-bottom:var(--space-3);padding:var(--space-2) var(--space-3);border:var(--rule) solid var(--color-accent-strong);border-radius:var(--radius-sm);background:var(--accent-bg);color:var(--color-text);font-size:var(--text-xs)}
+.lic-banner{margin:var(--space-3) 0;padding:var(--space-3);border:var(--rule) solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-raised);color:var(--color-text-muted);font-size:var(--text-xs);line-height:1.5}
+.lic-banner strong{display:block;margin-bottom:var(--space-1);color:var(--color-text);font-size:var(--text-sm)}
+.lic-banner-warn{border-color:var(--color-accent-strong);background:var(--accent-bg)}
.update-banner{display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3);padding:var(--space-2) var(--space-3);border:var(--rule) solid var(--color-accent-strong);border-radius:var(--radius-sm);background:var(--accent-bg);color:var(--color-text);font-size:var(--text-xs)}
.update-banner[hidden]{display:none}
.update-banner .ub-text{flex:1;min-width:0}
diff --git a/engraphis/static/dashboard.js b/engraphis/static/dashboard.js
index d9da2e8..a4adb5b 100644
--- a/engraphis/static/dashboard.js
+++ b/engraphis/static/dashboard.js
@@ -129,7 +129,7 @@ async function loadOverviewAnalytics(){
const el=document.getElementById('ov-analytics'),lock=document.getElementById('ov-lock');
try{
const a=await api('/analytics?workspace='+encodeURIComponent(WS||''));
- lock.textContent=(LIC&&LIC.is_trial)?'TRIAL':'';
+ lock.textContent=licTrialActive()?'TRIAL':'';
lock.className='pill pill-muted';
const t=a.totals||{},f=a.decay_forecast||{};
if(t.live==null){
@@ -151,19 +151,70 @@ async function loadOverviewAnalytics(){
}else if(hostedFeatureUnavailable(e)){
lock.textContent='PRO';
lock.className='pill pill-muted';
- const used=LIC&&LIC.trial&&LIC.trial.used;
- el.innerHTML='
Hosted growth, retention distribution, and decay forecast.
Hosted growth, retention distribution, and decay forecast.
'+esc(lockReason(false))+'
'+(offerTrial?' ':'')+'
';
}else el.innerHTML='
'+esc(e.message)+'
';
}
}
+/* ── hosted access state ──
+ /api/license reports what the control plane said, never what this client guessed:
+ access_state is one of active | trial | trial_expired | lapsed | inactive, and each one
+ is a different thing to tell the customer. Reading it here is what stopped a customer
+ whose trial had ended, and a customer whose card had failed, both being shown a
+ confident PRO badge over rows of locks with no reason given. */
+function licAccessState(){const s=LIC&&LIC.access_state;return s==='active'||s==='trial'||s==='trial_expired'||s==='lapsed'?s:'inactive'}
+function licAccessLive(){const s=licAccessState();return s==='active'||s==='trial'}
+function licTrialActive(){return licAccessState()==='trial'}
+/* The server refuses a second trial for any organization that already holds an
+ entitlement, so this is false for every connected customer — trialling, lapsed, or
+ paying — and the CTA that could only ever return 409 is not drawn. */
+function licTrialAvailable(){return !!(LIC&&LIC.trial&&LIC.trial.available)}
+function licPlanName(){return licPlanKey()?licPlanKey().toUpperCase():'LOCAL'}
+/* The plan the customer actually holds, as the wire spells it: '' when they hold none.
+ Renewal and billing actions must follow this, not a hardcoded 'pro'. */
+function licPlanKey(){const raw=String((LIC&&LIC.plan)||'local').toLowerCase();return raw==='pro'||raw==='team'?raw:''}
+function licTrialEnds(){return fmtDay(LIC&&LIC.trial&&LIC.trial.ends_at)}
+function fmtDay(epoch){const n=Number(epoch)||0;if(!(n>0))return '';try{const d=new Date(n*1000);return Number.isFinite(d.getTime())?d.toISOString().slice(0,10):''}catch(e){return ''}}
+
/* ── shared hosted upgrade / trial CTA ── */
+/* The plan-neutral hosted entry point. An account portal is not a checkout for either
+ plan, so it must not carry the ?plan= that would reframe it as one — and it cannot be
+ LIC.upgrade_url either: that is licensing.upgrade_url(), which resolves plan="pro" and
+ prefers ENGRAPHIS_PRO_UPGRADE_URL, so where the portal and the checkout are configured
+ separately it is the Pro checkout under a neutral name. LIC.account_url resolves the
+ generic value directly; the fallback only matters against a build that predates it. */
+function hostedAccountUrl(){return safeUrl((LIC&&LIC.account_url)||(LIC&&LIC.upgrade_url))}
function hostedPlanUrl(plan,trial){const raw=(LIC&&(plan==='team'?LIC.team_upgrade_url:LIC.pro_upgrade_url))||(LIC&&LIC.upgrade_url);const safe=safeUrl(raw);if(!safe||safe==='#')return '#';try{const url=new URL(safe,location.href);if(plan==='pro'||plan==='team')url.searchParams.set('plan',plan);if(trial)url.searchParams.set('trial',plan);return url.href}catch(e){return safe}}
-function unlockHtml(feature,plan){const url=hostedPlanUrl(plan),trialUrl=hostedPlanUrl(plan,true),team=plan==='team';const used=LIC&&LIC.trial&&LIC.trial.used;const trial=team?'Start hosted Team trial':'Start hosted Pro trial';const purchase=team?'Purchase Team license':'Purchase Pro license';const price=team?'$20 per seat/month or $200 per seat/year':'$10/month or $100/year';const detail=used?'Your free trial has already been used.':`The email-confirmed, no-card trial lasts exactly ${TRIAL_DAYS} active days.`;const benefits=team?['Everything in Pro','Hosted organizations, invitations, and named seats','Roles, scoped credentials, and Team audit history']:['Hosted Cloud Sync across your installations','Growth, retention, decay, and entity Analytics','Auto Consolidation with hosted retention policies','Auto Dreaming with reviewable managed proposals','Priority support'];return `
ENGRAPHIS ${team?'TEAM':'PRO'}
Unlock ${esc(feature)} and more
Make the local memory engine work across your installations—and keep improving without manual upkeep.
`}
+/* Why is this feature locked? One sentence per access state, so the panel never claims a
+ trial the customer cannot start nor blames billing for a trial that simply ran out.
+ This is DENIAL copy: every caller reaches it because a hosted request was refused. The
+ one surface that is not a denial — the Team tab, which renders for everyone on every
+ visit — must go through teamTeaserNote() instead. */
+function lockReason(team){const st=licAccessState(),ends=licTrialEnds();
+ if(st==='trial')return `Your free trial is live${ends?` until ${esc(ends)}`:''}. ${team?'Team':'Pro'} needs a subscription of its own.`;
+ if(st==='trial_expired')return `Your free trial has ended${ends?` (${esc(ends)})`:''}, so hosted features are locked. The trial cannot be started again.`;
+ if(st==='lapsed')return `Your ${esc(licPlanName())} subscription is no longer active, so hosted features are locked until billing is up to date.`;
+ if(st==='active')return `Your ${esc(licPlanName())} subscription does not include this.`;
+ if(licTrialAvailable())return `The email-confirmed, no-card trial lasts exactly ${TRIAL_DAYS} active days.`;
+ return 'Your free trial has already been used.'}
+/* The Team tab describes the hosted service; it is not an answer to a refused request, and
+ it renders for every customer including the ones who are paying for Team. Handing it
+ lockReason(true) told a live Team subscriber "Your TEAM subscription does not include
+ this" directly above an unlocked Team nav item and an Open Team Cloud button. A customer
+ whose live plan already grants Team gets the truth; everyone else still gets the denial
+ copy, which for them is accurate. */
+function teamTeaserNote(){const ends=licTrialEnds();
+ if(licPlanKey()!=='team'||!licAccessLive())return lockReason(true);
+ if(licAccessState()==='trial')return `Your free trial includes Team${ends?` until ${esc(ends)}`:''}. Organizations, roles, and seats are managed in Engraphis Cloud.`;
+ return 'Your TEAM subscription includes this. Organizations, roles, and seats are managed in Engraphis Cloud.'}
+function unlockHtml(feature,plan){const url=hostedPlanUrl(plan),trialUrl=hostedPlanUrl(plan,true),team=plan==='team';const offerTrial=licTrialAvailable();const trial=team?'Start hosted Team trial':'Start hosted Pro trial';const purchase=team?'Purchase Team license':'Purchase Pro license';const price=team?'$20 per seat/month or $200 per seat/year':'$10/month or $100/year';const detail=lockReason(team);const benefits=team?['Everything in Pro','Hosted organizations, invitations, and named seats','Roles, scoped credentials, and Team audit history']:['Hosted Cloud Sync across your installations','Growth, retention, decay, and entity Analytics','Auto Consolidation with hosted retention policies','Auto Dreaming with reviewable managed proposals','Priority support'];return `
ENGRAPHIS ${team?'TEAM':'PRO'}
Unlock ${esc(feature)} and more
Make the local memory engine work across your installations—and keep improving without manual upkeep.
`}
function startTrialPlan(plan){const url=hostedPlanUrl(plan,true);if(url==='#'){toast('Hosted signup URL is not configured','err');return}const link=document.createElement('a');link.href=url;link.target='_blank';link.rel='noopener';link.click()}
function startTrial(){return startTrialPlan('pro')}
function startTeamTrial(){return startTrialPlan('team')}
-function updateLicBadge(){const bd=document.getElementById('lic-badge');if(!bd||!LIC)return;const raw=String(LIC.plan||'local').toLowerCase(),hosted=!!LIC.is_trial||raw==='pro'||raw==='team';bd.textContent=LIC.is_trial?'TRIAL':(hosted?raw.toUpperCase():'LOCAL');bd.className='pill '+(hosted?'pill-accent':'pill-muted')}
+/* The badge follows the access state, not the plan name. A plan name alone told a trialist
+ they were a subscriber, and told a lapsed or expired customer nothing was wrong. */
+function updateLicBadge(){const bd=document.getElementById('lic-badge');if(!bd||!LIC)return;const st=licAccessState(),plan=licPlanName();bd.textContent=st==='trial'?'TRIAL':st==='trial_expired'?'TRIAL ENDED':st==='lapsed'?plan+' INACTIVE':st==='active'?plan:'LOCAL';bd.className='pill '+(licAccessLive()?'pill-accent':'pill-muted')}
function updateFeatureLocks(){
const has=f=>LIC&&(LIC.features||[]).includes(f);
const apply=(id,feature,label,plan)=>{
@@ -202,10 +253,10 @@ function managedConsentRequired(error){return error&&error.status===409&&error.d
the plain-error branch unreachable on the analytics and automation views, which is the
regression this comment previously described but the code did not implement. */
function hostedFeatureUnavailable(error){return !!error&&(error.status===402||error.status===501)}
-async function loadAnalytics(){const el=document.getElementById('analytics-body'),lock=document.getElementById('an-lock'),acts=document.getElementById('an-actions');el.innerHTML='';try{const a=await api('/analytics?workspace='+encodeURIComponent(WS||''));setPlanPill(lock,(LIC&&LIC.is_trial)?'TRIAL':'CLOUD','pill pill-accent');showAs(acts,true,'flex');el.innerHTML=renderAnalytics(a,false)}catch(e){if(managedConsentRequired(e)){setPlanPill(lock,'CLOUD','pill pill-accent');showAs(acts,false);el.innerHTML=managedConsentHtml('Analytics')}else if(hostedFeatureUnavailable(e)){setPlanPill(lock,'PRO','pill pill-muted');showAs(acts,false);el.innerHTML=unlockHtml('Analytics','pro')}else{el.innerHTML='
The cloud returns reviewable proposals. Pinned memories remain protected.
Cloud worker status
Status${p.enabled?'ENABLED':'OFF'}
Last run${esc(last)}
Requesting managed work uploads the selected workspace’s normal and sensitive memory content, excluding secret and session-scoped rows, capped at 16 MiB, over HTTPS without end-to-end encryption. Results are proposals and never automatically write the local database.
`}catch(e){if(managedConsentRequired(e)){setPlanPill(lock,'CLOUD','pill pill-accent');el.innerHTML=managedConsentHtml('Hosted Automation')}else if(hostedFeatureUnavailable(e)){setPlanPill(lock,'PRO','pill pill-muted');el.innerHTML=unlockHtml('Automation, Auto Consolidation, and Auto Dreaming','pro')}else{el.innerHTML='
'+esc(e.message)+'
'}}}
+async function loadAutomation(){const el=document.getElementById('automation-body'),lock=document.getElementById('au-lock'),ws='?workspace='+encodeURIComponent(WS||'');el.innerHTML='';try{const p=await api('/automation'+ws);setPlanPill(lock,licTrialActive()?'TRIAL':'CLOUD','pill pill-accent');const last=p.last_run?fmtRel(p.last_run):'never',dream=p.dream_enabled!=null?p.dream_enabled:p.dream;el.innerHTML=`
Hosted maintenance policy
The cloud returns reviewable proposals. Pinned memories remain protected.
Cloud worker status
Status${p.enabled?'ENABLED':'OFF'}
Last run${esc(last)}
Requesting managed work uploads the selected workspace’s normal and sensitive memory content, excluding secret and session-scoped rows, capped at 16 MiB, over HTTPS without end-to-end encryption. Results are proposals and never automatically write the local database.
`}catch(e){if(managedConsentRequired(e)){setPlanPill(lock,'CLOUD','pill pill-accent');el.innerHTML=managedConsentHtml('Hosted Automation')}else if(hostedFeatureUnavailable(e)){setPlanPill(lock,'PRO','pill pill-muted');el.innerHTML=unlockHtml('Automation, Auto Consolidation, and Auto Dreaming','pro')}else{el.innerHTML='
'+esc(e.message)+'
'}}}
async function saveAutomation(){const body={enabled:document.getElementById('au-enabled').checked,cadence_hours:Number(document.getElementById('au-cadence').value)||24,consolidate:document.getElementById('au-consolidate').checked,min_cluster:Number(document.getElementById('au-mincluster').value)||3,archive_below:Number(document.getElementById('au-archive').value)||0.05,dream_enabled:document.getElementById('au-dream').checked,dream_min_new:Number(document.getElementById('au-dream-min').value)||20,dream_idle_minutes:Number(document.getElementById('au-dream-idle').value)};try{await api('/automation?workspace='+encodeURIComponent(WS||''),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});toast('Hosted policy saved','ok');loadAutomation()}catch(e){if(managedConsentRequired(e)){const result=document.getElementById('au-result');if(result)result.innerHTML=managedConsentHtml('Hosted Automation');toast('Connect this installation to Engraphis Cloud to use managed compute','err');return}toast((e.status===402||e.status===501)?'Hosted Automation requires Pro or Team':e.message,'err')}}
async function runMaintenance(){const el=document.getElementById('au-result');if(el)el.innerHTML='';try{const d=await api('/maintenance/run?workspace='+encodeURIComponent(WS||''),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dry_run:true})});if(el)el.innerHTML=`PROPOSAL Hosted work was submitted for review.
';toast(managedConsentRequired(e)?'Connect this installation to Engraphis Cloud to use managed compute':((e.status===402||e.status===501)?'Hosted Automation requires Pro or Team':e.message),'err')}}
@@ -428,29 +479,56 @@ window.addEventListener('beforeunload',e=>{if(!editorIsDirty())return;e.preventD
document.addEventListener('keydown',e=>{if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='s'&&document.getElementById('view-mem-editor').classList.contains('active')){e.preventDefault();edSave()}});
/* license */
async function loadLicense(){const el=document.getElementById('lic-body');try{const d=await api('/license');LIC=d;updateLicBadge();updateFeatureLocks();renderLicense(d)}catch(e){if(el)el.innerHTML='
'+esc(e.message)+'
'}}
+/* Copy for the control plane's own entitlement status, when it named one. This is what
+ separates "your card was declined" from "you cancelled" inside a single lapsed state. */
+const LIC_STATUS_NOTE={past_due:'the last payment did not go through',canceled:'the subscription was cancelled',expired:'the billing period ended',revoked:'access was revoked'};
+const LIC_SOURCE_LABEL={environment:'operator override',session:'cloud handshake',cloud:'cloud entitlements read',connected:'not yet confirmed',local:'not connected',override:'operator override'};
+/* The whole point of the panel: when hosted features are locked, say why, and offer the
+ one action that fixes it. Never a plan badge over unexplained locks. */
+function licStateBanner(state,plan,ends,status){
+ if(state==='trial_expired')return `
Your free trial has ended${ends?' on '+esc(ends):''}Hosted features are locked. Everything you have written is still in your local database and still fully usable — only the cloud capabilities stopped. The free trial runs once per account and cannot be started again, so restoring them means subscribing.
Your ${esc(plan||'hosted')} subscription is no longer active${note?esc(note.charAt(0).toUpperCase()+note.slice(1))+', so hosted':'Hosted'} features are locked until billing is up to date. Your local memories are unaffected. Open the account portal to restore access.
`}
+ if(state==='inactive')return `
No hosted plan on this installationThe local memory engine is free and complete on its own. Cloud Sync, Analytics, Automation, and Team administration run in Engraphis Cloud.
`;
+ return ''}
+function licActionsHtml(state){
+ if(licTrialAvailable())return ``;
+ /* A lapsed customer is renewing the subscription they already hold, not shopping. Both
+ buttons used to be fixed to pro/team regardless of plan, so "Update billing" walked a
+ lapsed Team customer into the Pro checkout (?plan=pro) — the wrong product offered to
+ fix a billing problem — and "Open account portal" sent a lapsed Pro customer to the
+ Team one. */
+ if(state==='lapsed')return `
`;
+ /* Support diagnostics: which rule produced this answer and when the cloud last confirmed
+ it. Emitted by /api/license since the plan resolver landed, and never shown until now —
+ so "the dashboard says PRO" and "the cloud says PRO" could not be told apart. */
+ if(d.plan_source)h+=`
Plan source${esc(LIC_SOURCE_LABEL[d.plan_source]||d.plan_source)}${d.plan_checked_at?' · confirmed '+esc(fmtRel(d.plan_checked_at)):''}
`;
h+=`
The local core remains free. Pro and Team capabilities execute in Engraphis Cloud. The email-confirmed, no-card trial lasts exactly ${TRIAL_DAYS} active days; local-only write grace is separate, capped at 24 hours, and never extends cloud access.
`
- :``;
+ h+=licActionsHtml(state);
el.innerHTML=h;
}
async function exportWorkspace(){try{const d=await api('/export?workspace='+encodeURIComponent(WS||''));const blob=new Blob([JSON.stringify(d,null,2)],{type:'application/json'});const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='engraphis-export-'+Date.now()+'.json';a.click();URL.revokeObjectURL(a.href);toast('Exported','ok')}catch(e){toast(e.message,'err')}}
/* Hosted Team is a service CTA; local identity and seat administration are not shipped. */
-async function loadTeam(){const el=document.getElementById('team-body');let url=hostedPlanUrl('team');try{const st=await api('/auth/state');if(url==='#'&&st&&st.cloud_url)url=safeUrl(st.cloud_url)}catch(e){}let trialUrl=url;if(url!=='#')try{const parsed=new URL(url,location.href);parsed.searchParams.set('trial','team');trialUrl=parsed.href}catch(e){}el.innerHTML=`
Engraphis Team Cloud HOSTED
Organizations, invitations, roles, named seats, scoped device credentials, and team audit run on the private hosted service. This local dashboard is intentionally single-user.
The email-confirmed trial lasts exactly ${TRIAL_DAYS} active days. A separate local-only write grace is capped at 24 hours and never extends Team or other cloud access.
Organizations, invitations, roles, named seats, scoped device credentials, and team audit run on the private hosted service. This local dashboard is intentionally single-user.
${esc(teamTeaserNote())} A separate local-only write grace is capped at 24 hours and never extends Team or other cloud access.
`}
/* health + settings */
function connectionContext(){const host=(location.hostname||'').toLowerCase();return host==='localhost'||host==='127.0.0.1'||host==='::1'||host.endsWith('.localhost')?'Local engine':'Remote customer node'}
async function checkHealth(){const label=connectionContext();try{await api('/health');const d=document.getElementById('health-dot'),t=document.getElementById('health-text');if(d){d.classList.add('health-ok');d.classList.remove('health-error')}if(t)t.textContent=label+' connected'}catch(e){const d=document.getElementById('health-dot'),t=document.getElementById('health-text');if(d){d.classList.add('health-error');d.classList.remove('health-ok')}if(t)t.textContent=label+' unavailable'}}
diff --git a/tests/e2e/commercial.spec.js b/tests/e2e/commercial.spec.js
index b704b30..8acaed3 100644
--- a/tests/e2e/commercial.spec.js
+++ b/tests/e2e/commercial.spec.js
@@ -16,20 +16,38 @@ const knownFeatures = {
const proFeatures = ['analytics', 'automation', 'consolidation', 'dreaming', 'sync'];
const teamFeatures = [...proFeatures, 'team'];
-function licenseFor(plan, features) {
+// Mirrored from v2_api.get_license(). Every key the dashboard branches on is produced
+// here the way the route produces it, so a fixture cannot drift into a shape the server
+// can never send — `access_state` and `trial.available` in particular are *derived*
+// server-side (v2_api._access_state / the plan_source gate) and are what decide the badge,
+// the panel copy, and whether any surface offers a trial at all.
+function licenseFor(plan, features, overrides = {}) {
+ const paid = plan === 'pro' || plan === 'team';
return {
plan,
features,
known_features: knownFeatures,
cloud_managed: true,
- cloud_access_active: plan !== 'local',
+ cloud_access_active: paid,
+ access_state: paid ? 'active' : 'inactive',
+ entitlement_status: paid ? 'active' : '',
+ plan_source: paid ? 'session' : 'local',
+ plan_checked_at: 0,
+ is_trial: false,
trial_seconds: 259_200,
grace_seconds: 86_400,
grace_scope: 'existing authenticated local workspace writes only',
pro_upgrade_url: 'https://cloud.engraphis.test/pro',
team_upgrade_url: 'https://cloud.engraphis.test/team',
- upgrade_url: 'https://cloud.engraphis.test/pricing',
- trial: { used: false, trial_days: 3 },
+ upgrade_url: 'https://cloud.engraphis.test/pro',
+ // Plan-neutral by construction: `upgrade_url` above is what licensing.upgrade_url()
+ // returns, and that resolves plan="pro". Only `account_url` is safe for the portal.
+ account_url: 'https://cloud.engraphis.test/account',
+ // The control plane refuses a second trial for any organization that already holds an
+ // entitlement, so a connected customer is never offered one — only an installation
+ // that belongs to no organization is.
+ trial: { used: false, active: false, ends_at: 0, available: !paid, trial_days: 3 },
+ ...overrides,
};
}
@@ -39,8 +57,12 @@ const hostedLicense = licenseFor('local', []);
const proLicense = licenseFor('pro', proFeatures);
const teamLicense = licenseFor('team', teamFeatures);
// A Team subscription whose billing has lapsed: the plan is still Team, but the control
-// plane has withdrawn every feature.
-const lapsedTeamLicense = { ...licenseFor('team', []), cloud_access_active: false };
+// plane has withdrawn every feature and named why.
+const lapsedTeamLicense = licenseFor('team', [], {
+ cloud_access_active: false,
+ access_state: 'lapsed',
+ entitlement_status: 'past_due',
+});
async function mockLocalClient(
page,
@@ -334,6 +356,15 @@ test('a paying Team customer sees TEAM with Team administration unlocked', async
.toHaveCount(0);
await expect(licensePanel.getByRole('button', { name: 'Start hosted Pro trial' }))
.toHaveCount(0);
+
+ // The Team tab is a description of the hosted service, not an answer to a denial: it
+ // renders for everyone, so its copy must not tell the customer paying for Team that
+ // their subscription excludes it.
+ await openView(page, 'team');
+ const team = page.locator('#team-body');
+ await expect(team).toContainText('Your TEAM subscription includes this');
+ await expect(team).not.toContainText('does not include');
+ await expect(team.getByRole('link', { name: 'Start hosted Team trial' })).toHaveCount(0);
expect(errors).toEqual([]);
});
@@ -353,6 +384,11 @@ test('a paying Pro customer keeps only the Team upsell', async ({ page }) => {
await expect(licensePanel).toContainText(`✓ ${knownFeatures.analytics}`);
await expect(licensePanel).toContainText(`✓ ${knownFeatures.consolidation}`);
await expect(licensePanel).toContainText(`○ ${knownFeatures.team}`);
+
+ // A Pro subscriber genuinely does not have Team; the denial copy is accurate for them.
+ await openView(page, 'team');
+ await expect(page.locator('#team-body'))
+ .toContainText('Your PRO subscription does not include this.');
expect(errors).toEqual([]);
});
@@ -361,17 +397,60 @@ test('a lapsed Team subscription is sent to billing, not to a spent trial', asyn
await mockLocalClient(page, 402, null, null, lapsedTeamLicense);
await page.goto('/');
- // The plan name survives an inactive entitlement so the CTA stays the account portal;
- // the locks come back because the control plane has withdrawn the features.
- await expect(page.getByLabel('Open hosted plan settings')).toHaveText('TEAM');
+ // The badge says the plan AND that it is no longer live: a bare `TEAM` over rows of
+ // locks was the defect. The locks come back because the control plane withdrew the
+ // features.
+ await expect(page.getByLabel('Open hosted plan settings')).toHaveText('TEAM INACTIVE');
await expect(page.locator(navLocks.team)).toHaveText('TEAM');
await expect(page.locator(navLocks.analytics)).toHaveText('PRO');
await openView(page, 'settings');
const licensePanel = page.locator('.settings-license-panel');
- await expect(licensePanel.getByRole('link', { name: 'Open Team Cloud' })).toBeVisible();
+ // Why it is locked, in the customer's own terms, including the status the cloud named.
+ await expect(licensePanel).toContainText('subscription is no longer active');
+ await expect(licensePanel).toContainText('The last payment did not go through');
+ await expect(licensePanel).toContainText('Your local memories are unaffected');
+ // Renewal goes to the plan they actually hold — never the Pro checkout — and the portal
+ // is the plan-neutral hosted entry point rather than a second checkout.
+ await expect(licensePanel.getByRole('link', { name: 'Update billing' }))
+ .toHaveAttribute('href', 'https://cloud.engraphis.test/team?plan=team');
+ await expect(licensePanel.getByRole('link', { name: 'Open account portal' }))
+ .toHaveAttribute('href', 'https://cloud.engraphis.test/account');
+ // A lapsed subscription is a billing problem, not an unspent trial.
await expect(licensePanel.getByRole('button', { name: 'Start hosted Team trial' }))
.toHaveCount(0);
+ await expect(licensePanel.getByRole('button', { name: 'Start hosted Pro trial' }))
+ .toHaveCount(0);
+ expect(errors).toEqual([]);
+});
+
+test('a spent trial says so, and is never offered another one', async ({ page }) => {
+ const errors = recordBrowserErrors(page);
+ // The state that was unreachable before this client read `access_state`: the trial ran
+ // out, so the plan name is still Pro but nothing it grants is live.
+ await mockLocalClient(page, 402, null, null, licenseFor('pro', [], {
+ cloud_access_active: false,
+ access_state: 'trial_expired',
+ entitlement_status: 'expired',
+ is_trial: true,
+ trial: { used: true, active: false, ends_at: 1751068800, available: false, trial_days: 3 },
+ }));
+ await page.goto('/');
+
+ await expect(page.getByLabel('Open hosted plan settings')).toHaveText('TRIAL ENDED');
+
+ await openView(page, 'settings');
+ const licensePanel = page.locator('.settings-license-panel');
+ await expect(licensePanel).toContainText('Your free trial has ended on 2025-06-28');
+ await expect(licensePanel).toContainText('still in your local database');
+ await expect(licensePanel).toContainText('cannot be started again');
+ // Buyable, not trialable.
+ await expect(licensePanel.getByRole('link', { name: 'Subscribe to Pro' }))
+ .toHaveAttribute('href', 'https://cloud.engraphis.test/pro?plan=pro');
+ await expect(licensePanel.getByRole('link', { name: 'Subscribe to Team' }))
+ .toHaveAttribute('href', 'https://cloud.engraphis.test/team?plan=team');
+ await expect(licensePanel.getByRole('button', { name: 'Start hosted Pro trial' }))
+ .toHaveCount(0);
expect(errors).toEqual([]);
});
diff --git a/tests/test_client_launch.py b/tests/test_client_launch.py
index b65d4bf..8d77186 100644
--- a/tests/test_client_launch.py
+++ b/tests/test_client_launch.py
@@ -33,7 +33,7 @@
# install. Skip rather than error at collection, matching the rest of the suite.
pytest.importorskip("fastapi", reason="full-stack extra not installed")
-from engraphis import update_check # noqa: E402
+from engraphis import hosted_client, update_check # noqa: E402
from engraphis.routes import v2_api # noqa: E402
REPO_ROOT = Path(__file__).resolve().parents[1]
@@ -424,11 +424,46 @@ def test_license_route_emits_every_field_the_dashboard_reads(monkeypatch) -> Non
payload = v2_api.get_license()
for field in ("plan", "features", "known_features", "is_trial", "trial",
- "upgrade_url", "pro_upgrade_url", "team_upgrade_url"):
+ "access_state", "entitlement_status",
+ "upgrade_url", "pro_upgrade_url", "team_upgrade_url", "account_url"):
assert field in payload, field
- assert "used" in payload["trial"]
+ # ``used`` gates every "Start your free trial" affordance; ``available`` is what stops
+ # one being offered to a customer the control plane will answer 409 for; ``active`` and
+ # ``ends_at`` are what let the panel name a live trial and its boundary.
+ for field in ("used", "active", "available", "ends_at", "trial_days"):
+ assert field in payload["trial"], field
# Pro and Team bill through separate checkout targets.
assert payload["pro_upgrade_url"] and payload["team_upgrade_url"]
+ assert payload["account_url"]
+
+
+def test_the_account_url_is_plan_neutral_where_the_checkouts_are_separate(
+ monkeypatch,
+) -> None:
+ """"Open account portal" must not be the Pro checkout wearing a neutral name.
+
+ ``licensing.upgrade_url()`` takes no argument here but resolves ``plan="pro"`` inside,
+ so it prefers ``ENGRAPHIS_PRO_UPGRADE_URL`` — which is exactly the page a lapsed
+ customer with a payment-method problem must not be sent to. ``account_url`` resolves
+ the generic value directly.
+ """
+
+ monkeypatch.setenv("ENGRAPHIS_CLOUD_PLAN", "pro")
+ monkeypatch.setenv("ENGRAPHIS_UPGRADE_URL", "https://cloud.test/account")
+ monkeypatch.setenv("ENGRAPHIS_PRO_UPGRADE_URL", "https://cloud.test/checkout/pro")
+ monkeypatch.setenv("ENGRAPHIS_TEAM_UPGRADE_URL", "https://cloud.test/checkout/team")
+
+ payload = v2_api.get_license()
+
+ assert payload["account_url"] == "https://cloud.test/account"
+ assert payload["pro_upgrade_url"] == "https://cloud.test/checkout/pro"
+ assert payload["team_upgrade_url"] == "https://cloud.test/checkout/team"
+ # The regression itself: the generic key really is the Pro checkout here.
+ assert payload["upgrade_url"] == "https://cloud.test/checkout/pro"
+
+ # With nothing configured it falls back to the hosted account root, never to a plan.
+ monkeypatch.delenv("ENGRAPHIS_UPGRADE_URL")
+ assert v2_api.get_license()["account_url"] == hosted_client.DEFAULT_CLOUD_URL
# Every advertised key must be renderable, and every grantable key advertised.
assert set(payload["features"]) <= set(payload["known_features"])
assert set(v2_api.entitled_features("team")) == set(payload["known_features"])
diff --git a/tests/test_dashboard_auth_placement.py b/tests/test_dashboard_auth_placement.py
index fbcf0d6..ee23c00 100644
--- a/tests/test_dashboard_auth_placement.py
+++ b/tests/test_dashboard_auth_placement.py
@@ -93,6 +93,11 @@ def test_hosted_views_delegate_entitlement_to_cloud_proxy_responses():
# regression it guards (409 folded into ``hostedFeatureUnavailable``) kept every string
# these files already assert on, and only a run can tell which branch actually won.
_ROUTED_FUNCTIONS = (
+ # The access-state readers the panel copy is now derived from. They are bundled as the
+ # real shipped functions rather than stubbed, so "does this customer get offered a
+ # trial" is answered here by the code that answers it in the browser.
+ "licAccessState", "licAccessLive", "licTrialActive", "licTrialAvailable",
+ "licPlanName", "licPlanKey", "licTrialEnds", "fmtDay", "lockReason",
"hostedPlanUrl", "unlockHtml", "managedConsentHtml",
"managedConsentRequired", "hostedFeatureUnavailable",
"loadAnalytics", "loadAutomation",
@@ -115,9 +120,15 @@ def test_hosted_views_delegate_entitlement_to_cloud_proxy_responses():
function toast(){}
const TRIAL_DAYS = 3, WS = 'workspace';
let CURRENT_VIEW = 'overview';
-const LIC = {pro_upgrade_url:'https://engraphis.com/pricing',
+// The default is an unconnected installation: no hosted plan, unspent trial, and the
+// control plane says a trial may still be started. A case can replace ``access_state`` and
+// ``trial`` to model a trialist, a spent trial, a paying customer, or a lapsed one.
+const LIC_BASE = {pro_upgrade_url:'https://engraphis.com/pricing',
team_upgrade_url:'https://engraphis.com/pricing?plan=team',
- upgrade_url:'https://engraphis.com/pricing', trial:{used:false}};
+ upgrade_url:'https://engraphis.com/pricing',
+ plan:'local', access_state:'inactive',
+ trial:{used:false, active:false, available:true, ends_at:0}};
+let LIC = LIC_BASE;
const location = {href:'https://127.0.0.1:8077/'};
let THROWN = null;
async function api(){if(THROWN) throw THROWN; return {}}
@@ -129,6 +140,7 @@ def test_hosted_views_delegate_entitlement_to_cloud_proxy_responses():
const out = [];
for (const c of CASES) {
THROWN = Object.assign(new Error(c.message || 'request failed'), c.error);
+ LIC = Object.assign({}, LIC_BASE, c.lic || {});
CURRENT_VIEW = c.view;
for (const key of Object.keys(NODES)) delete NODES[key];
await (c.view === 'analytics' ? loadAnalytics() : loadAutomation());
@@ -222,6 +234,43 @@ def test_a_genuine_entitlement_failure_still_renders_the_upgrade_panel(
assert rendered["pill"] == "PRO"
+@pytest.mark.skipif(shutil.which("node") is None, reason="node is required to run the UI")
+@pytest.mark.parametrize("view", ["analytics", "automation"])
+@pytest.mark.parametrize("state,reason", [
+ ("trial", "Your free trial is live"),
+ ("trial_expired", "Your free trial has ended"),
+ ("lapsed", "no longer active"),
+ ("active", "does not include this"),
+])
+def test_the_upgrade_panel_never_offers_a_trial_the_server_would_refuse(
+ tmp_path, view, state, reason,
+):
+ """``start_trial`` refuses every organization that already holds an entitlement.
+
+ ``trial.used`` was hardcoded false by ``/api/license``, so this panel offered "Start
+ hosted Pro trial" to every connected customer forever — a trialist mid-trial, a
+ customer whose trial had already been spent, and an active subscriber alike. All three
+ got a 409 from the control plane for clicking it. The panel now says which of those
+ four situations the customer is actually in, and only sells what is buyable.
+ """
+
+ rendered = _route(tmp_path, [{
+ "name": "gated", "view": view, "error": {"status": 402},
+ "lic": {
+ "plan": "pro", "access_state": state,
+ "trial": {"used": state != "active", "active": state == "trial",
+ "available": False, "ends_at": 1785240000},
+ },
+ }])["gated"]
+
+ assert 'class="upgrade-panel"' in rendered["html"]
+ # The one thing that must always still be offered.
+ assert "Purchase Pro license" in rendered["html"]
+ # And the one thing that must not.
+ assert "Start hosted Pro trial" not in rendered["html"]
+ assert reason in rendered["html"]
+
+
@pytest.mark.skipif(shutil.which("node") is None, reason="node is required to run the UI")
@pytest.mark.parametrize("view", ["analytics", "automation"])
@pytest.mark.parametrize("status", [400, 401, 500, 503])
@@ -265,6 +314,176 @@ def test_a_transient_hosted_conflict_is_not_answered_with_a_purchase_panel(
assert "managed snapshot generation must advance" in rendered["html"]
+# ── the license panel's own actions, executed rather than grepped ─────────────
+# ``licActionsHtml`` is the second surface that turns an access state into a call to
+# action, and the one a lapsed customer actually clicks. Running it is the only way to see
+# which URL each button really carries.
+_ACTION_FUNCTIONS = (
+ "licAccessState", "licAccessLive", "licTrialAvailable", "licPlanName", "licPlanKey",
+ "licTrialEnds", "fmtDay", "lockReason", "teamTeaserNote",
+ "hostedAccountUrl", "hostedPlanUrl", "licActionsHtml",
+)
+
+_ACTION_STUBS = """
+'use strict';
+function esc(s){return String(s==null?'':s).replace(/[&<>"']/g, c=>(
+ {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
+function safeUrl(u){return (u && typeof u === 'string') ? u : '#'}
+const TRIAL_DAYS = 3;
+// Three distinct hosted targets, so a button carrying the wrong one is visible rather
+// than hidden behind a shared URL.
+// ``upgrade_url`` is deliberately the Pro checkout here: that is what
+// ``licensing.upgrade_url()`` resolves to whenever ENGRAPHIS_PRO_UPGRADE_URL is set, so a
+// portal button reading it instead of ``account_url`` is visible as a wrong URL.
+const LIC_BASE = {pro_upgrade_url:'https://engraphis.example/checkout/pro',
+ team_upgrade_url:'https://engraphis.example/checkout/team',
+ upgrade_url:'https://engraphis.example/checkout/pro',
+ account_url:'https://engraphis.example/account',
+ plan:'local', access_state:'inactive',
+ trial:{used:false, active:false, available:false, ends_at:0}};
+let LIC = LIC_BASE;
+const location = {href:'https://127.0.0.1:8700/'};
+"""
+
+_ACTION_DRIVER = """
+const CASES = JSON.parse(process.argv[2]);
+const out = [];
+for (const c of CASES) {
+ LIC = Object.assign({}, LIC_BASE, c.lic || {});
+ // Exactly how renderLicense and loadTeam call them.
+ out.push({name: c.name, html: licActionsHtml(licAccessState()),
+ teamNote: teamTeaserNote()});
+}
+process.stdout.write(JSON.stringify(out));
+"""
+
+
+def _actions(tmp_path, cases):
+ """Run the shipped license-panel actions over ``cases`` and return what they rendered."""
+
+ bundle = "\n".join([
+ _ACTION_STUBS,
+ "\n".join(_dashboard_function(name) for name in _ACTION_FUNCTIONS),
+ _ACTION_DRIVER,
+ ])
+ runner = tmp_path / "lic_actions.js"
+ runner.write_text(bundle, encoding="utf-8")
+ result = subprocess.run(
+ ["node", str(runner), json.dumps(cases)],
+ capture_output=True, text=True, timeout=60,
+ )
+ assert result.returncode == 0, result.stderr
+ return {row["name"]: row for row in json.loads(result.stdout)}
+
+
+@pytest.mark.skipif(shutil.which("node") is None, reason="node is required to run the UI")
+@pytest.mark.parametrize("plan,mine,theirs", [
+ ("team", "team", "pro"),
+ ("pro", "pro", "team"),
+])
+def test_a_lapsed_customer_renews_the_plan_they_actually_hold(tmp_path, plan, mine, theirs):
+ """"Update billing" was hardcoded to ``hostedPlanUrl('pro')`` for every lapsed plan.
+
+ A lapsed Team customer's most prominent button therefore opened the *Pro* checkout with
+ ``?plan=pro`` — the wrong product offered to fix a billing problem on a subscription
+ they already have — while "Open account portal" sent a lapsed Pro customer to the Team
+ checkout. Neither is a place to update a payment method.
+ """
+
+ html = _actions(tmp_path, [{
+ "name": "lapsed", "lic": {"plan": plan, "access_state": "lapsed"},
+ }])["lapsed"]["html"]
+
+ assert "Update billing" in html and "Open account portal" in html
+ # The renewal follows the plan the customer holds.
+ assert "checkout/%s?plan=%s" % (mine, mine) in html
+ assert theirs not in html
+ # The portal is the plan-neutral hosted entry point; it must not be a checkout at all,
+ # so it carries no ``?plan=`` that would reframe it as one.
+ assert 'href="https://engraphis.example/account"' in html
+ # A lapsed customer is never offered a trial.
+ assert "Start hosted" not in html
+
+
+@pytest.mark.skipif(shutil.which("node") is None, reason="node is required to run the UI")
+def test_a_lapsed_customer_with_no_readable_plan_still_gets_a_billing_target(tmp_path):
+ """``plan`` can be absent or unrecognised; the button must still go somewhere real."""
+
+ html = _actions(tmp_path, [{
+ "name": "lapsed", "lic": {"plan": "", "access_state": "lapsed"},
+ }])["lapsed"]["html"]
+
+ assert "Update billing" in html
+ assert "checkout/pro?plan=pro" in html
+
+
+@pytest.mark.skipif(shutil.which("node") is None, reason="node is required to run the UI")
+@pytest.mark.parametrize("state,expected,absent", [
+ # Only the state a trial can actually be started in draws the trial buttons; the
+ # control plane refuses one for every organization that already holds an entitlement.
+ ("inactive", "Start hosted Pro trial", "Subscribe to Pro"),
+ ("trial_expired", "Subscribe to Pro", "Start hosted Pro trial"),
+ ("trial", "Open Pro Cloud", "Start hosted Pro trial"),
+ ("active", "Open Pro Cloud", "Start hosted Pro trial"),
+])
+def test_each_access_state_offers_the_one_action_that_can_succeed(
+ tmp_path, state, expected, absent,
+):
+ html = _actions(tmp_path, [{
+ "name": state,
+ "lic": {"plan": "pro", "access_state": state,
+ "trial": {"used": state != "inactive", "active": state == "trial",
+ "available": state == "inactive", "ends_at": 0}},
+ }])[state]["html"]
+
+ assert expected in html
+ assert absent not in html
+
+
+@pytest.mark.skipif(shutil.which("node") is None, reason="node is required to run the UI")
+def test_a_paying_team_customer_is_not_told_team_is_excluded(tmp_path):
+ """The Team tab is a description of the hosted service, not an answer to a denial.
+
+ It renders for every customer on every visit, so handing it ``lockReason(true)`` — the
+ copy for a *refused* request — told a live Team subscriber "Your TEAM subscription does
+ not include this" directly above an unlocked Team nav item and an Open Team Cloud
+ button, while the backend plan table grants them the ``team`` feature.
+ """
+
+ rows = _actions(tmp_path, [
+ {"name": "team-active", "lic": {"plan": "team", "access_state": "active"}},
+ {"name": "team-trial", "lic": {
+ "plan": "team", "access_state": "trial",
+ "trial": {"used": True, "active": True, "available": False,
+ "ends_at": 1751068800}}},
+ # Everyone the denial copy is actually about still gets it.
+ {"name": "pro-active", "lic": {"plan": "pro", "access_state": "active"}},
+ {"name": "team-lapsed", "lic": {"plan": "team", "access_state": "lapsed"}},
+ {"name": "team-expired", "lic": {"plan": "team", "access_state": "trial_expired"}},
+ {"name": "free", "lic": {"plan": "local", "access_state": "inactive",
+ "trial": {"used": False, "active": False,
+ "available": True, "ends_at": 0}}},
+ ])
+
+ assert rows["team-active"]["teamNote"] == (
+ "Your TEAM subscription includes this. Organizations, roles, and seats are "
+ "managed in Engraphis Cloud."
+ )
+ assert rows["team-trial"]["teamNote"] == (
+ "Your free trial includes Team until 2025-06-28. Organizations, roles, and seats "
+ "are managed in Engraphis Cloud."
+ )
+ for name in ("team-active", "team-trial"):
+ assert "does not include" not in rows[name]["teamNote"], name
+
+ # A Pro subscriber genuinely does not have Team, and a plan that is no longer live
+ # grants nothing — both keep the accurate denial sentence.
+ assert rows["pro-active"]["teamNote"] == "Your PRO subscription does not include this."
+ assert "no longer active" in rows["team-lapsed"]["teamNote"]
+ assert "free trial has ended" in rows["team-expired"]["teamNote"]
+ assert "exactly 3 active days" in rows["free"]["teamNote"]
+
+
def test_only_an_entitlement_status_may_draw_the_purchase_panel():
"""Pin both routing predicates literally.
diff --git a/tests/test_hosted_plan_resolution.py b/tests/test_hosted_plan_resolution.py
index a867f36..ad9449a 100644
--- a/tests/test_hosted_plan_resolution.py
+++ b/tests/test_hosted_plan_resolution.py
@@ -22,6 +22,7 @@
from __future__ import annotations
import ast
+import calendar
import io
import json
import os
@@ -56,21 +57,53 @@
"pro": ["analytics", "automation", "sync"],
"team": ["analytics", "automation", "sync", "team"],
}
+# The trial disclosure this client now depends on, mirrored from (read-only)
+# engraphis-cloud ``EntitlementDTO`` and ``DeviceRegistrationResponse``. Both surfaces
+# carry the same names on purpose. Renaming any of them server-side reverts this client to
+# "not a trial, none consumed" in silence -- which is precisely the state this suite exists
+# to keep it out of -- so the drift has to fail here instead.
+SERVER_TRIAL_FIELDS = ("status", "is_trial", "trial_consumed", "trial_ends_at")
+
+
+#: A running trial's boundary, as the control plane serializes it (``engraphis-cloud``
+#: ``EntitlementDTO``; Pydantic emits a trailing ``Z`` for UTC, which this client's Python
+#: 3.9 floor cannot hand straight to ``datetime.fromisoformat``).
+#:
+#: Deliberately far in the future rather than "a couple of days out": ``_access_state``
+#: now expires a trial whose disclosed boundary has passed, so a boundary near the date
+#: these tests were written would quietly turn every "live trial" case into an expired one
+#: the week after. ``TRIAL_ENDED_AT`` is the same instant in the past, for the cases that
+#: want a stale ``cloud_access_active=true`` riding a boundary that is already gone.
+TRIAL_ENDS_AT = "2099-07-28T12:00:00Z"
+TRIAL_ENDED_AT = "2020-07-28T12:00:00Z"
+
+
+def _epoch(iso: str) -> float:
+ """Independent reference conversion, so the assertions do not use the code under test."""
+
+ return calendar.timegm(time.strptime(iso.rstrip("Zz"), "%Y-%m-%dT%H:%M:%S"))
def _entitlement_dto(plan: str, *, active: bool = True,
- organization_id: str = ORGANIZATION) -> dict:
+ organization_id: str = ORGANIZATION,
+ is_trial: bool = False, trial_consumed: bool = False,
+ status: str = "", trial_ends_at: str = TRIAL_ENDS_AT) -> dict:
return {
"organization_id": organization_id,
"entitlement_id": "ent_1",
"plan": plan,
- "status": "active" if active else "past_due",
+ "status": status or ("trialing" if is_trial and active
+ else "active" if active else "past_due"),
"cloud_access_active": active,
"cloud_features": SERVER_PLAN_FEATURES[plan] if active else [],
"seat_limit": 5,
"seat_assignment_basis": "named_members",
"starts_at": "2026-01-01T00:00:00Z",
"expires_at": None,
+ "is_trial": is_trial,
+ "trial_consumed": trial_consumed or is_trial,
+ "trial_duration_seconds": 259_200 if is_trial else None,
+ "trial_ends_at": trial_ends_at if is_trial else None,
"version": 3,
}
@@ -1516,7 +1549,412 @@ def test_a_replaced_hosted_plan_still_overrides_the_resolved_entitlement(
assert "team" in upgraded["features"]
-# ── (7) the shipped modules must still build on the supported floor ───────────
+# ── (7) the trial the control plane owns, rendered by the client that never read it ──
+#
+# ``/api/license`` hardcoded ``"is_trial": False`` and ``"trial": {"used": False, ...}``.
+# Those were the only assignments in the file, which made two customer-visible states
+# unreachable and one permanently wrong:
+#
+# * the dashboard's ``'TRIAL'`` badge branch could never be taken, so a trialist saw the
+# same confident PRO/TEAM badge a subscriber sees;
+# * ``trial.used`` never became true, so "Start hosted Pro/Team trial" was offered to
+# everyone forever - including paying subscribers and customers whose trial was already
+# spent, both of whom the control plane answers ``TrialAlreadyConsumedError`` for; and
+# * ``cloud_access_active`` was computed, returned, and rendered nowhere, so a lapsed or
+# expired customer got a PRO badge with every feature row locked and no explanation.
+
+
+def test_a_live_trial_is_reported_as_a_trial(monkeypatch) -> None:
+ """The badge branch the dashboard has always had, finally reachable."""
+
+ _connect(monkeypatch)
+ _serve(monkeypatch, _FakeControlPlane(_entitlement_dto("pro", is_trial=True)))
+
+ payload = _settled_license(monkeypatch)
+
+ assert payload["is_trial"] is True
+ assert payload["access_state"] == "trial"
+ assert payload["entitlement_status"] == "trialing"
+ assert payload["cloud_access_active"] is True
+ assert payload["trial"]["active"] is True
+ assert payload["trial"]["used"] is True
+ assert payload["trial"]["ends_at"] == pytest.approx(
+ _epoch(TRIAL_ENDS_AT)
+ )
+ # A live trial is not an offer to start another one.
+ assert payload["trial"]["available"] is False
+ # A trialist still has the paid feature set while the trial runs.
+ assert set(payload["features"]) >= {"analytics", "automation"}
+
+
+def test_a_spent_trial_is_told_apart_from_a_lapsed_subscription(monkeypatch) -> None:
+ """Both read ``cloud_access_active=false``; only ``is_trial`` separates the copy."""
+
+ _connect(monkeypatch)
+ _serve(
+ monkeypatch,
+ _FakeControlPlane(_entitlement_dto("pro", active=False, is_trial=True)),
+ )
+
+ payload = _settled_license(monkeypatch)
+
+ assert payload["access_state"] == "trial_expired"
+ assert payload["is_trial"] is True
+ assert payload["trial"]["used"] is True
+ assert payload["trial"]["active"] is False
+ assert payload["trial"]["available"] is False
+ assert payload["features"] == []
+
+
+def test_a_lapsed_subscription_is_never_offered_another_trial(monkeypatch) -> None:
+ """A customer whose subscription stopped needs billing, not a 409 from the server."""
+
+ _connect(monkeypatch)
+ _serve(
+ monkeypatch,
+ _FakeControlPlane(
+ _entitlement_dto("team", active=False, trial_consumed=True)
+ ),
+ )
+
+ payload = _settled_license(monkeypatch)
+
+ assert payload["access_state"] == "lapsed"
+ assert payload["is_trial"] is False
+ assert payload["trial"]["available"] is False
+ assert payload["plan"] == "team"
+ assert payload["features"] == []
+
+
+def test_a_paying_customer_is_never_offered_a_trial(monkeypatch) -> None:
+ """``start_trial`` refuses any organization that already holds an entitlement.
+
+ That includes a customer who bought outright and never trialled, so the offer has to
+ be suppressed by the *connection*, not only by ``trial_consumed``.
+ """
+
+ _connect(monkeypatch)
+ _serve(monkeypatch, _FakeControlPlane(_entitlement_dto("team")))
+
+ payload = _settled_license(monkeypatch)
+
+ assert payload["access_state"] == "active"
+ assert payload["is_trial"] is False
+ assert payload["trial"]["used"] is False
+ assert payload["trial"]["available"] is False
+ # No trial boundary is replayed at a paying customer.
+ assert payload["trial"]["ends_at"] == 0.0
+
+
+def test_an_unconnected_installation_is_the_one_place_a_trial_is_offered() -> None:
+ """The acquisition CTA must survive: an installation with no organization can trial."""
+
+ payload = v2_api.get_license()
+
+ assert payload["plan"] == "local"
+ assert payload["access_state"] == "inactive"
+ assert payload["trial"]["available"] is True
+ assert payload["trial"]["used"] is False
+
+
+def test_a_connected_but_unanswered_installation_offers_no_trial(monkeypatch) -> None:
+ """First boot after onboarding: the plan is inferred, the trial must not be.
+
+ A connected installation always belongs to an organization, and that organization is
+ trialling, has spent its trial, or is paying. Guessing "no trial consumed" here is how
+ the button that can only return 409 gets drawn during the one window nothing has
+ answered yet.
+ """
+
+ monkeypatch.delenv("ENGRAPHIS_CLOUD_CONTROL_URL", raising=False)
+ monkeypatch.setattr(cloud_session, "configured", lambda **kw: True)
+ monkeypatch.setattr(v2_api, "_session_entitlement", dict)
+ monkeypatch.setattr(v2_api, "_read_entitlement_cache", dict)
+
+ payload = v2_api.get_license()
+
+ assert payload["plan"] == "pro"
+ assert payload["plan_source"] == "connected"
+ assert payload["trial"]["available"] is False
+ assert payload["trial"]["used"] is True
+
+
+def test_the_trial_arrives_on_the_handshake_the_client_already_makes(monkeypatch) -> None:
+ """The primary path: no extra request, correct on the first boot after onboarding."""
+
+ _connect(monkeypatch, pinned_token=False)
+ cloud = _FakeControlPlane(registration={
+ "plan": "team",
+ "cloud_access_active": True,
+ "cloud_features": SERVER_PLAN_FEATURES["team"],
+ "status": "trialing",
+ "is_trial": True,
+ "trial_consumed": True,
+ "trial_ends_at": TRIAL_ENDS_AT,
+ })
+ _serve(monkeypatch, cloud)
+
+ payload = _settled_license(monkeypatch)
+
+ assert payload["plan_source"] == "session"
+ assert payload["access_state"] == "trial"
+ assert payload["trial"]["ends_at"] == pytest.approx(_epoch(TRIAL_ENDS_AT))
+ # Nothing dialled the entitlements route: the handshake already answered.
+ assert _entitlement_reads(cloud) == []
+
+
+def test_a_billing_denial_turns_a_running_trial_into_an_expired_one(monkeypatch) -> None:
+ """A 402 is authoritative. The trial facts survive it; the stale status does not."""
+
+ _connect(monkeypatch, pinned_token=False)
+ cloud = _FakeControlPlane(registration={
+ "plan": "pro",
+ "cloud_access_active": True,
+ "cloud_features": SERVER_PLAN_FEATURES["pro"],
+ "status": "trialing",
+ "is_trial": True,
+ "trial_consumed": True,
+ "trial_ends_at": TRIAL_ENDS_AT,
+ })
+ _serve(monkeypatch, cloud)
+ assert _settled_license(monkeypatch)["access_state"] == "trial"
+
+ assert cloud_session.record_billing_denial() is True
+ payload = v2_api.get_license()
+
+ assert payload["access_state"] == "trial_expired"
+ assert payload["is_trial"] is True
+ assert payload["trial"]["used"] is True
+ assert payload["trial"]["available"] is False
+ assert payload["features"] == []
+ # The last status the cloud named now contradicts the denial and is not rendered.
+ assert payload["entitlement_status"] == ""
+
+
+def test_a_control_plane_without_the_trial_fields_claims_no_trial(monkeypatch) -> None:
+ """An older control plane omits them; the client must not invent a trial."""
+
+ _connect(monkeypatch)
+ legacy = _entitlement_dto("pro")
+ for field in ("is_trial", "trial_consumed", "trial_duration_seconds",
+ "trial_ends_at", "status"):
+ legacy.pop(field)
+ _serve(monkeypatch, _FakeControlPlane(legacy))
+
+ payload = _settled_license(monkeypatch)
+
+ assert payload["plan"] == "pro"
+ assert payload["is_trial"] is False
+ assert payload["access_state"] == "active"
+ assert payload["entitlement_status"] == ""
+ assert payload["trial"]["ends_at"] == 0.0
+
+
+def test_the_trial_disclosure_survives_a_restart(monkeypatch, tmp_path) -> None:
+ """The compatibility cache round trips the trial through its own parser."""
+
+ monkeypatch.setenv("ENGRAPHIS_STATE_DIR", str(tmp_path))
+ _connect(monkeypatch)
+ _serve(
+ monkeypatch,
+ _FakeControlPlane(_entitlement_dto("team", is_trial=True)),
+ )
+ assert _settled_license(monkeypatch)["access_state"] == "trial"
+
+ reread = v2_api._read_entitlement_cache()
+
+ assert reread["is_trial"] is True
+ assert reread["trial_consumed"] is True
+ assert reread["status"] == "trialing"
+ assert reread["trial_ends_at"] == pytest.approx(_epoch(TRIAL_ENDS_AT))
+
+
+@pytest.mark.parametrize("value", [
+ "2099-07-28T12:00:00Z",
+ "2099-07-28T12:00:00z",
+ "2099-07-28T12:00:00+00:00",
+])
+def test_the_servers_utc_timestamp_is_read_on_the_python_39_floor(value) -> None:
+ """``datetime.fromisoformat`` on 3.9 rejects the ``Z`` Pydantic emits for UTC."""
+
+ assert v2_api._epoch_seconds(value) == pytest.approx(_epoch(TRIAL_ENDS_AT))
+
+
+@pytest.mark.parametrize("value", [
+ None, "", " ", "not-a-date", 0, -1, True, [], {}, "2026-13-45T99:99:99Z",
+])
+def test_an_unreadable_trial_boundary_is_unknown_rather_than_raising(value) -> None:
+ """This runs on the ``/api/bootstrap`` boot path; nothing here may raise."""
+
+ assert v2_api._epoch_seconds(value) == 0.0
+
+
+@pytest.mark.parametrize("value", [
+ float("inf"), float("-inf"), float("nan"), 10 ** 400, -(10 ** 400),
+])
+def test_a_non_finite_trial_boundary_never_reaches_the_json_encoder(value) -> None:
+ """``json.loads`` produces these; ``JSONResponse`` refuses to encode them.
+
+ A malformed entitlement or a hand-edited cache carrying ``1e309`` decodes to ``inf``,
+ and an arbitrary-precision integer literal decodes to an ``int`` no ``float`` can hold.
+ Either one used to travel out through ``/api/license`` and therefore ``/api/bootstrap``,
+ where Starlette's encoder raises on non-JSON-compliant floats — the dashboard failing to
+ boot at all over a value that only ever meant "unknown".
+ """
+
+ assert v2_api._epoch_seconds(value) == 0.0
+ # ``json.loads`` really does hand these back; this is not a synthetic input.
+ assert v2_api._epoch_seconds(json.loads('{"t": 1e309}')["t"]) == 0.0
+ assert v2_api._epoch_seconds(json.loads('{"t": Infinity}')["t"]) == 0.0
+ # And nothing derived from one is emitted, so the payload stays encodable.
+ facts = v2_api._trial_facts({"is_trial": True, "trial_ends_at": value})
+ assert facts["trial_ends_at"] == 0.0
+ json.dumps(facts, allow_nan=False)
+
+
+def test_a_cached_live_trial_expires_once_its_own_boundary_passes(monkeypatch) -> None:
+ """``cloud_access_active`` is only as fresh as the last answer from the control plane.
+
+ An installation that goes offline mid-trial keeps reading back the ``true`` that was
+ saved while the trial was live, so this reported ``trial`` forever: the panel calling
+ a trial live under a printed end date already in the past, and ``/api/license``
+ ticking feature rows every hosted call would now be denied. The server disclosed the
+ boundary; that is enough to settle it without another request.
+ """
+
+ _connect(monkeypatch)
+ _serve(monkeypatch, _FakeControlPlane(
+ _entitlement_dto("pro", is_trial=True, trial_ends_at=TRIAL_ENDED_AT)))
+
+ payload = _settled_license(monkeypatch)
+
+ # The control plane still says access is live — it answered while the trial was.
+ assert payload["cloud_access_active"] is True
+ assert payload["access_state"] == "trial_expired"
+ assert payload["trial"]["active"] is False
+ assert payload["trial"]["used"] is True
+ assert payload["trial"]["available"] is False
+ # A boundary in the past is still disclosed: the panel says *when* it ended.
+ assert payload["trial"]["ends_at"] == pytest.approx(_epoch(TRIAL_ENDED_AT))
+ # And no paid capability is advertised behind the expired boundary.
+ assert payload["features"] == []
+
+ # Going fully offline does not resurrect it: the cache round trips the same boundary.
+ _serve(monkeypatch, _FakeControlPlane(error=urllib.error.URLError("offline")))
+ monkeypatch.setattr(v2_api, "_ENTITLEMENT_REFRESH_SECONDS", 0)
+ offline = v2_api.get_license()
+ _drain_refresh()
+
+ assert offline["access_state"] == "trial_expired"
+ assert offline["features"] == []
+
+
+def test_a_running_trial_is_not_expired_by_a_boundary_that_has_not_arrived() -> None:
+ """The other half of the same rule — and an unknown boundary expires nothing."""
+
+ live = {"plan": "pro", "cloud_access_active": True, "is_trial": True}
+ assert v2_api._access_state(dict(live, trial_ends_at=time.time() + 3600)) == "trial"
+ # A control plane that never named a boundary must not be read as "already over".
+ assert v2_api._access_state(dict(live, trial_ends_at=0.0)) == "trial"
+ assert v2_api._access_state(live) == "trial"
+ assert v2_api._access_state(dict(live, trial_ends_at="not-a-date")) == "trial"
+ # A paid subscription has no trial boundary to cross, whatever is stored.
+ paid = {"plan": "team", "cloud_access_active": True, "is_trial": False}
+ assert v2_api._access_state(dict(paid, trial_ends_at=1.0)) == "active"
+
+
+def test_a_forged_entitlement_status_is_not_rendered() -> None:
+ """Only the control plane's own status vocabulary reaches the panel's copy."""
+
+ assert v2_api._normalized_status("PAST_DUE") == "past_due"
+ for value in ("", None, 7, "your account is fine", "active"):
+ assert v2_api._normalized_status(value) == ""
+
+
+def test_every_access_state_the_dashboard_renders_is_one_the_client_can_produce() -> None:
+ """Close the loop on the shipped JS: an unhandled state would render as a blank badge."""
+
+ script = DASHBOARD_JS.read_text(encoding="utf-8")
+ block = script[script.index("function licAccessState()"):]
+ handled = set(re.findall(r"s==='([a-z_]+)'", block[:block.index("\n")]))
+
+ assert handled | {"inactive"} == set(v2_api._ACCESS_STATES)
+
+ produced = set()
+ for plan in ("local", "pro", "team"):
+ for active in (True, False):
+ for is_trial in (True, False):
+ produced.add(v2_api._access_state({
+ "plan": plan, "cloud_access_active": active, "is_trial": is_trial,
+ }))
+ assert produced == set(v2_api._ACCESS_STATES)
+
+
+def test_the_dashboard_never_offers_a_trial_it_was_not_told_is_available() -> None:
+ """The one gate on every "Start trial" affordance in the shipped asset.
+
+ ``trial.used`` was hardcoded false, so every one of these drew a button whose only
+ outcome, for a connected customer, was a ``TrialAlreadyConsumedError``.
+ """
+
+ script = DASHBOARD_JS.read_text(encoding="utf-8")
+
+ assert "function licTrialAvailable(){return !!(LIC&&LIC.trial&&LIC.trial.available)}" \
+ in script
+ # No affordance may still be gated on the old always-false flag.
+ assert "LIC.trial.used" not in script
+ for marker in ("Start hosted Pro trial", "Start hosted Team trial",
+ "Start exactly '+TRIAL_DAYS+' days free"):
+ index = script.index(marker)
+ window = script[max(0, index - 400):index]
+ assert "licTrialAvailable()" in window, marker
+
+
+def test_the_dashboard_explains_a_locked_state_instead_of_badging_it() -> None:
+ """A PRO badge over rows of locks with no reason given is the defect itself."""
+
+ script = DASHBOARD_JS.read_text(encoding="utf-8")
+ banner = script[script.index("function licStateBanner("):]
+ banner = banner[:banner.index("\nfunction licActionsHtml")]
+
+ for state in ("trial_expired", "lapsed", "inactive"):
+ assert "state==='%s'" % state in banner, state
+ # Each locked state says what happened and what is still safe.
+ assert "free trial has ended" in banner
+ assert "no longer active" in banner
+ assert "local database" in banner
+ # And the badge itself follows the access state, not the plan name alone.
+ assert "function updateLicBadge()" in script
+ badge = script[script.index("function updateLicBadge()"):]
+ badge = badge[:badge.index("\n")]
+ assert "licAccessState()" in badge
+ assert "TRIAL ENDED" in badge and "INACTIVE" in badge
+
+
+def test_both_persisted_answers_read_every_trial_field_the_server_sends() -> None:
+ """One vocabulary across the handshake, the entitlements read, and the cache."""
+
+ resolver = (REPO_ROOT / "engraphis" / "routes" / "v2_api.py").read_text(
+ encoding="utf-8"
+ )
+ session = (REPO_ROOT / "engraphis" / "cloud_session.py").read_text(encoding="utf-8")
+
+ for field in SERVER_TRIAL_FIELDS:
+ assert '"%s"' % field in resolver, "the resolver ignores %s" % field
+ assert '"%s"' % field in session, "the session record drops %s" % field
+
+
+def test_the_plan_source_diagnostic_is_finally_rendered() -> None:
+ """``plan_source``/``plan_checked_at`` were emitted for support and shown nowhere."""
+
+ script = DASHBOARD_JS.read_text(encoding="utf-8")
+
+ assert "d.plan_source" in script
+ assert "d.plan_checked_at" in script
+ assert "LIC_SOURCE_LABEL" in script
+
+
+# ── (8) the shipped modules must still build on the supported floor ───────────
@pytest.mark.parametrize("relative", [
"engraphis/routes/v2_api.py",
"engraphis/routes/memory.py",