Switch remote config fetching to use new endpoint

This commit is contained in:
Alex Bakon 2025-08-22 11:20:57 -04:00 committed by GitHub
commit 1d37db78d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 303 additions and 155 deletions

View file

@ -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",

10
pnpm-lock.yaml generated
View file

@ -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

View file

@ -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<ConfigListenerType>;
};
@ -92,7 +92,13 @@ export const _refreshRemoteConfig = async (
server: WebAPIType
): Promise<void> => {
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<string, string | undefined> = 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

View file

@ -1509,10 +1509,12 @@ export async function startApp(): Promise<void> {
// 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(

View file

@ -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,
},
]);

View file

@ -362,7 +362,6 @@ describe('getCdnNumberForBackupTier', () => {
await updateRemoteConfig([
{
name: 'global.backups.mediaTierFallbackCdnNumber',
enabled: true,
value: '42',
},
]);

View file

@ -8,11 +8,16 @@ import type {
} from '../textsecure/WebAPI';
export async function updateRemoteConfig(
newConfig: RemoteConfigResponseType['config']
newConfig: Array<{ name: string; value: string }>
): Promise<void> {
const fakeServer = {
async getConfig() {
return { config: newConfig, serverTimestamp: Date.now() };
async getConfig(): Promise<RemoteConfigResponseType> {
const serverTimestamp = Date.now();
return {
config: new Map(newConfig.map(({ name, value }) => [name, value])),
serverTimestamp,
configHash: serverTimestamp.toString(),
};
},
} as Partial<WebAPIType> as unknown as WebAPIType;

View file

@ -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<ConfigListenerType>(() => {});
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 },
]);
});
});
});

View file

@ -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);
});
});

View file

@ -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', () => {

View file

@ -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);
});
});

View file

@ -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<typeof remoteConfigResponseZod> &
Readonly<{
serverTimestamp: number;
}>;
export type RemoteConfigResponseType = {
config: Map<string, string> | 'unmodified';
} & Readonly<{
serverTimestamp: number;
configHash: string;
}>;
export type ProfileType = Readonly<{
identityKey?: string;
@ -1823,7 +1820,7 @@ export type WebAPIType = {
) => Promise<string>;
whoami: () => Promise<WhoamiResultType>;
sendChallengeResponse: (challengeResponse: ChallengeType) => Promise<void>;
getConfig: () => Promise<RemoteConfigResponseType>;
getConfig: (configHash?: string) => Promise<RemoteConfigResponseType>;
authenticate: (credentials: WebAPICredentials) => Promise<void>;
logout: () => Promise<void>;
getServerAlerts: () => Array<ServerAlert>;
@ -2468,31 +2465,64 @@ export function initialize({
void socketManager.onHasStoriesDisabledChange(newValue);
}
async function getConfig() {
async function getConfig(
configHash?: string
): Promise<RemoteConfigResponseType> {
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,
};
}

View file

@ -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<GroupCredentialType>;