diff --git a/package.json b/package.json index ece1a5359b..b780fc98e0 100644 --- a/package.json +++ b/package.json @@ -236,7 +236,7 @@ "@indutny/parallel-prettier": "3.0.0", "@indutny/rezip-electron": "2.0.1", "@napi-rs/canvas": "0.1.61", - "@signalapp/mock-server": "13.2.2", + "@signalapp/mock-server": "13.3.0", "@storybook/addon-a11y": "8.4.4", "@storybook/addon-actions": "8.4.4", "@storybook/addon-controls": "8.4.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 192daddfc0..4ed6037a82 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -439,8 +439,8 @@ importers: specifier: 0.1.61 version: 0.1.61 '@signalapp/mock-server': - specifier: 13.2.2 - version: 13.2.2(bufferutil@4.0.9)(utf-8-validate@5.0.10) + specifier: 13.3.0 + version: 13.3.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) '@storybook/addon-a11y': specifier: 8.4.4 version: 8.4.4(storybook@8.4.4(bufferutil@4.0.9)(prettier@3.3.3)(utf-8-validate@5.0.10)) @@ -3302,8 +3302,8 @@ packages: '@signalapp/minimask@1.0.1': resolution: {integrity: sha512-QAwo0joA60urTNbW9RIz6vLKQjy+jdVtH7cvY0wD9PVooD46MAjE40MLssp4xUJrph91n2XvtJ3pbEUDrmT2AA==} - '@signalapp/mock-server@13.2.2': - resolution: {integrity: sha512-iwJ5fAXIPetc2mW4Q37Pkd+FwCxVvSYjn753KJlchTcyJq3xLnHHSvz1SDvgXwk6i6y6p7Mwe2V86WAXCyrxBA==} + '@signalapp/mock-server@13.3.0': + resolution: {integrity: sha512-qWLI+J0hptzKC3Xm9FWWqFMvJ+jpLLPRq+Y6gdbprfA/DMHcNK53T8A54onbEyqJHnxdPoyqxtH4wcsiS1HglQ==} '@signalapp/parchment-cjs@3.0.1': resolution: {integrity: sha512-hSBMQ1M7wE4GcC8ZeNtvpJF+DAJg3eIRRf1SiHS3I3Algav/sgJJNm6HIYm6muHuK7IJmuEjkL3ILSXgmu0RfQ==} @@ -13948,7 +13948,7 @@ snapshots: '@signalapp/minimask@1.0.1': {} - '@signalapp/mock-server@13.2.2(bufferutil@4.0.9)(utf-8-validate@5.0.10)': + '@signalapp/mock-server@13.3.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)': dependencies: '@indutny/parallel-prettier': 3.0.0(prettier@3.3.3) '@signalapp/libsignal-client': 0.76.7 diff --git a/ts/RemoteConfig.ts b/ts/RemoteConfig.ts index b0c8e281db..751518526d 100644 --- a/ts/RemoteConfig.ts +++ b/ts/RemoteConfig.ts @@ -10,60 +10,60 @@ import { parseIntOrThrow } from './util/parseIntOrThrow'; import { HOUR } from './util/durations'; import * as Bytes from './Bytes'; import { uuidToBytes } from './util/uuidToBytes'; -import { dropNull } from './util/dropNull'; import { HashType } from './types/Crypto'; import { getCountryCode } from './types/PhoneNumber'; import { parseRemoteClientExpiration } from './util/parseRemoteClientExpiration'; const log = createLogger('RemoteConfig'); -export type ConfigKeyType = - | 'desktop.chatFolders.alpha' - | 'desktop.chatFolders.beta' - | 'desktop.chatFolders.prod' - | 'desktop.clientExpiration' - | 'desktop.backup.credentialFetch' - | 'desktop.donations' - | 'desktop.internalUser' - | 'desktop.mediaQuality.levels' - | 'desktop.messageCleanup' - | 'desktop.retryRespondMaxAge' - | 'desktop.senderKey.retry' - | 'desktop.senderKeyMaxAge' - | 'desktop.experimentalTransport.enableAuth' - | 'desktop.experimentalTransportEnabled.alpha' - | 'desktop.experimentalTransportEnabled.beta' - | 'desktop.experimentalTransportEnabled.prod.2' - | 'desktop.libsignalNet.enforceMinimumTls' - | 'desktop.libsignalNet.shadowUnauthChatWithNoise' - | 'desktop.libsignalNet.shadowAuthChatWithNoise' - | 'desktop.cdsiViaLibsignal' - | 'desktop.cdsiViaLibsignal.disableNewConnectionLogic' - | 'desktop.funPicker' // alpha - | 'desktop.funPicker.beta' - | 'desktop.funPicker.prod' - | 'desktop.usePqRatchet' - | 'global.attachments.maxBytes' - | 'global.attachments.maxReceiveBytes' - | 'global.backups.mediaTierFallbackCdnNumber' - | 'global.calling.maxGroupCallRingSize' - | 'global.groupsv2.groupSizeHardLimit' - | 'global.groupsv2.maxGroupSize' - | 'global.messageQueueTimeInSeconds' - | 'global.nicknames.max' - | 'global.nicknames.min' - | 'global.textAttachmentLimitBytes'; +const KnownConfigKeys = [ + 'desktop.chatFolders.alpha', + 'desktop.chatFolders.beta', + 'desktop.chatFolders.prod', + 'desktop.clientExpiration', + 'desktop.backup.credentialFetch', + 'desktop.donations', + 'desktop.internalUser', + 'desktop.mediaQuality.levels', + 'desktop.messageCleanup', + 'desktop.retryRespondMaxAge', + 'desktop.senderKey.retry', + 'desktop.senderKeyMaxAge', + 'desktop.experimentalTransport.enableAuth', + 'desktop.experimentalTransportEnabled.alpha', + 'desktop.experimentalTransportEnabled.beta', + 'desktop.experimentalTransportEnabled.prod.2', + 'desktop.libsignalNet.enforceMinimumTls', + 'desktop.libsignalNet.shadowUnauthChatWithNoise', + 'desktop.libsignalNet.shadowAuthChatWithNoise', + 'desktop.cdsiViaLibsignal', + 'desktop.cdsiViaLibsignal.disableNewConnectionLogic', + 'desktop.funPicker', // alpha + 'desktop.funPicker.beta', + 'desktop.funPicker.prod', + 'desktop.usePqRatchet', + 'global.attachments.maxBytes', + 'global.attachments.maxReceiveBytes', + 'global.backups.mediaTierFallbackCdnNumber', + 'global.calling.maxGroupCallRingSize', + 'global.groupsv2.groupSizeHardLimit', + 'global.groupsv2.maxGroupSize', + 'global.messageQueueTimeInSeconds', + 'global.nicknames.max', + 'global.nicknames.min', + 'global.textAttachmentLimitBytes', +] as const; + +export type ConfigKeyType = (typeof KnownConfigKeys)[number]; type ConfigValueType = { name: ConfigKeyType; - enabled: boolean; enabledAt?: number; - value?: string; -}; +} & ({ enabled: true; value: string } | { enabled: false; value?: never }); export type ConfigMapType = { [key in ConfigKeyType]?: ConfigValueType; }; -type ConfigListenerType = (value: ConfigValueType) => unknown; +export type ConfigListenerType = (value: ConfigValueType) => unknown; type ConfigListenersMapType = { [key: string]: Array; }; @@ -92,7 +92,13 @@ export const _refreshRemoteConfig = async ( server: WebAPIType ): Promise => { const now = Date.now(); - const { config: newConfig, serverTimestamp } = await server.getConfig(); + const oldConfigHash = window.storage.get('remoteConfigHash'); + + const { + config: newConfig, + serverTimestamp, + configHash, + } = await server.getConfig(oldConfigHash); const serverTimeSkew = serverTimestamp - now; @@ -103,47 +109,73 @@ export const _refreshRemoteConfig = async ( ); } - // Process new configuration in light of the old configuration - // The old configuration is not set as the initial value in reduce because - // flags may have been deleted - const oldConfig = config; - config = newConfig.reduce((acc, { name, enabled, value }) => { - const previouslyEnabled: boolean = get(oldConfig, [name, 'enabled'], false); - const previousValue: string | undefined = get( - oldConfig, - [name, 'value'], - undefined + if (newConfig === 'unmodified') { + log.info( + 'remote config was unmodified; server-generated hash is %s', + configHash ); - // If a flag was previously not enabled and is now enabled, - // record the time it was enabled - const enabledAt: number | undefined = - previouslyEnabled && enabled ? now : get(oldConfig, [name, 'enabledAt']); + return; + } - const configValue = { - name: name as ConfigKeyType, - enabled, - enabledAt, - value: dropNull(value), - }; + // Process new configuration in light of the old configuration. Since the + // new configuration only includes enabled flags we can't distinguish betewen + // a remote flag being deleted or being disabled. We synthesize that for our + // known keys. + const newConfigValues: Map = new Map( + KnownConfigKeys.map(name => [name, undefined]) + ); + for (const [name, value] of newConfig) { + newConfigValues.set(name, value); + } - const hasChanged = - previouslyEnabled !== enabled || previousValue !== configValue.value; + const oldConfig = config; + config = Array.from(newConfigValues.entries()).reduce( + (acc, [name, value]) => { + const enabled = value !== undefined; + const previouslyEnabled: boolean = get( + oldConfig, + [name, 'enabled'], + false + ); + const previousValue: string | undefined = get( + oldConfig, + [name, 'value'], + undefined + ); - // If enablement changes at all, notify listeners - const currentListeners = listeners[name] || []; - if (hasChanged) { - log.info(`Remote Config: Flag ${name} has changed`); - currentListeners.forEach(listener => { - listener(configValue); - }); - } + // If a flag was previously not enabled and is now enabled, + // record the time it was enabled + const enabledAt: number | undefined = + previouslyEnabled && enabled + ? now + : get(oldConfig, [name, 'enabledAt']); - // Return new configuration object - return { - ...acc, - [name]: configValue, - }; - }, {}); + const configValue: ConfigValueType = { + name: name as ConfigKeyType, + enabledAt, + ...(enabled ? { enabled: true, value } : { enabled: false }), + }; + + const hasChanged = + previouslyEnabled !== enabled || previousValue !== configValue.value; + + // If enablement changes at all, notify listeners + const currentListeners = listeners[name] || []; + if (hasChanged) { + log.info(`Remote Config: Flag ${name} has changed`); + currentListeners.forEach(listener => { + listener(configValue); + }); + } + + // Return new configuration object + return { + ...acc, + [name]: configValue, + }; + }, + {} + ); const remoteExpirationValue = getValue('desktop.clientExpiration'); if (!remoteExpirationValue) { @@ -165,6 +197,7 @@ export const _refreshRemoteConfig = async ( } await window.storage.put('remoteConfig', config); + await window.storage.put('remoteConfigHash', configHash); await window.storage.put('serverTimeSkew', serverTimeSkew); }; @@ -193,7 +226,7 @@ export function isEnabled( } export function getValue(name: ConfigKeyType): string | undefined { - return get(config, [name, 'value'], undefined); + return get(config, [name, 'value']); } // See isRemoteConfigBucketEnabled in selectors/items.ts diff --git a/ts/background.ts b/ts/background.ts index f017bbccdf..72aca6ea83 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -1509,10 +1509,12 @@ export async function startApp(): Promise { // Listen for changes to the `desktop.clientExpiration` remote flag window.Signal.RemoteConfig.onChange( 'desktop.clientExpiration', - ({ value }) => { - const remoteBuildExpirationTimestamp = parseRemoteClientExpiration( - value as string - ); + ({ enabled, value }) => { + if (!enabled) { + return; + } + const remoteBuildExpirationTimestamp = + parseRemoteClientExpiration(value); if (remoteBuildExpirationTimestamp) { drop( window.storage.put( diff --git a/ts/test-electron/state/ducks/conversations_test.ts b/ts/test-electron/state/ducks/conversations_test.ts index 57ca34d9e1..8ffb3e7b7f 100644 --- a/ts/test-electron/state/ducks/conversations_test.ts +++ b/ts/test-electron/state/ducks/conversations_test.ts @@ -1971,11 +1971,10 @@ describe('both/state/ducks/conversations', () => { beforeEach(async () => { await updateRemoteConfig([ - { name: 'global.groupsv2.maxGroupSize', value: '22', enabled: true }, + { name: 'global.groupsv2.maxGroupSize', value: '22' }, { name: 'global.groupsv2.groupSizeHardLimit', value: '33', - enabled: true, }, ]); }); @@ -2057,18 +2056,23 @@ describe('both/state/ducks/conversations', () => { it('defaults the maximum recommended size to 151', async () => { for (const value of [null, 'xyz']) { // eslint-disable-next-line no-await-in-loop - await updateRemoteConfig([ - { - name: 'global.groupsv2.maxGroupSize', - value, - enabled: true, - }, - { - name: 'global.groupsv2.groupSizeHardLimit', - value: '33', - enabled: true, - }, - ]); + await updateRemoteConfig( + [ + { + name: 'global.groupsv2.groupSizeHardLimit', + value: '33', + }, + ].concat( + value + ? [ + { + name: 'global.groupsv2.maxGroupSize', + value, + }, + ] + : [] + ) + ); const state = { ...getEmptyState(), @@ -2145,14 +2149,18 @@ describe('both/state/ducks/conversations', () => { it('defaults the maximum group size to 1001 if the recommended maximum is smaller', async () => { for (const value of [null, 'xyz']) { // eslint-disable-next-line no-await-in-loop - await updateRemoteConfig([ - { name: 'global.groupsv2.maxGroupSize', value: '2', enabled: true }, - { - name: 'global.groupsv2.groupSizeHardLimit', - value, - enabled: true, - }, - ]); + await updateRemoteConfig( + [{ name: 'global.groupsv2.maxGroupSize', value: '2' }].concat( + value + ? [ + { + name: 'global.groupsv2.groupSizeHardLimit', + value, + }, + ] + : [] + ) + ); const state = { ...getEmptyState(), @@ -2169,12 +2177,10 @@ describe('both/state/ducks/conversations', () => { { name: 'global.groupsv2.maxGroupSize', value: '1234', - enabled: true, }, { name: 'global.groupsv2.groupSizeHardLimit', value: '2', - enabled: true, }, ]); diff --git a/ts/test-electron/util/downloadAttachment_test.ts b/ts/test-electron/util/downloadAttachment_test.ts index 301a048047..568e3d9515 100644 --- a/ts/test-electron/util/downloadAttachment_test.ts +++ b/ts/test-electron/util/downloadAttachment_test.ts @@ -362,7 +362,6 @@ describe('getCdnNumberForBackupTier', () => { await updateRemoteConfig([ { name: 'global.backups.mediaTierFallbackCdnNumber', - enabled: true, value: '42', }, ]); diff --git a/ts/test-helpers/RemoteConfigStub.ts b/ts/test-helpers/RemoteConfigStub.ts index c2856dd323..97df6400ba 100644 --- a/ts/test-helpers/RemoteConfigStub.ts +++ b/ts/test-helpers/RemoteConfigStub.ts @@ -8,11 +8,16 @@ import type { } from '../textsecure/WebAPI'; export async function updateRemoteConfig( - newConfig: RemoteConfigResponseType['config'] + newConfig: Array<{ name: string; value: string }> ): Promise { const fakeServer = { - async getConfig() { - return { config: newConfig, serverTimestamp: Date.now() }; + async getConfig(): Promise { + const serverTimestamp = Date.now(); + return { + config: new Map(newConfig.map(({ name, value }) => [name, value])), + serverTimestamp, + configHash: serverTimestamp.toString(), + }; }, } as Partial as unknown as WebAPIType; diff --git a/ts/test-node/RemoteConfig_test.ts b/ts/test-node/RemoteConfig_test.ts index 27049b3c55..af498fba48 100644 --- a/ts/test-node/RemoteConfig_test.ts +++ b/ts/test-node/RemoteConfig_test.ts @@ -2,13 +2,20 @@ // SPDX-License-Identifier: AGPL-3.0-only import { assert } from 'chai'; +import * as sinon from 'sinon'; +import { omit } from 'lodash'; import { normalizeAci } from '../util/normalizeAci'; +import type { ConfigKeyType, ConfigListenerType } from '../RemoteConfig'; import { getCountryCodeValue, getBucketValue, innerIsBucketValueEnabled, + onChange, + getValue, + isEnabled, } from '../RemoteConfig'; +import { updateRemoteConfig } from '../test-helpers/RemoteConfigStub'; describe('RemoteConfig', () => { const aci = normalizeAci('95b9729c-51ea-4ddb-b516-652befe78062', 'test'); @@ -95,4 +102,79 @@ describe('RemoteConfig', () => { assert.strictEqual(getBucketValue(aci, flagName), 222732); }); }); + + describe('#getValue', () => { + it('returns value if enabled', async () => { + await updateRemoteConfig([]); + + assert.equal(getValue('desktop.internalUser'), undefined); + + await updateRemoteConfig([ + { name: 'desktop.internalUser', value: 'yes' }, + ]); + assert.equal(getValue('desktop.internalUser'), 'yes'); + }); + + it('does not return disabled value', async () => { + await updateRemoteConfig([]); + assert.equal(getValue('desktop.internalUser'), undefined); + }); + }); + + describe('#isEnabled', () => { + it('is false for missing flag', async () => { + await updateRemoteConfig([]); + assert.equal(isEnabled('desktop.internalUser'), false); + }); + + it('is false for disabled flag', async () => { + await updateRemoteConfig([]); + assert.equal(isEnabled('desktop.internalUser'), false); + }); + + it('is true for enabled flag', async () => { + await updateRemoteConfig([ + { name: 'desktop.internalUser', value: 'yes' }, + ]); + assert.equal(isEnabled('desktop.internalUser'), true); + }); + + it('reflects the value of an unknown flag in the config', async () => { + assert.equal( + isEnabled('desktop.unknownFlagName' as ConfigKeyType), + false + ); + await updateRemoteConfig([ + { name: 'desktop.unknownFlagName', value: 'unknownFlagValue' }, + ]); + assert.equal(isEnabled('desktop.unknownFlagName' as ConfigKeyType), true); + }); + }); + + describe('#onChange', () => { + it('triggers listener on known flag change', async () => { + await updateRemoteConfig([]); + + // eslint-disable-next-line @typescript-eslint/no-empty-function + const listener = sinon.spy(() => {}); + onChange('desktop.internalUser', listener); + + await updateRemoteConfig([ + { name: 'desktop.internalUser', value: 'yes' }, + ]); + await updateRemoteConfig([]); + await updateRemoteConfig([ + { name: 'desktop.internalUser', value: 'yes' }, + ]); + + const calls = listener + .getCalls() + .map(call => omit(call.firstArg, 'enabledAt')); + assert.deepEqual(calls, [ + { name: 'desktop.internalUser', value: 'yes', enabled: true }, + { name: 'desktop.internalUser', enabled: false }, + { name: 'desktop.internalUser', value: 'yes', enabled: true }, + ]); + }); + }); }); diff --git a/ts/test-node/conversations/isConversationTooBigToRing_test.ts b/ts/test-node/conversations/isConversationTooBigToRing_test.ts index fe33764912..65522bc5ef 100644 --- a/ts/test-node/conversations/isConversationTooBigToRing_test.ts +++ b/ts/test-node/conversations/isConversationTooBigToRing_test.ts @@ -36,14 +36,12 @@ describe('isConversationTooBigToRing', () => { }); it('returns whether there are 16 or more people in the group, if the remote config value is bogus', async () => { - await updateRemoteConfig([ - { name: CONFIG_KEY, value: 'uh oh', enabled: true }, - ]); + await updateRemoteConfig([{ name: CONFIG_KEY, value: 'uh oh' }]); textMaximum(16); }); it('returns whether there are 9 or more people in the group, if the remote config value is 9', async () => { - await updateRemoteConfig([{ name: CONFIG_KEY, value: '9', enabled: true }]); + await updateRemoteConfig([{ name: CONFIG_KEY, value: '9' }]); textMaximum(9); }); }); diff --git a/ts/test-node/groups/add_banned_member_test.ts b/ts/test-node/groups/add_banned_member_test.ts index 9a7206c89f..9389ea0bbf 100644 --- a/ts/test-node/groups/add_banned_member_test.ts +++ b/ts/test-node/groups/add_banned_member_test.ts @@ -28,9 +28,7 @@ describe('group add banned member', () => { const clientZkGroupCipher = getClientZkGroupCipher(secretParams); before(async () => { - await updateRemoteConfig([ - { name: HARD_LIMIT_KEY, value: '5', enabled: true }, - ]); + await updateRemoteConfig([{ name: HARD_LIMIT_KEY, value: '5' }]); }); it('should add banned member without deleting', () => { diff --git a/ts/test-node/groups/limits_test.ts b/ts/test-node/groups/limits_test.ts index c1be17cbf1..a3a6242114 100644 --- a/ts/test-node/groups/limits_test.ts +++ b/ts/test-node/groups/limits_test.ts @@ -21,15 +21,13 @@ describe('group limit utilities', () => { it('throws if the value in remote config is not a parseable integer', async () => { await updateRemoteConfig([ - { name: RECOMMENDED_SIZE_KEY, value: 'uh oh', enabled: true }, + { name: RECOMMENDED_SIZE_KEY, value: 'uh oh' }, ]); assert.throws(getGroupSizeRecommendedLimit); }); it('returns the value in remote config, parsed as an integer', async () => { - await updateRemoteConfig([ - { name: RECOMMENDED_SIZE_KEY, value: '123', enabled: true }, - ]); + await updateRemoteConfig([{ name: RECOMMENDED_SIZE_KEY, value: '123' }]); assert.strictEqual(getGroupSizeRecommendedLimit(), 123); }); }); @@ -41,16 +39,12 @@ describe('group limit utilities', () => { }); it('throws if the value in remote config is not a parseable integer', async () => { - await updateRemoteConfig([ - { name: HARD_LIMIT_KEY, value: 'uh oh', enabled: true }, - ]); + await updateRemoteConfig([{ name: HARD_LIMIT_KEY, value: 'uh oh' }]); assert.throws(getGroupSizeHardLimit); }); it('returns the value in remote config, parsed as an integer', async () => { - await updateRemoteConfig([ - { name: HARD_LIMIT_KEY, value: '123', enabled: true }, - ]); + await updateRemoteConfig([{ name: HARD_LIMIT_KEY, value: '123' }]); assert.strictEqual(getGroupSizeHardLimit(), 123); }); }); diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index c4bf9e197d..0515310351 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -701,6 +701,7 @@ const CHAT_CALLS = { boostReceiptCredentials: 'v1/subscription/boost/receipt_credentials', challenge: 'v1/challenge', config: 'v1/config', + configV2: 'v2/config', createBoost: 'v1/subscription/boost/create', deliveryCert: 'v1/certificate/delivery', devices: 'v1/devices', @@ -925,18 +926,14 @@ export type UploadAvatarHeadersOrOtherType = z.infer< >; const remoteConfigResponseZod = z.object({ - config: z - .object({ - name: z.string(), - enabled: z.boolean(), - value: z.string().nullish(), - }) - .array(), + config: z.object({}).catchall(z.string()), }); -export type RemoteConfigResponseType = z.infer & - Readonly<{ - serverTimestamp: number; - }>; +export type RemoteConfigResponseType = { + config: Map | 'unmodified'; +} & Readonly<{ + serverTimestamp: number; + configHash: string; +}>; export type ProfileType = Readonly<{ identityKey?: string; @@ -1823,7 +1820,7 @@ export type WebAPIType = { ) => Promise; whoami: () => Promise; sendChallengeResponse: (challengeResponse: ChallengeType) => Promise; - getConfig: () => Promise; + getConfig: (configHash?: string) => Promise; authenticate: (credentials: WebAPICredentials) => Promise; logout: () => Promise; getServerAlerts: () => Array; @@ -2468,31 +2465,64 @@ export function initialize({ void socketManager.onHasStoriesDisabledChange(newValue); } - async function getConfig() { + async function getConfig( + configHash?: string + ): Promise { const { data, response } = await _ajax({ host: 'chatService', - call: 'config', + call: 'configV2', httpType: 'GET', responseType: 'jsonwithdetails', - zodSchema: remoteConfigResponseZod, + zodSchema: z.union([ + remoteConfigResponseZod, + // When a 304 is returned, the body of the response is empty. + z.literal(''), + ]), + headers: { + ...(configHash && { 'if-none-match': configHash }), + }, }); const serverTimestamp = safeParseNumber( response.headers.get('x-signal-timestamp') || '' ); + if (serverTimestamp == null) { throw new Error('Missing required x-signal-timestamp header'); } - return { - ...data, - serverTimestamp, - config: data.config.filter( - ({ name }: { name: string }) => + const newConfigHash = response.headers.get('etag'); + if (newConfigHash == null) { + throw new Error('Missing required ETag header'); + } + + const partialResponse = { serverTimestamp, configHash: newConfigHash }; + + if (response.status === 304) { + return { + config: 'unmodified', + ...partialResponse, + }; + } + + if (data === '') { + throw new Error('Empty data returned for non-304'); + } + + const { config: newConfig } = data; + + const config = new Map( + Object.entries(newConfig).filter( + ([name, _value]) => name.startsWith('desktop.') || name.startsWith('global.') || name.startsWith('cds.') - ), + ) + ); + + return { + config, + ...partialResponse, }; } diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts index 7eba4addb2..78b147a00b 100644 --- a/ts/types/Storage.d.ts +++ b/ts/types/Storage.d.ts @@ -147,6 +147,7 @@ export type StorageAccessType = { 'preferred-audio-input-device': AudioDevice | undefined; 'preferred-audio-output-device': AudioDevice | undefined; remoteConfig: RemoteConfigType; + remoteConfigHash: string; serverTimeSkew: number; unidentifiedDeliveryIndicators: boolean; groupCredentials: ReadonlyArray;