From 8e5634b81acadb23203cb7fb8474c96db3a406ee Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 6 Aug 2026 16:41:57 -0300 Subject: [PATCH 1/7] user_profile: add pro auto-renewing status (config key `A`) Clients sometimes need to know whether a Pro subscription is terminal or auto-renewing (e.g. "renews on X" vs "expires on X"). Store the backend's `auto_renewing` (from get_pro_status) as a presence-only config flag `A`: 1 when auto-renewing, absent otherwise (terminal / unknown / not Pro). Deliberately not tri-state: unlike blinded_msgreqs `M`, this is backend- derived fact, not a defaulted client preference, so there's no upgrade- default edge case that a distinct "unset" would guard. And no t/T bump -- it's synced pro state like E/I/R, not a user-initiated profile edit. Exposes get_/set_pro_auto_renewing (C++ bool; C 0/1) with unit + C-API coverage. Co-Authored-By: Claude Opus 4.8 (1M context) --- include/session/config/user_profile.h | 26 +++++++++++++++++++++++++ include/session/config/user_profile.hpp | 26 +++++++++++++++++++++++++ src/config/user_profile.cpp | 19 ++++++++++++++++++ tests/test_config_userprofile.cpp | 16 +++++++++++++++ 4 files changed, 87 insertions(+) diff --git a/include/session/config/user_profile.h b/include/session/config/user_profile.h index 17e3a578..4729105f 100644 --- a/include/session/config/user_profile.h +++ b/include/session/config/user_profile.h @@ -399,6 +399,32 @@ LIBSESSION_EXPORT int64_t user_profile_get_pro_access_expiry(const config_object LIBSESSION_EXPORT void user_profile_set_pro_access_expiry( config_object* conf, int64_t access_expiry_ts); +/// API: user_profile/user_profile_get_pro_auto_renewing +/// +/// Returns whether the account's current Session Pro subscription is auto-renewing. Backend-derived +/// (the `auto_renewing` field on /get_pro_status); set alongside the access expiry. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// +/// Outputs: +/// - `int` -- 1 if the subscription is known to be auto-renewing, otherwise 0 (terminal, unknown, +/// or not Pro). +LIBSESSION_EXPORT int user_profile_get_pro_auto_renewing(const config_object* conf); + +/// API: user_profile/user_profile_set_pro_auto_renewing +/// +/// Records whether the current Session Pro subscription is auto-renewing: nonzero stores the flag, +/// 0 clears it (which is also how it is cleared when the subscription lapses). +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// - `auto_renewing` -- [in] nonzero if auto-renewing, 0 to clear +/// +/// Outputs: +/// - `void` +LIBSESSION_EXPORT void user_profile_set_pro_auto_renewing(config_object* conf, int auto_renewing); + /// API: user_profile/user_profile_get_refund_requested /// /// Retrieves the timestamp at which the user requested a refund of their current Session Pro diff --git a/include/session/config/user_profile.hpp b/include/session/config/user_profile.hpp index ea20306c..0fa2eb14 100644 --- a/include/session/config/user_profile.hpp +++ b/include/session/config/user_profile.hpp @@ -40,6 +40,10 @@ using namespace std::literals; /// flight"), so all the account's devices poll the backend to pull the entitlement through. /// Inserted only when not already pro; cleared automatically when entitlement lands; values /// more than a week in the past are ignored on read. +/// A - set to 1 when the current Session Pro subscription is auto-renewing; omitted when it is +/// terminal (will not renew), unknown, or the account isn't Pro. Backend-derived +/// (get_pro_status.auto_renewing) and synced across devices; the client sets it alongside `E` +/// and clears it (sets false) when the subscription lapses. /// P - user profile url after re-uploading (should take precedence over `p` when `T > t`). /// Q - user profile decryption key (binary) after re-uploading (should take precedence over `q` /// when `T > t`). @@ -339,6 +343,28 @@ class UserProfile : public ConfigBase { /// will expire, or nullopt to remove the value. void set_pro_access_expiry(std::optional access_expiry_ts); + /// API: user_profile/UserProfile::get_pro_auto_renewing + /// + /// Returns whether the account's current Session Pro subscription is auto-renewing (true) or + /// terminal/unknown (false). Backend-derived (the `auto_renewing` field on /get_pro_status); + /// the client sets it alongside `set_pro_access_expiry`. Only a `true` value is stored, so an + /// account that isn't Pro, or whose renewal status has not been learned, reads as false. + /// + /// Inputs: None + /// + /// Outputs: + /// - `bool` -- true iff the subscription is known to be auto-renewing. + bool get_pro_auto_renewing() const; + + /// API: user_profile/UserProfile::set_pro_auto_renewing + /// + /// Records whether the current Session Pro subscription is auto-renewing. `true` stores the + /// flag; `false` erases it -- which is also how it is cleared when the subscription lapses. + /// + /// Inputs: + /// - `auto_renewing` -- true if the subscription auto-renews; false to clear the flag. + void set_pro_auto_renewing(bool auto_renewing); + /// API: user_profile/UserProfile::get_refund_requested /// /// Retrieves the timestamp at which the user requested a refund of their current Session Pro diff --git a/src/config/user_profile.cpp b/src/config/user_profile.cpp index 5ca98b05..8bf367ed 100644 --- a/src/config/user_profile.cpp +++ b/src/config/user_profile.cpp @@ -241,6 +241,17 @@ void UserProfile::set_pro_access_expiry(std::optional } } +bool UserProfile::get_pro_auto_renewing() const { + return data["A"].integer_or(0) != 0; +} + +void UserProfile::set_pro_auto_renewing(bool auto_renewing) { + // Presence-only: store 1 when auto-renewing, erase otherwise (absent == terminal/unknown). No + // t/T bump -- this is backend-derived pro state (like E/I/R), not a user-initiated profile + // edit. + set_nonzero_int(data["A"], auto_renewing); +} + std::optional UserProfile::get_refund_requested() const { if (auto* R = data["R"].integer()) { std::chrono::sys_seconds when{std::chrono::seconds{*R}}; @@ -541,6 +552,14 @@ LIBSESSION_C_API void user_profile_set_pro_access_expiry( unbox(conf)->set_pro_access_expiry(as_sys_seconds(access_expiry_ts)); } +LIBSESSION_C_API int user_profile_get_pro_auto_renewing(const config_object* conf) { + return unbox(conf)->get_pro_auto_renewing() ? 1 : 0; +} + +LIBSESSION_C_API void user_profile_set_pro_auto_renewing(config_object* conf, int auto_renewing) { + unbox(conf)->set_pro_auto_renewing(auto_renewing != 0); +} + LIBSESSION_C_API int64_t user_profile_get_refund_requested(const config_object* conf) { if (auto when = unbox(conf)->get_refund_requested()) return epoch_seconds(*when); diff --git a/tests/test_config_userprofile.cpp b/tests/test_config_userprofile.cpp index 856386b9..b3bb3b21 100644 --- a/tests/test_config_userprofile.cpp +++ b/tests/test_config_userprofile.cpp @@ -336,6 +336,12 @@ TEST_CASE("user profile C API", "[config][user_profile][c]") { CHECK(user_profile_get_blinded_msgreqs(conf2) == -1); user_profile_set_blinded_msgreqs(conf2, 1); CHECK(user_profile_get_blinded_msgreqs(conf2) == 1); + + CHECK(user_profile_get_pro_auto_renewing(conf2) == 0); + user_profile_set_pro_auto_renewing(conf2, 1); + CHECK(user_profile_get_pro_auto_renewing(conf2) == 1); + user_profile_set_pro_auto_renewing(conf2, 0); + CHECK(user_profile_get_pro_auto_renewing(conf2) == 0); UserProfileTester::set_profile_updated(conf2, std::chrono::sys_seconds{124s}); // Both have changes, so push need a push @@ -654,6 +660,16 @@ TEST_CASE("UserProfile Pro Storage", "[config][user_profile][pro]") { profile.set_pro_access_expiry(access_expiry); CHECK(profile.get_pro_access_expiry() == access_expiry); + // Pro auto-renewing flag: presence-only, defaults to false, and (backend-derived state, not a + // user edit) does not stamp the profile-updated timestamp. + CHECK_FALSE(profile.get_pro_auto_renewing()); + UserProfileTester::set_profile_updated(profile, std::chrono::sys_seconds{456s}); + profile.set_pro_auto_renewing(true); + CHECK(profile.get_pro_auto_renewing()); + CHECK(profile.get_profile_updated().time_since_epoch().count() == 456); + profile.set_pro_auto_renewing(false); + CHECK_FALSE(profile.get_pro_auto_renewing()); + // Refund-requested flag (synced via config, not the Pro backend) CHECK_FALSE(profile.get_refund_requested().has_value()); From 269f8b8872fc47668fd7cdbb571c45021d5305cf Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 7 Aug 2026 16:48:38 +1000 Subject: [PATCH 2/7] user_profile: add the pro grace period (config key `G`) Completes the config side of #121 against the Pro status-refresh spec, which asks for both `auto_renewing` and `grace` to be synced alongside `E`. #121 ships `auto_renewing`; this adds the grace period. The backend folds the grace period into the stored expiry for auto-renewing subscriptions (`payment_expiry_at = expiry_at + grace if auto_renewing`) and sends that verbatim as `get_pro_status.expiry_ts`, so `E` is the end of coverage rather than the date a renewal is due. With `G` synced, any device recovers the paid-through instant as `E - G`; without it a config-only consumer cannot compute it at all. Not presence-checked, unlike `A`: the backend sends 0 whenever the subscription isn't auto-renewing, so an absent key and a stored zero describe the same account and both give `E - 0 == E`. There is no state a caller could act on differently. Clearing `E` clears `G` with it. A grace that outlived its expiry would pair with whatever wrote `E` next, and `set_pro_access_expiry` already clears `I` and `R` as side effects, so this follows the existing shape. --- include/session/config/user_profile.h | 33 ++++++++++++++++++ include/session/config/user_profile.hpp | 32 +++++++++++++++++ src/config/user_profile.cpp | 30 +++++++++++++++- tests/test_config_userprofile.cpp | 46 +++++++++++++++++++++++++ 4 files changed, 140 insertions(+), 1 deletion(-) diff --git a/include/session/config/user_profile.h b/include/session/config/user_profile.h index 4729105f..135445a3 100644 --- a/include/session/config/user_profile.h +++ b/include/session/config/user_profile.h @@ -425,6 +425,39 @@ LIBSESSION_EXPORT int user_profile_get_pro_auto_renewing(const config_object* co /// - `void` LIBSESSION_EXPORT void user_profile_set_pro_auto_renewing(config_object* conf, int auto_renewing); +/// API: user_profile/user_profile_get_pro_grace_period +/// +/// Returns the account's grace period in seconds (`get_pro_status.grace_period_duration`), or 0 if +/// none is stored. Backend-derived and synced alongside the access expiry, so any linked device can +/// derive the paid-through instant as `access_expiry - grace_period`: the backend folds the grace +/// period into the stored expiry for auto-renewing subscriptions, so the access expiry is the end +/// of coverage rather than the date the renewal is due. +/// +/// There is deliberately no companion presence check: the backend sends 0 whenever the +/// subscription is not auto-renewing, so "unset" and "zero" describe the same account and both give +/// `expiry - 0 == expiry`. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// +/// Outputs: +/// - `int64_t` -- the grace period in seconds, or 0 if unset. +LIBSESSION_EXPORT int64_t user_profile_get_pro_grace_period(const config_object* conf); + +/// API: user_profile/user_profile_set_pro_grace_period +/// +/// Sets the account's grace period, in seconds. Set alongside `user_profile_set_pro_access_expiry` +/// from each `get_pro_status` response; 0 (or negative) clears it. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// - `grace_seconds` -- [in] the grace period in seconds, or 0 to clear +/// +/// Outputs: +/// - `void` +LIBSESSION_EXPORT void user_profile_set_pro_grace_period( + config_object* conf, int64_t grace_seconds); + /// API: user_profile/user_profile_get_refund_requested /// /// Retrieves the timestamp at which the user requested a refund of their current Session Pro diff --git a/include/session/config/user_profile.hpp b/include/session/config/user_profile.hpp index 0fa2eb14..af5631bf 100644 --- a/include/session/config/user_profile.hpp +++ b/include/session/config/user_profile.hpp @@ -44,6 +44,10 @@ using namespace std::literals; /// terminal (will not renew), unknown, or the account isn't Pro. Backend-derived /// (get_pro_status.auto_renewing) and synced across devices; the client sets it alongside `E` /// and clears it (sets false) when the subscription lapses. +/// G - the account's grace period, in seconds (get_pro_status.grace_period_duration), synced so any +/// device can derive the paid-through instant as `E - G`. Backend-derived and set alongside +/// `E`. Omitted when zero, which is also what the backend sends when the subscription is not +/// auto-renewing -- so an absent `G` and a zero `G` mean the same thing and `E - G == E`. /// P - user profile url after re-uploading (should take precedence over `p` when `T > t`). /// Q - user profile decryption key (binary) after re-uploading (should take precedence over `q` /// when `T > t`). @@ -365,6 +369,34 @@ class UserProfile : public ConfigBase { /// - `auto_renewing` -- true if the subscription auto-renews; false to clear the flag. void set_pro_auto_renewing(bool auto_renewing); + /// API: user_profile/UserProfile::get_pro_grace_period + /// + /// Returns the account's grace period (`get_pro_status.grace_period_duration`), or zero if none + /// is stored. Backend-derived and synced alongside `E`, so any linked device can derive the + /// paid-through instant as `get_pro_access_expiry() - get_pro_grace_period()`: the backend + /// folds the grace period into the stored expiry for auto-renewing subscriptions, so `E` is the + /// end of coverage rather than the date the renewal is due. + /// + /// Note this deliberately returns a plain duration rather than an optional: the backend sends + /// zero when the subscription is not auto-renewing, so "no grace stored" and "a grace of zero" + /// describe the same account and both give `E - 0 == E`. There is no state a caller could act + /// on differently, so there is nothing for a presence check to disambiguate. + /// + /// Inputs: None + /// + /// Outputs: + /// - `std::chrono::seconds` -- the grace period, or `0s` if unset. + std::chrono::seconds get_pro_grace_period() const; + + /// API: user_profile/UserProfile::set_pro_grace_period + /// + /// Records the account's grace period, in seconds. Set alongside `set_pro_access_expiry` from + /// each `get_pro_status` response; a zero (or negative) value erases the key. + /// + /// Inputs: + /// - `grace` -- the grace period; zero or negative clears it. + void set_pro_grace_period(std::chrono::seconds grace); + /// API: user_profile/UserProfile::get_refund_requested /// /// Retrieves the timestamp at which the user requested a refund of their current Session Pro diff --git a/src/config/user_profile.cpp b/src/config/user_profile.cpp index 8bf367ed..9189821b 100644 --- a/src/config/user_profile.cpp +++ b/src/config/user_profile.cpp @@ -227,8 +227,16 @@ std::optional UserProfile::get_pro_access_expiry() con void UserProfile::set_pro_access_expiry(std::optional access_expiry_ts) { if (access_expiry_ts) data["E"] = epoch_seconds(*access_expiry_ts); - else + else { data["E"].erase(); + // `G` is only meaningful as `E - G`, so it must never outlive the `E` it was paired with: + // a stranded `G` would silently pair with whatever the *next* `E` write happens to be, and + // that next write is usually a proof outcome, which carries no grace of its own to correct + // it with. Enforced here rather than left to callers because clearing `E` is the common + // case (the proof-outcome clears), and a rule spread across every call site is one a new + // call site inherits wrongly. + data["G"].erase(); + } // Confirming a live entitlement means any in-flight purchase resolved, and any long-stale // refund request is moot -- opportunistically clear both (we're already writing E anyway). @@ -252,6 +260,17 @@ void UserProfile::set_pro_auto_renewing(bool auto_renewing) { set_nonzero_int(data["A"], auto_renewing); } +std::chrono::seconds UserProfile::get_pro_grace_period() const { + return std::chrono::seconds{data["G"].integer_or(0)}; +} + +void UserProfile::set_pro_grace_period(std::chrono::seconds grace) { + // Omitted when zero: the backend sends 0 whenever the subscription isn't auto-renewing, and + // `E - 0 == E`, so an absent key and a stored zero describe the same account. Set alongside + // `E`; no t/T bump -- backend-derived pro state, like E/I/R/A. + set_nonzero_int(data["G"], grace.count() > 0 ? grace.count() : 0); +} + std::optional UserProfile::get_refund_requested() const { if (auto* R = data["R"].integer()) { std::chrono::sys_seconds when{std::chrono::seconds{*R}}; @@ -560,6 +579,15 @@ LIBSESSION_C_API void user_profile_set_pro_auto_renewing(config_object* conf, in unbox(conf)->set_pro_auto_renewing(auto_renewing != 0); } +LIBSESSION_C_API int64_t user_profile_get_pro_grace_period(const config_object* conf) { + return unbox(conf)->get_pro_grace_period().count(); +} + +LIBSESSION_C_API void user_profile_set_pro_grace_period( + config_object* conf, int64_t grace_seconds) { + unbox(conf)->set_pro_grace_period(std::chrono::seconds{grace_seconds}); +} + LIBSESSION_C_API int64_t user_profile_get_refund_requested(const config_object* conf) { if (auto when = unbox(conf)->get_refund_requested()) return epoch_seconds(*when); diff --git a/tests/test_config_userprofile.cpp b/tests/test_config_userprofile.cpp index b3bb3b21..45959ffc 100644 --- a/tests/test_config_userprofile.cpp +++ b/tests/test_config_userprofile.cpp @@ -342,6 +342,18 @@ TEST_CASE("user profile C API", "[config][user_profile][c]") { CHECK(user_profile_get_pro_auto_renewing(conf2) == 1); user_profile_set_pro_auto_renewing(conf2, 0); CHECK(user_profile_get_pro_auto_renewing(conf2) == 0); + + CHECK(user_profile_get_pro_grace_period(conf2) == 0); + user_profile_set_pro_grace_period(conf2, 3600); + CHECK(user_profile_get_pro_grace_period(conf2) == 3600); + // Zero erases and reads back as 0 -- unset and zero are the same account state here, which is + // why there is deliberately no presence check to go with it. + user_profile_set_pro_grace_period(conf2, 0); + CHECK(user_profile_get_pro_grace_period(conf2) == 0); + // Negative clears rather than storing a negative duration. + user_profile_set_pro_grace_period(conf2, -5); + CHECK(user_profile_get_pro_grace_period(conf2) == 0); + UserProfileTester::set_profile_updated(conf2, std::chrono::sys_seconds{124s}); // Both have changes, so push need a push @@ -670,6 +682,40 @@ TEST_CASE("UserProfile Pro Storage", "[config][user_profile][pro]") { profile.set_pro_auto_renewing(false); CHECK_FALSE(profile.get_pro_auto_renewing()); + // Grace period: synced so any device can derive the paid-through instant as `E - G`. The + // backend folds grace INTO the stored expiry for auto-renewing subscriptions, so `E` is the end + // of coverage rather than the renewal-due date -- deriving that is the whole reason this key + // exists. + CHECK(profile.get_pro_grace_period() == 0s); + UserProfileTester::set_profile_updated(profile, std::chrono::sys_seconds{456s}); + profile.set_pro_grace_period(1h); + CHECK(profile.get_pro_grace_period() == 1h); + // Backend-derived, like E/I/R/A: no profile-updated bump. + CHECK(profile.get_profile_updated().time_since_epoch().count() == 456); + // The property the key exists for: coverage end minus grace is the paid-through instant. + profile.set_pro_access_expiry(std::chrono::sys_seconds{5000s}); + CHECK(*profile.get_pro_access_expiry() - profile.get_pro_grace_period() == + std::chrono::sys_seconds{5000s} - 1h); + // Zero clears; unset and zero are indistinguishable *and* equivalent (`E - 0 == E`). + profile.set_pro_grace_period(0s); + CHECK(profile.get_pro_grace_period() == 0s); + CHECK(*profile.get_pro_access_expiry() - profile.get_pro_grace_period() == + std::chrono::sys_seconds{5000s}); + + // Clearing `E` also clears `G`: the pair is only meaningful as `E - G`, so a `G` that outlived + // its `E` would silently pair with the NEXT `E` write -- and that write is typically a proof + // outcome, which carries no grace to correct it with. Enforced in the setter, not at call + // sites. + profile.set_pro_grace_period(1h); + CHECK(profile.get_pro_grace_period() == 1h); + profile.set_pro_access_expiry(std::nullopt); + CHECK_FALSE(profile.get_pro_access_expiry().has_value()); + CHECK(profile.get_pro_grace_period() == 0s); + // ...and a later `E` write therefore cannot inherit the stale grace. + profile.set_pro_access_expiry(std::chrono::sys_seconds{9000s}); + CHECK(*profile.get_pro_access_expiry() - profile.get_pro_grace_period() == + std::chrono::sys_seconds{9000s}); + // Refund-requested flag (synced via config, not the Pro backend) CHECK_FALSE(profile.get_refund_requested().has_value()); From f197a0bd214982b4a83f6952826819b43b804c13 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 10 Aug 2026 12:35:39 +1000 Subject: [PATCH 3/7] pro_backend: parse the grace period and renewal flag off the proof response `generate_pro_proof` returns `account_expiry_ts` so a proof fetch can refresh the client's cached access expiry. The backend now sends the two values that qualify it -- `account_grace_period_duration` and `account_auto_renewing` -- and `parse_pro_proof` dropped both on the floor, so no client could reach them. That mattered because clients persist the account expiry into synced config from this response as well as from `get_pro_status`. Without the companions, a proof fetch wrote a fresh expiry beside a stale grace and a stale renewing flag. The flag is the sharp end: config stores it presence-only, so an account whose expiry has only ever been written by a proof reads back as terminal while it renews. Both are required on a successful proof, exactly like `account_expiry_ts`. A response that can't be paired with the values qualifying its expiry is treated as malformed rather than handing the caller a fresh expiry beside a defaulted grace and flag it would then persist -- and a defaulted `false` is not inert, because writing false to the config key ERASES it. They are zero/false on the failure outcomes that carry no proof, which is also the truthful value for `subscription_expired`, `not_subscribed` and `revoked`. Tests cover the pair round-tripping through the C and C++ parses with `E - G` recovering the paid-through instant, each field missing being a parse error, a wrong-typed flag being a parse error, and the non-auto-renewing account's genuine zero. --- include/session/pro_backend.h | 16 ++++++++++ include/session/pro_backend.hpp | 32 +++++++++++++++++++ src/pro_backend.cpp | 10 ++++++ tests/test_pro_backend.cpp | 55 ++++++++++++++++++++++++++++++++- 4 files changed, 112 insertions(+), 1 deletion(-) diff --git a/include/session/pro_backend.h b/include/session/pro_backend.h index e029f049..071fe7a2 100644 --- a/include/session/pro_backend.h +++ b/include/session/pro_backend.h @@ -140,6 +140,22 @@ typedef struct session_pro_backend_pro_proof_response { /// proof-validity window). Populated on a successful proof and on a `subscription_expired` /// failure (a now-past value); 0 on `not_subscribed` / `revoked` / protocol errors. int64_t account_expiry_ts; + /// The grace period (seconds) folded into `account_expiry_ts`, so the paid-through instant is + /// `account_expiry_ts - account_grace_period_duration`. 0 when the subscription is not + /// auto-renewing. + /// + /// ⚠️ MEANINGFUL ONLY WHEN `header.status` IS OK. Filled only on the success path, so every + /// non-OK outcome -- protocol error, `stale_request`, transport failure, where the account is + /// untouched -- yields a plain 0 that is indistinguishable from "no grace". Nothing in the + /// struct signals which you have. Read it inside the success branch or not at all. + int64_t account_grace_period_duration; + /// Whether the subscription behind `account_expiry_ts` renews itself. + /// + /// ⚠️ MEANINGFUL ONLY WHEN `header.status` IS OK, and this one is the more dangerous of the two: + /// every non-OK outcome yields a plain false, and the config key it feeds is presence-only, + /// where writing false ERASES. Reading it after a failed request destroys a renewing flag + /// learned from `get_pro_status`, on a response that said nothing about the account. + bool account_auto_renewing; } session_pro_backend_pro_proof_response; /// API: session_pro_backend/pro_proof_response_free diff --git a/include/session/pro_backend.hpp b/include/session/pro_backend.hpp index dbf4cb2f..0b1cd032 100644 --- a/include/session/pro_backend.hpp +++ b/include/session/pro_backend.hpp @@ -198,6 +198,38 @@ struct GenerateProProofResponse : ResponseBase { /// value carried top-level on the envelope); nullopt on other outcomes (`not_subscribed`, /// `revoked`, protocol errors). std::optional account_expiry; + + /// The grace period folded into `account_expiry` above, so a client can recover the + /// paid-through instant as `account_expiry - account_grace_period`. Zero whenever the + /// subscription is not auto-renewing, mirroring `get_pro_status`, because the backend only + /// folds grace in for auto-renewing payments. + /// + /// Required on a successful proof, like `account_expiry`: a response missing it can't be paired + /// with the expiry it qualifies, and a client that persisted the two out of step would compute + /// the wrong paid-through instant. + /// + /// ⚠️ **Meaningful ONLY when the response succeeded.** This is default-initialised and is filled + /// only on the success path, so on *every* non-OK outcome -- including a protocol error, a + /// `stale_request`, or a transport failure, where the account is untouched -- it reads as a + /// plain `0`, indistinguishable from a backend that really said "no grace". Nothing in the type + /// signals which you have. Read it inside the success branch or not at all; a caller that + /// persists this after a failed request erases a grace it had learned from `get_pro_status`. + std::chrono::seconds account_grace_period{0}; + + /// Whether the subscription behind `account_expiry` renews itself -- the same value + /// `get_pro_status` reports as `auto_renewing`. Advisory and UNSIGNED, like the two above. + /// + /// Required on a successful proof for the same reason as the grace period: clients persist it + /// into config beside the expiry, and a stale flag next to a fresh expiry reads as a terminal + /// subscription that is in fact renewing. + /// + /// ⚠️ **Meaningful ONLY when the response succeeded**, and this one is the more dangerous of the + /// two. Default-initialised and filled only on the success path, so every non-OK outcome yields + /// a plain `false` -- and the config key it feeds is presence-only, where writing `false` + /// **erases**. A caller that reads it after a protocol error or a `stale_request` therefore + /// destroys a renewing flag learned from `get_pro_status`, on a request that told it nothing + /// about the account at all. Read it inside the success branch or not at all. + bool account_auto_renewing{false}; }; /// Parse the reply to a generate-proof request. On success `proof` holds the issued proof; a diff --git a/src/pro_backend.cpp b/src/pro_backend.cpp index 9a97898e..e4ea2faf 100644 --- a/src/pro_backend.cpp +++ b/src/pro_backend.cpp @@ -311,6 +311,14 @@ namespace { // expiry, which breaks renewal, so treat a missing value as a malformed response. auto account_expiry_ts = json_require(result_obj, "account_expiry_ts"); result.account_expiry = std::chrono::sys_seconds(std::chrono::seconds(account_expiry_ts)); + + // The two values that qualify `account_expiry_ts`, required for the same reason it is: a + // client persists all three into config together, and a fresh expiry beside a stale grace + // or a stale renewing flag is worse than no refresh at all -- it computes a wrong + // paid-through instant, and reads a renewing subscription as terminal. + result.account_grace_period = std::chrono::seconds( + json_require(result_obj, "account_grace_period_duration")); + result.account_auto_renewing = json_require(result_obj, "account_auto_renewing"); } } // namespace @@ -845,6 +853,8 @@ session_pro_backend_pro_proof_response_parse(const char* json, size_t json_len) std::memcpy(result.proof.sig.data, p.sig.data(), p.sig.size()); result.account_expiry_ts = owned->account_expiry ? session::epoch_seconds(*owned->account_expiry) : 0; + result.account_grace_period_duration = owned->account_grace_period.count(); + result.account_auto_renewing = owned->account_auto_renewing; // All C fields aliased into *owned; hand ownership to the response (freed by *_free). result.header.internal_ = owned.release(); } catch (const std::exception& e) { diff --git a/tests/test_pro_backend.cpp b/tests/test_pro_backend.cpp index e2b57b75..10cf800f 100644 --- a/tests/test_pro_backend.cpp +++ b/tests/test_pro_backend.cpp @@ -148,7 +148,9 @@ TEST_CASE("Pro Backend C API", "[pro_backend]") { {"revocation_tag", oxenc::to_hex(fake_revocation_tag)}, {"rotating_pkey", oxenc::to_hex(rotating_pubkey.data)}, {"sig", oxenc::to_hex(master_privkey.data)}, - {"account_expiry_ts", unix_ts + 90 * 24 * 3600}}; + {"account_expiry_ts", unix_ts + 90 * 24 * 3600}, + {"account_grace_period_duration", 14 * 24 * 3600}, + {"account_auto_renewing", true}}; std::string json = j.dump(); // Valid JSON @@ -211,6 +213,39 @@ TEST_CASE("Pro Backend C API", "[pro_backend]") { j_no_ae["result"].erase("account_expiry_ts"); REQUIRE_THROWS_AS(parse_pro_proof(j_no_ae.dump()), parse_error); + // The two fields that QUALIFY account_expiry_ts: both surface through the C and + // C++ parses, and `E - G` recovers the paid-through instant. + REQUIRE(result_cpp.account_grace_period.count() == 14 * 24 * 3600); + REQUIRE(result_cpp.account_auto_renewing); + REQUIRE((*result_cpp.account_expiry - result_cpp.account_grace_period) + .time_since_epoch() + .count() == unix_ts + 90 * 24 * 3600 - 14 * 24 * 3600); + REQUIRE(result.account_grace_period_duration == 14 * 24 * 3600); + REQUIRE(result.account_auto_renewing); + + // Required on success, exactly like account_expiry_ts: a proof that cannot be + // paired with the values qualifying its expiry is malformed, rather than handing + // the client a fresh expiry beside a defaulted grace/flag it would then persist. + for (const auto* key : {"account_grace_period_duration", "account_auto_renewing"}) { + nlohmann::json j_missing = j; + j_missing["result"].erase(key); + REQUIRE_THROWS_AS(parse_pro_proof(j_missing.dump()), parse_error); + } + + // A wrong type is malformed for the same reason: a silently defaulted flag would be + // written to config as an explicit false, and there a false ERASES the key. + nlohmann::json j_bad = j; + j_bad["result"]["account_auto_renewing"] = 1; // int, not bool + REQUIRE_THROWS_AS(parse_pro_proof(j_bad.dump()), parse_error); + + // The non-auto-renewing account: a genuine zero grace, and `E - 0 == E`. + nlohmann::json j_false = j; + j_false["result"]["account_grace_period_duration"] = 0; + j_false["result"]["account_auto_renewing"] = false; + auto f_cpp = parse_pro_proof(j_false.dump()); + REQUIRE_FALSE(f_cpp.account_auto_renewing); + REQUIRE(f_cpp.account_grace_period.count() == 0); + // It also rides a subscription_expired failure (top-level, now-past value) so the // client can refresh its cached horizon without a separate status call. nlohmann::json j_exp; @@ -223,6 +258,24 @@ TEST_CASE("Pro Backend C API", "[pro_backend]") { REQUIRE(exp.account_expiry.has_value()); REQUIRE(exp.account_expiry->time_since_epoch().count() == unix_ts - 24 * 3600); + // 🔴 The failure path leaves BOTH qualifying fields at their struct defaults, and + // nothing in the type says so. `not_subscribed` here, but the same holds for a + // protocol error or a `stale_request` -- outcomes that say nothing about the + // account. A caller that reads them outside the success branch gets a plain + // 0/false, and writing that false to the presence-only config key ERASES a flag + // learned from get_pro_status. Pinned so the hazard is executable, not just + // documented: the protection here is SCOPE, not the type. + { + nlohmann::json j_fail; + j_fail["status"] = "fail"; + j_fail["error_code"] = "not_subscribed"; + j_fail["error"] = "no"; + auto fail = parse_pro_proof(j_fail.dump()); + REQUIRE_FALSE(static_cast(fail)); + REQUIRE(fail.account_grace_period.count() == 0); // default, NOT "no grace" + REQUIRE_FALSE(fail.account_auto_renewing); // default, NOT "not renewing" + } + // Other failures (e.g. not_subscribed) carry no horizon. nlohmann::json j_ns; j_ns["status"] = "fail"; From b066ba271c54253526f2741d2ad235e85eac5898 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 10 Aug 2026 14:40:19 +1000 Subject: [PATCH 4/7] user_profile: clear the auto-renewing flag with the expiry it describes `set_pro_access_expiry(nullopt)` already clears `G` -- a grace is only meaningful as `E - G`, so it must not outlive its expiry. `A` has the same relationship and was not being cleared: a renewing flag with no expiry beside it describes a subscription that is not there. Every caller that clears `E` is handling an account with no entitlement -- a proof cleared, a proof revoked, or a non-positive `expiry_ts` -- and none of those is auto-renewing, so there is no state in which the flag should survive its expiry. Without this the three keys are coherent only because every *consumer* happens to test `E` before reading `A`. That is true today on all three clients and nothing enforces it; a new consumer inherits the obligation without knowing it has one. Maintaining the invariant on the write side is what removes that. Note this changes the lifecycle of `A`, which is #121's key rather than mine -- raised deliberately as its own commit so it can be taken or dropped independently of the `G` work it sits beside. --- src/config/user_profile.cpp | 11 +++++++++++ tests/test_config_userprofile.cpp | 12 ++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/config/user_profile.cpp b/src/config/user_profile.cpp index 9189821b..940a8e23 100644 --- a/src/config/user_profile.cpp +++ b/src/config/user_profile.cpp @@ -236,6 +236,17 @@ void UserProfile::set_pro_access_expiry(std::optional // case (the proof-outcome clears), and a rule spread across every call site is one a new // call site inherits wrongly. data["G"].erase(); + // `A` describes the subscription `E` denotes, so the same argument applies: a renewing flag + // with no expiry beside it describes a subscription that is not there. Every caller that + // clears `E` is handling an account with no entitlement -- a proof cleared, a proof + // revoked, or a non-positive `expiry_ts` -- and none of those is auto-renewing, so there is + // no state in which the flag should survive its expiry. + // + // Without this the three keys are only coherent because every *consumer* happens to test + // `E` before reading `A`. That is true today on all three clients and it is not a property + // anything enforces; making the write side maintain the invariant is what stops the next + // consumer inheriting the obligation without knowing it has one. + data["A"].erase(); } // Confirming a live entitlement means any in-flight purchase resolved, and any long-stale diff --git a/tests/test_config_userprofile.cpp b/tests/test_config_userprofile.cpp index 45959ffc..1f6be6db 100644 --- a/tests/test_config_userprofile.cpp +++ b/tests/test_config_userprofile.cpp @@ -702,6 +702,18 @@ TEST_CASE("UserProfile Pro Storage", "[config][user_profile][pro]") { CHECK(*profile.get_pro_access_expiry() - profile.get_pro_grace_period() == std::chrono::sys_seconds{5000s}); + // Clearing `E` clears the renewing flag with it, for the same reason it clears `G`: `A` + // describes the subscription `E` denotes, and a renewing flag with no expiry beside it + // describes a subscription that is not there. Pinned because the alternative -- every consumer + // testing `E` before reading `A` -- is a convention nothing enforces. + profile.set_pro_auto_renewing(true); + profile.set_pro_access_expiry(std::chrono::sys_seconds{9000s}); + CHECK(profile.get_pro_auto_renewing()); + profile.set_pro_access_expiry(std::nullopt); + CHECK_FALSE(profile.get_pro_auto_renewing()); + CHECK(profile.get_pro_grace_period() == 0s); + CHECK_FALSE(profile.get_pro_access_expiry().has_value()); + // Clearing `E` also clears `G`: the pair is only meaningful as `E - G`, so a `G` that outlived // its `E` would silently pair with the NEXT `E` write -- and that write is typically a proof // outcome, which carries no grace to correct it with. Enforced in the setter, not at call From 1e8b521f5ee7bfc3af24f14c3a45384313779024 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 11 Aug 2026 10:04:00 +1000 Subject: [PATCH 5/7] user_profile, pro_backend: correct the expiry/grace model in the docs Every comment describing `G` said the backend folds grace INTO the stored expiry, so that `E` was the end of coverage and `E - G` recovered the paid-through instant. That was true of the backend at the commit this branch was written against, and has not been true since Session-Pro-Backend PR #15. The current model, from `subscription_coverage_end` and the `get_pro_status` handler: E the payment-due date -- the instant the term was paid through G how much longer service continues past it: the store's dunning window plus the backend's renewal-latency allowance; 0 when not auto-renewing coverage ends at E + G, and [E, E + G) is overdue-but-still-served No behaviour changes -- `G` is still a synced seconds value, still cleared with `E`, still omitted when zero. What changes is every explanation of what it is for, in both config headers, both pro_backend headers, the clear-pairing comment, and the tests that asserted the subtraction. The tests now assert `E + G` is the coverage end rather than `E - G` being the paid-through instant, which is the same property stated the right way round. --- include/session/config/user_profile.h | 6 +++--- include/session/config/user_profile.hpp | 21 +++++++++++-------- include/session/pro_backend.h | 14 ++++++------- include/session/pro_backend.hpp | 28 +++++++++++++------------ src/config/user_profile.cpp | 2 +- src/pro_backend.cpp | 2 +- tests/test_config_userprofile.cpp | 18 ++++++++-------- tests/test_pro_backend.cpp | 6 +++--- 8 files changed, 51 insertions(+), 46 deletions(-) diff --git a/include/session/config/user_profile.h b/include/session/config/user_profile.h index 135445a3..f0643ef2 100644 --- a/include/session/config/user_profile.h +++ b/include/session/config/user_profile.h @@ -429,9 +429,9 @@ LIBSESSION_EXPORT void user_profile_set_pro_auto_renewing(config_object* conf, i /// /// Returns the account's grace period in seconds (`get_pro_status.grace_period_duration`), or 0 if /// none is stored. Backend-derived and synced alongside the access expiry, so any linked device can -/// derive the paid-through instant as `access_expiry - grace_period`: the backend folds the grace -/// period into the stored expiry for auto-renewing subscriptions, so the access expiry is the end -/// of coverage rather than the date the renewal is due. +/// compute when coverage actually ends: `access_expiry + grace_period`. The access expiry is the +/// payment-due date -- the instant the term was paid through -- and `[E, E + G)` is the window +/// where the payment is overdue but service continues. /// /// There is deliberately no companion presence check: the backend sends 0 whenever the /// subscription is not auto-renewing, so "unset" and "zero" describe the same account and both give diff --git a/include/session/config/user_profile.hpp b/include/session/config/user_profile.hpp index af5631bf..8246dd6f 100644 --- a/include/session/config/user_profile.hpp +++ b/include/session/config/user_profile.hpp @@ -44,10 +44,12 @@ using namespace std::literals; /// terminal (will not renew), unknown, or the account isn't Pro. Backend-derived /// (get_pro_status.auto_renewing) and synced across devices; the client sets it alongside `E` /// and clears it (sets false) when the subscription lapses. -/// G - the account's grace period, in seconds (get_pro_status.grace_period_duration), synced so any -/// device can derive the paid-through instant as `E - G`. Backend-derived and set alongside -/// `E`. Omitted when zero, which is also what the backend sends when the subscription is not -/// auto-renewing -- so an absent `G` and a zero `G` mean the same thing and `E - G == E`. +/// G - how much longer the account keeps being served past `E`, in seconds +/// (get_pro_status.grace_period_duration): the store's dunning window plus the backend's own +/// renewal-latency allowance. Coverage ends at `E + G`; `[E, E + G)` is the window where the +/// payment is overdue but service continues. Backend-derived and set alongside `E`. Omitted +/// when zero, which is also what the backend sends when the subscription is not auto-renewing +/// -- so an absent `G` and a zero `G` mean the same thing and coverage ends at `E`. /// P - user profile url after re-uploading (should take precedence over `p` when `T > t`). /// Q - user profile decryption key (binary) after re-uploading (should take precedence over `q` /// when `T > t`). @@ -371,11 +373,12 @@ class UserProfile : public ConfigBase { /// API: user_profile/UserProfile::get_pro_grace_period /// - /// Returns the account's grace period (`get_pro_status.grace_period_duration`), or zero if none - /// is stored. Backend-derived and synced alongside `E`, so any linked device can derive the - /// paid-through instant as `get_pro_access_expiry() - get_pro_grace_period()`: the backend - /// folds the grace period into the stored expiry for auto-renewing subscriptions, so `E` is the - /// end of coverage rather than the date the renewal is due. + /// Returns how much longer the account keeps being served past `E` + /// (`get_pro_status.grace_period_duration`), or zero if none is stored. Backend-derived and + /// synced alongside `E`, so any linked device can compute when coverage actually ends: + /// `get_pro_access_expiry() + get_pro_grace_period()`. `E` itself is the payment-due date -- + /// the instant the term was paid through -- and `[E, E + G)` is the window where the payment is + /// overdue but service continues. /// /// Note this deliberately returns a plain duration rather than an optional: the backend sends /// zero when the subscription is not auto-renewing, so "no grace stored" and "a grace of zero" diff --git a/include/session/pro_backend.h b/include/session/pro_backend.h index 071fe7a2..17aaaedd 100644 --- a/include/session/pro_backend.h +++ b/include/session/pro_backend.h @@ -133,16 +133,16 @@ typedef struct session_pro_backend_response_header { typedef struct session_pro_backend_pro_proof_response { session_pro_backend_response_header header; session_protocol_pro_proof proof; - /// The account's true, grace-inclusive subscription entitlement end (unix seconds), or 0 if - /// this response carries no horizon. Advisory and unsigned (pro-wire-protocol.md §2.2): use for - /// display / refreshing the cached access expiry only -- NOT an entitlement authority and NOT - /// part of the proof signature. Distinct from `proof.expiry_ts` (the clamped <=30d + /// The end of the paid term (unix seconds) -- coverage runs to this plus the grace below -- or + /// 0 if this response carries no horizon. Advisory and unsigned (pro-wire-protocol.md §2.2): + /// use for display / refreshing the cached access expiry only -- NOT an entitlement authority + /// and NOT part of the proof signature. Distinct from `proof.expiry_ts` (the clamped <=30d /// proof-validity window). Populated on a successful proof and on a `subscription_expired` /// failure (a now-past value); 0 on `not_subscribed` / `revoked` / protocol errors. int64_t account_expiry_ts; - /// The grace period (seconds) folded into `account_expiry_ts`, so the paid-through instant is - /// `account_expiry_ts - account_grace_period_duration`. 0 when the subscription is not - /// auto-renewing. + /// How much longer (seconds) the account keeps being served past `account_expiry_ts`, so + /// coverage ends at `account_expiry_ts + account_grace_period_duration`. 0 when the + /// subscription is not auto-renewing. /// /// ⚠️ MEANINGFUL ONLY WHEN `header.status` IS OK. Filled only on the success path, so every /// non-OK outcome -- protocol error, `stale_request`, transport failure, where the account is diff --git a/include/session/pro_backend.hpp b/include/session/pro_backend.hpp index 0b1cd032..0d2f5023 100644 --- a/include/session/pro_backend.hpp +++ b/include/session/pro_backend.hpp @@ -189,24 +189,26 @@ struct ProRequest { struct GenerateProProofResponse : ResponseBase { ProProof proof; - /// The account's true, grace-inclusive subscription entitlement end -- the same value - /// `get_pro_status` reports as its `expiry_ts` (pro-wire-protocol.md §2.2). Advisory and - /// UNSIGNED: not part of the proof's signed message and never fed into signature verification; - /// it rides on the response so a proof fetch also refreshes the client's cached access expiry. - /// Distinct from `proof.expiry_at` (the clamped, rolling <=30d proof-validity window). Present - /// (and required) on a successful proof, and on a `subscription_expired` failure (a now-past - /// value carried top-level on the envelope); nullopt on other outcomes (`not_subscribed`, - /// `revoked`, protocol errors). + /// The end of the paid term -- the same value `get_pro_status` reports as its `expiry_ts` + /// (pro-wire-protocol.md §2.2), carrying neither the store's grace nor the backend's + /// renewal-latency allowance; coverage runs to `account_expiry + account_grace_period`. + /// Advisory and UNSIGNED: not part of the proof's signed message and never fed into signature + /// verification; it rides on the response so a proof fetch also refreshes the client's cached + /// access expiry. Distinct from `proof.expiry_at` (the clamped, rolling <=30d proof-validity + /// window). Present (and required) on a successful proof, and on a `subscription_expired` + /// failure (a now-past value carried top-level on the envelope); nullopt on other outcomes + /// (`not_subscribed`, `revoked`, protocol errors). std::optional account_expiry; - /// The grace period folded into `account_expiry` above, so a client can recover the - /// paid-through instant as `account_expiry - account_grace_period`. Zero whenever the - /// subscription is not auto-renewing, mirroring `get_pro_status`, because the backend only - /// folds grace in for auto-renewing payments. + /// How much longer the account keeps being served past `account_expiry` above -- the store's + /// dunning window plus the backend's renewal-latency allowance -- so coverage ends at + /// `account_expiry + account_grace_period`. Zero whenever the subscription is not + /// auto-renewing, mirroring `get_pro_status`, because neither span applies to a term that is + /// simply ending. /// /// Required on a successful proof, like `account_expiry`: a response missing it can't be paired /// with the expiry it qualifies, and a client that persisted the two out of step would compute - /// the wrong paid-through instant. + /// the wrong coverage end. /// /// ⚠️ **Meaningful ONLY when the response succeeded.** This is default-initialised and is filled /// only on the success path, so on *every* non-OK outcome -- including a protocol error, a diff --git a/src/config/user_profile.cpp b/src/config/user_profile.cpp index 940a8e23..8f405fb5 100644 --- a/src/config/user_profile.cpp +++ b/src/config/user_profile.cpp @@ -229,7 +229,7 @@ void UserProfile::set_pro_access_expiry(std::optional data["E"] = epoch_seconds(*access_expiry_ts); else { data["E"].erase(); - // `G` is only meaningful as `E - G`, so it must never outlive the `E` it was paired with: + // `G` is only meaningful as `E + G`, so it must never outlive the `E` it was paired with: // a stranded `G` would silently pair with whatever the *next* `E` write happens to be, and // that next write is usually a proof outcome, which carries no grace of its own to correct // it with. Enforced here rather than left to callers because clearing `E` is the common diff --git a/src/pro_backend.cpp b/src/pro_backend.cpp index e4ea2faf..fcbc57b3 100644 --- a/src/pro_backend.cpp +++ b/src/pro_backend.cpp @@ -315,7 +315,7 @@ namespace { // The two values that qualify `account_expiry_ts`, required for the same reason it is: a // client persists all three into config together, and a fresh expiry beside a stale grace // or a stale renewing flag is worse than no refresh at all -- it computes a wrong - // paid-through instant, and reads a renewing subscription as terminal. + // coverage end, and reads a renewing subscription as terminal. result.account_grace_period = std::chrono::seconds( json_require(result_obj, "account_grace_period_duration")); result.account_auto_renewing = json_require(result_obj, "account_auto_renewing"); diff --git a/tests/test_config_userprofile.cpp b/tests/test_config_userprofile.cpp index 1f6be6db..476b10e1 100644 --- a/tests/test_config_userprofile.cpp +++ b/tests/test_config_userprofile.cpp @@ -682,21 +682,21 @@ TEST_CASE("UserProfile Pro Storage", "[config][user_profile][pro]") { profile.set_pro_auto_renewing(false); CHECK_FALSE(profile.get_pro_auto_renewing()); - // Grace period: synced so any device can derive the paid-through instant as `E - G`. The - // backend folds grace INTO the stored expiry for auto-renewing subscriptions, so `E` is the end - // of coverage rather than the renewal-due date -- deriving that is the whole reason this key - // exists. + // Grace period: synced so any device can compute when coverage actually ends, at `E + G`. `E` + // is the payment-due date -- the instant the term was paid through -- and `G` is how much + // longer the backend keeps serving past it (the store's dunning window plus its renewal-latency + // allowance). Carrying `G` is the whole reason this key exists: `E` alone cannot answer it. CHECK(profile.get_pro_grace_period() == 0s); UserProfileTester::set_profile_updated(profile, std::chrono::sys_seconds{456s}); profile.set_pro_grace_period(1h); CHECK(profile.get_pro_grace_period() == 1h); // Backend-derived, like E/I/R/A: no profile-updated bump. CHECK(profile.get_profile_updated().time_since_epoch().count() == 456); - // The property the key exists for: coverage end minus grace is the paid-through instant. + // The property the key exists for: expiry plus grace is the instant coverage ends. profile.set_pro_access_expiry(std::chrono::sys_seconds{5000s}); - CHECK(*profile.get_pro_access_expiry() - profile.get_pro_grace_period() == - std::chrono::sys_seconds{5000s} - 1h); - // Zero clears; unset and zero are indistinguishable *and* equivalent (`E - 0 == E`). + CHECK(*profile.get_pro_access_expiry() + profile.get_pro_grace_period() == + std::chrono::sys_seconds{5000s} + 1h); + // Zero clears; unset and zero are indistinguishable *and* equivalent (coverage ends at `E`). profile.set_pro_grace_period(0s); CHECK(profile.get_pro_grace_period() == 0s); CHECK(*profile.get_pro_access_expiry() - profile.get_pro_grace_period() == @@ -714,7 +714,7 @@ TEST_CASE("UserProfile Pro Storage", "[config][user_profile][pro]") { CHECK(profile.get_pro_grace_period() == 0s); CHECK_FALSE(profile.get_pro_access_expiry().has_value()); - // Clearing `E` also clears `G`: the pair is only meaningful as `E - G`, so a `G` that outlived + // Clearing `E` also clears `G`: the pair is only meaningful as `E + G`, so a `G` that outlived // its `E` would silently pair with the NEXT `E` write -- and that write is typically a proof // outcome, which carries no grace to correct it with. Enforced in the setter, not at call // sites. diff --git a/tests/test_pro_backend.cpp b/tests/test_pro_backend.cpp index 10cf800f..704b315f 100644 --- a/tests/test_pro_backend.cpp +++ b/tests/test_pro_backend.cpp @@ -214,12 +214,12 @@ TEST_CASE("Pro Backend C API", "[pro_backend]") { REQUIRE_THROWS_AS(parse_pro_proof(j_no_ae.dump()), parse_error); // The two fields that QUALIFY account_expiry_ts: both surface through the C and - // C++ parses, and `E - G` recovers the paid-through instant. + // C++ parses, and `E + G` is the instant coverage ends. REQUIRE(result_cpp.account_grace_period.count() == 14 * 24 * 3600); REQUIRE(result_cpp.account_auto_renewing); - REQUIRE((*result_cpp.account_expiry - result_cpp.account_grace_period) + REQUIRE((*result_cpp.account_expiry + result_cpp.account_grace_period) .time_since_epoch() - .count() == unix_ts + 90 * 24 * 3600 - 14 * 24 * 3600); + .count() == unix_ts + 90 * 24 * 3600 + 14 * 24 * 3600); REQUIRE(result.account_grace_period_duration == 14 * 24 * 3600); REQUIRE(result.account_auto_renewing); From 54e246b202453622816e46c409d38dd788f9c8a9 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 11 Aug 2026 10:13:36 +1000 Subject: [PATCH 6/7] pro_backend: correct the get_pro_status docs to match the backend's model The previous commit corrected the proof-response fields and left the status-response ones, which describe the SAME model for the endpoint that actually changed. The header contradicted itself: one struct said grace is additive, the other said expiry_at = subscription_expiry + grace_period_duration and told the reader to subtract the grace to recover the subscription expiry. That text predates this branch, but leaving it beside the corrected text made the more detailed -- and wrong -- version the one a reader would trust. `ProStatusResponse::expiry_at` is the payment-due date; entitlement runs to `expiry_at + grace_period_duration`, and the backend judges `user_status` against that sum. Stated at both fields, with the subtraction called out as the shape that no longer means anything. Also documents two things that were bare fields in the C header and are a live trap for a binding author: - `grace_period_duration` exists at BOTH levels with the same name and is a different quantity at each. The account-level one adds the backend's renewal-latency allowance and is gated on the account renewing; the payment-level one is raw store data and is not gated, so a cancelled subscription can carry a stale non-zero value. Reading the wrong one puts the end of entitlement days late. - `expiry_ts` at both levels, for the same reason. Comment-only; no signature or behaviour changes. Found by pro-refresh-ios while flipping the client to the corrected model: the doc pass had been checked by grepping for the phrasings that were corrected, which cannot find the same claim stated in other words. --- include/session/pro_backend.h | 16 +++++++++++ include/session/pro_backend.hpp | 51 ++++++++++++++++----------------- 2 files changed, 40 insertions(+), 27 deletions(-) diff --git a/include/session/pro_backend.h b/include/session/pro_backend.h index 17aaaedd..ff7c86ed 100644 --- a/include/session/pro_backend.h +++ b/include/session/pro_backend.h @@ -217,7 +217,13 @@ typedef struct session_pro_backend_pro_payment_item { /// Provider purchase time, fractional UNIX seconds. Millisecond-precise: the value passes /// through a millisecond-resolution representation, so sub-millisecond digits are not retained. double purchased_ts; + /// When THIS payment's term was paid through. int64_t expiry_ts; + /// The dunning window this one payment's provider declared -- raw store data. NOT the same + /// quantity as the account-level `grace_period_duration` on the status response, which adds the + /// backend's renewal-latency allowance and is gated on the account renewing. This one is not + /// gated, so a cancelled subscription can still carry a stale non-zero value. For "when does + /// entitlement end", use the account-level field. int64_t grace_period_duration; int64_t platform_refund_expiry_ts; /// Provider revocation instant, fractional UNIX seconds (millisecond-precise; 0 if not revoked) @@ -234,7 +240,17 @@ typedef struct session_pro_backend_get_pro_status_response { /// NUL-terminated; points into the response's `internal_`. const char* status; bool auto_renewing; + /// The account's PAYMENT-DUE date: when the current term was paid through. Does NOT include the + /// grace period -- entitlement runs to `expiry_ts + grace_period_duration`, and `status` is + /// judged against that sum. Do not subtract the grace from this. int64_t expiry_ts; + /// How much longer (seconds) entitlement continues PAST `expiry_ts`: the provider's dunning + /// window plus the backend's renewal-latency allowance. Add it to `expiry_ts` to get the + /// instant entitlement ends; `[expiry_ts, expiry_ts + this)` is overdue-but-still-served. 0 + /// when `auto_renewing` is false, so the sum stays correct there without a special case. + /// + /// ACCOUNT-level. `latest_payment.grace_period_duration` shares the name and is a different + /// quantity -- see there. int64_t grace_period_duration; /// True if the account has at least one payment, in which case `latest_payment` is populated /// with the most recent one; false means the account has no payments and `latest_payment` is diff --git a/include/session/pro_backend.hpp b/include/session/pro_backend.hpp index 0d2f5023..3bb6bd4c 100644 --- a/include/session/pro_backend.hpp +++ b/include/session/pro_backend.hpp @@ -371,10 +371,13 @@ struct ProPaymentItem { /// Unix timestamp of when the payment was expiry. 0 if not activated sys_seconds expiry_at; - /// Duration of the grace period, e.g. when the payment provider will start to attempt to renew - /// the Session Pro subscription. During the period between - /// [expiry_at, expiry_at + grace_period_duration] the user continues to have - /// entitlement to Session Pro. This value is only applicable if `auto_renewing` is `true`. + /// The dunning window this ONE payment's provider declared -- raw store data, and NOT the same + /// quantity as `ProStatusResponse::grace_period_duration`, which is account-level and adds the + /// backend's renewal-latency allowance. This one is also not gated on `auto_renewing`, so a + /// cancelled subscription can still carry a stale non-zero value here. + /// + /// Use the account-level field for "when does entitlement end"; this is for showing what a + /// given payment was granted. Only meaningful when this payment's `auto_renewing` is true. std::chrono::seconds grace_period_duration; /// Unix deadline timestamp of when the user is able to refund the subscription via the payment @@ -403,36 +406,30 @@ struct ProStatusResponse : ResponseBase { /// Flag to indicate if the user will automatically renew their subscription. bool auto_renewing; - /// Deadline UNIX timestamp that a user is entitled to Session Pro Proofs. The user is allowed - /// to request a Session Pro Proof from the Pro Backend up until this timestamp. Thereafter - /// the user is no longer entitled to Session Pro. This deadline includes the grace period if - /// applicable. - /// - /// The grace period is enabled when `auto_renewing` is `true` and is the extra period after a - /// user's subscription has elapsed that the payment provider allocates to continue entitlement - /// to Session Pro whilst attempting to execute the billing of a Session Pro subscription. - /// - /// This allows a user to maintain entitlement to Session Pro across billing cycles by giving - /// some leeway as to the time required for the payment provider to successfully bill the user. - /// This expiry timestamp is hence calculated as: + /// The account's PAYMENT-DUE date: the instant the current term was paid through. This does + /// NOT include the grace period -- entitlement continues to `expiry_at + + /// grace_period_duration`, and the backend judges `user_status` against that sum rather than + /// against this value. /// - /// expiry_at = subscription_expiry + grace_period_duration - /// - /// E.g. The subscription expiry timestamp can be calculated by subtracting - /// `grace_period_duration` to determine if the user is currently in a grace period. Some - /// platforms do not support a grace period so this value can be 0. - /// - /// Finally, a reminder that the grace period is not activated or included in this deadline - /// timestamp if they have configured subscription `auto_renewing` to be off. + /// So `expiry_at` alone answers "when is the next payment due", and `[expiry_at, + /// expiry_at + grace_period_duration)` is the window where the payment is overdue but service + /// continues. Do not subtract the grace from this -- that was the shape before the backend + /// stopped folding grace into the stored expiry, and it now yields an instant that means + /// nothing. /// /// This timestamp may be in the past if the user no longer has active payments. Overtime the /// Pro Backend may prune user history and so after long lapses of activity, a user's /// subscription history may be deleted. sys_seconds expiry_at; - /// Duration that a user is entitled to for their grace period. This value is to be ignored if - /// `auto_renewing` is false. It can be used to calculate the subscription expiry timestamp by - /// subtracting it from `expiry_at`. + /// How much longer entitlement continues PAST `expiry_at`: the payment provider's dunning + /// window (the leeway it allows itself to retry a failed renewal) plus the backend's own + /// renewal-latency allowance, which covers the gap between a term ending and the backend + /// learning whether it renewed. + /// + /// Add it to `expiry_at` to get the instant entitlement actually ends. 0 when `auto_renewing` + /// is false, because neither span applies to a term that is simply ending -- so the sum is + /// still correct there without a special case. std::chrono::seconds grace_period_duration; }; From de208ff19804b2f29763601a351aa41f3e45b241 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 11 Aug 2026 15:20:32 +1000 Subject: [PATCH 7/7] user_profile: fix a sign typo in the grace-period docs The zero-grace note read `E - 0 == E`, a subtraction in the one comment explaining that coverage is an addition. Same sign error the whole model correction was about, left in the prose describing it. --- include/session/config/user_profile.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/include/session/config/user_profile.hpp b/include/session/config/user_profile.hpp index 8246dd6f..e2f5f96a 100644 --- a/include/session/config/user_profile.hpp +++ b/include/session/config/user_profile.hpp @@ -382,7 +382,7 @@ class UserProfile : public ConfigBase { /// /// Note this deliberately returns a plain duration rather than an optional: the backend sends /// zero when the subscription is not auto-renewing, so "no grace stored" and "a grace of zero" - /// describe the same account and both give `E - 0 == E`. There is no state a caller could act + /// describe the same account and both give `E + 0 == E`. There is no state a caller could act /// on differently, so there is nothing for a presence check to disambiguate. /// /// Inputs: None