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.
'+(used?'':' ')+'
'; + const offerTrial=licTrialAvailable(); + el.innerHTML='
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.

${price}
Your license unlocks

${detail}

${used?'':`${trial}`}${purchase}
`} +/* 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.

${price}
Your license unlocks

${detail}

${offerTrial?`${trial}`:''}${purchase}
`} 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='
'+esc(e.message)+'
'}}} +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,licTrialActive()?'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='
'+esc(e.message)+'
'}}} /* ── hosted automation policy (Pro / Team) ── */ -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,(LIC&&LIC.is_trial)?'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 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.
${esc(JSON.stringify(d,null,2))}
`;toast('Managed proposal requested','ok')}catch(e){if(el)el.innerHTML=managedConsentRequired(e)?managedConsentHtml('Hosted Automation'):'
'+esc(e.message)+'
';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.
`; + if(state==='lapsed'){const note=LIC_STATUS_NOTE[status];return `
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 `
Update billingOpen account portal
`; + const buy=state==='trial_expired'; + const primary=buy?'Subscribe to Pro':'Open Pro Cloud'; + const secondary=buy?'Subscribe to Team':'Open Team Cloud'; + return `
${primary}${secondary}
`} function renderLicense(d){ const el=document.getElementById('lic-body');if(!el)return; - const raw=String(d.plan||'local').toLowerCase(),trial=!!d.is_trial; - const hosted=trial||raw==='pro'||raw==='team'; - const label=trial?(raw==='team'?'TEAM TRIAL':'PRO TRIAL'):(hosted?raw.toUpperCase():'LOCAL CORE'); + const state=licAccessState(),raw=String(d.plan||'local').toLowerCase(); + const plan=raw==='pro'||raw==='team'?raw.toUpperCase():''; + const hosted=state!=='inactive',live=licAccessLive(),ends=licTrialEnds(); + const label=state==='trial'?(plan||'PRO')+' TRIAL':state==='trial_expired'?(plan||'PRO')+' TRIAL ENDED':state==='lapsed'?plan+' INACTIVE':state==='active'?plan:'LOCAL CORE'; const known=d.known_features||{}; const feats=hosted?Object.keys(known).map(f=>`${(d.features||[]).includes(f)?'✓':'○'} ${esc(known[f])}`).join(''):''; - const used=!!(d.trial&&d.trial.used); - let h=`
${hosted?'Hosted plan':'Local runtime'}${esc(label)}
`; + let h=`
${hosted?'Hosted plan':'Local runtime'}${esc(label)}
`; if(d.error)h+=`
Hosted authorization unavailable — ${esc(d.error)}
`; + h+=licStateBanner(state,plan,ends,d.entitlement_status); if(hosted&&d.email&&d.email!=='trial')h+=`
Hosted account${esc(d.email)}
`; - if(hosted&&d.expires)h+=`
${trial?'Trial ends':'Authorization expires'}${new Date(d.expires*1000).toISOString().slice(0,10)}
`; + if(state==='trial'&&ends)h+=`
Trial ends${esc(ends)}
`; + else if(hosted&&d.expires)h+=`
Authorization expires${esc(fmtDay(d.expires))}
`; if(feats)h+=`
${feats}
`; + /* 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+=hosted||used - ?`
Open Pro CloudOpen Team Cloud
` - :`
`; + 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.
Start hosted Team trialOpen Team Cloud
`} +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.
${esc(teamTeaserNote())} A separate local-only write grace is capped at 24 hours and never extends Team or other cloud access.
${licTrialAvailable()?`Start hosted Team trial`:''}Open Team Cloud
`} /* 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",