diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 0a6065286f..d88608b423 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -370,9 +370,9 @@ message GroupUpdateDeleteMemberContentMessage { message ProProof { optional uint32 version = 1; - optional bytes genIndexHash = 2; + optional bytes revocationTag = 2; optional bytes rotatingPublicKey = 3; - optional uint64 expireAtMs = 4; // Epoch timestamp in milliseconds + optional uint64 expiryUnixTs = 4; // Epoch timestamp in whole seconds (what the Session Pro backend signs over) optional bytes sig = 5; } diff --git a/ts/components/basic/Localizer.tsx b/ts/components/basic/Localizer.tsx index 5beba4ce59..6aafbec32e 100644 --- a/ts/components/basic/Localizer.tsx +++ b/ts/components/basic/Localizer.tsx @@ -37,6 +37,14 @@ const StyledHtmlRenderer = styled.span` export type WithAsTag = { asTag?: LocalizerHtmlTag }; export type WithClassName = { className?: string }; +/** + * Names of args whose value is trusted, pre-formatted HTML that should skip the arg-level escaping + * applied when the template contains formatting tags. Safe only for app-controlled content: the final + * string is still run through DOMPurify (the {@link supportedFormattingTags} whitelist) by + * SessionHtmlRenderer, so only formatting survives — never scripts or attributes. NEVER list a + * user-controlled arg here. + */ +export type WithHtmlArgs = { htmlArgs?: Array }; /** * Retrieve a localized message string, substituting dynamic parts where necessary and formatting it as HTML if necessary. @@ -48,14 +56,32 @@ export type WithClassName = { className?: string }; * @returns The localized message string with substitutions and formatting applied. */ export const Localizer = ( - props: GetMessageArgs & WithAsTag & WithClassName + props: GetMessageArgs & WithAsTag & WithClassName & WithHtmlArgs ) => { const args = messageArgsToArgsOnly(props); let rawString: string = getRawMessage(getCrowdinLocale(), props); const containsFormattingTags = createSupportedFormattingTagsRegex().test(rawString); - const cleanArgs = args && containsFormattingTags ? sanitizeArgs(args) : args; + // When the template has formatting tags we HTML-escape the args so dynamic values can't inject + // markup. Args named in `htmlArgs` opt out of that escaping so they can carry trusted, pre-formatted + // HTML (e.g. a client-built
• list) — still XSS-safe because SessionHtmlRenderer runs DOMPurify + // (supportedFormattingTags whitelist) on the final string. + const cleanArgs = ((): typeof args => { + if (!args || !containsFormattingTags) { + return args; + } + const htmlArgKeys = props.htmlArgs; + if (!htmlArgKeys?.length) { + return sanitizeArgs(args) as typeof args; + } + const toSanitize: Record = {}; + const trusted: Record = {}; + for (const [key, value] of Object.entries(args as Record)) { + (htmlArgKeys.includes(key) ? trusted : toSanitize)[key] = value; + } + return { ...sanitizeArgs(toSanitize), ...trusted } as typeof args; + })(); const containsIcons = !!(cleanArgs && Object.keys(cleanArgs).includes('icon')); if (containsIcons && (cleanArgs as any).icon) { diff --git a/ts/components/dialog/ProCTADescription.tsx b/ts/components/dialog/ProCTADescription.tsx index 95a34f8a15..6fe9ead66c 100644 --- a/ts/components/dialog/ProCTADescription.tsx +++ b/ts/components/dialog/ProCTADescription.tsx @@ -9,7 +9,7 @@ import { formatNumber } from '../../util/i18n/formatting/generics'; import { assertUnreachable } from '../../types/sqlSharedTypes'; import { ProIconButton } from '../buttons/ProButton'; import { CTAVariant, type ProCTAVariant } from './cta/types'; -import { useProBackendProDetails } from '../../state/selectors/proBackendData'; +import { useProBackendProStatus } from '../../state/selectors/proBackendData'; const variantsWithoutFeatureList = [ CTAVariant.PRO_GROUP_NON_ADMIN, @@ -96,7 +96,7 @@ function FeatureList({ variant }: { variant: CTAVariant }) { } function ProExpiringSoonDescription() { - const { data } = useProBackendProDetails(); + const { data } = useProBackendProStatus(); return ; } diff --git a/ts/components/dialog/debug/FeatureFlags.tsx b/ts/components/dialog/debug/FeatureFlags.tsx index 3625acf749..834d6e9485 100644 --- a/ts/components/dialog/debug/FeatureFlags.tsx +++ b/ts/components/dialog/debug/FeatureFlags.tsx @@ -2,7 +2,7 @@ import { isBoolean, isNil } from 'lodash'; import { Dispatch, useCallback, useEffect, useMemo, useState } from 'react'; import { clipboard } from 'electron'; import useAsync from 'react-use/lib/useAsync'; -import { ProConfig, type ProProof } from 'libsession_util_nodejs'; +import { ProConfig } from 'libsession_util_nodejs'; import { getAppDispatch } from '../../../state/dispatch'; import { getDataFeatureFlag, @@ -41,7 +41,7 @@ import { import { UserConfigWrapperActions } from '../../../webworker/workers/browser/libsession/libsession_worker_userconfig_interface'; import { isDebugMode } from '../../../shared/env_vars'; import { - useProBackendProDetails, + useProBackendProStatus, useProBackendRefetch, } from '../../../state/selectors/proBackendData'; import ProBackendAPI from '../../../session/apis/pro_backend_api/ProBackendAPI'; @@ -740,7 +740,7 @@ function ProConfigForm({ ); const [sigInput, setSigInput] = useState(proConfig?.proProof.signatureHex ?? ''); const [genHashInput, setGenHashInput] = useState( - proConfig?.proProof.genIndexHashB64 ?? '' + proConfig?.proProof.revocationTagB64 ?? '' ); const [versionInput, setVersionInput] = useState( proConfig?.proProof.version.toString() ?? '' @@ -780,7 +780,7 @@ function ProConfigForm({ } function ProConfigManager({ forceUpdate }: { forceUpdate: () => void }) { - const { isFetching } = useProBackendProDetails(); + const { isFetching } = useProBackendProStatus(); const refetch = useProBackendRefetch(); const [proConfig, setProConfig] = useState(null); const getProConfig = useCallback(async () => { @@ -829,7 +829,7 @@ export const ProDebugSection = ({ const resetPro = useCallback(async () => { await UserConfigWrapperActions.removeProConfig(); - await Storage.remove(SettingsKey.proDetails); + await Storage.remove(SettingsKey.proStatus); await Storage.remove(SettingsKey.proExpiringSoonCTA); await Storage.remove(SettingsKey.proExpiredCTA); dispatch(proBackendDataActions.reset({ key: 'details' })); @@ -907,10 +907,10 @@ export const ProDebugSection = ({ if (!proAvailable) { return; } - dispatch(proBackendDataActions.refreshGetProDetailsFromProBackend({}) as any); + dispatch(proBackendDataActions.refreshGetProStatusFromProBackend({}) as any); }} > - Refresh Pro Details + Refresh Pro Status { @@ -924,15 +924,12 @@ export const ProDebugSection = ({ if (getFeatureFlag('debugServerRequests')) { window?.log?.debug('getProProof response: ', response); } - if (response?.status_code === 200) { - const proProof: ProProof = { - expiryMs: response.result.expiry_unix_ts_ms, - genIndexHashB64: response.result.gen_index_hash_b64, - rotatingPubkeyHex: response.result.rotating_pkey_hex, - version: response.result.version, - signatureHex: response.result.sig_hex, - }; - await UserConfigWrapperActions.setProConfig({ proProof, rotatingSeedHex }); + if (response && response.status === 'ok') { + // libsession returns a ready-made ProProof; relay it verbatim. + await UserConfigWrapperActions.setProConfig({ + proProof: response.proof, + rotatingSeedHex, + }); } }} > @@ -941,13 +938,13 @@ export const ProDebugSection = ({ { const masterPrivKeyHex = await getProMasterKeyHex(); - const response = await ProBackendAPI.getProDetails({ masterPrivKeyHex }); + const response = await ProBackendAPI.getProStatus({ masterPrivKeyHex }); if (getFeatureFlag('debugServerRequests')) { - window?.log?.debug('Pro Details: ', response); + window?.log?.debug('Pro Status: ', response); } }} > - Get Pro Details + Get Pro Status v !== ProStatus.NeverBeenPro, + isVisible: v => v !== ProStatus.Never, }} /> v !== ProStatus.NeverBeenPro, + isVisible: v => v !== ProStatus.Never, }} /> { export const DefaultSettingPage = (modalState: UserSettingsModalState) => { const dispatch = getAppDispatch(); const closeAction = useUserSettingsCloseAction(modalState); - const { t } = useProBackendProDetails(); + const { t } = useProBackendProStatus(); const refetch = useProBackendRefetch(); const profileName = useOurConversationUsername() || ''; diff --git a/ts/components/dialog/user-settings/pages/user-pro/ProNonOriginatingPage.tsx b/ts/components/dialog/user-settings/pages/user-pro/ProNonOriginatingPage.tsx index a844a84404..950e262d51 100644 --- a/ts/components/dialog/user-settings/pages/user-pro/ProNonOriginatingPage.tsx +++ b/ts/components/dialog/user-settings/pages/user-pro/ProNonOriginatingPage.tsx @@ -1,7 +1,7 @@ import styled from 'styled-components'; -import { type ReactNode } from 'react'; +import { type ReactNode, useEffect, useState } from 'react'; import { getAppDispatch } from '../../../../../state/dispatch'; -import { tr } from '../../../../../localization/localeTools'; +import { isSimpleTokenNoArgs, tr } from '../../../../../localization/localeTools'; import { Localizer } from '../../../../basic/Localizer'; import { ModalBasicHeader } from '../../../../SessionWrapperModal'; import { ModalBackButton } from '../../../shared/ModalBackButton'; @@ -16,6 +16,8 @@ import { LucideIcon } from '../../../../icon/LucideIcon'; import { LUCIDE_ICONS_UNICODE, WithLucideUnicode } from '../../../../icon/lucide'; import { SessionButton, SessionButtonColor } from '../../../../basic/SessionButton'; import { showLinkVisitWarningDialog } from '../../../OpenUrlModal'; +import { ProWrapperActions } from '../../../../../webworker/workers/browser/libsession_worker_interface'; +import type { ProviderUrls } from 'libsession_util_nodejs'; import { proButtonProps } from '../../../SessionCTA'; import { Flex } from '../../../../basic/Flex'; import type { ProNonOriginatingPageVariant } from '../../../../../types/ReduxTypes'; @@ -23,8 +25,8 @@ import { useCurrentNeverHadPro } from '../../../../../hooks/useHasPro'; import LIBSESSION_CONSTANTS from '../../../../../session/utils/libsession/libsession_constants'; import { ProPaymentProvider } from '../../../../../session/apis/pro_backend_api/types'; import { - useProBackendProDetails, - type ProcessedProDetails, + useProBackendProStatus, + type ProcessedProStatus, } from '../../../../../state/selectors/proBackendData'; import { userSettingsModal } from '../../../../../state/ducks/modalDialog'; @@ -32,21 +34,83 @@ type VariantPageProps = { variant: ProNonOriginatingPageVariant; }; -const useProBackendProDetailsLocal = useProBackendProDetails; +const useProBackendProStatusLocal = useProBackendProStatus; + +/** + * Provider support/management URLs are libsession's (fetched by provider slug), not client display data, + * so we resolve them on demand at click time via the worker rather than threading async state into the + * (synchronous) selector. `pick` chooses which URL from the resolved set. + */ +async function openProviderUrl( + provider: ProPaymentProvider, + pick: (urls: ProviderUrls) => string, + dispatch: Parameters[1] +) { + const urls = await ProWrapperActions.providerUrls({ code: provider }); + if (!urls) { + return; + } + // A provider may expose only some URLs; libsession returns '' for an absent one. Guard on the + // picked URL (not just the container) so we never open the link-visit dialog on an empty string. + const url = pick(urls); + if (url) { + showLinkVisitWarningDialog(url, dispatch); + } +} const useCurrentNeverHadProLocal = useCurrentNeverHadPro; /** * For some texts, we want `Apple website` for apple but `Google Play Store website` for google... * Those two are not stored in the same field, so this hook can be used to fetch the right one */ -function useStoreOrPlatformFromProvider(data: ProcessedProDetails['data']) { - return data.provider === ProPaymentProvider.iOSAppStore +function useStoreOrPlatformFromProvider(data: ProcessedProStatus['data']) { + return data.provider === ProPaymentProvider.AppStore ? data.providerConstants.platform // we want `Apple website` for apple : data.providerConstants.store; // but `Google Play Store website` for google... } +// The purchasable-platform slug set is a static libsession constant, so fetch it once and cache it. +let cachedVisiblePlatformSlugs: Array | null = null; + +/** + * Build the `{pro_stores}` bulleted list from the purchasable provider slugs, keeping only those with a + * `pro_provider__store` translation (a new/untranslated provider is skipped gracefully). Desktop + * keeps libsession's order as-is (it has no "own" platform to hoist). + */ +function buildProStoresList(slugs: Array): string { + return slugs + .map(slug => { + const token = `pro_provider_${slug}_store`; + return isSimpleTokenNoArgs(token) ? tr(token) : undefined; + }) + .filter((store): store is string => !!store) + .map(store => `
• ${store}`) + .join(''); +} + +/** The localized `{pro_stores}` list for the "purchase via …" messages (fetched once; empty until loaded). */ +function useProStoresList(): string { + const [slugs, setSlugs] = useState>(cachedVisiblePlatformSlugs ?? []); + useEffect(() => { + if (cachedVisiblePlatformSlugs) { + return undefined; + } + let cancelled = false; + void ProWrapperActions.visiblePlatforms().then(result => { + cachedVisiblePlatformSlugs = result; + if (!cancelled) { + setSlugs(result); + } + }); + return () => { + cancelled = true; + }; + }, []); + return buildProStoresList(slugs); +} + function ProStatusTextUpdate() { - const { data } = useProBackendProDetailsLocal(); + const { data } = useProBackendProStatusLocal(); return data.autoRenew ? ( } @@ -170,8 +234,8 @@ function ProInfoBlockDevice({ textElement }: { textElement: ReactNode }) { } function ProInfoBlockDeviceLinked() { - const { data } = useProBackendProDetailsLocal(); const hasNeverHadPro = useCurrentNeverHadProLocal(); + const proStores = useProStoresList(); return ( {tr('onLinkedDevice')} } @@ -197,7 +261,7 @@ function ProInfoBlockWebsite({ textElement: ReactNode; titleType: 'via' | 'onThe'; }) { - const { data } = useProBackendProDetailsLocal(); + const { data } = useProBackendProStatusLocal(); const storeOrPlatform = useStoreOrPlatformFromProvider(data); return ( @@ -257,15 +321,15 @@ function ProInfoBlockLayout({ function ProInfoBlockUpgrade() { const dispatch = getAppDispatch(); - const { data } = useProBackendProDetailsLocal(); + const proStores = useProStoresList(); return ( } @@ -281,7 +345,7 @@ function ProInfoBlockUpgrade() { } function ProInfoBlockUpdate() { - const { data } = useProBackendProDetailsLocal(); + const { data } = useProBackendProStatusLocal(); const storeOrPlatform = useStoreOrPlatformFromProvider(data); return ( @@ -326,8 +390,9 @@ function ProInfoBlockUpdate() { function ProInfoBlockRenew() { const dispatch = getAppDispatch(); - const { data } = useProBackendProDetailsLocal(); + const { data } = useProBackendProStatusLocal(); const storeOrPlatform = useStoreOrPlatformFromProvider(data); + const proStores = useProStoresList(); return ( } @@ -368,7 +433,7 @@ function ProInfoBlockRenew() { } function ProInfoBlockCancel() { - const { data } = useProBackendProDetailsLocal(); + const { data } = useProBackendProStatusLocal(); const storeOrPlatform = useStoreOrPlatformFromProvider(data); return ( @@ -439,7 +504,7 @@ function ProInfoBlockRefundSessionSupport() { } function ProInfoBlockRefundGooglePlay() { - const { data } = useProBackendProDetailsLocal(); + const { data } = useProBackendProStatusLocal(); return ( @@ -455,7 +520,7 @@ function ProInfoBlockRefundGooglePlay() { } function ProInfoBlockRefundIOS() { - const { data } = useProBackendProDetailsLocal(); + const { data } = useProBackendProStatusLocal(); return ( ; } switch (data.provider) { - case ProPaymentProvider.iOSAppStore: + case ProPaymentProvider.AppStore: return ; - case ProPaymentProvider.GooglePlayStore: + case ProPaymentProvider.GooglePlay: return ; - case ProPaymentProvider.Nil: - case ProPaymentProvider.Rangeproof: - return ; default: - return assertUnreachable(data.provider, `Unknown pro payment provider: ${data.provider}`); + // Rangeproof, none (''), or an unknown/future provider slug -> the Session-support refund flow. + return ; } } function ProInfoBlockRefundRequested() { - const { data } = useProBackendProDetailsLocal(); + const { data } = useProBackendProStatusLocal(); const dispatch = getAppDispatch(); return ( @@ -532,7 +595,7 @@ function ProInfoBlockRefundRequested() { - showLinkVisitWarningDialog(data.providerConstants.refund_status_url, dispatch) + void openProviderUrl(data.provider, u => u.refundStatusUrl, dispatch) } style={{ cursor: 'pointer' }} > @@ -567,7 +630,7 @@ function ProInfoBlock({ variant }: VariantPageProps) { function ProPageButtonUpdate() { const dispatch = getAppDispatch(); - const { data } = useProBackendProDetailsLocal(); + const { data } = useProBackendProStatusLocal(); const storeOrPlatform = useStoreOrPlatformFromProvider(data); return ( @@ -575,7 +638,7 @@ function ProPageButtonUpdate() { {...proButtonProps} buttonColor={SessionButtonColor.Primary} onClick={() => { - showLinkVisitWarningDialog(data.providerConstants.update_subscription_url, dispatch); + void openProviderUrl(data.provider, u => u.updateSubscriptionUrl, dispatch); }} dataTestId="pro-open-platform-website-button" > @@ -586,14 +649,14 @@ function ProPageButtonUpdate() { function ProPageButtonCancel() { const dispatch = getAppDispatch(); - const { data } = useProBackendProDetailsLocal(); + const { data } = useProBackendProStatusLocal(); const storeOrPlatform = useStoreOrPlatformFromProvider(data); return ( { - showLinkVisitWarningDialog(data.providerConstants.cancel_subscription_url, dispatch); + void openProviderUrl(data.provider, u => u.cancelSubscriptionUrl, dispatch); }} dataTestId="pro-open-platform-website-button" > @@ -604,17 +667,16 @@ function ProPageButtonCancel() { function ProPageButtonRefund() { const dispatch = getAppDispatch(); - const { data } = useProBackendProDetailsLocal(); + const { data } = useProBackendProStatusLocal(); const storeOrPlatform = useStoreOrPlatformFromProvider(data); return ( { - showLinkVisitWarningDialog( - data.isPlatformRefundAvailable - ? data.providerConstants.refund_platform_url - : data.providerConstants.refund_support_url, + void openProviderUrl( + data.provider, + u => (data.isPlatformRefundAvailable ? u.refundPlatformUrl : u.refundSupportUrl), dispatch ); }} diff --git a/ts/components/dialog/user-settings/pages/user-pro/ProSettingsPage.tsx b/ts/components/dialog/user-settings/pages/user-pro/ProSettingsPage.tsx index 639f59a767..e6ff6ff95c 100644 --- a/ts/components/dialog/user-settings/pages/user-pro/ProSettingsPage.tsx +++ b/ts/components/dialog/user-settings/pages/user-pro/ProSettingsPage.tsx @@ -57,7 +57,7 @@ import { } from '../../../../../models/proMessageFeature'; import { usePinnedConversationsCount } from '../../../../../state/selectors/conversations'; import { - useProBackendProDetails, + useProBackendProStatus, useProBackendRefetch, } from '../../../../../state/selectors/proBackendData'; import { formatNumber } from '../../../../../util/i18n/formatting/generics'; @@ -214,7 +214,7 @@ function useBackendErrorDialogButtons() { } // NOTE: [react-compiler] this convinces the compiler the hook is static -const useProBackendProDetailsInternal = useProBackendProDetails; +const useProBackendProStatusInternal = useProBackendProStatus; const useCurrentUserHasProInternal = useCurrentUserHasPro; const useCurrentUserHasExpiredProInternal = useCurrentUserHasExpiredPro; const useCurrentNeverHadProInternal = useCurrentNeverHadPro; @@ -225,7 +225,7 @@ function ProNonProContinueButton({ state }: SectionProps) { const { returnToThisModalAction, centerAlign, afterCloseAction } = state; const dispatch = getAppDispatch(); const neverHadPro = useCurrentNeverHadProInternal(); - const { isLoading, isError } = useProBackendProDetailsInternal(); + const { isLoading, isError } = useProBackendProStatusInternal(); const backendErrorButtons = useBackendErrorDialogButtons(); @@ -434,7 +434,7 @@ function ProSettings({ state }: SectionProps) { const userHasPro = useCurrentUserHasProInternal(); const userHasExpiredPro = useCurrentUserHasExpiredProInternal(); const userNeverHadPro = useCurrentNeverHadProInternal(); - const { data, isLoading, isError } = useProBackendProDetailsInternal(); + const { data, isLoading, isError } = useProBackendProStatusInternal(); const backendErrorButtons = useBackendErrorDialogButtons(); const forceRefresh = useUpdate(); @@ -765,7 +765,7 @@ function ProFeatures({ state }: SectionProps) { function ManageProCurrentAccess({ state }: SectionProps) { const dispatch = getAppDispatch(); - const { data } = useProBackendProDetailsInternal(); + const { data } = useProBackendProStatusInternal(); const userHasPro = useCurrentUserHasProInternal(); if (!userHasPro) { return null; @@ -822,7 +822,7 @@ function ManageProAccess({ state }: SectionProps) { const { returnToThisModalAction, centerAlign } = state; - const { isLoading, isError } = useProBackendProDetailsInternal(); + const { isLoading, isError } = useProBackendProStatusInternal(); const backendErrorButtons = useBackendErrorDialogButtons(); @@ -962,7 +962,7 @@ function PageHero({ state }: SectionProps) { const dispatch = getAppDispatch(); const isPro = useCurrentUserHasProInternal(); const proExpired = useCurrentUserHasExpiredProInternal(); - const { isLoading, isError } = useProBackendProDetailsInternal(); + const { isLoading, isError } = useProBackendProStatusInternal(); const backendErrorButtons = useBackendErrorDialogButtons(); diff --git a/ts/data/settings-key.ts b/ts/data/settings-key.ts index 485ced4df1..bb9d089948 100644 --- a/ts/data/settings-key.ts +++ b/ts/data/settings-key.ts @@ -92,7 +92,7 @@ export const SettingsKey = { * */ proRevocationListNextRunAtMs: 'proRevocationListNextRunAtMs', - proDetails: 'proDetails', + proStatus: 'proStatus', // NOTE: for these CTAs undefined means it has never been shown in this cycle of pro access, true means it needs to be shown and false means it has been shown and dont show it again. proExpiringSoonCTA: 'proExpiringSoonCTA', proExpiredCTA: 'proExpiredCTA', diff --git a/ts/hooks/useHasPro.ts b/ts/hooks/useHasPro.ts index 7ff7b93581..d6411459a1 100644 --- a/ts/hooks/useHasPro.ts +++ b/ts/hooks/useHasPro.ts @@ -56,7 +56,7 @@ export function useCurrentNeverHadPro() { const isProAvailable = getIsProAvailableMemo(); const status = useCurrentUserProStatus(); - return isProAvailable && status === ProStatus.NeverBeenPro; + return isProAvailable && status === ProStatus.Never; } /** diff --git a/ts/localization b/ts/localization index f824751eba..31da9d8d50 160000 --- a/ts/localization +++ b/ts/localization @@ -1 +1 @@ -Subproject commit f824751eba28e98843e082c08672393224e6b939 +Subproject commit 31da9d8d50a7043928bb699bccca496488b6f04c diff --git a/ts/models/conversation.ts b/ts/models/conversation.ts index bd9c24d4c9..e1f984de96 100644 --- a/ts/models/conversation.ts +++ b/ts/models/conversation.ts @@ -795,7 +795,7 @@ export class ConversationModel extends Model { if (!proProof) { return false; } - if (ProRevocationCache.isB64HashEffectivelyRevoked(proProof.genIndexHashB64)) { + if (ProRevocationCache.isB64HashEffectivelyRevoked(proProof.revocationTagB64)) { // `false` because the proof is not valid (revoked) return false; } @@ -804,12 +804,12 @@ export class ConversationModel extends Model { } const proDetails = this.dbContactProDetails(); - if (!proDetails || !proDetails.proExpiryTsMs || !proDetails.proGenIndexHashB64) { + if (!proDetails || !proDetails.proExpiryTsMs || !proDetails.proRevocationTagB64) { return false; } - // make sure that genIndexHash was not revoked first - if (ProRevocationCache.isB64HashEffectivelyRevoked(proDetails.proGenIndexHashB64)) { + // make sure that revocation tag was not revoked first + if (ProRevocationCache.isB64HashEffectivelyRevoked(proDetails.proRevocationTagB64)) { // `false` because the proof is not valid (revoked) return false; } @@ -1731,11 +1731,11 @@ export class ConversationModel extends Model { ) { return null; } - const proGenIndexHashB64 = this.get('proGenIndexHashB64'); + const proRevocationTagB64 = this.get('proRevocationTagB64'); const proExpiryTsMs = this.get('proExpiryTsMs'); const bitsetProFeatures = this.get('bitsetProFeatures'); return { - proGenIndexHashB64, + proRevocationTagB64, proExpiryTsMs, bitsetProFeatures, }; diff --git a/ts/models/conversationAttributes.ts b/ts/models/conversationAttributes.ts index 63c7abae87..8d6f44a944 100644 --- a/ts/models/conversationAttributes.ts +++ b/ts/models/conversationAttributes.ts @@ -117,7 +117,7 @@ export interface ConversationAttributes { */ bitsetProFeatures?: string; - proGenIndexHashB64?: string; + proRevocationTagB64?: string; proExpiryTsMs?: number; triggerNotificationsFor: ConversationNotificationSettingType; @@ -181,7 +181,7 @@ export const fillConvoAttributesWithDefaults = ( lastMessageInteractionStatus: null, bitsetProFeatures: undefined, - proGenIndexHashB64: undefined, + proRevocationTagB64: undefined, proExpiryTsMs: undefined, triggerNotificationsFor: 'all', // if the settings is not set in the db, this is the default diff --git a/ts/models/profile.ts b/ts/models/profile.ts index 62846fadd5..81583b1252 100644 --- a/ts/models/profile.ts +++ b/ts/models/profile.ts @@ -43,7 +43,7 @@ type WithOptionalName = { }; type ProDetailsContact = { - proGenIndexHashB64: string | null; + proRevocationTagB64: string | null; proExpiryTsMs: number | null; bitsetProFeatures: bigint | string | null; }; @@ -190,7 +190,7 @@ abstract class SessionProfileChanges { } protected applyProDetailsChange( - { bitsetProFeatures, proExpiryTsMs, proGenIndexHashB64 }: ProDetailsContact, + { bitsetProFeatures, proExpiryTsMs, proRevocationTagB64 }: ProDetailsContact, newProfileUpdatedAtSeconds: number | null ) { let proDetailsChanged = false; @@ -216,8 +216,8 @@ abstract class SessionProfileChanges { this.convo[privateSetKey]('bitsetProFeatures', undefined); proDetailsChanged = true; } - if (!isNil(proGenIndexHashB64) && this.convo.get('proGenIndexHashB64') !== proGenIndexHashB64) { - this.convo[privateSetKey]('proGenIndexHashB64', proGenIndexHashB64); + if (!isNil(proRevocationTagB64) && this.convo.get('proRevocationTagB64') !== proRevocationTagB64) { + this.convo[privateSetKey]('proRevocationTagB64', proRevocationTagB64); proDetailsChanged = true; } if (!isNil(proExpiryTsMs) && this.convo.get('proExpiryTsMs') !== proExpiryTsMs) { @@ -530,7 +530,7 @@ const emptyProDetails = { proDetails: { bitsetProFeatures: null, proExpiryTsMs: null, - proGenIndexHashB64: null, + proRevocationTagB64: null, }, }; @@ -545,7 +545,7 @@ function buildProProfileDetailsFromContact({ proDetails: { bitsetProFeatures: contact?.proProfileBitset ?? null, proExpiryTsMs: convoVolatileDetails?.proExpiryTsMs ?? null, - proGenIndexHashB64: convoVolatileDetails?.genIndexHashB64 ?? null, + proRevocationTagB64: convoVolatileDetails?.revocationTagB64 ?? null, }, }; } @@ -560,7 +560,7 @@ function buildProProfileDetailsFromEnvelope({ } if ( !decodedEnvelope.validPro.proProfileBitset || - !decodedEnvelope.validPro.proProof.genIndexHashB64 + !decodedEnvelope.validPro.proProof.revocationTagB64 ) { return emptyProDetails; } @@ -571,7 +571,7 @@ function buildProProfileDetailsFromEnvelope({ proDetails: { bitsetProFeatures: decodedEnvelope.validPro.proProfileBitset, proExpiryTsMs: decodedEnvelope.validPro.proProof.expiryMs, - proGenIndexHashB64: decodedEnvelope.validPro.proProof.genIndexHashB64, + proRevocationTagB64: decodedEnvelope.validPro.proProof.revocationTagB64, }, } : emptyProDetails; @@ -625,7 +625,7 @@ export function buildPrivateProfileChangeFromMetaGroupMember({ // Pass null to the fields so we don't overwrite them. bitsetProFeatures: null, // The member object has no pro details. proExpiryTsMs: null, // The member object has no pro details. - proGenIndexHashB64: null, // The member object has no pro details. + proRevocationTagB64: null, // The member object has no pro details. }, }; if (member.profilePicture?.url && member.profilePicture?.key) { @@ -684,7 +684,7 @@ export function buildPrivateProfileChangeFromSwarmDataMessage({ proDetails: { bitsetProFeatures: decodedPro?.proProfileBitset ?? null, proExpiryTsMs: decodedPro?.proProof.expiryMs ?? null, - proGenIndexHashB64: decodedPro?.proProof.genIndexHashB64 ?? null, + proRevocationTagB64: decodedPro?.proProof.revocationTagB64 ?? null, }, }; @@ -713,7 +713,7 @@ export async function buildPrivateProfileChangeFromUserProfileUpdate(ourConvo: C proDetails: { bitsetProFeatures: null, // NTS case, we don't care about those proExpiryTsMs: null, // NTS case, we don't care about those - proGenIndexHashB64: null, // NTS case, we don't care about those + proRevocationTagB64: null, // NTS case, we don't care about those }, }; return profilePic.url && profilePic.key diff --git a/ts/node/database_utility.ts b/ts/node/database_utility.ts index 6e6afedfda..21ceb15800 100644 --- a/ts/node/database_utility.ts +++ b/ts/node/database_utility.ts @@ -78,7 +78,7 @@ const allowedKeysFormatRowOfConversation = [ 'lastMessageInteractionType', 'lastMessageInteractionStatus', 'bitsetProFeatures', - 'proGenIndexHashB64', + 'proRevocationTagB64', 'proExpiryTsMs', 'triggerNotificationsFor', 'profileUpdatedSeconds', @@ -183,8 +183,8 @@ export function formatRowOfConversation( convo.bitsetProFeatures = undefined; } - if (!convo.proGenIndexHashB64) { - convo.proGenIndexHashB64 = undefined; + if (!convo.proRevocationTagB64) { + convo.proRevocationTagB64 = undefined; } if (!convo.proExpiryTsMs) { @@ -230,7 +230,7 @@ const allowedKeysOfConversationAttributes = [ 'lastMessageInteractionStatus', 'triggerNotificationsFor', 'bitsetProFeatures', - 'proGenIndexHashB64', + 'proRevocationTagB64', 'proExpiryTsMs', 'profileUpdatedSeconds', 'lastJoinedTimestamp', diff --git a/ts/node/migration/helpers/v31.ts b/ts/node/migration/helpers/v31.ts index 439e93ee3e..fd7d800177 100644 --- a/ts/node/migration/helpers/v31.ts +++ b/ts/node/migration/helpers/v31.ts @@ -156,7 +156,7 @@ function insertContactIntoContactWrapper( volatileConfigWrapper.set1o1(contact.id, { lastReadTsMs: lastRead, forcedUnread: false, - proGenIndexHashB64: null, + proRevocationTagB64: null, proExpiryTsMs: null, }); } catch (e) { diff --git a/ts/node/migration/sessionMigrations.ts b/ts/node/migration/sessionMigrations.ts index 5f5c29a5f2..d285dfb55d 100644 --- a/ts/node/migration/sessionMigrations.ts +++ b/ts/node/migration/sessionMigrations.ts @@ -132,6 +132,7 @@ const LOKI_SCHEMA_VERSIONS: Array<(currentVersion: number, db: Database) => void updateToSessionSchemaVersion54, updateToSessionSchemaVersion55, updateToSessionSchemaVersion56, + updateToSessionSchemaVersion57, ]; function updateToSessionSchemaVersion1(currentVersion: number, db: Database) { @@ -2445,6 +2446,39 @@ async function updateToSessionSchemaVersion56(currentVersion: number, db: Databa await removeDonateAppealCTAReadFlag(targetVersion, db); } +async function updateToSessionSchemaVersion57(currentVersion: number, db: Database) { + const targetVersion = 57; + if (currentVersion >= targetVersion) { + return; + } + console.log(`updateToSessionSchemaVersion${targetVersion}: starting...`); + + db.transaction(() => { + // Rename the pro-proof column proGenIndexHashB64 -> proRevocationTagB64 to match libsession's + // `revocation_tag`. v50 shipped the column as `proGenIndexHashB64`, so we rename it in place + // here rather than editing v50: existing installs already ran v50 and would otherwise keep the + // old column while the app reads the new name (SQLITE_ERROR: no such column). Guarded so it is + // idempotent for internal builds that already carry the new column. + const columns = db.pragma(`table_info('${CONVERSATIONS_TABLE}');`) as Array<{ name: string }>; + const hasOld = columns.some(c => c.name === 'proGenIndexHashB64'); + const hasNew = columns.some(c => c.name === 'proRevocationTagB64'); + + if (hasOld && !hasNew) { + db.exec( + `ALTER TABLE ${CONVERSATIONS_TABLE} RENAME COLUMN proGenIndexHashB64 TO proRevocationTagB64;` + ); + } else if (!hasOld && !hasNew) { + // Neither column present (shouldn't happen given v50) — add the new one so later code is safe. + db.exec(`ALTER TABLE ${CONVERSATIONS_TABLE} ADD COLUMN proRevocationTagB64 TEXT;`); + } + // else: the new column already exists — nothing to do. + + writeSessionSchemaVersion(targetVersion, db); + })(); + + console.log(`updateToSessionSchemaVersion${targetVersion}: success!`); +} + export function printTableColumns(table: string, db: Database) { console.info(db.pragma(`table_info('${table}');`)); } diff --git a/ts/node/sql.ts b/ts/node/sql.ts index 79bc9dce66..2255a3be3e 100644 --- a/ts/node/sql.ts +++ b/ts/node/sql.ts @@ -521,7 +521,7 @@ function saveConversation(data: ConversationAttributes): SaveConversationReturn avatarPointer, triggerNotificationsFor, bitsetProFeatures, - proGenIndexHashB64, + proRevocationTagB64, proExpiryTsMs, profileUpdatedSeconds, isTrustedForAttachmentDownload, @@ -570,7 +570,7 @@ function saveConversation(data: ConversationAttributes): SaveConversationReturn groupAdmins: groupAdmins && groupAdmins.length ? arrayStrToJson(groupAdmins) : '[]', avatarPointer: avatarPointer || null, bitsetProFeatures: bitsetProFeatures || null, - proGenIndexHashB64: proGenIndexHashB64 || null, + proRevocationTagB64: proRevocationTagB64 || null, proExpiryTsMs: proExpiryTsMs || null, triggerNotificationsFor, profileUpdatedSeconds: profileUpdatedSeconds || null, diff --git a/ts/receiver/configMessage.ts b/ts/receiver/configMessage.ts index 8bdc1562af..140d3f2de7 100644 --- a/ts/receiver/configMessage.ts +++ b/ts/receiver/configMessage.ts @@ -151,10 +151,10 @@ async function mergeUserConfigsWithIncomingUpdates( if (proAccessExpiryBefore !== proAccessExpiryAfter) { window.log.debug( - `[mergeConfigsWithInboxUpdates] proAccessExpiry changed from ${proAccessExpiryBefore} to ${proAccessExpiryAfter}. Refreshing our pro details.` + `[mergeConfigsWithInboxUpdates] proAccessExpiry changed from ${proAccessExpiryBefore} to ${proAccessExpiryAfter}. Refreshing our pro status.` ); window.inboxStore?.dispatch( - proBackendDataActions.refreshGetProDetailsFromProBackend({}) as any + proBackendDataActions.refreshGetProStatusFromProBackend({}) as any ); } diff --git a/ts/receiver/types.ts b/ts/receiver/types.ts index 4fcab80526..97ba89709e 100644 --- a/ts/receiver/types.ts +++ b/ts/receiver/types.ts @@ -112,7 +112,7 @@ export abstract class BaseDecodedEnvelope { return false; } const alreadyRevoked = ProRevocationCache.isB64HashEffectivelyRevoked( - this.validPro.proProof.genIndexHashB64 + this.validPro.proProof.revocationTagB64 ); return alreadyRevoked; diff --git a/ts/session/apis/pro_backend_api/ProBackendAPI.ts b/ts/session/apis/pro_backend_api/ProBackendAPI.ts index f2b7e6f359..a2327d56f0 100644 --- a/ts/session/apis/pro_backend_api/ProBackendAPI.ts +++ b/ts/session/apis/pro_backend_api/ProBackendAPI.ts @@ -6,13 +6,10 @@ import type { import { PRO_API } from './ProBackendTarget'; import SessionBackendServerApi from '../session_backend_server'; -import { - GenerateProProofResponseSchema, +import type { GenerateProProofResponseType, GetProRevocationsResponseType, - GetProDetailsResponseSchema, - GetProDetailsResponseType, - GetProRevocationsResponseAPISchema, + ProStatusResultType, } from './schemas'; import { ProWrapperActions } from '../../../webworker/workers/browser/libsession_worker_interface'; import { NetworkTime } from '../../../util/NetworkTime'; @@ -20,74 +17,66 @@ import { getFeatureFlag } from '../../../state/ducks/types/releasedFeaturesRedux export default class ProBackendAPI { private static readonly server = new SessionBackendServerApi(PRO_API.PRO_BACKENDS.DEFAULT); - private static readonly testServer = new SessionBackendServerApi(PRO_API.PRO_BACKENDS.DEV); - - static readonly requestVersion = 0; - - static getProSigningArgs({ masterPrivKeyHex }: WithMasterPrivKeyHex) { - return { - requestVersion: ProBackendAPI.requestVersion, - masterPrivKeyHex, - unixTsMs: NetworkTime.now(), - }; - } static getServer() { - return getFeatureFlag('useTestProBackend') ? ProBackendAPI.testServer : ProBackendAPI.server; + if (getFeatureFlag('useTestProBackend')) { + // There is no dev Pro backend configured yet (the DEV target holds no real URL/keys — see + // ProBackendTarget). Fail loudly rather than silently using the default backend or placeholders. + throw new Error('useTestProBackend is enabled but no dev Pro backend is configured yet'); + } + return ProBackendAPI.server; } - private static async getProProofBody({ - masterPrivKeyHex, - rotatingPrivKeyHex, - }: WithMasterPrivKeyHex & WithRotatingPrivKeyHex) { - return ProWrapperActions.proProofRequestBody({ - ...ProBackendAPI.getProSigningArgs({ masterPrivKeyHex }), - rotatingPrivKeyHex, + /** + * POST a libsession-built request (`{ endpoint, body }`) and relay the RAW response bytes to + * libsession's parser — desktop never parses or interprets the wire itself (that's a + * libsession<->backend contract). Returns null on transport failure or a missing body; app-level + * (backend) errors surface via the parsed struct's `errors` (and non-success `status`). + */ + private static async sendAndParse( + request: { endpoint: string; contentType: string; body: string }, + parse: (body: Uint8Array) => Promise + ): Promise { + const { status_code, bodyBinary } = await ProBackendAPI.getServer().makeRequestReturningRawBody({ + path: `/${request.endpoint}`, + method: 'POST', + contentType: request.contentType, + bodyGetter: async () => request.body, }); + + if (status_code !== 200 || !bodyBinary) { + return null; + } + + return parse(bodyBinary); } static async generateProProof( args: WithMasterPrivKeyHex & WithRotatingPrivKeyHex ): Promise { - return ProBackendAPI.getServer().makeRequestWithSchema({ - path: '/generate_pro_proof', - method: 'POST', - bodyGetter: () => ProBackendAPI.getProProofBody(args), - withZodSchema: GenerateProProofResponseSchema, - }); - } - - private static async getProDetailsBody(args: WithMasterPrivKeyHex) { - const request = await ProWrapperActions.proStatusRequestBody({ - ...ProBackendAPI.getProSigningArgs(args), - // NOTE: The latest payment is the only one required for state derivation - count: 1, + const request = await ProWrapperActions.proProofRequest({ + ...args, + unixTsMs: NetworkTime.now(), }); - return request; - } - - private static async getRevocationListBody(args: WithTicket) { - const body = await ProWrapperActions.proRevocationsRequestBody({ requestVersion: 0, ...args }); - return body; + return ProBackendAPI.sendAndParse(request, body => + ProWrapperActions.parseProProofResponse({ body }) + ); } - static async getProDetails( - args: WithMasterPrivKeyHex - ): Promise { - return ProBackendAPI.getServer().makeRequestWithSchema({ - path: '/get_pro_details', - method: 'POST', - bodyGetter: () => ProBackendAPI.getProDetailsBody(args), - withZodSchema: GetProDetailsResponseSchema, + static async getProStatus(args: WithMasterPrivKeyHex): Promise { + const request = await ProWrapperActions.proStatusRequest({ + ...args, + unixTsMs: NetworkTime.now(), }); + return ProBackendAPI.sendAndParse(request, body => + ProWrapperActions.parseProStatusResponse({ body }) + ); } static async getRevocationList(args: WithTicket): Promise { - return ProBackendAPI.getServer().makeRequestWithSchema({ - path: '/get_pro_revocations', - method: 'POST', - bodyGetter: () => ProBackendAPI.getRevocationListBody(args), - withZodSchema: GetProRevocationsResponseAPISchema, - }); + const request = await ProWrapperActions.proRevocationsRequest(args); + return ProBackendAPI.sendAndParse(request, body => + ProWrapperActions.parseRevocationsResponse({ body }) + ); } } diff --git a/ts/session/apis/pro_backend_api/ProBackendTarget.ts b/ts/session/apis/pro_backend_api/ProBackendTarget.ts index 8bb87961c1..f4379a155c 100644 --- a/ts/session/apis/pro_backend_api/ProBackendTarget.ts +++ b/ts/session/apis/pro_backend_api/ProBackendTarget.ts @@ -1,10 +1,13 @@ import { SERVER_HOSTS } from '..'; import { assertUnreachable } from '../../../types/sqlSharedTypes'; import { SessionServerConfigType } from '../session_backend_server'; +import LIBSESSION_CONSTANTS from '../../utils/libsession/libsession_constants'; -// not exported/included in the SERVER_HOSTS as this is for testing only -const PRO_BACKEND_DEV = 'pro.session.codes'; -// const PRO_BACKEND_DEV = '192.168.1.223:8888'; +// There is no dev Pro backend yet (and a future one likely can't carry full signing keys), so the DEV +// target below is intentionally non-functional: it holds no real URL/keys, and getServer() throws if +// `useTestProBackend` is enabled (see ProBackendAPI). Wire real values in when a dev backend exists — +// ideally sourced from libsession, not hard-copied here. Not in SERVER_HOSTS (testing only). +const PRO_BACKEND_DEV = 'dev-pro-backend-not-configured.invalid'; const PRO_BACKENDS: Record< 'DEFAULT' | 'DEV', @@ -12,17 +15,18 @@ const PRO_BACKENDS: Record< > = { DEFAULT: { name: 'ProBackend', - url: `http://${SERVER_HOSTS.PRO_SERVER}`, - // FIXME: to be replaced by the real pubkey - edPkHex: 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', - xPkHex: 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', + // URL + pubkeys come from libsession — the single source of truth; no hand-carried copies here. + url: LIBSESSION_CONSTANTS.LIBSESSION_PRO_BACKEND_URL as SessionServerConfigType['url'], + edPkHex: LIBSESSION_CONSTANTS.LIBSESSION_PRO_BACKEND_PUBKEY_HEX, + xPkHex: LIBSESSION_CONSTANTS.LIBSESSION_PRO_BACKEND_PUBKEY_X25519_HEX, }, DEV: { name: 'ProBackendDev', + // Intentionally non-functional placeholders (see PRO_BACKEND_DEV above): no real URL/keys copied + // here. getServer() throws before this target is ever used. url: `https://${PRO_BACKEND_DEV}`, - // url: `http://${PRO_BACKEND_DEV}`, - edPkHex: '479ffca8bcec7b4a0f0f7afe48b8a6d15635a8c7ff15ad16add05752c19414d4', - xPkHex: 'ce5a75f64b6c43db6c1374d362c3ea9d85951c4f42a3d04cf94f87822d4f803b', + edPkHex: '', + xPkHex: '', }, }; diff --git a/ts/session/apis/pro_backend_api/proErrorMessage.ts b/ts/session/apis/pro_backend_api/proErrorMessage.ts new file mode 100644 index 0000000000..fcc0a71c75 --- /dev/null +++ b/ts/session/apis/pro_backend_api/proErrorMessage.ts @@ -0,0 +1,21 @@ +import { isSimpleTokenNoArgs, tr } from '../../../localization/localeTools'; + +/** + * Resolve a failed Pro backend request to a user-facing message: prefer the localized + * `pro_error_` string for the backend's `errorCode` (a new slug needs only a translation, no + * code change), else the backend's diagnostic `error`, else a generic message. + */ +export function proErrorMessage( + errorCode: string | null | undefined, + backendError: string | null | undefined +): string { + if (errorCode) { + const key = `pro_error_${errorCode}`; + // pro_error_* strings take no args, so this is just an existence check — "does a translation + // exist for this slug" — against the base-English strings; tr() then resolves the locale. + if (isSimpleTokenNoArgs(key)) { + return tr(key); + } + } + return backendError ?? tr('errorGeneric'); +} diff --git a/ts/session/apis/pro_backend_api/schemas.ts b/ts/session/apis/pro_backend_api/schemas.ts index 3eb5a12b23..13433c61e9 100644 --- a/ts/session/apis/pro_backend_api/schemas.ts +++ b/ts/session/apis/pro_backend_api/schemas.ts @@ -1,117 +1,29 @@ -import { base64_variants, from_hex, to_base64 } from 'libsodium-wrappers-sumo'; import { z } from '../../../util/zod'; -import { ProItemStatus, ProAccessVariant, ProPaymentProvider, ProStatus } from './types'; -import { SessionBackendBaseResponseSchema } from '../session_backend_server'; - -function hexToBase64(hex: string) { - return to_base64(from_hex(hex), base64_variants.ORIGINAL); -} - -export const ProProofResultSchema = z - .object({ - version: z.number(), - expiry_unix_ts_ms: z.number(), - /** - * This is hex but transformed to base64 (see below) - */ - gen_index_hash: z.string(), - rotating_pkey: z.string(), - sig: z.string(), - }) - .transform(data => ({ - version: data.version, - expiry_unix_ts_ms: data.expiry_unix_ts_ms, - gen_index_hash_b64: hexToBase64(data.gen_index_hash), - rotating_pkey_hex: data.rotating_pkey, - sig_hex: data.sig, - })); - -export type ProProofResultType = z.infer; - -export const GenerateProProofResponseSchema = SessionBackendBaseResponseSchema.extend({ - result: ProProofResultSchema, -}); - -export type GenerateProProofResponseType = z.infer; - +import type { + GenerateProProofResponse, + GetProStatusResponse, + GetProRevocationsResponse, + ProPaymentItem, + ProProof, +} from 'libsession_util_nodejs'; + +// Wire parsing now lives in libsession (the nodejs glue `parse*Response` functions) — the single +// source of truth for the wire shape. The hand-rolled zod request/response schemas that used to live +// here were deleted; we re-export the glue-parsed response *types* from this module so the rest of the +// app keeps a stable import site. +export type ProProofResultType = ProProof; +export type GenerateProProofResponseType = GenerateProProofResponse; +export type GetProRevocationsResponseType = GetProRevocationsResponse; +export type ProStatusResultType = GetProStatusResponse; +export type ProPaymentItemType = ProPaymentItem; + +// Revocation items are cached to local storage; keep a small DB schema to validate cached blobs on +// read (shape matches the glue's ProRevocationItem). An old/unknown shape fails safeParse and the +// caller resets the (re-fetchable) cache — see pro_revocation_list.ts. export const ProRevocationItemDBSchema = z.object({ - expiry_unix_ts_ms: z.number(), - effective_unix_ts_ms: z.number(), - gen_index_hash_b64: z.string(), + revocationTagB64: z.string(), + effectiveMs: z.number(), }); - -export type ProRevocationItemDBType = z.infer; - -const ProRevocationItemAPISchema = z - .object({ - expiry_unix_ts_ms: z.number(), - /** - * When the current revocation item is to be made effective, this is the unix timestamp in milliseconds. - */ - effective_unix_ts_ms: z.number(), - /** - * This is hex but transformed to base64 (see below) - */ - gen_index_hash: z.string(), - }) - .transform(data => ({ - expiry_unix_ts_ms: data.expiry_unix_ts_ms, - effective_unix_ts_ms: data.effective_unix_ts_ms, - gen_index_hash_b64: hexToBase64(data.gen_index_hash), - })); - -export const ProRevocationItemsAPISchema = z.array(ProRevocationItemAPISchema); export const ProRevocationItemsDBSchema = z.array(ProRevocationItemDBSchema); - -export type ProRevocationItemsAPIType = z.infer; +export type ProRevocationItemDBType = z.infer; export type ProRevocationItemsDBType = z.infer; - -const ProRevocationsResultAPISchema = z.object({ - ticket: z.number(), - items: ProRevocationItemsAPISchema, - retry_in_s: z.number(), -}); - -export const GetProRevocationsResponseAPISchema = SessionBackendBaseResponseSchema.extend({ - result: ProRevocationsResultAPISchema, -}); - -export type GetProRevocationsResponseType = z.infer; - -const ProDetailsItemSchema = z.object({ - status: z.enum(ProItemStatus), - plan: z.enum(ProAccessVariant), - payment_provider: z.enum(ProPaymentProvider), - auto_renewing: z.boolean(), - unredeemed_unix_ts_ms: z.number(), - refund_requested_unix_ts_ms: z.number(), - redeemed_unix_ts_ms: z.number(), - expiry_unix_ts_ms: z.number(), - grace_period_duration_ms: z.number(), - platform_refund_expiry_unix_ts_ms: z.number(), - revoked_unix_ts_ms: z.number(), - google_payment_token: z.string().optional(), - google_order_id: z.string().optional(), - apple_original_tx_id: z.string().optional(), - apple_tx_id: z.string().optional(), - apple_web_line_order_id: z.string().optional(), -}); - -export const ProDetailsResultSchema = z.object({ - status: z.enum(ProStatus), - auto_renewing: z.boolean(), - expiry_unix_ts_ms: z.number(), - grace_period_duration_ms: z.number(), - error_report: z.number(), - refund_requested_unix_ts_ms: z.number(), - payments_total: z.number(), - items: z.array(ProDetailsItemSchema), -}); - -export type ProDetailsResultType = z.infer; - -export const GetProDetailsResponseSchema = SessionBackendBaseResponseSchema.extend({ - result: ProDetailsResultSchema, -}); - -export type GetProDetailsResponseType = z.infer; diff --git a/ts/session/apis/pro_backend_api/types.ts b/ts/session/apis/pro_backend_api/types.ts index 9b592a967f..fbcce9fecc 100644 --- a/ts/session/apis/pro_backend_api/types.ts +++ b/ts/session/apis/pro_backend_api/types.ts @@ -1,70 +1,38 @@ -import { ProOriginatingPlatform } from 'libsession_util_nodejs'; -import { assertUnreachable } from '../../../types/sqlSharedTypes'; +// The Session Pro wire no longer uses fixed integer enums: account status, per-payment status, plan, +// and payment provider are all opaque string codes that libsession parses and passes through verbatim. +// We keep the canonical slugs as named constants; an unknown/future value passes through as-is (map the +// known ones for display, never hard-fail on a new one). Human-readable NAMES are translation data owned +// by the client (i18n), keyed on these slugs. -// Mirrors backend enum -export enum ProStatus { - NeverBeenPro = 0, - Active = 1, - Expired = 2, -} +export const ProStatus = { + Never: 'never', + Active: 'active', + Expired: 'expired', +} as const; +/** Account-level Pro status slug (canonical values in {@link ProStatus}); may be an unknown slug. */ +export type ProStatus = string; -// Mirrors backend enum -export enum ProAccessVariant { - Nil = 0, - OneMonth = 1, - ThreeMonth = 2, - TwelveMonth = 3, -} +export const ProItemStatus = { + Unredeemed: 'unredeemed', + Redeemed: 'redeemed', + Expired: 'expired', + Revoked: 'revoked', +} as const; +/** Per-payment status slug (canonical values in {@link ProItemStatus}); may be an unknown slug. */ +export type ProItemStatus = string; -// Mirrors backend enum -export enum ProItemStatus { - Nil = 0, - Unredeemed = 1, - Redeemed = 2, - Expired = 3, - Revoked = 4, -} +export const ProAccessVariant = { + OneMonth: '1m', + ThreeMonth: '3m', + TwelveMonth: '1y', +} as const; +/** Billing-period slug (canonical values in {@link ProAccessVariant}); may be an unknown slug. */ +export type ProAccessVariant = string; -// Mirrors backend enum -export enum ProPaymentProvider { - Nil = 0, - GooglePlayStore = 1, - iOSAppStore = 2, - Rangeproof = 3, -} - -export function getProPaymentProviderFromProOriginatingPlatform( - v: ProOriginatingPlatform -): ProPaymentProvider { - switch (v) { - case 'Nil': - return ProPaymentProvider.Nil; - case 'Google': - return ProPaymentProvider.GooglePlayStore; - case 'iOS': - return ProPaymentProvider.iOSAppStore; - case 'Rangeproof': - return ProPaymentProvider.Rangeproof; - default: - assertUnreachable(v, 'getProPaymentProviderFromProOriginatingPlatform'); - throw new Error('getProPaymentProviderFromProOriginatingPlatform: case not handled'); - } -} - -export function getProOriginatingPlatformFromProPaymentProvider( - v: ProPaymentProvider -): ProOriginatingPlatform { - switch (v) { - case ProPaymentProvider.Nil: - return 'Nil'; - case ProPaymentProvider.GooglePlayStore: - return 'Google'; - case ProPaymentProvider.iOSAppStore: - return 'iOS'; - case ProPaymentProvider.Rangeproof: - return 'Rangeproof'; - default: - assertUnreachable(v, 'getProOriginatingPlatformFromProPaymentProvider'); - throw new Error('getProOriginatingPlatformFromProPaymentProvider: case not handled'); - } -} +export const ProPaymentProvider = { + GooglePlay: 'google_play', + AppStore: 'app_store', + Rangeproof: 'rangeproof', +} as const; +/** Payment-provider slug (canonical values in {@link ProPaymentProvider}); may be an unknown slug. */ +export type ProPaymentProvider = string; diff --git a/ts/session/apis/session_backend_server.ts b/ts/session/apis/session_backend_server.ts index 3ae622bc5a..d9e49d5f97 100644 --- a/ts/session/apis/session_backend_server.ts +++ b/ts/session/apis/session_backend_server.ts @@ -258,4 +258,62 @@ export default class SessionBackendServerApi { return this.parseSchema({ path: requestParams.path, response, schema: withZodSchema }); } + + /** + * Like `makeRequest`, but returns the RAW response body bytes + the transport status, WITHOUT + * parsing the body or assuming a content-type. The Session Pro response wire format is a contract + * between libsession and the backend only, so the caller relays these bytes straight to libsession's + * parser (via the nodejs glue) rather than parsing/interpreting them here — a future wire-format + * change never touches this client. + */ + public async makeRequestReturningRawBody( + params: SessionBackendServerMakeRequestParams & { contentType?: string } + ): Promise<{ status_code: number; bodyBinary: Uint8Array | null }> { + const body = typeof params.bodyGetter === 'function' ? await params.bodyGetter() : null; + + const blindHeaders = params.blindSignRequest + ? await this.getBlindSignedHeaders({ method: params.method, path: params.path, body }) + : {}; + + if (blindHeaders === null) { + this.logError('failed to blind sign request parameters', params.path); + return { status_code: 500, bodyBinary: null }; + } + + // Content-Type is supplied by libsession (the wire format is its contract with the backend); relay it. + const headers = params.contentType + ? { ...blindHeaders, 'Content-Type': params.contentType } + : blindHeaders; + + const url = new URL(params.path, this.server.url); + + const controller = new AbortController(); + const result = await timeoutWithAbort( + OnionSending.sendViaOnionV4ToNonSnodeWithRetries( + this.server.xPkHex, + url, + { + method: params.method, + headers, + body, + useV4: true, + }, + false, + controller.signal, + this.server.requestTimeoutMs, + FetchDestination.SESSION_SERVER, + 'SessionBackendServerApi.makeRequestReturningRawBody' + ), + this.server.abortControllerTimeoutMs, + controller + ); + + if (!result) { + this.logError('makeRequestReturningRawBody: returned no response', params.path); + return { status_code: 500, bodyBinary: null }; + } + + // NOTE: no content-type parsing here on purpose — the body is relayed raw to libsession. + return { status_code: result.status_code, bodyBinary: result.bodyBinary }; + } } diff --git a/ts/session/revocation_list/pro_revocation_list.ts b/ts/session/revocation_list/pro_revocation_list.ts index e376ab18dd..7dbe8d2664 100644 --- a/ts/session/revocation_list/pro_revocation_list.ts +++ b/ts/session/revocation_list/pro_revocation_list.ts @@ -101,14 +101,14 @@ function clear() { /** * Returns true if the hash is effectively revoked. * That is, if the hash is present in the revocation list, - * and has an `effective_unix_ts_ms` that is in the past (network time). + * and has an `effectiveMs` that is in the past (network time). */ -function isB64HashEffectivelyRevoked(genIndexHashBase64: string) { +function isB64HashEffectivelyRevoked(revocationTagBase64: string) { assertInitialFetchFromDBDone('isB64HashEffectivelyRevoked'); - const found = cachedProRevocationListItems.find(m => m.gen_index_hash_b64 === genIndexHashBase64); + const found = cachedProRevocationListItems.find(m => m.revocationTagB64 === revocationTagBase64); - return !!found && NetworkTime.now() >= found.effective_unix_ts_ms; + return !!found && NetworkTime.now() >= found.effectiveMs; } export const ProRevocationCache = { diff --git a/ts/session/utils/job_runners/jobs/AvatarMigrateJob.ts b/ts/session/utils/job_runners/jobs/AvatarMigrateJob.ts index 0fa3d3b78e..bf169b7318 100644 --- a/ts/session/utils/job_runners/jobs/AvatarMigrateJob.ts +++ b/ts/session/utils/job_runners/jobs/AvatarMigrateJob.ts @@ -141,7 +141,7 @@ class AvatarMigrateJob extends PersistedJob { // as we can't decrypt it. profileUpdatedAtSeconds: NetworkTime.nowSeconds(), // Don't overwrite those if they are set - proDetails: { bitsetProFeatures: null, proExpiryTsMs: null, proGenIndexHashB64: null }, + proDetails: { bitsetProFeatures: null, proExpiryTsMs: null, proRevocationTagB64: null }, }); await profile.applyChangesIfNeeded(); @@ -196,7 +196,7 @@ class AvatarMigrateJob extends PersistedJob { // as we can't decrypt it. profileUpdatedAtSeconds: NetworkTime.nowSeconds(), // Don't overwrite those if they are set - proDetails: { bitsetProFeatures: null, proExpiryTsMs: null, proGenIndexHashB64: null }, + proDetails: { bitsetProFeatures: null, proExpiryTsMs: null, proRevocationTagB64: null }, }); await profile.applyChangesIfNeeded(); } diff --git a/ts/session/utils/job_runners/jobs/UpdateProRevocationListJob.ts b/ts/session/utils/job_runners/jobs/UpdateProRevocationListJob.ts index b9f9812363..36a1842e29 100644 --- a/ts/session/utils/job_runners/jobs/UpdateProRevocationListJob.ts +++ b/ts/session/utils/job_runners/jobs/UpdateProRevocationListJob.ts @@ -93,34 +93,31 @@ class UpdateProRevocationListJob extends PersistedJob { const proDetails = m.dbContactProDetails(); - if (!proDetails?.proGenIndexHashB64) { + if (!proDetails?.proRevocationTagB64) { return; } const revoked = ProRevocationCache.isB64HashEffectivelyRevoked( - proDetails.proGenIndexHashB64 + proDetails.proRevocationTagB64 ); if (revoked) { window.log.debug( - `UpdateProRevocationListJob: found an effectively revoked genIndexHash for convo ${m.idForLogging()}. Triggering UI refresh.` + `UpdateProRevocationListJob: found an effectively revoked revocation tag for convo ${m.idForLogging()}. Triggering UI refresh.` ); m.triggerUIRefresh(); } diff --git a/ts/session/utils/libsession/libsession_constants.ts b/ts/session/utils/libsession/libsession_constants.ts index 2fbfce7df2..4745bd4baf 100644 --- a/ts/session/utils/libsession/libsession_constants.ts +++ b/ts/session/utils/libsession/libsession_constants.ts @@ -10,8 +10,10 @@ const { LIBSESSION_NODEJS_COMMIT, LIBSESSION_NODEJS_VERSION, LIBSESSION_UTIL_VERSION, - LIBSESSION_PRO_PROVIDERS, LIBSESSION_PRO_URLS, + LIBSESSION_PRO_BACKEND_URL, + LIBSESSION_PRO_BACKEND_PUBKEY_HEX, + LIBSESSION_PRO_BACKEND_PUBKEY_X25519_HEX, } = CONSTANTS; const LIBSESSION_CONSTANTS: ConstantsType = { @@ -23,8 +25,10 @@ const LIBSESSION_CONSTANTS: ConstantsType = { LIBSESSION_NODEJS_COMMIT, LIBSESSION_NODEJS_VERSION, LIBSESSION_UTIL_VERSION, - LIBSESSION_PRO_PROVIDERS, LIBSESSION_PRO_URLS, + LIBSESSION_PRO_BACKEND_URL, + LIBSESSION_PRO_BACKEND_PUBKEY_HEX, + LIBSESSION_PRO_BACKEND_PUBKEY_X25519_HEX, }; export default LIBSESSION_CONSTANTS; diff --git a/ts/session/utils/libsession/libsession_utils_convo_info_volatile.ts b/ts/session/utils/libsession/libsession_utils_convo_info_volatile.ts index c12839dfa6..4c24679d2c 100644 --- a/ts/session/utils/libsession/libsession_utils_convo_info_volatile.ts +++ b/ts/session/utils/libsession/libsession_utils_convo_info_volatile.ts @@ -104,7 +104,7 @@ async function insertConvoFromDBIntoWrapperAndRefresh(convoId: string): Promise< const toSet = { forcedUnread: isForcedUnread, lastReadTsMs: lastReadMessageTimestamp, - proGenIndexHashB64: proDetails?.proGenIndexHashB64 || null, + proRevocationTagB64: proDetails?.proRevocationTagB64 || null, proExpiryTsMs: proDetails?.proExpiryTsMs || null, }; await ConvoInfoVolatileWrapperActions.set1o1(convoId, toSet); diff --git a/ts/state/ducks/proBackendData.ts b/ts/state/ducks/proBackendData.ts index 2051196a6c..781b85709c 100644 --- a/ts/state/ducks/proBackendData.ts +++ b/ts/state/ducks/proBackendData.ts @@ -10,12 +10,11 @@ import { updateLocalizedPopupDialog } from './modalDialog'; import { showLinkVisitWarningDialog } from '../../components/dialog/OpenUrlModal'; import { ProStatus } from '../../session/apis/pro_backend_api/types'; import { SettingsKey } from '../../data/settings-key'; -import { ProDetailsResultType } from '../../session/apis/pro_backend_api/schemas'; +import { ProStatusResultType } from '../../session/apis/pro_backend_api/schemas'; +import { proErrorMessage } from '../../session/apis/pro_backend_api/proErrorMessage'; import { Storage } from '../../util/storage'; import { NetworkTime } from '../../util/NetworkTime'; -import { assertUnreachable } from '../../types/sqlSharedTypes'; import { DURATION } from '../../session/constants'; -import { SessionBackendBaseResponseType } from '../../session/apis/session_backend_server'; import { getCachedUserConfig, UserConfigWrapperActions, @@ -32,7 +31,6 @@ type RequestState = { // True if the request has been made isEnabled: boolean; error: string | null; - t: number; data: D | null; }; @@ -42,7 +40,6 @@ const defaultRequestState = { isError: false, isEnabled: false, error: null, - t: 0, data: null, } satisfies RequestState; @@ -54,22 +51,23 @@ export type RequestActionArgs = { type ReducerBooleanStateAction = PayloadAction; export type ProBackendDataState = { - details: RequestState; + details: RequestState; }; export const initialProBackendDataState: ProBackendDataState = { details: defaultRequestState, }; -type ApiResponse = SessionBackendBaseResponseType & { - result: T; -}; - type PayloadCreatorType = Parameters['1']>['1']; -type CreateProBackendFetchAsyncThunk = { +// The getter returns a libsession-parsed response struct (or null on transport failure). Every parsed +// struct carries the response header (§5): `status` ('ok' on success) + an optional machine `errorCode` slug +// and an English diagnostic `error`. +type CreateProBackendFetchAsyncThunk< + D extends { status: 'ok' | 'fail' | 'error'; errorCode: string | null; error: string | null }, +> = { key: keyof ProBackendDataState; - getter: () => Promise | null>; + getter: () => Promise; payloadCreator: PayloadCreatorType; contextHandler?: (state: RequestState) => Promise; // Runs at the end of the function, as long as the function doesn't early return because it was already fetching. @@ -78,7 +76,9 @@ type CreateProBackendFetchAsyncThunk = { export type WithCallerContext = { callerContext?: 'recover' }; -async function createProBackendFetchAsyncThunk({ +async function createProBackendFetchAsyncThunk< + D extends { status: 'ok' | 'fail' | 'error'; errorCode: string | null; error: string | null }, +>({ key, getter, payloadCreator, @@ -117,37 +117,29 @@ async function createProBackendFetchAsyncThunk({ const response = await getter(); if (!response) { + // null == transport failure (or a non-200 the glue swallowed); the selector falls back to the + // last details persisted in storage, so surfacing this as a hard error is fine. throw new Error('Data fetch failed'); } - let error = response.error ?? null; - - if (response.status_code !== 200) { - if (!error) { - error = `Received ${response.status_code} status code with no error message`; - } - result = { - data: result.data, - error, - isError: true, - isFetching: false, - isLoading: false, - t: response.t, - isEnabled: true, - }; - } + // App-level failure = status !== 'ok'. Keep the raw backend diagnostic for logging, but surface a + // user-facing message mapped from the `errorCode` slug to a localized `pro_error_` string + // (falling back to the diagnostic, then a generic message). + const diagnostic = + response.status === 'ok' ? null : (response.error ?? response.errorCode ?? 'request failed'); + const error = + response.status === 'ok' ? null : proErrorMessage(response.errorCode, response.error); - if (error && debug) { - window?.log?.error(error); + if (diagnostic && debug) { + window?.log?.error(diagnostic); } result = { - data: error ? result.data : response.result, + data: error ? result.data : response, error, isError: !!error, isFetching: false, isLoading: false, - t: response.t, isEnabled: true, }; } catch (e) { @@ -158,7 +150,6 @@ async function createProBackendFetchAsyncThunk({ isError: true, isFetching: false, isLoading: false, - t: 0, isEnabled: true, }; } @@ -174,8 +165,11 @@ async function createProBackendFetchAsyncThunk({ return result; } -async function putProDetailsInStorage(details: ProDetailsResultType) { - await Storage.put(SettingsKey.proDetails, details); +async function putProStatusInStorage(details: ProStatusResultType) { + // We persist, verbatim, the JS object the libsession-util-nodejs glue produced from the backend + // response (not a raw libsession struct). See getProStatusFromStorage for the transition reminder + // that applies before adding any REQUIRED field to this shape. + await Storage.put(SettingsKey.proStatus, details); } async function handleNewProProof(rotatingPrivKeyHex: string): Promise { @@ -184,14 +178,9 @@ async function handleNewProProof(rotatingPrivKeyHex: string): Promise | null = null; @@ -263,10 +252,10 @@ let scheduledAccessExpiryTaskId: ReturnType | null = null; function scheduleRefresh(timestampMs: number) { const delay = Math.max(timestampMs - NetworkTime.now(), 15 * DURATION.SECONDS); - window?.log?.info(`Scheduling a pro details refresh in ${delay}ms for ${timestampMs}`); + window?.log?.info(`Scheduling a pro status refresh in ${delay}ms for ${timestampMs}`); return setTimeout(() => { window?.inboxStore?.dispatch( - proBackendDataActions.refreshGetProDetailsFromProBackend({}) as any + proBackendDataActions.refreshGetProStatusFromProBackend({}) as any ); }, delay); } @@ -346,35 +335,35 @@ async function handleProProof(accessExpiryTsMs: number, autoRenewing: boolean, s } } -const fetchGetProDetailsFromProBackend = createAsyncThunk( - 'proBackendData/fetchGetProDetails', +const fetchGetProStatusFromProBackend = createAsyncThunk( + 'proBackendData/fetchGetProStatus', async ( { callerContext: context, ...args }: WithMasterPrivKeyHex & WithCallerContext, payloadCreator - ): Promise> => { + ): Promise> => { return createProBackendFetchAsyncThunk({ key: 'details', - getter: () => ProBackendAPI.getProDetails(args), + getter: () => ProBackendAPI.getProStatus(args), payloadCreator, callback: async state => { if (state.data) { - if (state.data.error_report === 1) { + if (state.data.errorReport === 1) { state.isError = true; state.error = 'Backend unable to process current state, please try again later.'; // NOTE: we want to continue processing the state, as even if there was an error we need to try to handle the pro proofs. } - switch (state.data.status) { + switch (state.data.userStatus) { case ProStatus.Active: window.log.debug(`[handleBackendProStatusChange] ProStatus.Active`); await handleProProof( - state.data.expiry_unix_ts_ms, - state.data.auto_renewing, - state.data.status + state.data.expiryMs, + state.data.autoRenewing, + state.data.userStatus ); break; - case ProStatus.NeverBeenPro: - window.log.debug(`[handleBackendProStatusChange] ProStatus.NeverBeenPro`); + case ProStatus.Never: + window.log.debug(`[handleBackendProStatusChange] ProStatus.Never`); await handleClearProProof(); break; @@ -385,23 +374,31 @@ const fetchGetProDetailsFromProBackend = createAsyncThunk( break; default: - assertUnreachable(state.data.status, 'handleBackendProStatusChange'); + // Opaque/unknown status: we have no basis to conclude the subscription ended, and + // handleClearProProof() writes SYNCED user config — clearing here would erase a valid + // proof across ALL the user's devices just because this (possibly older) client didn't + // recognise a new status slug. Leave the proof untouched: entitlement is governed by + // the proof's own signature + expiry (hasValidCurrentProProof), and the backend simply + // won't refresh it (or will revoke it) if the account has genuinely lapsed. + window.log.warn( + `[handleBackendProStatusChange] unknown pro userStatus: ${state.data.userStatus}; leaving proof untouched` + ); break; } await handleExpiryCTAs( - state.data.expiry_unix_ts_ms, - state.data.auto_renewing, - state.data.status + state.data.expiryMs, + state.data.autoRenewing, + state.data.userStatus ); - // on the first fetch of our pro details after a restart, we want to show the CTAs if needed - if (window.inboxStore?.dispatch && !firstFetchProDetailsHappened) { + // on the first fetch of our pro status after a restart, we want to show the CTAs if needed + if (window.inboxStore?.dispatch && !firstFetchProStatusHappened) { void handleTriggeredCTAs(window.inboxStore?.dispatch, false); } - firstFetchProDetailsHappened = true; + firstFetchProStatusHappened = true; } if (state.data) { - await putProDetailsInStorage(state.data); + await putProStatusInStorage(state.data); } // trigger a UI refresh so our state and Pro rights are up to date without a restart (animated image should stop animating) ConvoHub.use().get(UserUtils.getOurPubKeyStrFromCache())?.triggerUIRefresh(); @@ -409,7 +406,7 @@ const fetchGetProDetailsFromProBackend = createAsyncThunk( }, contextHandler: async state => { if (context === 'recover') { - if (state.data?.status === ProStatus.Active) { + if (state.data?.userStatus === ProStatus.Active) { payloadCreator.dispatch( updateLocalizedPopupDialog({ title: { token: 'proAccessRestored' }, @@ -448,8 +445,8 @@ const fetchGetProDetailsFromProBackend = createAsyncThunk( } ); -const refreshGetProDetailsFromProBackend = createAsyncThunk( - 'proBackendData/refreshGetProDetails', +const refreshGetProStatusFromProBackend = createAsyncThunk( + 'proBackendData/refreshGetProStatus', async (opts: WithCallerContext = {}, payloadCreator) => { if (!getFeatureFlag('proAvailable')) { return; @@ -457,7 +454,7 @@ const refreshGetProDetailsFromProBackend = createAsyncThunk( if (getFeatureFlag('debugServerRequests')) { window.log.info( - `[proBackend/refreshGetProDetailsFromProBackend] starting ${new Date().toISOString()}` + `[proBackend/refreshGetProStatusFromProBackend] starting ${new Date().toISOString()}` ); } @@ -469,11 +466,11 @@ const refreshGetProDetailsFromProBackend = createAsyncThunk( if (getFeatureFlag('debugServerRequests')) { window.log.info( - `[proBackend/refreshGetProDetailsFromProBackend] triggered refresh at ${new Date().toISOString()}` + `[proBackend/refreshGetProStatusFromProBackend] triggered refresh at ${new Date().toISOString()}` ); } const masterPrivKeyHex = await UserUtils.getProMasterKeyHex(); - payloadCreator.dispatch(fetchGetProDetailsFromProBackend({ ...opts, masterPrivKeyHex }) as any); + payloadCreator.dispatch(fetchGetProStatusFromProBackend({ ...opts, masterPrivKeyHex }) as any); } ); @@ -503,15 +500,15 @@ export const proBackendDataSlice = createSlice({ }, }, extraReducers: builder => { - builder.addCase(fetchGetProDetailsFromProBackend.rejected, (_state, action) => { + builder.addCase(fetchGetProStatusFromProBackend.rejected, (_state, action) => { window.log.error( - `[proBackend / fetchGetProDetailsFromProBackend] rejected ${action.error.message || action.error} ` + `[proBackend / fetchGetProStatusFromProBackend] rejected ${action.error.message || action.error} ` ); }); - builder.addCase(fetchGetProDetailsFromProBackend.fulfilled, (state, action) => { + builder.addCase(fetchGetProStatusFromProBackend.fulfilled, (state, action) => { if (getFeatureFlag('debugServerRequests')) { window.log.info( - `[proBackend / fetchGetProDetailsFromProBackend] fulfilled ${new Date().toISOString()} `, + `[proBackend / fetchGetProStatusFromProBackend] fulfilled ${new Date().toISOString()} `, JSON.stringify(action.payload) ); } @@ -523,6 +520,6 @@ export const proBackendDataSlice = createSlice({ export default proBackendDataSlice.reducer; export const proBackendDataActions = { ...proBackendDataSlice.actions, - fetchGetProDetailsFromProBackend, - refreshGetProDetailsFromProBackend, + fetchGetProStatusFromProBackend, + refreshGetProStatusFromProBackend, }; diff --git a/ts/state/ducks/user.ts b/ts/state/ducks/user.ts index 4e672e23c3..816f328952 100644 --- a/ts/state/ducks/user.ts +++ b/ts/state/ducks/user.ts @@ -80,7 +80,7 @@ const clearOurAvatar = createAsyncThunk('user/clearOurAvatar', async () => { displayName: null, profileUpdatedAtSeconds: NetworkTime.nowSeconds(), // NTS case, we don't care about those - proDetails: { bitsetProFeatures: null, proExpiryTsMs: null, proGenIndexHashB64: null }, + proDetails: { bitsetProFeatures: null, proExpiryTsMs: null, proRevocationTagB64: null }, }); await profile.applyChangesIfNeeded(); diff --git a/ts/state/selectors/proBackendData.ts b/ts/state/selectors/proBackendData.ts index 6e56a85207..fd68f255c3 100644 --- a/ts/state/selectors/proBackendData.ts +++ b/ts/state/selectors/proBackendData.ts @@ -1,6 +1,4 @@ import { useSelector } from 'react-redux'; -import { ProOriginatingPlatform } from 'libsession_util_nodejs'; -import { zodSafeParse } from '../../util/zod'; import type { StateType } from '../reducer'; import { proBackendDataActions, @@ -10,7 +8,7 @@ import { } from '../ducks/proBackendData'; import { SettingsKey } from '../../data/settings-key'; import { Storage } from '../../util/storage'; -import { ProDetailsResultSchema } from '../../session/apis/pro_backend_api/schemas'; +import type { ProStatusResultType } from '../../session/apis/pro_backend_api/schemas'; import { getDataFeatureFlag, getFeatureFlag, @@ -20,37 +18,44 @@ import { import { NetworkTime } from '../../util/NetworkTime'; import { formatDateWithLocale, + formatPlanDuration, formatRoundedUpTimeUntilTimestamp, } from '../../util/i18n/formatting/generics'; import { - getProOriginatingPlatformFromProPaymentProvider, ProAccessVariant, ProPaymentProvider, ProStatus, } from '../../session/apis/pro_backend_api/types'; -import LIBSESSION_CONSTANTS from '../../session/utils/libsession/libsession_constants'; +import { isSimpleTokenNoArgs, tr } from '../../localization/localeTools'; import { getAppDispatch } from '../dispatch'; import { sleepFor } from '../../session/utils/Promise'; -import { assertUnreachable } from '../../types/sqlSharedTypes'; const getProBackendData = (state: StateType): ProBackendDataState => { return state.proBackendData; }; -function getProDetailsFromStorage() { - const response = Storage.get(SettingsKey.proDetails); +function getProStatusFromStorage(): ProStatusResultType | null { + const response = Storage.get(SettingsKey.proStatus); if (!response) { return null; } - const result = zodSafeParse(ProDetailsResultSchema, response); - if (result.success) { - return result.data; + // We persist, verbatim, the JS object the libsession-util-nodejs glue produced from the backend + // response (see putProStatusInStorage) — not a raw libsession struct — and cast it back to the + // CURRENT ProStatusResultType (an alias of the glue's GetProStatusResponse). session-desktop and + // libsession-util-nodejs ship in lockstep, so within a single build the producer, the type and this + // consumer can't drift — but a cache written by an OLDER build can. + // + // ⚠️ TRANSITION REQUIRED IF YOU ADD A REQUIRED FIELD to this shape: an upgraded client would read a + // stale-shape cache here and cast it to the new type with that field undefined (the Array.isArray + // check below validates only the top level, not individual fields). Handle it as part of that change — + // e.g. drop this cache behind a stored shape/version marker, or keep the new field optional at this + // boundary. It's a transient, re-fetched cache, so drop-and-refetch is the simplest transition. This + // is left as a reminder rather than pre-built, since pre-building would just be guessing at the shape. + if (typeof response === 'object' && response !== null && 'userStatus' in response) { + return response as ProStatusResultType; } - void Storage.remove(SettingsKey.proDetails); - window?.log?.error( - 'failed to parse pro details from storage, removing item. error:', - result.error - ); + void Storage.remove(SettingsKey.proStatus); + window?.log?.error('pro status in storage were malformed; removing.'); return null; } @@ -62,26 +67,76 @@ export function proAccessVariantToString(variant: ProAccessVariant): string { return '3 Months'; case ProAccessVariant.TwelveMonth: return '12 Months'; - case ProAccessVariant.Nil: - return 'N/A'; default: - return assertUnreachable(variant, `Unknown pro access variant: ${variant}`); + // '' (none) or an unrecognized/future billing-period slug. + return 'N/A'; } } -export function getProProviderConstantsWithFallbacks(provider: ProPaymentProvider) { - const libsessionPaymentProvider = getProOriginatingPlatformFromProPaymentProvider(provider); - const constants = LIBSESSION_CONSTANTS.LIBSESSION_PRO_PROVIDERS[libsessionPaymentProvider]; - - if (!constants.store) { - constants.store = LIBSESSION_CONSTANTS.LIBSESSION_PRO_PROVIDERS.Google.store; +/** + * Display string for a parsed plan period {planCount, planUnit} (libsession, plan grammar §1). The duration + * units render via date-fns (localized + pluralized, unit preserved as-is — 12 months stays "12 + * months"). "lifetime" isn't a duration: use the localized `proPlanLifetime` if that Crowdin key + * exists yet, else the English fallback (same gate as the pro_provider_* / pro_error_* stopgaps). + */ +export function planPeriodToString( + planCount: number | undefined, + planUnit: string | undefined +): string { + if (!planUnit) { + return 'N/A'; } - - if (!constants.store_other) { - constants.store_other = LIBSESSION_CONSTANTS.LIBSESSION_PRO_PROVIDERS.Google.store_other; + if (planUnit === 'lifetime') { + const lifetimeToken = 'proPlanLifetime'; + return isSimpleTokenNoArgs(lifetimeToken) ? tr(lifetimeToken) : 'Lifetime'; + } + const n = planCount ?? 0; + switch (planUnit) { + case 'second': + case 'day': + case 'week': + case 'month': + case 'year': + return formatPlanDuration(n, planUnit); + default: + // libsession's closed grammar means we shouldn't get an unrecognized unit; degrade gracefully. + return 'N/A'; } +} + +/** + * Per-provider display strings. Client-owned i18n now — libsession no longer supplies them. + * URLs are NOT here: they're only needed for link clicks and are fetched on demand via + * ProWrapperActions.providerUrls(slug). Resolved dynamically from `pro_provider__` tokens + * (a temporary stopgap injected into the generated localization; wiped by the next Crowdin sync, so + * upstream them first), so a new provider needs only translations, not a code change. + */ +export type ProviderDisplayConstants = { + store: string; + platform: string; + device: string; + platform_account: string; +}; - return constants; +/** + * Resolve one provider display field for [slug]: the localized `pro_provider__` if that token + * exists, else the raw slug (an unknown/untranslated provider degrades to its slug — the same + * "gate on the translation existing" rule used by the {pro_stores} list). + */ +function providerDisplay(slug: string, suffix: 'platform' | 'store' | 'device' | 'account'): string { + const token = `pro_provider_${slug}_${suffix}`; + return isSimpleTokenNoArgs(token) ? tr(token) : slug; +} + +export function getProProviderConstantsWithFallbacks( + provider: ProPaymentProvider +): ProviderDisplayConstants { + return { + store: providerDisplay(provider, 'store'), + platform: providerDisplay(provider, 'platform'), + device: providerDisplay(provider, 'device'), + platform_account: providerDisplay(provider, 'account'), + }; } function getMockedProAccessExpiry(variant: MockProAccessExpiryOptions): number | null { @@ -141,28 +196,27 @@ type ProAccessDetails = { expiryTimeRelativeString: string; isPlatformRefundAvailable: boolean; provider: ProPaymentProvider; - providerConstants: (typeof LIBSESSION_CONSTANTS)['LIBSESSION_PRO_PROVIDERS'][ProOriginatingPlatform]; + providerConstants: ProviderDisplayConstants; }; // These values are used if pro isnt available or if no data is available from the backend. export const defaultProAccessDetailsSourceData = { - currentStatus: ProStatus.NeverBeenPro, + currentStatus: ProStatus.Never, autoRenew: true, inGracePeriod: false, - variant: ProAccessVariant.Nil, + variant: '', expiryTimeMs: 0, isPlatformRefundAvailable: false, - provider: ProPaymentProvider.Nil, + provider: '', isLoading: false, isError: false, } satisfies ProAccessDetailsSourceData; -export type ProcessedProDetails = { +export type ProcessedProStatus = { isLoading: boolean; isFetching: boolean; isError: boolean; data: ProAccessDetails; - t: number; }; function processProBackendData({ @@ -170,8 +224,7 @@ function processProBackendData({ isFetching: _isFetching, isError: _isError, data, - t, -}: ProBackendDataState['details']): ProcessedProDetails { +}: ProBackendDataState['details']): ProcessedProStatus { const mockIsLoading = getFeatureFlag('mockProBackendLoading'); const mockIsError = getFeatureFlag('mockProBackendError'); @@ -200,25 +253,29 @@ function processProBackendData({ const now = NetworkTime.now(); const expiryTimeMs = - mockExpiry ?? data?.expiry_unix_ts_ms ?? defaultProAccessDetailsSourceData.expiryTimeMs; + mockExpiry ?? data?.expiryMs ?? defaultProAccessDetailsSourceData.expiryTimeMs; - const latestAccess = data?.items?.[0]; + const latestAccess = data?.latestPayment ?? undefined; const provider = - mockPlatform ?? latestAccess?.payment_provider ?? defaultProAccessDetailsSourceData.provider; - const variant = mockVariant ?? latestAccess?.plan ?? defaultProAccessDetailsSourceData.variant; + mockPlatform ?? latestAccess?.paymentProvider ?? defaultProAccessDetailsSourceData.provider; + const variant = mockVariant ?? defaultProAccessDetailsSourceData.variant; + // Real data carries a parsed {planCount, planUnit}; the mock still uses a legacy variant slug. + const variantString = mockVariant + ? proAccessVariantToString(mockVariant) + : planPeriodToString(latestAccess?.planCount, latestAccess?.planUnit); const isPlatformRefundAvailable = mockIsPlatformRefundAvailable || - (latestAccess?.platform_refund_expiry_unix_ts_ms && - now < latestAccess.platform_refund_expiry_unix_ts_ms) || + (latestAccess?.platformRefundExpiryTsMs && + now < latestAccess.platformRefundExpiryTsMs) || defaultProAccessDetailsSourceData.isPlatformRefundAvailable; const autoRenew = mockCancelled ? !mockCancelled - : (data?.auto_renewing ?? defaultProAccessDetailsSourceData.autoRenew); + : (data?.autoRenewing ?? defaultProAccessDetailsSourceData.autoRenew); let beginAutoRenew = 0; if (data) { - beginAutoRenew = data.expiry_unix_ts_ms - data.grace_period_duration_ms; + beginAutoRenew = data.expiryMs - data.gracePeriodDurationMs; } let inGracePeriod = mockInGracePeriod; @@ -226,16 +283,16 @@ function processProBackendData({ inGracePeriod = autoRenew && now >= beginAutoRenew && now < expiryTimeMs; } - const isProcessingRefund = !!data?.refund_requested_unix_ts_ms; + const isProcessingRefund = !!data?.refundRequestedTsMs; return { data: { - currentStatus: data?.status ?? defaultProAccessDetailsSourceData.currentStatus, + currentStatus: data?.userStatus ?? defaultProAccessDetailsSourceData.currentStatus, autoRenew, inGracePeriod, isProcessingRefund, variant, - variantString: proAccessVariantToString(variant), + variantString, expiryTimeMs, expiryTimeDateString: formatDateWithLocale({ date: new Date(beginAutoRenew), @@ -249,23 +306,22 @@ function processProBackendData({ isLoading, isFetching, isError, - t, }; } -export const getProBackendProDetails = (state: StateType): ProcessedProDetails => { +export const getProBackendProStatus = (state: StateType): ProcessedProStatus => { const details = getProBackendData(state).details; - const mergedDetails = details.data ? details : { ...details, data: getProDetailsFromStorage() }; + const mergedDetails = details.data ? details : { ...details, data: getProStatusFromStorage() }; return processProBackendData(mergedDetails); }; export const getProBackendCurrentUserStatus = (state: StateType) => { - return getProBackendProDetails(state).data?.currentStatus; + return getProBackendProStatus(state).data?.currentStatus; }; -export const useProBackendProDetails = () => { - return useSelector(getProBackendProDetails); +export const useProBackendProStatus = () => { + return useSelector(getProBackendProStatus); }; export const useProBackendCurrentUserStatus = () => { @@ -275,7 +331,7 @@ export const useProBackendCurrentUserStatus = () => { export function useProBackendRefetch() { const dispatch = getAppDispatch(); - const details = useProBackendProDetails(); + const details = useProBackendProStatus(); const mockSuccess = getFeatureFlagMemo('mockProRecoverButtonAlwaysSucceed'); const mockFail = getFeatureFlagMemo('mockProRecoverButtonAlwaysFail'); @@ -320,7 +376,7 @@ export function useProBackendRefetch() { void mockRefetchSuccess(); return; } - dispatch(proBackendDataActions.refreshGetProDetailsFromProBackend(args) as any); + dispatch(proBackendDataActions.refreshGetProStatusFromProBackend(args) as any); }; return refetch; diff --git a/ts/state/startup.ts b/ts/state/startup.ts index 7fcfb60e68..2635020389 100644 --- a/ts/state/startup.ts +++ b/ts/state/startup.ts @@ -171,7 +171,7 @@ export const doAppStartUp = async () => { // trigger any other actions that need to be done after the swarm is ready window.inboxStore?.dispatch(networkDataActions.fetchInfoFromSeshServer() as any); window.inboxStore?.dispatch( - proBackendDataActions.refreshGetProDetailsFromProBackend({}) as any + proBackendDataActions.refreshGetProStatusFromProBackend({}) as any ); if (window.inboxStore) { const delayedTimeout = getDataFeatureFlag('useLocalDevNet') && isTestIntegration() ? 2000 : 0; diff --git a/ts/test/session/unit/libsession_wrapper/libsession_wrapper_user_profile_test.ts b/ts/test/session/unit/libsession_wrapper/libsession_wrapper_user_profile_test.ts index f7a84a7129..f4f91bc24f 100644 --- a/ts/test/session/unit/libsession_wrapper/libsession_wrapper_user_profile_test.ts +++ b/ts/test/session/unit/libsession_wrapper/libsession_wrapper_user_profile_test.ts @@ -76,7 +76,7 @@ describe('libsession_user_profile', () => { rotatingSeedHex: to_hex(proSeed), proProof: { expiryMs: Date.now() + 1000, - genIndexHashB64: to_base64(ed25519Seed, base64_variants.ORIGINAL), + revocationTagB64: to_base64(ed25519Seed, base64_variants.ORIGINAL), version: 132, signatureHex: to_hex(randombytes_buf(64)), }, @@ -95,7 +95,7 @@ describe('libsession_user_profile', () => { proProof: { rotatingPubkeyHex: rotatingPubKeyHex, expiryMs: proConfig.proProof.expiryMs, - genIndexHashB64: proConfig.proProof.genIndexHashB64, + revocationTagB64: proConfig.proProof.revocationTagB64, version: proConfig.proProof.version, signatureHex: proConfig.proProof.signatureHex, }, diff --git a/ts/test/session/unit/models/ConversationModels_test.ts b/ts/test/session/unit/models/ConversationModels_test.ts index b9456d6a2a..67a967291e 100644 --- a/ts/test/session/unit/models/ConversationModels_test.ts +++ b/ts/test/session/unit/models/ConversationModels_test.ts @@ -147,21 +147,21 @@ describe('fillConvoAttributesWithDefaults', () => { }); }); - describe('proGenIndexHashB64', () => { - it('initialize proGenIndexHashB64 if not given', () => { + describe('proRevocationTagB64', () => { + it('initialize proRevocationTagB64 if not given', () => { expect(fillConvoAttributesWithDefaults({} as ConversationAttributes)).to.have.deep.property( - 'proGenIndexHashB64', + 'proRevocationTagB64', undefined ); }); - it('do not override proGenIndexHashB64 if given', () => { + it('do not override proRevocationTagB64 if given', () => { expect( fillConvoAttributesWithDefaults({ - proGenIndexHashB64: to_base64('123456789123456789123456789123456789'), + proRevocationTagB64: to_base64('123456789123456789123456789123456789'), } as ConversationAttributes) ).to.have.deep.property( - 'proGenIndexHashB64', + 'proRevocationTagB64', to_base64('123456789123456789123456789123456789') ); }); diff --git a/ts/test/session/unit/pro/pro_revocation_cache_test.ts b/ts/test/session/unit/pro/pro_revocation_cache_test.ts index cc75087716..3475201736 100644 --- a/ts/test/session/unit/pro/pro_revocation_cache_test.ts +++ b/ts/test/session/unit/pro/pro_revocation_cache_test.ts @@ -7,19 +7,16 @@ import type { Storage } from '../../../../util/storage'; import { DURATION } from '../../../../session/constants'; const validItemEffective = { - effective_unix_ts_ms: Date.now() - 2 * DURATION.DAYS, // this one is effective already - expiry_unix_ts_ms: Date.now() + 2 * DURATION.DAYS, // and not expired - gen_index_hash_b64: 'QJGEFg4+FQkDzqeoWW5ghObbGB0IR/TTs4ve1MHzL9I=', + effectiveMs: Date.now() - 2 * DURATION.DAYS, // this one is effective already + revocationTagB64: 'QJGEFg4+FQkDzqeoWW5ghObbGB0IR/TTs4ve1MHzL9I=', }; const validItemDelayed = { - effective_unix_ts_ms: Date.now() + 2 * DURATION.DAYS, // this one is delayed (not effective yet) - expiry_unix_ts_ms: Date.now() + 6 * DURATION.DAYS, // and not expired - gen_index_hash_b64: 'b2ArHxhrhbSrV6/aVqOF5RYG55l74doHcB935pZyFxo=', + effectiveMs: Date.now() + 2 * DURATION.DAYS, // this one is delayed (not effective yet) + revocationTagB64: 'b2ArHxhrhbSrV6/aVqOF5RYG55l74doHcB935pZyFxo=', }; const validItemExpired = { - effective_unix_ts_ms: Date.now() - 6 * DURATION.DAYS, // this one is effective - expiry_unix_ts_ms: Date.now() - 2 * DURATION.DAYS, // and expired - gen_index_hash_b64: 'dfL3b6/G//0jNGOZDwOGVY4CWUOjoNkoR5tDTGeJtI4=', + effectiveMs: Date.now() - 6 * DURATION.DAYS, // this one is effective + revocationTagB64: 'dfL3b6/G//0jNGOZDwOGVY4CWUOjoNkoR5tDTGeJtI4=', }; // loadFromDbIfNeeded makes a few setup calls to get/put/remove, so we need to reset them @@ -125,7 +122,7 @@ describe('ProRevocationCache', () => { await ProRevocationCache.setListItems([validItemEffective]); expect( - ProRevocationCache.isB64HashEffectivelyRevoked(validItemEffective.gen_index_hash_b64) + ProRevocationCache.isB64HashEffectivelyRevoked(validItemEffective.revocationTagB64) ).to.eq(true); }); it('non-present hash returns false', async () => { @@ -134,7 +131,7 @@ describe('ProRevocationCache', () => { await ProRevocationCache.setListItems([validItemEffective]); expect( - ProRevocationCache.isB64HashEffectivelyRevoked(validItemDelayed.gen_index_hash_b64) + ProRevocationCache.isB64HashEffectivelyRevoked(validItemDelayed.revocationTagB64) ).to.eq(false); }); it('present but non effective hash returns false', async () => { @@ -143,7 +140,7 @@ describe('ProRevocationCache', () => { await ProRevocationCache.setListItems([validItemDelayed]); expect( - ProRevocationCache.isB64HashEffectivelyRevoked(validItemDelayed.gen_index_hash_b64) + ProRevocationCache.isB64HashEffectivelyRevoked(validItemDelayed.revocationTagB64) ).to.eq(false); }); @@ -153,7 +150,7 @@ describe('ProRevocationCache', () => { await ProRevocationCache.setListItems([validItemExpired]); expect( - ProRevocationCache.isB64HashEffectivelyRevoked(validItemExpired.gen_index_hash_b64) + ProRevocationCache.isB64HashEffectivelyRevoked(validItemExpired.revocationTagB64) ).to.eq(true); }); }); diff --git a/ts/types/message/OutgoingProMessageDetails.ts b/ts/types/message/OutgoingProMessageDetails.ts index 69a4fe6b27..486c004026 100644 --- a/ts/types/message/OutgoingProMessageDetails.ts +++ b/ts/types/message/OutgoingProMessageDetails.ts @@ -58,15 +58,17 @@ export class OutgoingProMessageDetails { } return null; } - const { expiryMs, genIndexHashB64, rotatingPubkeyHex, signatureHex, version } = + const { expiryMs, revocationTagB64, rotatingPubkeyHex, signatureHex, version } = this.proConfig.proProof; return new SignalService.ProMessage({ profileBitset: bigIntToLong(this.proProfileBitset), messageBitset: bigIntToLong(this.proMessageBitset), proof: { - expireAtMs: expiryMs, - genIndexHash: from_base64(genIndexHashB64, base64_variants.ORIGINAL), + // The proof expiry travels as whole seconds on the wire (what libsession signs over and + // parses via `as_sys_seconds`); our JS domain keeps it in ms, so convert back down here. + expiryUnixTs: Math.floor(expiryMs / 1000), + revocationTag: from_base64(revocationTagB64, base64_variants.ORIGINAL), rotatingPublicKey: from_hex(rotatingPubkeyHex), version, sig: from_hex(signatureHex), diff --git a/ts/util/i18n/formatting/generics.ts b/ts/util/i18n/formatting/generics.ts index 9f3e329c64..6fbd1b5877 100644 --- a/ts/util/i18n/formatting/generics.ts +++ b/ts/util/i18n/formatting/generics.ts @@ -161,6 +161,30 @@ export function formatRoundedUpDuration(durationMs: number): string { return formatDuration(duration, { locale }); } +/** date-fns duration units a parsed plan {count, unit} can map onto (excludes 'lifetime', which + * isn't a duration and is handled by the caller with a localized "Lifetime" string). */ +export type PlanDurationUnit = 'second' | 'day' | 'week' | 'month' | 'year'; + +/** + * Formats a parsed plan period {count, unit} (libsession, plan grammar §1) into a localized, pluralized + * duration string like "3 months" or "1 year", using the date-fns locale. The unit is rendered + * exactly as given — never canonicalized, so 12 months stays "12 months", not "1 year". + */ +export function formatPlanDuration(count: number, unit: PlanDurationUnit): string { + const locale = getTimeLocaleDictionary(); + const duration = + unit === 'second' + ? { seconds: count } + : unit === 'day' + ? { days: count } + : unit === 'week' + ? { weeks: count } + : unit === 'month' + ? { months: count } + : { years: count }; + return formatDuration(duration, { locale }); +} + /** * Formats the time remaining until a unix timestamp with localized duration strings. * @see {@link formatRoundedUpDuration} diff --git a/ts/util/storage.ts b/ts/util/storage.ts index 0dbdf9c800..a56b2fa2eb 100644 --- a/ts/util/storage.ts +++ b/ts/util/storage.ts @@ -4,7 +4,7 @@ import { DEFAULT_RECENT_REACTS } from '../session/constants'; import { deleteSettingsBoolValue, updateSettingsBoolValue } from '../state/ducks/settings'; import { Data } from '../data/data'; import { SettingsKey } from '../data/settings-key'; -import { ProProofResultType, ProDetailsResultType } from '../session/apis/pro_backend_api/schemas'; +import { ProProofResultType, ProStatusResultType } from '../session/apis/pro_backend_api/schemas'; import { UrlInteractionsType } from './urlHistory'; import { updateStorageSchema } from './storageMigrations'; import { CtaInteractionsType } from './ctaHistory'; @@ -20,7 +20,7 @@ type ValueType = | Array | UrlInteractionsType | CtaInteractionsType - | ProDetailsResultType + | ProStatusResultType | ProProofResultType; type InsertedValueType = { id: string; value: ValueType }; let items: Record; diff --git a/ts/webworker/workers/browser/libsession_worker_interface.ts b/ts/webworker/workers/browser/libsession_worker_interface.ts index e2fd074ff1..4effeea1e0 100644 --- a/ts/webworker/workers/browser/libsession_worker_interface.ts +++ b/ts/webworker/workers/browser/libsession_worker_interface.ts @@ -781,17 +781,37 @@ export const ProWrapperActions: ProActionsCalls = { callLibSessionWorker(['Pro', 'utf16CountTruncatedToCodepoints', first]) as Promise< ReturnType >, - proProofRequestBody: async first => - callLibSessionWorker(['Pro', 'proProofRequestBody', first]) as Promise< - ReturnType + proProofRequest: async first => + callLibSessionWorker(['Pro', 'proProofRequest', first]) as Promise< + ReturnType >, - proRevocationsRequestBody: async first => - callLibSessionWorker(['Pro', 'proRevocationsRequestBody', first]) as Promise< - ReturnType + proRevocationsRequest: async first => + callLibSessionWorker(['Pro', 'proRevocationsRequest', first]) as Promise< + ReturnType >, - proStatusRequestBody: async first => - callLibSessionWorker(['Pro', 'proStatusRequestBody', first]) as Promise< - ReturnType + proStatusRequest: async first => + callLibSessionWorker(['Pro', 'proStatusRequest', first]) as Promise< + ReturnType + >, + parseProProofResponse: async first => + callLibSessionWorker(['Pro', 'parseProProofResponse', first]) as Promise< + ReturnType + >, + parseRevocationsResponse: async first => + callLibSessionWorker(['Pro', 'parseRevocationsResponse', first]) as Promise< + ReturnType + >, + parseProStatusResponse: async first => + callLibSessionWorker(['Pro', 'parseProStatusResponse', first]) as Promise< + ReturnType + >, + providerUrls: async first => + callLibSessionWorker(['Pro', 'providerUrls', first]) as Promise< + ReturnType + >, + visiblePlatforms: async () => + callLibSessionWorker(['Pro', 'visiblePlatforms']) as Promise< + ReturnType >, };