From 2b67c41bb45c0b2125c4766d8cccf0ac61dbaa7c Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Sun, 19 Jul 2026 15:29:20 -0300 Subject: [PATCH 01/24] =?UTF-8?q?WIP:=20desktop=20Part=20B=20(1/n)=20?= =?UTF-8?q?=E2=80=94=20type=20foundation=20for=20the=20libsession-parsed?= =?UTF-8?q?=20wire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Start migrating the desktop app off hand-rolled zod wire parsing onto the libsession (nodejs glue) parsers + structs. - types.ts: provider/plan and now account/payment status are all opaque wire slugs (libsession emits strings). Replaced the numeric enums with slug constants (ProStatus never/active/expired, ProItemStatus unredeemed/…/revoked, ProAccessVariant 1m/3m/1y, ProPaymentProvider google_play/app_store/rangeproof) + open string types; deleted the ProOriginatingPlatform<->numeric bridges. - schemas.ts: deleted the zod request/response wire schemas; re-export the response types from libsession_util_nodejs (GetProDetailsResponse, etc.) so import sites stay stable. Kept a small DB schema for the revocation cache (glue shape: genIndexHashB64 + effectiveMs). - libsession_constants.ts: dropped LIBSESSION_PRO_PROVIDERS (metadata table gone); added LIBSESSION_PRO_BACKEND_URL + _PUBKEY_HEX (single source of truth). Remaining (next commits): rewire ProBackendAPI request/response flow to the glue parsers, then the field-rename ripple through ducks/selectors/revocation/UI + persistence migration + ProBackendTarget URL/pubkey. WIP, non-compiling. Co-Authored-By: Claude Opus 4.8 (1M context) --- ts/session/apis/pro_backend_api/schemas.ts | 136 ++++-------------- ts/session/apis/pro_backend_api/types.ts | 100 +++++-------- .../utils/libsession/libsession_constants.ts | 6 +- 3 files changed, 62 insertions(+), 180 deletions(-) diff --git a/ts/session/apis/pro_backend_api/schemas.ts b/ts/session/apis/pro_backend_api/schemas.ts index 3eb5a12b23..17b239fc7d 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, + GetProDetailsResponse, + 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 ProDetailsResultType = GetProDetailsResponse; +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(), + genIndexHashB64: 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..2eca2ef0df 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 = { + NeverBeenPro: '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/utils/libsession/libsession_constants.ts b/ts/session/utils/libsession/libsession_constants.ts index 2fbfce7df2..ee622757e5 100644 --- a/ts/session/utils/libsession/libsession_constants.ts +++ b/ts/session/utils/libsession/libsession_constants.ts @@ -10,8 +10,9 @@ 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, } = CONSTANTS; const LIBSESSION_CONSTANTS: ConstantsType = { @@ -23,8 +24,9 @@ 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, }; export default LIBSESSION_CONSTANTS; From 8af30785d28be7f9b1e430c591d0782906578baf Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Sun, 19 Jul 2026 16:05:17 -0300 Subject: [PATCH 02/24] =?UTF-8?q?WIP:=20desktop=20Part=20B=20(2/n)=20?= =?UTF-8?q?=E2=80=94=20relay=20raw=20response=20bytes=20to=20libsession,?= =?UTF-8?q?=20no=20client=20parsing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop no longer parses (or assumes the format of) the Pro backend response — that wire format is a libsession<->backend contract. Desktop just relays bytes. - session_backend_server: new makeRequestReturningRawBody() returns { status_code, bodyBinary } straight from the onion V4 response — transport status from the V4 metadata, body as the raw bytes (no content-type parsing). The existing makeRequestWithSchema/_makeRequest JSON path is untouched (still used by NetworkApi). - ProBackendAPI: requests are built by libsession ({ endpoint, body }); the endpoint is no longer hardcoded and requestVersion is gone. Responses relay bodyBinary to the glue parsers (parseProProofResponse / parsePaymentDetailsResponse / parseRevocationsResponse) and return the typed struct. Transport failure -> null; app-level errors come back in the struct's errors/status. - worker interface: ProWrapperActions updated to the new glue methods (proProofRequest/ proStatusRequest/proRevocationsRequest returning {endpoint,body}; parse*Response taking { body: Uint8Array }; providerUrls). Worker dispatch is dynamic so no switch change. Remaining: field-rename ripple through ducks/selectors/revocation/UI + persistence + ProBackendTarget URL/pubkey. WIP, non-compiling until the glue is rebuilt. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../apis/pro_backend_api/ProBackendAPI.ts | 94 ++++++++----------- ts/session/apis/session_backend_server.ts | 53 +++++++++++ .../browser/libsession_worker_interface.ts | 34 +++++-- 3 files changed, 118 insertions(+), 63 deletions(-) diff --git a/ts/session/apis/pro_backend_api/ProBackendAPI.ts b/ts/session/apis/pro_backend_api/ProBackendAPI.ts index f2b7e6f359..b30532acc2 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, + GetProRevocationsResponseType, } from './schemas'; import { ProWrapperActions } from '../../../webworker/workers/browser/libsession_worker_interface'; import { NetworkTime } from '../../../util/NetworkTime'; @@ -22,72 +19,61 @@ 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; } - 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; body: string }, + parse: (body: Uint8Array) => Promise + ): Promise { + const { status_code, bodyBinary } = await ProBackendAPI.getServer().makeRequestReturningRawBody({ + path: `/${request.endpoint}`, + method: 'POST', + 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, + const request = await ProWrapperActions.proProofRequest({ + ...args, + unixTsMs: NetworkTime.now(), }); + return ProBackendAPI.sendAndParse(request, body => + ProWrapperActions.parseProProofResponse({ body }) + ); } - 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 + static async getProDetails(args: WithMasterPrivKeyHex): Promise { + const request = await ProWrapperActions.proStatusRequest({ + ...args, + unixTsMs: NetworkTime.now(), + // NOTE: the latest payment is the only one required for state derivation count: 1, }); - return request; - } - - private static async getRevocationListBody(args: WithTicket) { - const body = await ProWrapperActions.proRevocationsRequestBody({ requestVersion: 0, ...args }); - return body; - } - - static async getProDetails( - args: WithMasterPrivKeyHex - ): Promise { - return ProBackendAPI.getServer().makeRequestWithSchema({ - path: '/get_pro_details', - method: 'POST', - bodyGetter: () => ProBackendAPI.getProDetailsBody(args), - withZodSchema: GetProDetailsResponseSchema, - }); + return ProBackendAPI.sendAndParse(request, body => + ProWrapperActions.parsePaymentDetailsResponse({ 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/session_backend_server.ts b/ts/session/apis/session_backend_server.ts index 3ae622bc5a..3ae7220a4a 100644 --- a/ts/session/apis/session_backend_server.ts +++ b/ts/session/apis/session_backend_server.ts @@ -258,4 +258,57 @@ 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 + ): Promise<{ status_code: number; bodyBinary: Uint8Array | null }> { + const body = typeof params.bodyGetter === 'function' ? await params.bodyGetter() : null; + + const headers = params.blindSignRequest + ? await this.getBlindSignedHeaders({ method: params.method, path: params.path, body }) + : {}; + + if (headers === null) { + this.logError('failed to blind sign request parameters', params.path); + return { status_code: 500, bodyBinary: null }; + } + + 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/webworker/workers/browser/libsession_worker_interface.ts b/ts/webworker/workers/browser/libsession_worker_interface.ts index e2fd074ff1..ba079e344c 100644 --- a/ts/webworker/workers/browser/libsession_worker_interface.ts +++ b/ts/webworker/workers/browser/libsession_worker_interface.ts @@ -781,17 +781,33 @@ 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 + >, + parsePaymentDetailsResponse: async first => + callLibSessionWorker(['Pro', 'parsePaymentDetailsResponse', first]) as Promise< + ReturnType + >, + providerUrls: async first => + callLibSessionWorker(['Pro', 'providerUrls', first]) as Promise< + ReturnType >, }; From 1e3ebc7f7c564b1cba13836c1e75fb12ec2f1c0c Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Mon, 20 Jul 2026 10:15:36 -0300 Subject: [PATCH 03/24] pro: finish request-side raw-wire relay + single-source url/pubkeys Relay libsession's Content-Type through the onion request path (makeRequestReturningRawBody takes contentType, sets the header; ProBackendAPI.sendAndParse threads request.contentType) so the client never assumes the wire format. Point the DEFAULT pro backend target at the libsession constants (url + ed/x25519 pubkeys) instead of hand-carried copies, so there is a single source of truth; add LIBSESSION_PRO_BACKEND_PUBKEY_X25519_HEX to the desktop constants mirror. Co-Authored-By: Claude Opus 4.8 (1M context) --- ts/session/apis/pro_backend_api/ProBackendAPI.ts | 3 ++- ts/session/apis/pro_backend_api/ProBackendTarget.ts | 9 +++++---- ts/session/apis/session_backend_server.ts | 11 ++++++++--- ts/session/utils/libsession/libsession_constants.ts | 2 ++ 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/ts/session/apis/pro_backend_api/ProBackendAPI.ts b/ts/session/apis/pro_backend_api/ProBackendAPI.ts index b30532acc2..ea554bcfaa 100644 --- a/ts/session/apis/pro_backend_api/ProBackendAPI.ts +++ b/ts/session/apis/pro_backend_api/ProBackendAPI.ts @@ -30,12 +30,13 @@ export default class ProBackendAPI { * (backend) errors surface via the parsed struct's `errors` (and non-success `status`). */ private static async sendAndParse( - request: { endpoint: string; body: string }, + 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, }); diff --git a/ts/session/apis/pro_backend_api/ProBackendTarget.ts b/ts/session/apis/pro_backend_api/ProBackendTarget.ts index 8bb87961c1..377eca901b 100644 --- a/ts/session/apis/pro_backend_api/ProBackendTarget.ts +++ b/ts/session/apis/pro_backend_api/ProBackendTarget.ts @@ -1,6 +1,7 @@ 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'; @@ -12,10 +13,10 @@ 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', diff --git a/ts/session/apis/session_backend_server.ts b/ts/session/apis/session_backend_server.ts index 3ae7220a4a..d9e49d5f97 100644 --- a/ts/session/apis/session_backend_server.ts +++ b/ts/session/apis/session_backend_server.ts @@ -267,19 +267,24 @@ export default class SessionBackendServerApi { * change never touches this client. */ public async makeRequestReturningRawBody( - params: SessionBackendServerMakeRequestParams + params: SessionBackendServerMakeRequestParams & { contentType?: string } ): Promise<{ status_code: number; bodyBinary: Uint8Array | null }> { const body = typeof params.bodyGetter === 'function' ? await params.bodyGetter() : null; - const headers = params.blindSignRequest + const blindHeaders = params.blindSignRequest ? await this.getBlindSignedHeaders({ method: params.method, path: params.path, body }) : {}; - if (headers === null) { + 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(); diff --git a/ts/session/utils/libsession/libsession_constants.ts b/ts/session/utils/libsession/libsession_constants.ts index ee622757e5..4745bd4baf 100644 --- a/ts/session/utils/libsession/libsession_constants.ts +++ b/ts/session/utils/libsession/libsession_constants.ts @@ -13,6 +13,7 @@ const { LIBSESSION_PRO_URLS, LIBSESSION_PRO_BACKEND_URL, LIBSESSION_PRO_BACKEND_PUBKEY_HEX, + LIBSESSION_PRO_BACKEND_PUBKEY_X25519_HEX, } = CONSTANTS; const LIBSESSION_CONSTANTS: ConstantsType = { @@ -27,6 +28,7 @@ const LIBSESSION_CONSTANTS: ConstantsType = { LIBSESSION_PRO_URLS, LIBSESSION_PRO_BACKEND_URL, LIBSESSION_PRO_BACKEND_PUBKEY_HEX, + LIBSESSION_PRO_BACKEND_PUBKEY_X25519_HEX, }; export default LIBSESSION_CONSTANTS; From 0f324e3175f5bf4074403c9186026ed6a4b60b72 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Mon, 20 Jul 2026 10:26:07 -0300 Subject: [PATCH 04/24] pro: adapt response consumers to flat libsession-parsed structs ProBackendAPI now returns libsession's parsed response structs directly (flat `{ errors, ...camelCase ms fields }`) instead of an HTTP-wrapper `{ status_code, t, result }`. Adapt the consumers: - proBackendData duck: fetch thunk keys success off `errors` (not status_code) and stores the struct as-is; handleNewProProof relays the ready-made `proof`; callback reads userStatus/expiryMs/autoRenewing/ errorReport; unknown status slug no longer hard-fails. - UpdateProRevocationListJob: use libsession's absolute `retryAtMs` (arithmetic subsumed into the glue) and the flat ticket/items fields. - pro_revocation_list cache + its test: item shape is { genIndexHashB64, effectiveMs } (matches the glue's ProRevocationItem). - FeatureFlags debug proof button: relay the ready-made proof. Provider-model selectors/UI (LIBSESSION_PRO_PROVIDERS -> providerUrls + client-owned display names) are intentionally not touched here; that is a follow-up needing a localization decision. Co-Authored-By: Claude Opus 4.8 (1M context) --- ts/components/dialog/debug/FeatureFlags.tsx | 17 ++--- .../revocation_list/pro_revocation_list.ts | 6 +- .../jobs/UpdateProRevocationListJob.ts | 23 +++--- ts/state/ducks/proBackendData.ts | 74 ++++++++----------- .../unit/pro/pro_revocation_cache_test.ts | 23 +++--- 5 files changed, 59 insertions(+), 84 deletions(-) diff --git a/ts/components/dialog/debug/FeatureFlags.tsx b/ts/components/dialog/debug/FeatureFlags.tsx index 3625acf749..c0071146db 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, @@ -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.errors.length === 0) { + // libsession returns a ready-made ProProof; relay it verbatim. + await UserConfigWrapperActions.setProConfig({ + proProof: response.proof, + rotatingSeedHex, + }); } }} > diff --git a/ts/session/revocation_list/pro_revocation_list.ts b/ts/session/revocation_list/pro_revocation_list.ts index e376ab18dd..9c878c2c26 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) { assertInitialFetchFromDBDone('isB64HashEffectivelyRevoked'); - const found = cachedProRevocationListItems.find(m => m.gen_index_hash_b64 === genIndexHashBase64); + const found = cachedProRevocationListItems.find(m => m.genIndexHashB64 === genIndexHashBase64); - 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/UpdateProRevocationListJob.ts b/ts/session/utils/job_runners/jobs/UpdateProRevocationListJob.ts index b9f9812363..5548d570d8 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 0) { window.log.debug(`UpdateProRevocationListJob run() failed: ${JSON.stringify(response)}`); window.log.warn(`UpdateProRevocationListJob run() failed. Will retry soon if possible`); return RunJobResult.RetryJobIfPossible; } - const retryInSecondsFromBackend = response.result.retry_in_s; - const retryInSeconds = Math.max(retryInSecondsFromBackend, 0); + // libsession already resolved the backend's retry-after into an absolute unix instant (ms), + // clamped to now; we just persist it as the next run time — no arithmetic here. + const { retryAtMs } = response; - const retryAtMs = Date.now() + toNumber(retryInSeconds) * DURATION.SECONDS; - - window.log.debug( - `UpdateProRevocationListJob: got 'retry_in_s' from server: ${retryInSeconds}, i.e we will retryAtMs: ${retryAtMs}` - ); + window.log.debug(`UpdateProRevocationListJob: next revocation refresh at retryAtMs: ${retryAtMs}`); await updateNextRunAtMs(retryAtMs); - if (response.result.ticket <= ticketFromDb) { + if (response.ticket <= ticketFromDb) { window.log.debug( `UpdateProRevocationListJob: no new revocations from our existing ticket #${ticketFromDb}` ); return RunJobResult.Success; } - const newTicket = response.result.ticket; - const newItems = response.result.items; + const newTicket = response.ticket; + const newItems = response.items; window.log.debug( - `UpdateProRevocationListJob: new revocations from ticket #${ticketFromDb}: to #${newTicket}. items: ${stringify(response.result.items)}` + `UpdateProRevocationListJob: new revocations from ticket #${ticketFromDb}: to #${newTicket}. items: ${stringify(response.items)}` ); // Note: we only want to update the lastRunAt once we have successfully fetched the new revocations @@ -128,7 +125,7 @@ class UpdateProRevocationListJob extends PersistedJob = 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 `errors` (empty on success) — a non-empty list means the backend reported a problem. +type CreateProBackendFetchAsyncThunk }> = { 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 +74,7 @@ type CreateProBackendFetchAsyncThunk = { export type WithCallerContext = { callerContext?: 'recover' }; -async function createProBackendFetchAsyncThunk({ +async function createProBackendFetchAsyncThunk }>({ key, getter, payloadCreator, @@ -117,37 +113,25 @@ 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 failures come back as a non-empty `errors` on the parsed struct (not an HTTP status). + const error = response.errors.length ? response.errors.join('; ') : null; if (error && debug) { window?.log?.error(error); } result = { - data: error ? result.data : response.result, + data: error ? result.data : response, error, isError: !!error, isFetching: false, isLoading: false, - t: response.t, + t: NetworkTime.now(), isEnabled: true, }; } catch (e) { @@ -184,14 +168,9 @@ async function handleNewProProof(rotatingPrivKeyHex: string): Promise { 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; @@ -385,13 +364,18 @@ const fetchGetProDetailsFromProBackend = createAsyncThunk( break; default: - assertUnreachable(state.data.status, 'handleBackendProStatusChange'); + // Status is now an opaque slug — an unknown/future value must not hard-fail; treat it + // like "no active pro" and clear any stale proof. + window.log.warn( + `[handleBackendProStatusChange] unknown pro userStatus: ${state.data.userStatus}` + ); + await handleClearProProof(); 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) { @@ -409,7 +393,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' }, 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..80386d6ce5 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 + genIndexHashB64: '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) + genIndexHashB64: '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 + genIndexHashB64: '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.genIndexHashB64) ).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.genIndexHashB64) ).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.genIndexHashB64) ).to.eq(false); }); @@ -153,7 +150,7 @@ describe('ProRevocationCache', () => { await ProRevocationCache.setListItems([validItemExpired]); expect( - ProRevocationCache.isB64HashEffectivelyRevoked(validItemExpired.gen_index_hash_b64) + ProRevocationCache.isB64HashEffectivelyRevoked(validItemExpired.genIndexHashB64) ).to.eq(true); }); }); From f3a24599e7a7ccb5005d038d4f6e5802d5626de6 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Mon, 20 Jul 2026 17:22:46 -0300 Subject: [PATCH 05/24] pro: re-key desktop response handling to the Delta #12 status field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nodejs glue no longer emits an `errors` array — the response header is now `status` ('ok'/'fail'/'error') + optional `errorCode` slug + diagnostic `error`. Success is `status === 'ok'` (was `errors.length === 0`); the fetch thunk derives its error from `error`/`errorCode`. Fixes the earlier response-consumer commit, which keyed off the removed `errors` field. (Provider display-name i18n and the error_code->localized-string mapping remain, pending Crowdin tokens.) Co-Authored-By: Claude Opus 4.8 (1M context) --- ts/components/dialog/debug/FeatureFlags.tsx | 2 +- .../jobs/UpdateProRevocationListJob.ts | 2 +- ts/state/ducks/proBackendData.ts | 19 +++++++++++++------ 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/ts/components/dialog/debug/FeatureFlags.tsx b/ts/components/dialog/debug/FeatureFlags.tsx index c0071146db..868792446a 100644 --- a/ts/components/dialog/debug/FeatureFlags.tsx +++ b/ts/components/dialog/debug/FeatureFlags.tsx @@ -924,7 +924,7 @@ export const ProDebugSection = ({ if (getFeatureFlag('debugServerRequests')) { window?.log?.debug('getProProof response: ', response); } - if (response && response.errors.length === 0) { + if (response && response.status === 'ok') { // libsession returns a ready-made ProProof; relay it verbatim. await UserConfigWrapperActions.setProConfig({ proProof: response.proof, diff --git a/ts/session/utils/job_runners/jobs/UpdateProRevocationListJob.ts b/ts/session/utils/job_runners/jobs/UpdateProRevocationListJob.ts index 5548d570d8..4e0a15ae9f 100644 --- a/ts/session/utils/job_runners/jobs/UpdateProRevocationListJob.ts +++ b/ts/session/utils/job_runners/jobs/UpdateProRevocationListJob.ts @@ -93,7 +93,7 @@ class UpdateProRevocationListJob extends PersistedJob 0) { + if (!response || response.status !== 'ok') { window.log.debug(`UpdateProRevocationListJob run() failed: ${JSON.stringify(response)}`); window.log.warn(`UpdateProRevocationListJob run() failed. Will retry soon if possible`); return RunJobResult.RetryJobIfPossible; diff --git a/ts/state/ducks/proBackendData.ts b/ts/state/ducks/proBackendData.ts index 02f9805e2e..e52fbeddf2 100644 --- a/ts/state/ducks/proBackendData.ts +++ b/ts/state/ducks/proBackendData.ts @@ -62,8 +62,11 @@ export const initialProBackendDataState: ProBackendDataState = { type PayloadCreatorType = Parameters['1']>['1']; // The getter returns a libsession-parsed response struct (or null on transport failure). Every parsed -// struct carries `errors` (empty on success) — a non-empty list means the backend reported a problem. -type CreateProBackendFetchAsyncThunk }> = { +// struct carries the Delta #12 header: `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; payloadCreator: PayloadCreatorType; @@ -74,7 +77,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, @@ -118,8 +123,10 @@ async function createProBackendFetchAsyncThunk throw new Error('Data fetch failed'); } - // App-level failures come back as a non-empty `errors` on the parsed struct (not an HTTP status). - const error = response.errors.length ? response.errors.join('; ') : null; + // App-level failure = status !== 'ok'. The diagnostic `error` string is for logging; user-facing + // text should come from mapping `errorCode` to a localized string (TODO: the error_code i18n work). + const error = + response.status === 'ok' ? null : (response.error ?? response.errorCode ?? 'request failed'); if (error && debug) { window?.log?.error(error); @@ -168,7 +175,7 @@ async function handleNewProProof(rotatingPrivKeyHex: string): Promise Date: Mon, 20 Jul 2026 18:20:23 -0300 Subject: [PATCH 06/24] =?UTF-8?q?pro:=20desktop-B=20=E2=80=94=20provider?= =?UTF-8?q?=20metadata=20via=20i18n=20+=20on-demand=20URLs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit libsession no longer supplies provider display names or (synchronously) URLs. Rework the provider-constants model: - Display names come from client i18n: getProProviderConstantsWithFallbacks(slug) returns store/platform/device/platform_account/store_other via tr() tokens (proProvider*). Those strings are a TEMP stopgap injected into the generated localization (english.ts/locales.ts) so they resolve now but are wiped by the next Crowdin sync — upstream them first. - URLs are fetched on demand at click time via ProWrapperActions.providerUrls(slug) in ProNonOriginatingPage (openProviderUrl helper), not threaded through the synchronous selector. Also finish the selector's Delta #12 field renames (expiryMs/userStatus/ autoRenewing/paymentProvider/platformRefundExpiryTsMs/refundRequestedTsMs), replace the deleted ProDetailsResultSchema with a light storage check, make proAccessVariantToString/provider handling tolerate slug/unknown values (drop assertUnreachable), and fix stale ProPaymentProvider member names (iOSAppStore->AppStore, GooglePlayStore->GooglePlay, Nil->''). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../pages/user-pro/ProNonOriginatingPage.tsx | 43 ++++++--- ts/localization | 2 +- ts/state/selectors/proBackendData.ts | 87 ++++++++++--------- 3 files changed, 77 insertions(+), 55 deletions(-) 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..fb410cc386 100644 --- a/ts/components/dialog/user-settings/pages/user-pro/ProNonOriginatingPage.tsx +++ b/ts/components/dialog/user-settings/pages/user-pro/ProNonOriginatingPage.tsx @@ -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'; @@ -33,6 +35,22 @@ type VariantPageProps = { }; const useProBackendProDetailsLocal = useProBackendProDetails; + +/** + * 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) { + showLinkVisitWarningDialog(pick(urls), dispatch); + } +} const useCurrentNeverHadProLocal = useCurrentNeverHadPro; /** @@ -40,7 +58,7 @@ const useCurrentNeverHadProLocal = useCurrentNeverHadPro; * 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 + return data.provider === ProPaymentProvider.AppStore ? data.providerConstants.platform // we want `Apple website` for apple : data.providerConstants.store; // but `Google Play Store website` for google... } @@ -504,15 +522,13 @@ function ProInfoBlockRefund() { } 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 ; } } @@ -532,7 +548,7 @@ function ProInfoBlockRefundRequested() { - showLinkVisitWarningDialog(data.providerConstants.refund_status_url, dispatch) + void openProviderUrl(data.provider, u => u.refundStatusUrl, dispatch) } style={{ cursor: 'pointer' }} > @@ -575,7 +591,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" > @@ -593,7 +609,7 @@ function ProPageButtonCancel() { {...proButtonProps} buttonColor={SessionButtonColor.Danger} onClick={() => { - showLinkVisitWarningDialog(data.providerConstants.cancel_subscription_url, dispatch); + void openProviderUrl(data.provider, u => u.cancelSubscriptionUrl, dispatch); }} dataTestId="pro-open-platform-website-button" > @@ -611,10 +627,9 @@ function ProPageButtonRefund() { {...proButtonProps} buttonColor={SessionButtonColor.Danger} onClick={() => { - 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/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/state/selectors/proBackendData.ts b/ts/state/selectors/proBackendData.ts index 6e56a85207..26d33ebe7d 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 { ProDetailsResultType } from '../../session/apis/pro_backend_api/schemas'; import { getDataFeatureFlag, getFeatureFlag, @@ -23,34 +21,31 @@ import { 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 { 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() { +function getProDetailsFromStorage(): ProDetailsResultType | null { const response = Storage.get(SettingsKey.proDetails); if (!response) { return null; } - const result = zodSafeParse(ProDetailsResultSchema, response); - if (result.success) { - return result.data; + // We persist the libsession-parsed struct verbatim (see putProDetailsInStorage). There's no hand-rolled + // schema to validate against anymore, so trust a well-formed object and drop anything obviously wrong + // (it's re-fetched on the next poll). + if (typeof response === 'object' && Array.isArray((response as ProDetailsResultType).items)) { + return response as ProDetailsResultType; } void Storage.remove(SettingsKey.proDetails); - window?.log?.error( - 'failed to parse pro details from storage, removing item. error:', - result.error - ); + window?.log?.error('pro details in storage were malformed; removing.'); return null; } @@ -62,26 +57,38 @@ 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; - } - - if (!constants.store_other) { - constants.store_other = LIBSESSION_CONSTANTS.LIBSESSION_PRO_PROVIDERS.Google.store_other; - } +/** + * Per-provider display strings. Client-owned i18n now (Delta #10) — 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). (These proProvider* tokens are a temporary stopgap injected + * into the generated localization; they're wiped by the next Crowdin sync, so upstream them first.) + */ +export type ProviderDisplayConstants = { + store: string; + platform: string; + device: string; + platform_account: string; + store_other: string; +}; - return constants; +export function getProProviderConstantsWithFallbacks( + provider: ProPaymentProvider +): ProviderDisplayConstants { + const isApple = provider === ProPaymentProvider.AppStore; + return { + store: isApple ? tr('proProviderAppleStore') : tr('proProviderGoogleStore'), + platform: isApple ? tr('proProviderApplePlatform') : tr('proProviderGooglePlatform'), + device: isApple ? tr('proProviderAppleDevice') : tr('proProviderGoogleDevice'), + platform_account: isApple ? tr('proProviderAppleAccount') : tr('proProviderGoogleAccount'), + // "the other store": for Apple show Google's, and vice-versa. + store_other: isApple ? tr('proProviderGoogleStore') : tr('proProviderAppleStore'), + }; } function getMockedProAccessExpiry(variant: MockProAccessExpiryOptions): number | null { @@ -141,7 +148,7 @@ 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. @@ -149,10 +156,10 @@ export const defaultProAccessDetailsSourceData = { currentStatus: ProStatus.NeverBeenPro, autoRenew: true, inGracePeriod: false, - variant: ProAccessVariant.Nil, + variant: '', expiryTimeMs: 0, isPlatformRefundAvailable: false, - provider: ProPaymentProvider.Nil, + provider: '', isLoading: false, isError: false, } satisfies ProAccessDetailsSourceData; @@ -200,25 +207,25 @@ 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 provider = - mockPlatform ?? latestAccess?.payment_provider ?? defaultProAccessDetailsSourceData.provider; + mockPlatform ?? latestAccess?.paymentProvider ?? defaultProAccessDetailsSourceData.provider; const variant = mockVariant ?? latestAccess?.plan ?? defaultProAccessDetailsSourceData.variant; 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,11 +233,11 @@ 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, From aad28675f0085f0659c09cb372ff8b6f843fc94d Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Mon, 20 Jul 2026 23:47:17 -0300 Subject: [PATCH 07/24] Pro: dynamic provider display, {pro_stores} store list, and error_code messages Resolve provider display from pro_provider__ tokens; build the {pro_stores} purchasable-store list from ProWrapperActions.visiblePlatforms() (gated on the store token existing); map error_code slugs to localized pro_error_ strings. Adds ProWrapperActions.visiblePlatforms; removes the now-unused store_other provider constant. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../pages/user-pro/ProNonOriginatingPage.tsx | 58 +++++++++++++++---- .../apis/pro_backend_api/proErrorMessage.ts | 27 +++++++++ ts/state/ducks/proBackendData.ts | 14 +++-- ts/state/selectors/proBackendData.ts | 29 ++++++---- .../browser/libsession_worker_interface.ts | 4 ++ 5 files changed, 106 insertions(+), 26 deletions(-) create mode 100644 ts/session/apis/pro_backend_api/proErrorMessage.ts 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 fb410cc386..4b07c945a7 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'; @@ -63,6 +63,46 @@ function useStoreOrPlatformFromProvider(data: ProcessedProDetails['data']) { : 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(); return data.autoRenew ? ( @@ -188,8 +228,8 @@ function ProInfoBlockDevice({ textElement }: { textElement: ReactNode }) { } function ProInfoBlockDeviceLinked() { - const { data } = useProBackendProDetailsLocal(); const hasNeverHadPro = useCurrentNeverHadProLocal(); + const proStores = useProStoresList(); return ( {tr('onLinkedDevice')} } @@ -275,15 +314,14 @@ function ProInfoBlockLayout({ function ProInfoBlockUpgrade() { const dispatch = getAppDispatch(); - const { data } = useProBackendProDetailsLocal(); + const proStores = useProStoresList(); return ( } @@ -346,6 +384,7 @@ function ProInfoBlockRenew() { const dispatch = getAppDispatch(); const { data } = useProBackendProDetailsLocal(); const storeOrPlatform = useStoreOrPlatformFromProvider(data); + const proStores = useProStoresList(); return ( } 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..677cc5ff3c --- /dev/null +++ b/ts/session/apis/pro_backend_api/proErrorMessage.ts @@ -0,0 +1,27 @@ +import { isSimpleTokenNoArgs, tr } from '../../../localization/localeTools'; + +/** + * Resolve a failed Pro backend request to a user-facing message. + * + * The backend sends an open-ended `errorCode` slug (wire spec §5.1) plus an English diagnostic + * `error`. We prefer a localized `pro_error_` string when one exists (so a brand-new slug needs + * only a translation entry — no code change), and fall back to the backend diagnostic, then a generic + * message. + * + * Unlike android/iOS, no brand-token ({pro}/{app_pro}/…) handling is needed here: the shared-scripts + * generator bakes brand constants into the desktop strings at build time, so a `pro_error_*` string is + * already fully substituted by the time it reaches us. + */ +export function proErrorMessage( + errorCode: string | null | undefined, + backendError: string | null | undefined +): string { + if (errorCode) { + const key = `pro_error_${errorCode}`; + // Base-English existence check; tr() then resolves the current locale, falling back to English. + if (isSimpleTokenNoArgs(key)) { + return tr(key); + } + } + return backendError ?? tr('errorGeneric'); +} diff --git a/ts/state/ducks/proBackendData.ts b/ts/state/ducks/proBackendData.ts index e52fbeddf2..d011884336 100644 --- a/ts/state/ducks/proBackendData.ts +++ b/ts/state/ducks/proBackendData.ts @@ -11,6 +11,7 @@ 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 { proErrorMessage } from '../../session/apis/pro_backend_api/proErrorMessage'; import { Storage } from '../../util/storage'; import { NetworkTime } from '../../util/NetworkTime'; import { DURATION } from '../../session/constants'; @@ -123,13 +124,16 @@ async function createProBackendFetchAsyncThunk< throw new Error('Data fetch failed'); } - // App-level failure = status !== 'ok'. The diagnostic `error` string is for logging; user-facing - // text should come from mapping `errorCode` to a localized string (TODO: the error_code i18n work). - const error = + // 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 = { diff --git a/ts/state/selectors/proBackendData.ts b/ts/state/selectors/proBackendData.ts index 26d33ebe7d..f7f69b76f1 100644 --- a/ts/state/selectors/proBackendData.ts +++ b/ts/state/selectors/proBackendData.ts @@ -25,7 +25,7 @@ import { ProPaymentProvider, ProStatus, } from '../../session/apis/pro_backend_api/types'; -import { tr } from '../../localization/localeTools'; +import { isSimpleTokenNoArgs, tr } from '../../localization/localeTools'; import { getAppDispatch } from '../dispatch'; import { sleepFor } from '../../session/utils/Promise'; @@ -66,28 +66,35 @@ export function proAccessVariantToString(variant: ProAccessVariant): string { /** * Per-provider display strings. Client-owned i18n now (Delta #10) — 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). (These proProvider* tokens are a temporary stopgap injected - * into the generated localization; they're wiped by the next Crowdin sync, so upstream them first.) + * 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; - store_other: string; }; +/** + * 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 { - const isApple = provider === ProPaymentProvider.AppStore; return { - store: isApple ? tr('proProviderAppleStore') : tr('proProviderGoogleStore'), - platform: isApple ? tr('proProviderApplePlatform') : tr('proProviderGooglePlatform'), - device: isApple ? tr('proProviderAppleDevice') : tr('proProviderGoogleDevice'), - platform_account: isApple ? tr('proProviderAppleAccount') : tr('proProviderGoogleAccount'), - // "the other store": for Apple show Google's, and vice-versa. - store_other: isApple ? tr('proProviderGoogleStore') : tr('proProviderAppleStore'), + store: providerDisplay(provider, 'store'), + platform: providerDisplay(provider, 'platform'), + device: providerDisplay(provider, 'device'), + platform_account: providerDisplay(provider, 'account'), }; } diff --git a/ts/webworker/workers/browser/libsession_worker_interface.ts b/ts/webworker/workers/browser/libsession_worker_interface.ts index ba079e344c..f188268675 100644 --- a/ts/webworker/workers/browser/libsession_worker_interface.ts +++ b/ts/webworker/workers/browser/libsession_worker_interface.ts @@ -809,6 +809,10 @@ export const ProWrapperActions: ProActionsCalls = { callLibSessionWorker(['Pro', 'providerUrls', first]) as Promise< ReturnType >, + visiblePlatforms: async () => + callLibSessionWorker(['Pro', 'visiblePlatforms']) as Promise< + ReturnType + >, }; export const allKnownEncryptionDomains: Array = ['SessionGroupKickedMessage']; From f8c23d85501419b1b4e34c64c530ca99d4a27288 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Tue, 21 Jul 2026 21:55:52 -0300 Subject: [PATCH 08/24] Pro: rename genIndexHash -> revocationTag (match libsession revocation_tag; no migration, Pro unshipped) Co-Authored-By: Claude Opus 4.8 (1M context) --- ts/components/dialog/debug/FeatureFlags.tsx | 2 +- ts/models/conversation.ts | 12 +++++----- ts/models/conversationAttributes.ts | 4 ++-- ts/models/profile.ts | 22 +++++++++---------- ts/node/database_utility.ts | 8 +++---- ts/node/migration/helpers/v31.ts | 2 +- ts/node/migration/sessionMigrations.ts | 2 +- ts/node/sql.ts | 4 ++-- ts/receiver/types.ts | 2 +- ts/session/apis/pro_backend_api/schemas.ts | 2 +- .../revocation_list/pro_revocation_list.ts | 4 ++-- .../job_runners/jobs/AvatarMigrateJob.ts | 4 ++-- .../jobs/UpdateProRevocationListJob.ts | 14 ++++++------ .../libsession_utils_convo_info_volatile.ts | 2 +- ts/state/ducks/user.ts | 2 +- .../libsession_wrapper_user_profile_test.ts | 4 ++-- .../unit/models/ConversationModels_test.ts | 12 +++++----- .../unit/pro/pro_revocation_cache_test.ts | 14 ++++++------ ts/types/message/OutgoingProMessageDetails.ts | 4 ++-- 19 files changed, 60 insertions(+), 60 deletions(-) diff --git a/ts/components/dialog/debug/FeatureFlags.tsx b/ts/components/dialog/debug/FeatureFlags.tsx index 868792446a..2e8d152d91 100644 --- a/ts/components/dialog/debug/FeatureFlags.tsx +++ b/ts/components/dialog/debug/FeatureFlags.tsx @@ -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() ?? '' 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..1bb68c2526 100644 --- a/ts/node/migration/sessionMigrations.ts +++ b/ts/node/migration/sessionMigrations.ts @@ -2227,7 +2227,7 @@ async function updateToSessionSchemaVersion50(currentVersion: number, db: Databa db.transaction(() => { db.exec(` ALTER TABLE ${CONVERSATIONS_TABLE} ADD COLUMN bitsetProFeatures TEXT; - ALTER TABLE ${CONVERSATIONS_TABLE} ADD COLUMN proGenIndexHashB64 TEXT; + ALTER TABLE ${CONVERSATIONS_TABLE} ADD COLUMN proRevocationTagB64 TEXT; ALTER TABLE ${CONVERSATIONS_TABLE} ADD COLUMN proExpiryTsMs INTEGER; `); 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/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/schemas.ts b/ts/session/apis/pro_backend_api/schemas.ts index 17b239fc7d..4c8313bbe0 100644 --- a/ts/session/apis/pro_backend_api/schemas.ts +++ b/ts/session/apis/pro_backend_api/schemas.ts @@ -21,7 +21,7 @@ export type ProPaymentItemType = ProPaymentItem; // 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({ - genIndexHashB64: z.string(), + revocationTagB64: z.string(), effectiveMs: z.number(), }); export const ProRevocationItemsDBSchema = z.array(ProRevocationItemDBSchema); diff --git a/ts/session/revocation_list/pro_revocation_list.ts b/ts/session/revocation_list/pro_revocation_list.ts index 9c878c2c26..7dbe8d2664 100644 --- a/ts/session/revocation_list/pro_revocation_list.ts +++ b/ts/session/revocation_list/pro_revocation_list.ts @@ -103,10 +103,10 @@ function clear() { * That is, if the hash is present in the revocation list, * 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.genIndexHashB64 === genIndexHashBase64); + const found = cachedProRevocationListItems.find(m => m.revocationTagB64 === revocationTagBase64); return !!found && NetworkTime.now() >= found.effectiveMs; } 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 4e0a15ae9f..142371616a 100644 --- a/ts/session/utils/job_runners/jobs/UpdateProRevocationListJob.ts +++ b/ts/session/utils/job_runners/jobs/UpdateProRevocationListJob.ts @@ -132,33 +132,33 @@ 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_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/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/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 80386d6ce5..3475201736 100644 --- a/ts/test/session/unit/pro/pro_revocation_cache_test.ts +++ b/ts/test/session/unit/pro/pro_revocation_cache_test.ts @@ -8,15 +8,15 @@ import { DURATION } from '../../../../session/constants'; const validItemEffective = { effectiveMs: Date.now() - 2 * DURATION.DAYS, // this one is effective already - genIndexHashB64: 'QJGEFg4+FQkDzqeoWW5ghObbGB0IR/TTs4ve1MHzL9I=', + revocationTagB64: 'QJGEFg4+FQkDzqeoWW5ghObbGB0IR/TTs4ve1MHzL9I=', }; const validItemDelayed = { effectiveMs: Date.now() + 2 * DURATION.DAYS, // this one is delayed (not effective yet) - genIndexHashB64: 'b2ArHxhrhbSrV6/aVqOF5RYG55l74doHcB935pZyFxo=', + revocationTagB64: 'b2ArHxhrhbSrV6/aVqOF5RYG55l74doHcB935pZyFxo=', }; const validItemExpired = { effectiveMs: Date.now() - 6 * DURATION.DAYS, // this one is effective - genIndexHashB64: 'dfL3b6/G//0jNGOZDwOGVY4CWUOjoNkoR5tDTGeJtI4=', + revocationTagB64: 'dfL3b6/G//0jNGOZDwOGVY4CWUOjoNkoR5tDTGeJtI4=', }; // loadFromDbIfNeeded makes a few setup calls to get/put/remove, so we need to reset them @@ -122,7 +122,7 @@ describe('ProRevocationCache', () => { await ProRevocationCache.setListItems([validItemEffective]); expect( - ProRevocationCache.isB64HashEffectivelyRevoked(validItemEffective.genIndexHashB64) + ProRevocationCache.isB64HashEffectivelyRevoked(validItemEffective.revocationTagB64) ).to.eq(true); }); it('non-present hash returns false', async () => { @@ -131,7 +131,7 @@ describe('ProRevocationCache', () => { await ProRevocationCache.setListItems([validItemEffective]); expect( - ProRevocationCache.isB64HashEffectivelyRevoked(validItemDelayed.genIndexHashB64) + ProRevocationCache.isB64HashEffectivelyRevoked(validItemDelayed.revocationTagB64) ).to.eq(false); }); it('present but non effective hash returns false', async () => { @@ -140,7 +140,7 @@ describe('ProRevocationCache', () => { await ProRevocationCache.setListItems([validItemDelayed]); expect( - ProRevocationCache.isB64HashEffectivelyRevoked(validItemDelayed.genIndexHashB64) + ProRevocationCache.isB64HashEffectivelyRevoked(validItemDelayed.revocationTagB64) ).to.eq(false); }); @@ -150,7 +150,7 @@ describe('ProRevocationCache', () => { await ProRevocationCache.setListItems([validItemExpired]); expect( - ProRevocationCache.isB64HashEffectivelyRevoked(validItemExpired.genIndexHashB64) + ProRevocationCache.isB64HashEffectivelyRevoked(validItemExpired.revocationTagB64) ).to.eq(true); }); }); diff --git a/ts/types/message/OutgoingProMessageDetails.ts b/ts/types/message/OutgoingProMessageDetails.ts index 69a4fe6b27..651f91b1dd 100644 --- a/ts/types/message/OutgoingProMessageDetails.ts +++ b/ts/types/message/OutgoingProMessageDetails.ts @@ -58,7 +58,7 @@ export class OutgoingProMessageDetails { } return null; } - const { expiryMs, genIndexHashB64, rotatingPubkeyHex, signatureHex, version } = + const { expiryMs, revocationTagB64, rotatingPubkeyHex, signatureHex, version } = this.proConfig.proProof; return new SignalService.ProMessage({ @@ -66,7 +66,7 @@ export class OutgoingProMessageDetails { messageBitset: bigIntToLong(this.proMessageBitset), proof: { expireAtMs: expiryMs, - genIndexHash: from_base64(genIndexHashB64, base64_variants.ORIGINAL), + genIndexHash: from_base64(revocationTagB64, base64_variants.ORIGINAL), rotatingPublicKey: from_hex(rotatingPubkeyHex), version, sig: from_hex(signatureHex), From 94e0f0d466ac9f61488140262ded5fb2e8451c77 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Tue, 21 Jul 2026 22:43:33 -0300 Subject: [PATCH 09/24] Pro: send proof expiry as whole seconds on the wire (was milliseconds) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ProProof expiry travels on the wire as whole seconds — that is what the Session Pro backend signs over and what libsession parses via `as_sys_seconds` (session_protocol.cpp). Our JS domain keeps proof timestamps in milliseconds (the nodejs wrapper multiplies by 1000 on the way in), so the outgoing proof proto was serialising a millisecond value into a seconds field, ~1000x too large. Any receiver reconstructs `expiry_at = as_sys_seconds()` and the proof signature check fails. Convert back to seconds at the proto boundary and rename the field `expireAtMs` -> `expiryUnixTs` (field number 4 unchanged, wire-compatible) to match the canonical libsession proto and the android/iOS copies, and to stop the name lying about the unit. Co-Authored-By: Claude Opus 4.8 (1M context) --- protos/SignalService.proto | 4 ++-- ts/types/message/OutgoingProMessageDetails.ts | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) 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/types/message/OutgoingProMessageDetails.ts b/ts/types/message/OutgoingProMessageDetails.ts index 651f91b1dd..486c004026 100644 --- a/ts/types/message/OutgoingProMessageDetails.ts +++ b/ts/types/message/OutgoingProMessageDetails.ts @@ -65,8 +65,10 @@ export class OutgoingProMessageDetails { profileBitset: bigIntToLong(this.proProfileBitset), messageBitset: bigIntToLong(this.proMessageBitset), proof: { - expireAtMs: expiryMs, - genIndexHash: from_base64(revocationTagB64, 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), From 0cb446a12f936c96c97fc0061f2d03a6d8fab4fe Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Tue, 21 Jul 2026 23:20:54 -0300 Subject: [PATCH 10/24] Pro: migrate proGenIndexHashB64 column via a new v57, not by editing v50 The genIndexHash->revocationTag rename edited migration v50 in place, changing the column it adds from `proGenIndexHashB64` to `proRevocationTagB64`. Existing installs already ran v50 and so still have `proGenIndexHashB64`; editing v50 does not re-run for them, and the app now reads `proRevocationTagB64` -> "SQLITE_ERROR: no such column". (Pro is dormant but its schema is built into shipped clients, so v50 has been applied in the wild.) Restore v50 to its shipped form (`proGenIndexHashB64`) and add a new schema version 57 that renames the column in place, preserving data. The rename is guarded on the actual columns present so it is idempotent for internal builds that already ran the edited v50 (new column already present) or somehow have neither column. Co-Authored-By: Claude Opus 4.8 (1M context) --- ts/node/migration/sessionMigrations.ts | 36 +++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/ts/node/migration/sessionMigrations.ts b/ts/node/migration/sessionMigrations.ts index 1bb68c2526..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) { @@ -2227,7 +2228,7 @@ async function updateToSessionSchemaVersion50(currentVersion: number, db: Databa db.transaction(() => { db.exec(` ALTER TABLE ${CONVERSATIONS_TABLE} ADD COLUMN bitsetProFeatures TEXT; - ALTER TABLE ${CONVERSATIONS_TABLE} ADD COLUMN proRevocationTagB64 TEXT; + ALTER TABLE ${CONVERSATIONS_TABLE} ADD COLUMN proGenIndexHashB64 TEXT; ALTER TABLE ${CONVERSATIONS_TABLE} ADD COLUMN proExpiryTsMs INTEGER; `); @@ -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}');`)); } From 3c8516c9263dfbef7a61cdf2d7f61f8d3dc4a97b Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 22 Jul 2026 12:54:11 -0300 Subject: [PATCH 11/24] Pro: remove the unused request-state `t` field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pro RequestState carried a `t` timestamp copied from the shared shape used by networkData (where `t` is a real server-provided marker read via getInfoTimestamp). For the pro backend it was never populated by the server, and after the raw-relay refactor there's no `response.t` at all — it was just a local "fetched at" stamp that nothing downstream reads (verified: every consumer destructures only `data`). Rather than keep a dead field that had already drifted to the wrong unit (ms vs the seconds it should be), drop it from RequestState, defaultRequestState, the fetch results, and ProcessedProDetails. Co-Authored-By: Claude Opus 4.8 (1M context) --- ts/state/ducks/proBackendData.ts | 4 ---- ts/state/selectors/proBackendData.ts | 3 --- 2 files changed, 7 deletions(-) diff --git a/ts/state/ducks/proBackendData.ts b/ts/state/ducks/proBackendData.ts index d011884336..8aa7570d8a 100644 --- a/ts/state/ducks/proBackendData.ts +++ b/ts/state/ducks/proBackendData.ts @@ -31,7 +31,6 @@ type RequestState = { // True if the request has been made isEnabled: boolean; error: string | null; - t: number; data: D | null; }; @@ -41,7 +40,6 @@ const defaultRequestState = { isError: false, isEnabled: false, error: null, - t: 0, data: null, } satisfies RequestState; @@ -142,7 +140,6 @@ async function createProBackendFetchAsyncThunk< isError: !!error, isFetching: false, isLoading: false, - t: NetworkTime.now(), isEnabled: true, }; } catch (e) { @@ -153,7 +150,6 @@ async function createProBackendFetchAsyncThunk< isError: true, isFetching: false, isLoading: false, - t: 0, isEnabled: true, }; } diff --git a/ts/state/selectors/proBackendData.ts b/ts/state/selectors/proBackendData.ts index f7f69b76f1..0539c7c45b 100644 --- a/ts/state/selectors/proBackendData.ts +++ b/ts/state/selectors/proBackendData.ts @@ -176,7 +176,6 @@ export type ProcessedProDetails = { isFetching: boolean; isError: boolean; data: ProAccessDetails; - t: number; }; function processProBackendData({ @@ -184,7 +183,6 @@ function processProBackendData({ isFetching: _isFetching, isError: _isError, data, - t, }: ProBackendDataState['details']): ProcessedProDetails { const mockIsLoading = getFeatureFlag('mockProBackendLoading'); const mockIsError = getFeatureFlag('mockProBackendError'); @@ -263,7 +261,6 @@ function processProBackendData({ isLoading, isFetching, isError, - t, }; } From 844c6d58d0aba1af9ea9f70036adb11a4e1e9608 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 22 Jul 2026 13:18:00 -0300 Subject: [PATCH 12/24] Pro: don't clear the (synced) proof on an unknown backend status The get-details handler cleared the pro proof for any non-active userStatus, including the `default` (unknown/future slug) case. handleClearProProof() writes the SYNCED user config (removeProConfig + setProAccessExpiry(null)), so an older client that doesn't recognise a new status slug would erase a still-valid proof and propagate that deletion to all the user's devices. Make the unknown case non-destructive: log and leave the proof. Entitlement is governed by the proof's own signature + expiry (hasValidCurrentProProof); a genuinely-lapsed account simply won't get its proof refreshed (or gets revoked). Explicit Expired/NeverBeenPro still clear as before. Co-Authored-By: Claude Opus 4.8 (1M context) --- ts/state/ducks/proBackendData.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/ts/state/ducks/proBackendData.ts b/ts/state/ducks/proBackendData.ts index 8aa7570d8a..bc3a7b8ecc 100644 --- a/ts/state/ducks/proBackendData.ts +++ b/ts/state/ducks/proBackendData.ts @@ -371,12 +371,15 @@ const fetchGetProDetailsFromProBackend = createAsyncThunk( break; default: - // Status is now an opaque slug — an unknown/future value must not hard-fail; treat it - // like "no active pro" and clear any stale proof. + // 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}` + `[handleBackendProStatusChange] unknown pro userStatus: ${state.data.userStatus}; leaving proof untouched` ); - await handleClearProProof(); break; } await handleExpiryCTAs( From 65cf2b3f3642e17214e0a62cb08d844d4b931ba0 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 22 Jul 2026 13:50:20 -0300 Subject: [PATCH 13/24] Pro: guard openProviderUrl against an empty provider URL openProviderUrl only checked that the providerUrls object was non-null before opening the link-visit warning dialog. A provider may expose only some URLs, and libsession returns an empty string for an absent one, so picking (e.g.) cancelSubscriptionUrl could hand the dialog '' and open it pointing at nothing. Guard on the picked URL, not just the container. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../pages/user-pro/ProNonOriginatingPage.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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 4b07c945a7..017f2d4192 100644 --- a/ts/components/dialog/user-settings/pages/user-pro/ProNonOriginatingPage.tsx +++ b/ts/components/dialog/user-settings/pages/user-pro/ProNonOriginatingPage.tsx @@ -47,8 +47,14 @@ async function openProviderUrl( dispatch: Parameters[1] ) { const urls = await ProWrapperActions.providerUrls({ code: provider }); - if (urls) { - showLinkVisitWarningDialog(pick(urls), dispatch); + 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; From 5e205e78af2ba297ce6fd9f8ff8d4de2c50719d0 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 22 Jul 2026 15:27:48 -0300 Subject: [PATCH 14/24] Pro: version the persisted get-details cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getProDetailsFromStorage read the persisted libsession struct back with only a top-level object/items[] check and cast it to the current ProDetailsResultType. That meant a future libsession change adding a required field couldn't be made safely: an older client's cache lacks the field but the type assumes it. (No live crash today — processProBackendData reads every field defensively and the cache is re-fetched next poll — but a real forward-compat trap.) Wrap the cache as { version, data } and drop it on a version mismatch, so a stale-shape cache is re-fetched rather than mis-read. Bumping PRO_DETAILS_CACHE_VERSION on any shape change now makes adding a required field safe. The pre-versioning format has no version and is dropped the same way. Co-Authored-By: Claude Opus 4.8 (1M context) --- ts/state/ducks/proBackendData.ts | 9 ++++++++- ts/state/selectors/proBackendData.ts | 24 ++++++++++++++++-------- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/ts/state/ducks/proBackendData.ts b/ts/state/ducks/proBackendData.ts index bc3a7b8ecc..a6ea3ed95e 100644 --- a/ts/state/ducks/proBackendData.ts +++ b/ts/state/ducks/proBackendData.ts @@ -165,8 +165,15 @@ async function createProBackendFetchAsyncThunk< return result; } +/** + * Bump whenever the persisted {@link ProDetailsResultType} shape changes (e.g. libsession adds a new + * required field). getProDetailsFromStorage drops a cache whose version doesn't match, so an older + * client's stale-shape cache is re-fetched rather than mis-read against the current type. + */ +export const PRO_DETAILS_CACHE_VERSION = 1; + async function putProDetailsInStorage(details: ProDetailsResultType) { - await Storage.put(SettingsKey.proDetails, details); + await Storage.put(SettingsKey.proDetails, { version: PRO_DETAILS_CACHE_VERSION, data: details }); } async function handleNewProProof(rotatingPrivKeyHex: string): Promise { diff --git a/ts/state/selectors/proBackendData.ts b/ts/state/selectors/proBackendData.ts index 0539c7c45b..953b2d1c51 100644 --- a/ts/state/selectors/proBackendData.ts +++ b/ts/state/selectors/proBackendData.ts @@ -2,6 +2,7 @@ import { useSelector } from 'react-redux'; import type { StateType } from '../reducer'; import { proBackendDataActions, + PRO_DETAILS_CACHE_VERSION, RequestActionArgs, WithCallerContext, type ProBackendDataState, @@ -34,18 +35,25 @@ const getProBackendData = (state: StateType): ProBackendDataState => { }; function getProDetailsFromStorage(): ProDetailsResultType | null { - const response = Storage.get(SettingsKey.proDetails); - if (!response) { + const stored = Storage.get(SettingsKey.proDetails) as + | { version?: number; data?: ProDetailsResultType } + | null + | undefined; + if (!stored) { return null; } - // We persist the libsession-parsed struct verbatim (see putProDetailsInStorage). There's no hand-rolled - // schema to validate against anymore, so trust a well-formed object and drop anything obviously wrong - // (it's re-fetched on the next poll). - if (typeof response === 'object' && Array.isArray((response as ProDetailsResultType).items)) { - return response as ProDetailsResultType; + // We persist the libsession-parsed struct verbatim (see putProDetailsInStorage), wrapped with a + // version. There's no hand-rolled schema to validate against, so we only drop a cache whose version + // doesn't match the current shape (an older client's stale shape — e.g. missing a newly-required + // field — must not be mis-read against the current type) or that is obviously malformed. Anything + // dropped is re-fetched on the next poll. (The pre-versioning format has no `version` and is dropped + // here too.) + const data = stored.version === PRO_DETAILS_CACHE_VERSION ? stored.data : undefined; + if (data && typeof data === 'object' && Array.isArray(data.items)) { + return data; } void Storage.remove(SettingsKey.proDetails); - window?.log?.error('pro details in storage were malformed; removing.'); + window?.log?.error('pro details in storage were stale/malformed; removing.'); return null; } From 5357c13348079f48c484f646ec0eb5b5d00ecde5 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 22 Jul 2026 15:52:48 -0300 Subject: [PATCH 15/24] Pro: leave a transition reminder for the persisted get-details cache (no versioning) Reverts the preemptive cache-versioning: session-desktop and libsession-util-nodejs ship in lockstep, so within a build the glue-produced shape, the type, and this consumer can't drift. The only cross-version exposure is a cache written by an older build, and that is owned by whatever future change adds a required field (handle the cache then, like a migration). Pre-building versioning now would just be guessing at the future shape. Instead, leave a clear reminder at getProDetailsFromStorage (and putProDetailsInStorage) that adding a REQUIRED field to the persisted shape needs a transition here. Co-Authored-By: Claude Opus 4.8 (1M context) --- ts/state/ducks/proBackendData.ts | 12 ++++------ ts/state/selectors/proBackendData.ts | 33 ++++++++++++++-------------- 2 files changed, 21 insertions(+), 24 deletions(-) diff --git a/ts/state/ducks/proBackendData.ts b/ts/state/ducks/proBackendData.ts index a6ea3ed95e..a3864cacef 100644 --- a/ts/state/ducks/proBackendData.ts +++ b/ts/state/ducks/proBackendData.ts @@ -165,15 +165,11 @@ async function createProBackendFetchAsyncThunk< return result; } -/** - * Bump whenever the persisted {@link ProDetailsResultType} shape changes (e.g. libsession adds a new - * required field). getProDetailsFromStorage drops a cache whose version doesn't match, so an older - * client's stale-shape cache is re-fetched rather than mis-read against the current type. - */ -export const PRO_DETAILS_CACHE_VERSION = 1; - async function putProDetailsInStorage(details: ProDetailsResultType) { - await Storage.put(SettingsKey.proDetails, { version: PRO_DETAILS_CACHE_VERSION, data: details }); + // We persist, verbatim, the JS object the libsession-util-nodejs glue produced from the backend + // response (not a raw libsession struct). See getProDetailsFromStorage for the transition reminder + // that applies before adding any REQUIRED field to this shape. + await Storage.put(SettingsKey.proDetails, details); } async function handleNewProProof(rotatingPrivKeyHex: string): Promise { diff --git a/ts/state/selectors/proBackendData.ts b/ts/state/selectors/proBackendData.ts index 953b2d1c51..29002c44e0 100644 --- a/ts/state/selectors/proBackendData.ts +++ b/ts/state/selectors/proBackendData.ts @@ -2,7 +2,6 @@ import { useSelector } from 'react-redux'; import type { StateType } from '../reducer'; import { proBackendDataActions, - PRO_DETAILS_CACHE_VERSION, RequestActionArgs, WithCallerContext, type ProBackendDataState, @@ -35,25 +34,27 @@ const getProBackendData = (state: StateType): ProBackendDataState => { }; function getProDetailsFromStorage(): ProDetailsResultType | null { - const stored = Storage.get(SettingsKey.proDetails) as - | { version?: number; data?: ProDetailsResultType } - | null - | undefined; - if (!stored) { + const response = Storage.get(SettingsKey.proDetails); + if (!response) { return null; } - // We persist the libsession-parsed struct verbatim (see putProDetailsInStorage), wrapped with a - // version. There's no hand-rolled schema to validate against, so we only drop a cache whose version - // doesn't match the current shape (an older client's stale shape — e.g. missing a newly-required - // field — must not be mis-read against the current type) or that is obviously malformed. Anything - // dropped is re-fetched on the next poll. (The pre-versioning format has no `version` and is dropped - // here too.) - const data = stored.version === PRO_DETAILS_CACHE_VERSION ? stored.data : undefined; - if (data && typeof data === 'object' && Array.isArray(data.items)) { - return data; + // We persist, verbatim, the JS object the libsession-util-nodejs glue produced from the backend + // response (see putProDetailsInStorage) — not a raw libsession struct — and cast it back to the + // CURRENT ProDetailsResultType (an alias of the glue's GetProDetailsResponse). 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' && Array.isArray((response as ProDetailsResultType).items)) { + return response as ProDetailsResultType; } void Storage.remove(SettingsKey.proDetails); - window?.log?.error('pro details in storage were stale/malformed; removing.'); + window?.log?.error('pro details in storage were malformed; removing.'); return null; } From f6e1da30c7aad56272349d6c06869ac773074756 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 22 Jul 2026 17:08:28 -0300 Subject: [PATCH 16/24] Pro: remove hardcoded dev-backend URL/key copies; fail fast instead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DEV pro-backend target hard-copied a URL + Ed25519/X25519 pubkeys that were byte-identical to libsession's own pro_backend constants — duplicated single- source data that shouldn't exist in the client (the production DEFAULT target already reads them from libsession). There is no distinct dev backend today (and a future one likely can't carry full signing keys), so rather than keep stale copies or silently fall back to the default backend, the DEV target now holds non-functional placeholders and getServer() throws a clear error if `useTestProBackend` is enabled. Wire real values in — sourced from libsession — if/when a dev backend exists. Co-Authored-By: Claude Opus 4.8 (1M context) --- ts/session/apis/pro_backend_api/ProBackendAPI.ts | 8 ++++++-- .../apis/pro_backend_api/ProBackendTarget.ts | 15 +++++++++------ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/ts/session/apis/pro_backend_api/ProBackendAPI.ts b/ts/session/apis/pro_backend_api/ProBackendAPI.ts index ea554bcfaa..a2a7351e0a 100644 --- a/ts/session/apis/pro_backend_api/ProBackendAPI.ts +++ b/ts/session/apis/pro_backend_api/ProBackendAPI.ts @@ -17,10 +17,14 @@ 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 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; } /** diff --git a/ts/session/apis/pro_backend_api/ProBackendTarget.ts b/ts/session/apis/pro_backend_api/ProBackendTarget.ts index 377eca901b..f4379a155c 100644 --- a/ts/session/apis/pro_backend_api/ProBackendTarget.ts +++ b/ts/session/apis/pro_backend_api/ProBackendTarget.ts @@ -3,9 +3,11 @@ 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', @@ -20,10 +22,11 @@ const PRO_BACKENDS: Record< }, 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: '', }, }; From 4d8ffe753846a896167b1db5bbcbdcac17ccf191 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 22 Jul 2026 19:01:06 -0300 Subject: [PATCH 17/24] Pro: clarify the isSimpleTokenNoArgs existence check + trim doc comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reviewer flagged isSimpleTokenNoArgs. It's the correct check — pro_error_* strings take no args, so membership is exactly "does a translation exist for this slug." Say that at the check itself, and drop the docstring's unrelated cross-references/backstory. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../apis/pro_backend_api/proErrorMessage.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/ts/session/apis/pro_backend_api/proErrorMessage.ts b/ts/session/apis/pro_backend_api/proErrorMessage.ts index 677cc5ff3c..fcc0a71c75 100644 --- a/ts/session/apis/pro_backend_api/proErrorMessage.ts +++ b/ts/session/apis/pro_backend_api/proErrorMessage.ts @@ -1,16 +1,9 @@ import { isSimpleTokenNoArgs, tr } from '../../../localization/localeTools'; /** - * Resolve a failed Pro backend request to a user-facing message. - * - * The backend sends an open-ended `errorCode` slug (wire spec §5.1) plus an English diagnostic - * `error`. We prefer a localized `pro_error_` string when one exists (so a brand-new slug needs - * only a translation entry — no code change), and fall back to the backend diagnostic, then a generic - * message. - * - * Unlike android/iOS, no brand-token ({pro}/{app_pro}/…) handling is needed here: the shared-scripts - * generator bakes brand constants into the desktop strings at build time, so a `pro_error_*` string is - * already fully substituted by the time it reaches us. + * 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, @@ -18,7 +11,8 @@ export function proErrorMessage( ): string { if (errorCode) { const key = `pro_error_${errorCode}`; - // Base-English existence check; tr() then resolves the current locale, falling back to English. + // 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); } From a3ad3940cabf859c00e34a505f72d2653b7fdab7 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 22 Jul 2026 19:26:17 -0300 Subject: [PATCH 18/24] Pro: rename ProStatus.NeverBeenPro -> Never The account-status enum lives in a Pro-only namespace and the wire value is already "never"; NeverBeenPro was a redundant/awkward member name. Identifier-only rename, value unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- ts/components/dialog/debug/FeatureFlags.tsx | 6 +++--- ts/hooks/useHasPro.ts | 2 +- ts/session/apis/pro_backend_api/types.ts | 2 +- ts/state/ducks/proBackendData.ts | 4 ++-- ts/state/selectors/proBackendData.ts | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/ts/components/dialog/debug/FeatureFlags.tsx b/ts/components/dialog/debug/FeatureFlags.tsx index 2e8d152d91..b637d5c41c 100644 --- a/ts/components/dialog/debug/FeatureFlags.tsx +++ b/ts/components/dialog/debug/FeatureFlags.tsx @@ -980,7 +980,7 @@ export const ProDebugSection = ({ label="Current Status" flag="mockProCurrentStatus" options={[ - { label: 'Never Had Pro', value: ProStatus.NeverBeenPro }, + { label: 'Never Had Pro', value: ProStatus.Never }, { label: 'Active', value: ProStatus.Active }, { label: 'Expired', value: ProStatus.Expired }, ]} @@ -1004,7 +1004,7 @@ export const ProDebugSection = ({ visibleWithBooleanFlag="proAvailable" visibleWithEnumFlag={{ flag: 'mockProCurrentStatus', - isVisible: v => v !== ProStatus.NeverBeenPro, + isVisible: v => v !== ProStatus.Never, }} /> v !== ProStatus.NeverBeenPro, + isVisible: v => v !== ProStatus.Never, }} /> Date: Wed, 22 Jul 2026 22:29:34 -0300 Subject: [PATCH 19/24] Pro: desktop app consumes get_pro_status (was get_pro_details) Wire the desktop app to the split endpoint (libsession Delta #15, glue b9f5893): - ProBackendAPI.getProDetails -> getProStatus; proStatusRequest drops count; parsePaymentDetailsResponse -> parseProStatusResponse; return ProStatusResultType (fixes a pre-existing broken import of the never-exported GetProDetailsResponseType). - schemas: GetProDetailsResponse -> GetProStatusResponse, ProDetailsResultType -> ProStatusResultType. - selector: items[0] -> latestPayment; storage shape-guard keys off userStatus (was Array.isArray(items)); plan display now derives from {planCount, planUnit} via planPeriodToString (minimal English; Crowdin-localized version is separate). - worker interface + storage + debug menu renamed accordingly. tsc clean. Localized plan strings and the remaining internal *ProDetails* identifier purge are follow-ups. Co-Authored-By: Claude Opus 4.8 (1M context) --- ts/components/dialog/debug/FeatureFlags.tsx | 2 +- .../apis/pro_backend_api/ProBackendAPI.ts | 8 ++-- ts/session/apis/pro_backend_api/schemas.ts | 4 +- ts/state/ducks/proBackendData.ts | 14 +++--- ts/state/selectors/proBackendData.ts | 44 ++++++++++++++----- ts/util/storage.ts | 4 +- .../browser/libsession_worker_interface.ts | 6 +-- 7 files changed, 52 insertions(+), 30 deletions(-) diff --git a/ts/components/dialog/debug/FeatureFlags.tsx b/ts/components/dialog/debug/FeatureFlags.tsx index b637d5c41c..46457d282f 100644 --- a/ts/components/dialog/debug/FeatureFlags.tsx +++ b/ts/components/dialog/debug/FeatureFlags.tsx @@ -938,7 +938,7 @@ 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); } diff --git a/ts/session/apis/pro_backend_api/ProBackendAPI.ts b/ts/session/apis/pro_backend_api/ProBackendAPI.ts index a2a7351e0a..a2327d56f0 100644 --- a/ts/session/apis/pro_backend_api/ProBackendAPI.ts +++ b/ts/session/apis/pro_backend_api/ProBackendAPI.ts @@ -8,8 +8,8 @@ import { PRO_API } from './ProBackendTarget'; import SessionBackendServerApi from '../session_backend_server'; import type { GenerateProProofResponseType, - GetProDetailsResponseType, GetProRevocationsResponseType, + ProStatusResultType, } from './schemas'; import { ProWrapperActions } from '../../../webworker/workers/browser/libsession_worker_interface'; import { NetworkTime } from '../../../util/NetworkTime'; @@ -63,15 +63,13 @@ export default class ProBackendAPI { ); } - static async getProDetails(args: WithMasterPrivKeyHex): Promise { + static async getProStatus(args: WithMasterPrivKeyHex): Promise { const request = await ProWrapperActions.proStatusRequest({ ...args, unixTsMs: NetworkTime.now(), - // NOTE: the latest payment is the only one required for state derivation - count: 1, }); return ProBackendAPI.sendAndParse(request, body => - ProWrapperActions.parsePaymentDetailsResponse({ body }) + ProWrapperActions.parseProStatusResponse({ body }) ); } diff --git a/ts/session/apis/pro_backend_api/schemas.ts b/ts/session/apis/pro_backend_api/schemas.ts index 4c8313bbe0..13433c61e9 100644 --- a/ts/session/apis/pro_backend_api/schemas.ts +++ b/ts/session/apis/pro_backend_api/schemas.ts @@ -1,7 +1,7 @@ import { z } from '../../../util/zod'; import type { GenerateProProofResponse, - GetProDetailsResponse, + GetProStatusResponse, GetProRevocationsResponse, ProPaymentItem, ProProof, @@ -14,7 +14,7 @@ import type { export type ProProofResultType = ProProof; export type GenerateProProofResponseType = GenerateProProofResponse; export type GetProRevocationsResponseType = GetProRevocationsResponse; -export type ProDetailsResultType = GetProDetailsResponse; +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 diff --git a/ts/state/ducks/proBackendData.ts b/ts/state/ducks/proBackendData.ts index 7b65ef7e97..a0f1b22673 100644 --- a/ts/state/ducks/proBackendData.ts +++ b/ts/state/ducks/proBackendData.ts @@ -10,7 +10,7 @@ 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'; @@ -51,7 +51,7 @@ export type RequestActionArgs = { type ReducerBooleanStateAction = PayloadAction; export type ProBackendDataState = { - details: RequestState; + details: RequestState; }; export const initialProBackendDataState: ProBackendDataState = { @@ -165,9 +165,9 @@ async function createProBackendFetchAsyncThunk< return result; } -async function putProDetailsInStorage(details: ProDetailsResultType) { +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 getProDetailsFromStorage for the transition reminder + // 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.proDetails, details); } @@ -340,10 +340,10 @@ const fetchGetProDetailsFromProBackend = createAsyncThunk( 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) { @@ -398,7 +398,7 @@ const fetchGetProDetailsFromProBackend = createAsyncThunk( } 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(); diff --git a/ts/state/selectors/proBackendData.ts b/ts/state/selectors/proBackendData.ts index c9f4f55644..5116d6657d 100644 --- a/ts/state/selectors/proBackendData.ts +++ b/ts/state/selectors/proBackendData.ts @@ -8,7 +8,7 @@ import { } from '../ducks/proBackendData'; import { SettingsKey } from '../../data/settings-key'; import { Storage } from '../../util/storage'; -import type { ProDetailsResultType } from '../../session/apis/pro_backend_api/schemas'; +import type { ProStatusResultType } from '../../session/apis/pro_backend_api/schemas'; import { getDataFeatureFlag, getFeatureFlag, @@ -33,14 +33,14 @@ const getProBackendData = (state: StateType): ProBackendDataState => { return state.proBackendData; }; -function getProDetailsFromStorage(): ProDetailsResultType | null { +function getProStatusFromStorage(): ProStatusResultType | null { const response = Storage.get(SettingsKey.proDetails); if (!response) { return null; } // We persist, verbatim, the JS object the libsession-util-nodejs glue produced from the backend - // response (see putProDetailsInStorage) — not a raw libsession struct — and cast it back to the - // CURRENT ProDetailsResultType (an alias of the glue's GetProDetailsResponse). session-desktop and + // 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. // @@ -50,8 +50,8 @@ function getProDetailsFromStorage(): ProDetailsResultType | null { // 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' && Array.isArray((response as ProDetailsResultType).items)) { - return response as ProDetailsResultType; + if (typeof response === 'object' && response !== null && 'userStatus' in response) { + return response as ProStatusResultType; } void Storage.remove(SettingsKey.proDetails); window?.log?.error('pro details in storage were malformed; removing.'); @@ -72,6 +72,26 @@ export function proAccessVariantToString(variant: ProAccessVariant): string { } } +/** + * Display string for a parsed plan period {planCount, planUnit} (libsession Delta #14). Minimal English + * for now; the localized/pluralized version is tracked separately. "Lifetime" for the lifetime unit, + * " (s)" otherwise. + */ +export function planPeriodToString( + planCount: number | undefined, + planUnit: string | undefined +): string { + if (!planUnit) { + return 'N/A'; + } + if (planUnit === 'lifetime') { + return 'Lifetime'; + } + const n = planCount ?? 0; + const unit = planUnit.charAt(0).toUpperCase() + planUnit.slice(1); + return `${n} ${unit}${n === 1 ? '' : 's'}`; +} + /** * Per-provider display strings. Client-owned i18n now (Delta #10) — libsession no longer supplies them. * URLs are NOT here: they're only needed for link clicks and are fetched on demand via @@ -223,10 +243,14 @@ function processProBackendData({ const expiryTimeMs = mockExpiry ?? data?.expiryMs ?? defaultProAccessDetailsSourceData.expiryTimeMs; - const latestAccess = data?.items?.[0]; + const latestAccess = data?.latestPayment ?? undefined; const provider = mockPlatform ?? latestAccess?.paymentProvider ?? defaultProAccessDetailsSourceData.provider; - const variant = mockVariant ?? latestAccess?.plan ?? defaultProAccessDetailsSourceData.variant; + 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?.platformRefundExpiryTsMs && @@ -256,7 +280,7 @@ function processProBackendData({ inGracePeriod, isProcessingRefund, variant, - variantString: proAccessVariantToString(variant), + variantString, expiryTimeMs, expiryTimeDateString: formatDateWithLocale({ date: new Date(beginAutoRenew), @@ -275,7 +299,7 @@ function processProBackendData({ export const getProBackendProDetails = (state: StateType): ProcessedProDetails => { const details = getProBackendData(state).details; - const mergedDetails = details.data ? details : { ...details, data: getProDetailsFromStorage() }; + const mergedDetails = details.data ? details : { ...details, data: getProStatusFromStorage() }; return processProBackendData(mergedDetails); }; 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 f188268675..4effeea1e0 100644 --- a/ts/webworker/workers/browser/libsession_worker_interface.ts +++ b/ts/webworker/workers/browser/libsession_worker_interface.ts @@ -801,9 +801,9 @@ export const ProWrapperActions: ProActionsCalls = { callLibSessionWorker(['Pro', 'parseRevocationsResponse', first]) as Promise< ReturnType >, - parsePaymentDetailsResponse: async first => - callLibSessionWorker(['Pro', 'parsePaymentDetailsResponse', first]) as Promise< - ReturnType + parseProStatusResponse: async first => + callLibSessionWorker(['Pro', 'parseProStatusResponse', first]) as Promise< + ReturnType >, providerUrls: async first => callLibSessionWorker(['Pro', 'providerUrls', first]) as Promise< From 0eb631e4cd872ca0d46698b46304cbea4514e98e Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 22 Jul 2026 22:32:20 -0300 Subject: [PATCH 20/24] Pro: purge get_pro_status endpoint naming on desktop (-> ProStatus) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the endpoint-tied identifiers to match the get_pro_status split: ProcessedProDetails->ProcessedProStatus, getProBackendProDetails->getProBackendProStatus, useProBackendProDetails->useProBackendProStatus (+ Internal/Local), fetch/refreshGetProDetailsFromProBackend->...ProStatus..., firstFetchProDetailsHappened. Deliberately NOT touched: the separate per-contact/message pro-proof-metadata concept (ProDetailsContact, processProDetailsForMsg, applyProDetailsChange, dbContactProDetails, emptyProDetails, WithProDetailsContact, the `proDetails` locals/`proDetailsChanged` flag), the SettingsKey.proDetails storage key, and DecodedPro.proStatus — none of those are the endpoint. tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- ts/components/dialog/ProCTADescription.tsx | 4 +-- ts/components/dialog/debug/FeatureFlags.tsx | 6 ++-- .../pages/DefaultSettingsPage.tsx | 4 +-- .../pages/user-pro/ProNonOriginatingPage.tsx | 34 +++++++++---------- .../pages/user-pro/ProSettingsPage.tsx | 14 ++++---- ts/receiver/configMessage.ts | 2 +- .../jobs/UpdateProRevocationListJob.ts | 2 +- ts/state/ducks/proBackendData.ts | 34 +++++++++---------- ts/state/selectors/proBackendData.ts | 16 ++++----- ts/state/startup.ts | 2 +- 10 files changed, 59 insertions(+), 59 deletions(-) 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 46457d282f..ce47ca0ce4 100644 --- a/ts/components/dialog/debug/FeatureFlags.tsx +++ b/ts/components/dialog/debug/FeatureFlags.tsx @@ -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'; @@ -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 () => { @@ -907,7 +907,7 @@ export const ProDebugSection = ({ if (!proAvailable) { return; } - dispatch(proBackendDataActions.refreshGetProDetailsFromProBackend({}) as any); + dispatch(proBackendDataActions.refreshGetProStatusFromProBackend({}) as any); }} > Refresh Pro Details diff --git a/ts/components/dialog/user-settings/pages/DefaultSettingsPage.tsx b/ts/components/dialog/user-settings/pages/DefaultSettingsPage.tsx index 553e53ceff..d5e0f0a2f6 100644 --- a/ts/components/dialog/user-settings/pages/DefaultSettingsPage.tsx +++ b/ts/components/dialog/user-settings/pages/DefaultSettingsPage.tsx @@ -41,7 +41,7 @@ import { APP_URL, DURATION_SECONDS } from '../../../../session/constants'; import { getFeatureFlag } from '../../../../state/ducks/types/releasedFeaturesReduxTypes'; import { useUserSettingsCloseAction } from './userSettingsHooks'; import { - useProBackendProDetails, + useProBackendProStatus, useProBackendRefetch, } from '../../../../state/selectors/proBackendData'; import { focusVisibleBoxShadowOutsetStr } from '../../../../styles/focusVisible'; @@ -330,7 +330,7 @@ const SessionInfo = () => { 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 017f2d4192..d82b2a4f63 100644 --- a/ts/components/dialog/user-settings/pages/user-pro/ProNonOriginatingPage.tsx +++ b/ts/components/dialog/user-settings/pages/user-pro/ProNonOriginatingPage.tsx @@ -25,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'; @@ -34,7 +34,7 @@ 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, @@ -63,7 +63,7 @@ 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']) { +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... @@ -110,7 +110,7 @@ function useProStoresList(): string { } function ProStatusTextUpdate() { - const { data } = useProBackendProDetailsLocal(); + const { data } = useProBackendProStatusLocal(); return data.autoRenew ? ( } @@ -260,7 +260,7 @@ function ProInfoBlockWebsite({ textElement: ReactNode; titleType: 'via' | 'onThe'; }) { - const { data } = useProBackendProDetailsLocal(); + const { data } = useProBackendProStatusLocal(); const storeOrPlatform = useStoreOrPlatformFromProvider(data); return ( @@ -343,7 +343,7 @@ function ProInfoBlockUpgrade() { } function ProInfoBlockUpdate() { - const { data } = useProBackendProDetailsLocal(); + const { data } = useProBackendProStatusLocal(); const storeOrPlatform = useStoreOrPlatformFromProvider(data); return ( @@ -388,7 +388,7 @@ function ProInfoBlockUpdate() { function ProInfoBlockRenew() { const dispatch = getAppDispatch(); - const { data } = useProBackendProDetailsLocal(); + const { data } = useProBackendProStatusLocal(); const storeOrPlatform = useStoreOrPlatformFromProvider(data); const proStores = useProStoresList(); @@ -430,7 +430,7 @@ function ProInfoBlockRenew() { } function ProInfoBlockCancel() { - const { data } = useProBackendProDetailsLocal(); + const { data } = useProBackendProStatusLocal(); const storeOrPlatform = useStoreOrPlatformFromProvider(data); return ( @@ -501,7 +501,7 @@ function ProInfoBlockRefundSessionSupport() { } function ProInfoBlockRefundGooglePlay() { - const { data } = useProBackendProDetailsLocal(); + const { data } = useProBackendProStatusLocal(); return ( @@ -517,7 +517,7 @@ function ProInfoBlockRefundGooglePlay() { } function ProInfoBlockRefundIOS() { - const { data } = useProBackendProDetailsLocal(); + const { data } = useProBackendProStatusLocal(); return ( ; @@ -577,7 +577,7 @@ function ProInfoBlockRefund() { } function ProInfoBlockRefundRequested() { - const { data } = useProBackendProDetailsLocal(); + const { data } = useProBackendProStatusLocal(); const dispatch = getAppDispatch(); return ( @@ -627,7 +627,7 @@ function ProInfoBlock({ variant }: VariantPageProps) { function ProPageButtonUpdate() { const dispatch = getAppDispatch(); - const { data } = useProBackendProDetailsLocal(); + const { data } = useProBackendProStatusLocal(); const storeOrPlatform = useStoreOrPlatformFromProvider(data); return ( @@ -646,7 +646,7 @@ function ProPageButtonUpdate() { function ProPageButtonCancel() { const dispatch = getAppDispatch(); - const { data } = useProBackendProDetailsLocal(); + const { data } = useProBackendProStatusLocal(); const storeOrPlatform = useStoreOrPlatformFromProvider(data); return ( | null = null; @@ -255,7 +255,7 @@ function scheduleRefresh(timestampMs: number) { window?.log?.info(`Scheduling a pro details refresh in ${delay}ms for ${timestampMs}`); return setTimeout(() => { window?.inboxStore?.dispatch( - proBackendDataActions.refreshGetProDetailsFromProBackend({}) as any + proBackendDataActions.refreshGetProStatusFromProBackend({}) as any ); }, delay); } @@ -335,8 +335,8 @@ 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 @@ -391,10 +391,10 @@ const fetchGetProDetailsFromProBackend = createAsyncThunk( 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) { + if (window.inboxStore?.dispatch && !firstFetchProStatusHappened) { void handleTriggeredCTAs(window.inboxStore?.dispatch, false); } - firstFetchProDetailsHappened = true; + firstFetchProStatusHappened = true; } if (state.data) { @@ -445,8 +445,8 @@ const fetchGetProDetailsFromProBackend = createAsyncThunk( } ); -const refreshGetProDetailsFromProBackend = createAsyncThunk( - 'proBackendData/refreshGetProDetails', +const refreshGetProStatusFromProBackend = createAsyncThunk( + 'proBackendData/refreshGetProStatus', async (opts: WithCallerContext = {}, payloadCreator) => { if (!getFeatureFlag('proAvailable')) { return; @@ -454,7 +454,7 @@ const refreshGetProDetailsFromProBackend = createAsyncThunk( if (getFeatureFlag('debugServerRequests')) { window.log.info( - `[proBackend/refreshGetProDetailsFromProBackend] starting ${new Date().toISOString()}` + `[proBackend/refreshGetProStatusFromProBackend] starting ${new Date().toISOString()}` ); } @@ -466,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); } ); @@ -500,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) ); } @@ -520,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/selectors/proBackendData.ts b/ts/state/selectors/proBackendData.ts index 5116d6657d..b59094709f 100644 --- a/ts/state/selectors/proBackendData.ts +++ b/ts/state/selectors/proBackendData.ts @@ -200,7 +200,7 @@ export const defaultProAccessDetailsSourceData = { isError: false, } satisfies ProAccessDetailsSourceData; -export type ProcessedProDetails = { +export type ProcessedProStatus = { isLoading: boolean; isFetching: boolean; isError: boolean; @@ -212,7 +212,7 @@ function processProBackendData({ isFetching: _isFetching, isError: _isError, data, -}: ProBackendDataState['details']): ProcessedProDetails { +}: ProBackendDataState['details']): ProcessedProStatus { const mockIsLoading = getFeatureFlag('mockProBackendLoading'); const mockIsError = getFeatureFlag('mockProBackendError'); @@ -297,7 +297,7 @@ function processProBackendData({ }; } -export const getProBackendProDetails = (state: StateType): ProcessedProDetails => { +export const getProBackendProStatus = (state: StateType): ProcessedProStatus => { const details = getProBackendData(state).details; const mergedDetails = details.data ? details : { ...details, data: getProStatusFromStorage() }; @@ -305,11 +305,11 @@ export const getProBackendProDetails = (state: StateType): ProcessedProDetails = }; 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 = () => { @@ -319,7 +319,7 @@ export const useProBackendCurrentUserStatus = () => { export function useProBackendRefetch() { const dispatch = getAppDispatch(); - const details = useProBackendProDetails(); + const details = useProBackendProStatus(); const mockSuccess = getFeatureFlagMemo('mockProRecoverButtonAlwaysSucceed'); const mockFail = getFeatureFlagMemo('mockProRecoverButtonAlwaysFail'); @@ -364,7 +364,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; From e62d7ea19e72dc7dbf8f6f5aa386dfc728ed395a Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 23 Jul 2026 01:23:07 -0300 Subject: [PATCH 21/24] Render plan period generically from {count, unit} Replace planPeriodToString's hardcoded English with date-fns formatDuration (new formatPlanDuration helper), so any parsed plan period renders localized and pluralized with the unit preserved (12 months stays "12 months", never rewritten to 1 year). "lifetime" isn't a duration: resolve proPlanLifetime if the Crowdin key exists yet, else fall back to English "Lifetime". Co-Authored-By: Claude Opus 4.8 (1M context) --- ts/state/selectors/proBackendData.ts | 24 ++++++++++++++++++------ ts/util/i18n/formatting/generics.ts | 24 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/ts/state/selectors/proBackendData.ts b/ts/state/selectors/proBackendData.ts index b59094709f..a5c4a3a51c 100644 --- a/ts/state/selectors/proBackendData.ts +++ b/ts/state/selectors/proBackendData.ts @@ -18,6 +18,7 @@ import { import { NetworkTime } from '../../util/NetworkTime'; import { formatDateWithLocale, + formatPlanDuration, formatRoundedUpTimeUntilTimestamp, } from '../../util/i18n/formatting/generics'; import { @@ -73,9 +74,10 @@ export function proAccessVariantToString(variant: ProAccessVariant): string { } /** - * Display string for a parsed plan period {planCount, planUnit} (libsession Delta #14). Minimal English - * for now; the localized/pluralized version is tracked separately. "Lifetime" for the lifetime unit, - * " (s)" otherwise. + * Display string for a parsed plan period {planCount, planUnit} (libsession Delta #14). 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, @@ -85,11 +87,21 @@ export function planPeriodToString( return 'N/A'; } if (planUnit === 'lifetime') { - return 'Lifetime'; + const lifetimeToken = 'proPlanLifetime'; + return isSimpleTokenNoArgs(lifetimeToken) ? tr(lifetimeToken) : 'Lifetime'; } const n = planCount ?? 0; - const unit = planUnit.charAt(0).toUpperCase() + planUnit.slice(1); - return `${n} ${unit}${n === 1 ? '' : 's'}`; + 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'; + } } /** diff --git a/ts/util/i18n/formatting/generics.ts b/ts/util/i18n/formatting/generics.ts index 9f3e329c64..6dcc5437ce 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 Delta #14) 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} From b654fa4c39766c725c18b64591d3f17fcbbbb2b4 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 23 Jul 2026 14:51:05 -0300 Subject: [PATCH 22/24] =?UTF-8?q?Drop=20stale=20Delta-N=20spec=20citations?= =?UTF-8?q?;=20reference=20current=20=C2=A7=20sections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wire spec (docs/pro-wire-protocol.md) dropped its Delta-N change-history annotations, so every 'Delta #N' citation was a dangling reference. Reworded each to the current section (envelope -> §5/§5.1/§5.2, plan grammar -> §1, get_pro_status -> §3.4) or dropped the parenthetical where there is no section. Verified all remaining pro-wire-protocol § references resolve. Co-Authored-By: Claude Opus 4.8 (1M context) --- ts/state/ducks/proBackendData.ts | 2 +- ts/state/selectors/proBackendData.ts | 4 ++-- ts/util/i18n/formatting/generics.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ts/state/ducks/proBackendData.ts b/ts/state/ducks/proBackendData.ts index 3a333fb60b..c4e4b42d81 100644 --- a/ts/state/ducks/proBackendData.ts +++ b/ts/state/ducks/proBackendData.ts @@ -61,7 +61,7 @@ export const initialProBackendDataState: ProBackendDataState = { type PayloadCreatorType = Parameters['1']>['1']; // The getter returns a libsession-parsed response struct (or null on transport failure). Every parsed -// struct carries the Delta #12 header: `status` ('ok' on success) + an optional machine `errorCode` slug +// 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 }, diff --git a/ts/state/selectors/proBackendData.ts b/ts/state/selectors/proBackendData.ts index a5c4a3a51c..453770b6d5 100644 --- a/ts/state/selectors/proBackendData.ts +++ b/ts/state/selectors/proBackendData.ts @@ -74,7 +74,7 @@ export function proAccessVariantToString(variant: ProAccessVariant): string { } /** - * Display string for a parsed plan period {planCount, planUnit} (libsession Delta #14). The duration + * 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). @@ -105,7 +105,7 @@ export function planPeriodToString( } /** - * Per-provider display strings. Client-owned i18n now (Delta #10) — libsession no longer supplies them. + * 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 diff --git a/ts/util/i18n/formatting/generics.ts b/ts/util/i18n/formatting/generics.ts index 6dcc5437ce..6fbd1b5877 100644 --- a/ts/util/i18n/formatting/generics.ts +++ b/ts/util/i18n/formatting/generics.ts @@ -166,7 +166,7 @@ export function formatRoundedUpDuration(durationMs: number): string { export type PlanDurationUnit = 'second' | 'day' | 'week' | 'month' | 'year'; /** - * Formats a parsed plan period {count, unit} (libsession Delta #14) into a localized, pluralized + * 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". */ From 70f1658eb004f2e8ba100d80ae700b10738d063b Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 23 Jul 2026 20:03:28 -0300 Subject: [PATCH 23/24] Localizer: allow trusted HTML args via `htmlArgs` opt-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a template contains formatting tags, Localizer HTML-escapes all substitution args so dynamic values can't inject markup. That also escaped the client-built {pro_stores} bulleted list (
• …), rendering it as literal text. Add an opt-in `htmlArgs` prop naming args whose value is trusted, pre-formatted HTML and should skip that escaping. It stays XSS-safe: SessionHtmlRenderer still runs DOMPurify with the supportedFormattingTags whitelist on the final string, so only formatting survives — never scripts or attributes. Wire the three {pro_stores} call sites (ProNonOriginatingPage) to use it. Co-Authored-By: Claude Opus 4.8 (1M context) --- ts/components/basic/Localizer.tsx | 30 +++++++++++++++++-- .../pages/user-pro/ProNonOriginatingPage.tsx | 3 ++ 2 files changed, 31 insertions(+), 2 deletions(-) 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/user-settings/pages/user-pro/ProNonOriginatingPage.tsx b/ts/components/dialog/user-settings/pages/user-pro/ProNonOriginatingPage.tsx index d82b2a4f63..950e262d51 100644 --- a/ts/components/dialog/user-settings/pages/user-pro/ProNonOriginatingPage.tsx +++ b/ts/components/dialog/user-settings/pages/user-pro/ProNonOriginatingPage.tsx @@ -246,6 +246,7 @@ function ProInfoBlockDeviceLinked() { } @@ -328,6 +329,7 @@ function ProInfoBlockUpgrade() { } @@ -399,6 +401,7 @@ function ProInfoBlockRenew() { } From 82031e2be8f5c1a6c84b7b696647d7750e754128 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 23 Jul 2026 20:18:23 -0300 Subject: [PATCH 24/24] Purge remaining "pro details" naming for the get_pro_status result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The get_pro_status backend result was still persisted, surfaced, and logged as "proDetails"/"Pro Details" even though its types were already renamed to ProStatus — e.g. getProStatusFromStorage() read SettingsKey.proDetails. Rename the leftover endpoint-result naming to proStatus: the SettingsKey (constant + value), the Storage put/get/remove, the debug menu buttons ("Get/Refresh Pro Status") and logs, and the refresh comments/logs. The persisted value string changes proDetails -> proStatus; it's a transient, re-fetched cache so any stale entry is simply orphaned and repopulated. Note: the remaining `proDetails`/ProDetailsContact identifiers are a separate concept (a contact's/message's synced pro-proof metadata), not this endpoint. Co-Authored-By: Claude Opus 4.8 (1M context) --- ts/components/dialog/debug/FeatureFlags.tsx | 8 ++++---- ts/data/settings-key.ts | 2 +- ts/receiver/configMessage.ts | 2 +- ts/state/ducks/proBackendData.ts | 6 +++--- ts/state/selectors/proBackendData.ts | 6 +++--- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/ts/components/dialog/debug/FeatureFlags.tsx b/ts/components/dialog/debug/FeatureFlags.tsx index ce47ca0ce4..834d6e9485 100644 --- a/ts/components/dialog/debug/FeatureFlags.tsx +++ b/ts/components/dialog/debug/FeatureFlags.tsx @@ -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' })); @@ -910,7 +910,7 @@ export const ProDebugSection = ({ dispatch(proBackendDataActions.refreshGetProStatusFromProBackend({}) as any); }} > - Refresh Pro Details + Refresh Pro Status
{ @@ -940,11 +940,11 @@ export const ProDebugSection = ({ const masterPrivKeyHex = await getProMasterKeyHex(); 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 { @@ -252,7 +252,7 @@ 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.refreshGetProStatusFromProBackend({}) as any @@ -390,7 +390,7 @@ const fetchGetProStatusFromProBackend = createAsyncThunk( 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 + // 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); } diff --git a/ts/state/selectors/proBackendData.ts b/ts/state/selectors/proBackendData.ts index 453770b6d5..fd68f255c3 100644 --- a/ts/state/selectors/proBackendData.ts +++ b/ts/state/selectors/proBackendData.ts @@ -35,7 +35,7 @@ const getProBackendData = (state: StateType): ProBackendDataState => { }; function getProStatusFromStorage(): ProStatusResultType | null { - const response = Storage.get(SettingsKey.proDetails); + const response = Storage.get(SettingsKey.proStatus); if (!response) { return null; } @@ -54,8 +54,8 @@ function getProStatusFromStorage(): ProStatusResultType | null { if (typeof response === 'object' && response !== null && 'userStatus' in response) { return response as ProStatusResultType; } - void Storage.remove(SettingsKey.proDetails); - window?.log?.error('pro details in storage were malformed; removing.'); + void Storage.remove(SettingsKey.proStatus); + window?.log?.error('pro status in storage were malformed; removing.'); return null; }