diff --git a/include/pro/pro.hpp b/include/pro/pro.hpp index 38bfd1a..90d338a 100644 --- a/include/pro/pro.hpp +++ b/include/pro/pro.hpp @@ -34,14 +34,6 @@ class ProWrapper : public Napi::ObjectWrap { "ProWrapperNode", { // Pro features - StaticMethod<&ProWrapper::utf16CountTruncatedToCodepoints>( - "utf16CountTruncatedToCodepoints", - static_cast( - napi_writable | napi_configurable)), - StaticMethod<&ProWrapper::utf16Count>( - "utf16Count", - static_cast( - napi_writable | napi_configurable)), StaticMethod<&ProWrapper::proFeaturesForMessage>( "proFeaturesForMessage", static_cast( @@ -134,8 +126,10 @@ class ProWrapper : public Napi::ObjectWrap { return wrapResult(info, [&] { // we expect one argument that matches: // first: { - // "utf16": string, + // "codepointCount": number, // } + // The caller counts codepoints natively (JS: [...text].length) and passes the count; + // libsession owns the count -> feature policy. assertInfoLength(info, 1); assertIsObject(info[0]); @@ -146,84 +140,21 @@ class ProWrapper : public Napi::ObjectWrap { if (first.IsEmpty()) throw std::invalid_argument("proFeaturesForMessage first received empty"); - assertIsString(first.Get("utf16"), "proFeaturesForMessage.utf16"); - std::u16string utf16 = first.Get("utf16").As().Utf16Value(); - ProFeaturesForMsg pro_features_msg = - session::pro_features_for_utf16((utf16.data()), utf16.length()); + assertIsNumber(first.Get("codepointCount"), "proFeaturesForMessage.codepointCount"); + size_t codepoint_count = first.Get("codepointCount").As().Uint32Value(); + ProFeaturesForMsg pro_features_msg = session::pro_features_for_message(codepoint_count); auto obj = Napi::Object::New(env); obj["status"] = toJs(env, proBackendEnumToString(pro_features_msg.status)); obj["error"] = pro_features_msg.error.size() ? toJs(env, pro_features_msg.error) : env.Null(); - obj["codepointCount"] = toJs(env, pro_features_msg.codepoint_count); obj["proMessageBitset"] = proMessageBitsetToJS(env, pro_features_msg.bitset); return obj; }); }; - static Napi::Value utf16Count(const Napi::CallbackInfo& info) { - return wrapResult(info, [&] { - // we expect one argument that matches: - // first: { - // "utf16": string, - // } - // we return an object with a single property {`codepointCount: number`} - - assertInfoLength(info, 1); - assertIsObject(info[0]); - auto env = info.Env(); - - auto first = info[0].As(); - - if (first.IsEmpty()) - throw std::invalid_argument("utf16Count first received empty"); - - assertIsString(first.Get("utf16"), "utf16Count.utf16"); - std::u16string utf16 = first.Get("utf16").As().Utf16Value(); - size_t codepoint_count = session::utf16_count(utf16); - - auto obj = Napi::Object::New(env); - obj["codepointCount"] = toJs(env, codepoint_count); - - return obj; - }); - }; - - static Napi::Value utf16CountTruncatedToCodepoints(const Napi::CallbackInfo& info) { - return wrapResult(info, [&] { - // we expect one argument that matches: - // first: { - // "utf16": string, - // "codepointLen": number, - // } - // we return an object with a single property {`truncateAt: number`} - - assertInfoLength(info, 1); - assertIsObject(info[0]); - auto env = info.Env(); - - auto first = info[0].As(); - - if (first.IsEmpty()) - throw std::invalid_argument("utf16CountTruncatedToCodepoints first received empty"); - - assertIsString(first.Get("utf16"), "utf16CountTruncatedToCodepoints.utf16"); - std::u16string utf16 = first.Get("utf16").As().Utf16Value(); - assertIsNumber( - first.Get("codepointLen"), "utf16CountTruncatedToCodepoints.codepointLen"); - size_t codepointLen = first.Get("codepointLen").As().Uint32Value(); - - size_t truncate_at = session::utf16_count_truncated_to_codepoints(utf16, codepointLen); - - auto obj = Napi::Object::New(env); - obj["truncateAt"] = toJs(env, truncate_at); - - return obj; - }); - }; - static Napi::Value proProofRequest(const Napi::CallbackInfo& info) { return wrapResult(info, [&] { // first: { @@ -340,6 +271,16 @@ class ProWrapper : public Napi::ObjectWrap { auto obj = Napi::Object::New(env); emitResponseHeader(env, obj, resp); obj["proof"] = toJs(env, resp.proof); + // Advisory account (subscription) expiry — grace-inclusive true entitlement end. + // Present on success + subscription_expired (a past value there), null otherwise. + // Distinct from the proof's own clamped expiry; unsigned/not-in-M, for display + `E` + // refresh only. + std::optional> account_expiry_ms; + if (resp.account_expiry) + account_expiry_ms = std::chrono::sys_time( + std::chrono::duration_cast( + resp.account_expiry->time_since_epoch())); + obj["accountExpiryMs"] = toJs(env, account_expiry_ms); return obj; }); }; @@ -408,7 +349,6 @@ class ProWrapper : public Napi::ObjectWrap { item["expiryTsMs"] = toJsMs(env, src.expiry_at); item["gracePeriodDurationMs"] = toJsMs(env, src.grace_period_duration); item["platformRefundExpiryTsMs"] = toJsMs(env, src.platform_refund_expiry_at); - item["refundRequestedTsMs"] = toJsMs(env, src.refund_requested_at); item["paymentId"] = toJs(env, src.payment_id); return item; } @@ -431,7 +371,6 @@ class ProWrapper : public Napi::ObjectWrap { obj["autoRenewing"] = toJs(env, resp.auto_renewing); obj["expiryMs"] = toJsMs(env, resp.expiry_at); obj["gracePeriodDurationMs"] = toJsMs(env, resp.grace_period_duration); - obj["refundRequestedTsMs"] = toJsMs(env, resp.refund_requested_at); if (resp.latest_payment) { obj["latestPayment"] = paymentItemToJs(env, *resp.latest_payment); } else { diff --git a/include/user_config.hpp b/include/user_config.hpp index 6af4579..be2527e 100644 --- a/include/user_config.hpp +++ b/include/user_config.hpp @@ -48,5 +48,18 @@ class UserConfigWrapper : public ConfigBaseImpl, public Napi::ObjectWrap feature decision goes via proFeaturesForMessage). + ObjectWrap::StaticValue( + "MESSAGE_CHARACTER_LIMIT_STANDARD", + Napi::Number::New(env, SESSION_PROTOCOL_STANDARD_CHARACTER_LIMIT), + napi_enumerable), + ObjectWrap::StaticValue( + "MESSAGE_CHARACTER_LIMIT_PRO", + Napi::Number::New(env, SESSION_PROTOCOL_PRO_HIGHER_CHARACTER_LIMIT), + napi_enumerable), ObjectWrap::StaticValue("LIBSESSION_PRO_URLS", pro_urls, napi_enumerable), // Session Pro backend identity — the single source of truth clients read instead of // hand-carrying their own copies (URL is the overridable prod/default; pubkey is hex). diff --git a/src/encrypt_decrypt/encrypt_decrypt.cpp b/src/encrypt_decrypt/encrypt_decrypt.cpp index feb7d76..5041dc8 100644 --- a/src/encrypt_decrypt/encrypt_decrypt.cpp +++ b/src/encrypt_decrypt/encrypt_decrypt.cpp @@ -467,7 +467,8 @@ Napi::Value MultiEncryptWrapper::encryptForCommunityInbox(const Napi::CallbackIn extractPlaintext(obj, "encryptForCommunityInbox.obj.plaintext"), extractSenderEd25519SeedAsVector( obj, "encryptForCommunityInbox.obj.senderEd25519Seed"), - extractSentTimestampMs(obj, "encryptForCommunityInbox.obj.sentTimestampMs"), + // §4: sent_timestamp_ms removed — community-inbox messages carry no envelope + // ts. extractRecipientPubkeyAsArray( obj, "encryptForCommunityInbox.obj.recipientPubkey"), extractCommunityPubkeyAsArray( diff --git a/src/pro/pro.cpp b/src/pro/pro.cpp index d439fe8..af38172 100644 --- a/src/pro/pro.cpp +++ b/src/pro/pro.cpp @@ -9,7 +9,6 @@ namespace session::nodeapi { std::string_view proBackendEnumToString(session::ProFeaturesForMsgStatus v) { switch (v) { case session::ProFeaturesForMsgStatus::Success: return "SUCCESS"; - case session::ProFeaturesForMsgStatus::UTFDecodingError: return "UTF_DECODING_ERROR"; case session::ProFeaturesForMsgStatus::ExceedsCharacterLimit: return "EXCEEDS_CHARACTER_LIMIT"; } diff --git a/src/user_config.cpp b/src/user_config.cpp index 6d1b488..55dcde3 100644 --- a/src/user_config.cpp +++ b/src/user_config.cpp @@ -8,6 +8,7 @@ #include "profile_pic.hpp" #include "session/config/user_profile.hpp" #include "session/ed25519.hpp" +#include "session/session_protocol.hpp" #include "utilities.hpp" namespace session::nodeapi { @@ -102,6 +103,13 @@ void UserConfigWrapper::Init(Napi::Env env, Napi::Object exports) { InstanceMethod( "generateRotatingPrivKeyHex", &UserConfigWrapper::generateRotatingPrivKeyHex), + InstanceMethod( + "deriveProRotatingKey", &UserConfigWrapper::deriveProRotatingKey), + InstanceMethod("getRefundRequested", &UserConfigWrapper::getRefundRequested), + InstanceMethod("setRefundRequested", &UserConfigWrapper::setRefundRequested), + InstanceMethod("getProPrepaid", &UserConfigWrapper::getProPrepaid), + InstanceMethod("setProPrepaid", &UserConfigWrapper::setProPrepaid), + InstanceMethod("getProRenewalTarget", &UserConfigWrapper::getProRenewalTarget), }); } @@ -376,4 +384,97 @@ Napi::Value UserConfigWrapper::generateRotatingPrivKeyHex(const Napi::CallbackIn }); } +Napi::Value UserConfigWrapper::deriveProRotatingKey(const Napi::CallbackInfo& info) { + return wrapResult(info, [&] { + assertInfoLength(info, 1); + auto input = info[0]; + assertIsObject(input); + auto obj_in = input.As(); + + auto master_key_js_hex = obj_in.Get("proMasterKeyHex"); + assertIsString(master_key_js_hex, "deriveProRotatingKey.proMasterKeyHex"); + auto master_key_hex = + toCppString(master_key_js_hex, "deriveProRotatingKey.proMasterKeyHex"); + auto master_key = from_hex_to_vector(master_key_hex); + + auto now = std::chrono::floor( + toCppSysMs(obj_in.Get("nowMs"), "deriveProRotatingKey.nowMs")); + + // Deterministic seed for `now` (shared across the account's devices), then its ed25519 + // keypair. + auto rotating_seed = session::ProProof::rotating_seed(master_key, now); + auto [rotating_pk, rotating_sk] = session::ed25519::ed25519_key_pair(rotating_seed); + + auto obj = Napi::Object::New(info.Env()); + obj["rotatingSeedHex"] = toJs(info.Env(), to_hex(rotating_seed)); + obj["rotatingPrivKeyHex"] = toJs(info.Env(), to_hex(rotating_sk)); + return obj; + }); +} + +Napi::Value UserConfigWrapper::getRefundRequested(const Napi::CallbackInfo& info) { + return wrapResult(info, [&] { + // libsession stores whole seconds; the JS domain is milliseconds. Null when unset (or + // gated). + auto refund_s = config.get_refund_requested(); + std::optional> refund_ms; + if (refund_s) + refund_ms = std::chrono::sys_time( + std::chrono::duration_cast( + refund_s->time_since_epoch())); + return toJs(info.Env(), refund_ms); + }); +} + +void UserConfigWrapper::setRefundRequested(const Napi::CallbackInfo& info) { + wrapExceptions(info, [&] { + assertInfoLength(info, 1); + assertIsNumberOrNull(info[0], "setRefundRequested"); + auto refund_ms = maybeNonemptyTimeMs(info[0], "UserConfigWrapper::setRefundRequested"); + std::optional refund; + if (refund_ms) + refund = std::chrono::floor(*refund_ms); + config.set_refund_requested(refund); + }); +} + +Napi::Value UserConfigWrapper::getProPrepaid(const Napi::CallbackInfo& info) { + return wrapResult(info, [&] { + auto prepaid_s = config.get_pro_prepaid(); + std::optional> prepaid_ms; + if (prepaid_s) + prepaid_ms = std::chrono::sys_time( + std::chrono::duration_cast( + prepaid_s->time_since_epoch())); + return toJs(info.Env(), prepaid_ms); + }); +} + +void UserConfigWrapper::setProPrepaid(const Napi::CallbackInfo& info) { + wrapExceptions(info, [&] { + assertInfoLength(info, 1); + assertIsNumberOrNull(info[0], "setProPrepaid"); + auto prepaid_ms = maybeNonemptyTimeMs(info[0], "UserConfigWrapper::setProPrepaid"); + std::optional prepaid; + if (prepaid_ms) + prepaid = std::chrono::floor(*prepaid_ms); + config.set_pro_prepaid(prepaid); + }); +} + +Napi::Value UserConfigWrapper::getProRenewalTarget(const Napi::CallbackInfo& info) { + return wrapResult(info, [&] { + assertInfoLength(info, 1); + auto now = std::chrono::floor( + toCppSysMs(info[0], "UserConfigWrapper::getProRenewalTarget")); + auto target_s = config.pro_renewal_target(now); + std::optional> target_ms; + if (target_s) + target_ms = std::chrono::sys_time( + std::chrono::duration_cast( + target_s->time_since_epoch())); + return toJs(info.Env(), target_ms); + }); +} + } // namespace session::nodeapi diff --git a/types/pro/pro.d.ts b/types/pro/pro.d.ts index e0052fb..17bc0b1 100644 --- a/types/pro/pro.d.ts +++ b/types/pro/pro.d.ts @@ -127,6 +127,13 @@ declare module 'libsession_util_nodejs' { type GenerateProProofResponse = WithProResponseHeader & { proof: ProProof; + /** + * Advisory account (subscription) expiry (ms) — grace-inclusive true entitlement end; present on + * a successful proof and on a `subscription_expired` failure (a past value), null otherwise. + * Distinct from the proof's own clamped expiry; unsigned / not-in-M — use for the "Pro until X" + * display and to refresh the cached access expiry (`E`), never for entitlement gating. + */ + accountExpiryMs: number | null; }; type ProRevocationItem = WithRevocationTag & { @@ -179,7 +186,6 @@ declare module 'libsession_util_nodejs' { expiryTsMs: number; gracePeriodDurationMs: number; platformRefundExpiryTsMs: number; - refundRequestedTsMs: number; /** * Opaque payment identifier (confidential). */ @@ -203,19 +209,14 @@ declare module 'libsession_util_nodejs' { autoRenewing: boolean; expiryMs: number; gracePeriodDurationMs: number; - refundRequestedTsMs: number; /** The single most-recent payment, or null when the account has no payments. (Full history is a * separate library-only query — not wired.) */ latestPayment: ProPaymentItem | null; }; type ProWrapper = { - proFeaturesForMessage: (args: { utf16: string }) => WithProMessageBitset & { - status: 'SUCCESS' | 'UTF_DECODING_ERROR' | 'EXCEEDS_CHARACTER_LIMIT'; - }; - utf16Count: (args: { utf16: string }) => { codepointCount: number }; - utf16CountTruncatedToCodepoints: (args: { utf16: string; codepointLen: number }) => { - truncateAt: number; + proFeaturesForMessage: (args: { codepointCount: number }) => WithProMessageBitset & { + status: 'SUCCESS' | 'EXCEEDS_CHARACTER_LIMIT'; }; proProofRequest: ( @@ -260,8 +261,6 @@ declare module 'libsession_util_nodejs' { */ export class ProWrapperNode { public static proFeaturesForMessage: ProWrapper['proFeaturesForMessage']; - public static utf16Count: ProWrapper['utf16Count']; - public static utf16CountTruncatedToCodepoints: ProWrapper['utf16CountTruncatedToCodepoints']; public static proProofRequest: ProWrapper['proProofRequest']; public static proRevocationsRequest: ProWrapper['proRevocationsRequest']; public static proStatusRequest: ProWrapper['proStatusRequest']; @@ -279,8 +278,6 @@ declare module 'libsession_util_nodejs' { */ export type ProActionsType = | MakeActionCall - | MakeActionCall - | MakeActionCall | MakeActionCall | MakeActionCall | MakeActionCall diff --git a/types/shared.d.ts b/types/shared.d.ts index 780a017..c44043d 100644 --- a/types/shared.d.ts +++ b/types/shared.d.ts @@ -164,6 +164,10 @@ declare module 'libsession_util_nodejs' { * BASE_URL_MAX_LENGTH + '/r/' + ROOM_MAX_LENGTH + qs_pubkey.size() + hex pubkey + null terminator */ COMMUNITY_FULL_URL_MAX_LENGTH: number; + /** Max message codepoints for a standard (non-Pro) message */ + MESSAGE_CHARACTER_LIMIT_STANDARD: number; + /** Max message codepoints for a Pro message */ + MESSAGE_CHARACTER_LIMIT_PRO: number; /** * A string that looks like libsession-util v1.2.0-nogit */ diff --git a/types/user/userconfig.d.ts b/types/user/userconfig.d.ts index db7d498..7b1c4eb 100644 --- a/types/user/userconfig.d.ts +++ b/types/user/userconfig.d.ts @@ -72,6 +72,32 @@ declare module 'libsession_util_nodejs' { * Note: this should only be done once per device, and saved to the DB or the extra_data of `UserProfile`. */ generateRotatingPrivKeyHex: () => WithRotatingPrivKeyHex; + + /** + * Deterministically derive the rotating seed (and its ed25519 keypair) for `nowMs` from the Pro + * master key (libsession owns the rotation schedule). Every device deriving from the same master + * key + time converges on the same key, so concurrent proof (re)generations don't race. Feed the + * priv key to a proof request and persist the seed via setProConfig once the backend returns a + * signed proof. + */ + deriveProRotatingKey: (args: { proMasterKeyHex: string; nowMs: number }) => { + /** 32 bytes, 64 chars */ + rotatingSeedHex: string; + /** 64 bytes, 128 chars */ + rotatingPrivKeyHex: string; + }; + + /** Refund-requested marker (config key R), in ms; null when unset or past the 1-week read gate. */ + getRefundRequested: () => number | null; + setRefundRequested: (refundTsMs: number | null) => void; + /** Pro-prepaid / purchase-in-flight marker (config key I), in ms; null when unset or gated. */ + getProPrepaid: () => number | null; + setProPrepaid: (prepaidTsMs: number | null) => void; + /** + * When to (re)request a proof given `nowMs`: nowMs (request now), a future ms (preemptive + * renewal ~1h before expiry), or null (don't renew). Supersedes bespoke auto-renew logic. + */ + getProRenewalTarget: (nowMs: number) => number | null; }; export type UserConfigWrapperActionsCalls = MakeWrapperActionCalls; @@ -105,6 +131,12 @@ declare module 'libsession_util_nodejs' { public generateProMasterKey: UserConfigWrapper['generateProMasterKey']; public generateRotatingPrivKeyHex: UserConfigWrapper['generateRotatingPrivKeyHex']; + public deriveProRotatingKey: UserConfigWrapper['deriveProRotatingKey']; + public getRefundRequested: UserConfigWrapper['getRefundRequested']; + public setRefundRequested: UserConfigWrapper['setRefundRequested']; + public getProPrepaid: UserConfigWrapper['getProPrepaid']; + public setProPrepaid: UserConfigWrapper['setProPrepaid']; + public getProRenewalTarget: UserConfigWrapper['getProRenewalTarget']; } /** @@ -137,5 +169,11 @@ declare module 'libsession_util_nodejs' { | MakeActionCall | MakeActionCall | MakeActionCall - | MakeActionCall; + | MakeActionCall + | MakeActionCall + | MakeActionCall + | MakeActionCall + | MakeActionCall + | MakeActionCall + | MakeActionCall; }