Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions include/session/config/user_profile.h
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,92 @@ 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_has_pro_auto_renewing
///
/// Returns whether the auto-renewing flag has ever been stored -- the distinction
/// `user_profile_get_pro_auto_renewing` cannot express, since the flag is presence-only and that
/// getter answers 0 both for "not auto-renewing" and for "never learned". A client needs it when
/// deciding at startup whether to fetch `/get_pro_status` at all: an account entitled before this
/// field existed reads as `false` forever otherwise, and the fetch that would populate it is the
/// one being skipped. Use as a pair -- `has_` first, then the getter for the value.
///
/// Note the pair expresses *unknown vs true*, not three states: setting the flag false erases the
/// key, so a stored false and an absent one are the same state. Do not read a 0 here as "definitely
/// not auto-renewing".
///
/// This is a separate 0/1 predicate rather than a -1 sentinel on the getter *deliberately*: with a
/// sentinel, the natural binding on some platforms converts "unknown" to `true` silently -- a
/// negative int cast to an unsigned boolean type is non-zero -- and that is the reading which
/// suppresses the very fetch that would resolve it.
///
/// Inputs:
/// - `conf` -- [in] Pointer to the config object
///
/// Outputs:
/// - `int` -- 1 if the flag has been stored (read it with `user_profile_get_pro_auto_renewing`), 0
/// if
/// it has never been stored.
LIBSESSION_EXPORT int user_profile_has_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_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.
///
/// Unlike `user_profile_has_pro_auto_renewing` there is no companion presence check, and
/// deliberately so: 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
Expand Down
78 changes: 78 additions & 0 deletions include/session/config/user_profile.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ 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.
/// 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`).
Expand Down Expand Up @@ -339,6 +347,76 @@ class UserProfile : public ConfigBase {
/// will expire, or nullopt to remove the value.
void set_pro_access_expiry(std::optional<sys_seconds> 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::get_pro_auto_renewing_opt
///
/// As `get_pro_auto_renewing`, but distinguishes *not set* from *false*. Because the flag is
/// stored presence-only, the plain getter cannot tell "known not to be auto-renewing" from
/// "never learned"; a caller that must not act on an unpopulated value should use this and
/// treat `nullopt` as unknown. The motivating case is a client deciding at startup whether to
/// fetch
/// `/get_pro_status` at all: an account entitled before this field existed reads as `false`
/// forever otherwise, and the fetch that would populate it is the one being skipped.
///
/// Note this is *unknown vs true*, not a three-state flag: `set_pro_auto_renewing(false)`
/// erases the key, so a stored false and an absent one are the same state on the wire.
///
/// Inputs: None
///
/// Outputs:
/// - `std::optional<bool>` -- `nullopt` if the flag has never been stored; otherwise its value.
std::optional<bool> get_pro_auto_renewing_opt() 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_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, unlike
/// `get_pro_auto_renewing_opt`: 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
Expand Down
61 changes: 60 additions & 1 deletion src/config/user_profile.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -227,8 +227,16 @@ std::optional<std::chrono::sys_seconds> UserProfile::get_pro_access_expiry() con
void UserProfile::set_pro_access_expiry(std::optional<std::chrono::sys_seconds> 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).
Expand All @@ -241,6 +249,36 @@ void UserProfile::set_pro_access_expiry(std::optional<std::chrono::sys_seconds>
}
}

bool UserProfile::get_pro_auto_renewing() const {
return data["A"].integer_or(0) != 0;
}

std::optional<bool> UserProfile::get_pro_auto_renewing_opt() const {
// `integer()` yields nullptr for an absent key, which is exactly the distinction that
// `get_pro_auto_renewing`'s `integer_or(0)` discards. Same shape as `get_pro_access_expiry`.
if (auto* A = data["A"].integer())
return *A != 0;
return std::nullopt;
}

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::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<std::chrono::sys_seconds> UserProfile::get_refund_requested() const {
if (auto* R = data["R"].integer()) {
std::chrono::sys_seconds when{std::chrono::seconds{*R}};
Expand Down Expand Up @@ -541,6 +579,27 @@ LIBSESSION_C_API void user_profile_set_pro_access_expiry(
unbox<UserProfile>(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<UserProfile>(conf)->get_pro_auto_renewing() ? 1 : 0;
}

LIBSESSION_C_API int user_profile_has_pro_auto_renewing(const config_object* conf) {
return unbox<UserProfile>(conf)->get_pro_auto_renewing_opt().has_value() ? 1 : 0;
}

LIBSESSION_C_API void user_profile_set_pro_auto_renewing(config_object* conf, int auto_renewing) {
unbox<UserProfile>(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<UserProfile>(conf)->get_pro_grace_period().count();
}

LIBSESSION_C_API void user_profile_set_pro_grace_period(
config_object* conf, int64_t grace_seconds) {
unbox<UserProfile>(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<UserProfile>(conf)->get_refund_requested())
return epoch_seconds(*when);
Expand Down
77 changes: 77 additions & 0 deletions tests/test_config_userprofile.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,31 @@ 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);
// "never stored", which the plain getter reports as 0 -- the whole point of the has_ predicate.
CHECK(user_profile_has_pro_auto_renewing(conf2) == 0);
user_profile_set_pro_auto_renewing(conf2, 1);
CHECK(user_profile_get_pro_auto_renewing(conf2) == 1);
CHECK(user_profile_has_pro_auto_renewing(conf2) == 1);
user_profile_set_pro_auto_renewing(conf2, 0);
CHECK(user_profile_get_pro_auto_renewing(conf2) == 0);
// Back to "not stored": clearing erases the key rather than storing a 0, so `has_` goes to 0
// too
// -- which is why a 0 here must not be read as "explicitly not auto-renewing".
CHECK(user_profile_has_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 (unlike `A`) 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
Expand Down Expand Up @@ -654,6 +679,58 @@ 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());
// ...and `_opt` separates "never stored" from "stored false", which the getter above cannot --
// both read as `false` there.
CHECK_FALSE(profile.get_pro_auto_renewing_opt().has_value());
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_pro_auto_renewing_opt() == std::optional<bool>{true});
CHECK(profile.get_profile_updated().time_since_epoch().count() == 456);
profile.set_pro_auto_renewing(false);
CHECK_FALSE(profile.get_pro_auto_renewing());
// Setting false *erases* the key rather than storing a 0, so we return to "unset" rather than
// to an explicit false. This pins the presence-only encoding: `_opt` is unknown-vs-true, NOT a
// genuine tri-state, so a caller must not read `nullopt` as "definitely not renewing".
CHECK_FALSE(profile.get_pro_auto_renewing_opt().has_value());

// 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());

Expand Down