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/parallel-prettier": "3.0.0",
"@indutny/rezip-electron": "2.0.1", "@indutny/rezip-electron": "2.0.1",
"@napi-rs/canvas": "0.1.61", "@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-a11y": "8.4.4",
"@storybook/addon-actions": "8.4.4", "@storybook/addon-actions": "8.4.4",
"@storybook/addon-controls": "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 specifier: 0.1.61
version: 0.1.61 version: 0.1.61
'@signalapp/mock-server': '@signalapp/mock-server':
specifier: 13.2.2 specifier: 13.3.0
version: 13.2.2(bufferutil@4.0.9)(utf-8-validate@5.0.10) version: 13.3.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)
'@storybook/addon-a11y': '@storybook/addon-a11y':
specifier: 8.4.4 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)) 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': '@signalapp/minimask@1.0.1':
resolution: {integrity: sha512-QAwo0joA60urTNbW9RIz6vLKQjy+jdVtH7cvY0wD9PVooD46MAjE40MLssp4xUJrph91n2XvtJ3pbEUDrmT2AA==} resolution: {integrity: sha512-QAwo0joA60urTNbW9RIz6vLKQjy+jdVtH7cvY0wD9PVooD46MAjE40MLssp4xUJrph91n2XvtJ3pbEUDrmT2AA==}
'@signalapp/mock-server@13.2.2': '@signalapp/mock-server@13.3.0':
resolution: {integrity: sha512-iwJ5fAXIPetc2mW4Q37Pkd+FwCxVvSYjn753KJlchTcyJq3xLnHHSvz1SDvgXwk6i6y6p7Mwe2V86WAXCyrxBA==} resolution: {integrity: sha512-qWLI+J0hptzKC3Xm9FWWqFMvJ+jpLLPRq+Y6gdbprfA/DMHcNK53T8A54onbEyqJHnxdPoyqxtH4wcsiS1HglQ==}
'@signalapp/parchment-cjs@3.0.1': '@signalapp/parchment-cjs@3.0.1':
resolution: {integrity: sha512-hSBMQ1M7wE4GcC8ZeNtvpJF+DAJg3eIRRf1SiHS3I3Algav/sgJJNm6HIYm6muHuK7IJmuEjkL3ILSXgmu0RfQ==} resolution: {integrity: sha512-hSBMQ1M7wE4GcC8ZeNtvpJF+DAJg3eIRRf1SiHS3I3Algav/sgJJNm6HIYm6muHuK7IJmuEjkL3ILSXgmu0RfQ==}
@ -13948,7 +13948,7 @@ snapshots:
'@signalapp/minimask@1.0.1': {} '@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: dependencies:
'@indutny/parallel-prettier': 3.0.0(prettier@3.3.3) '@indutny/parallel-prettier': 3.0.0(prettier@3.3.3)
'@signalapp/libsignal-client': 0.76.7 '@signalapp/libsignal-client': 0.76.7

View file

@ -10,60 +10,60 @@ import { parseIntOrThrow } from './util/parseIntOrThrow';
import { HOUR } from './util/durations'; import { HOUR } from './util/durations';
import * as Bytes from './Bytes'; import * as Bytes from './Bytes';
import { uuidToBytes } from './util/uuidToBytes'; import { uuidToBytes } from './util/uuidToBytes';
import { dropNull } from './util/dropNull';
import { HashType } from './types/Crypto'; import { HashType } from './types/Crypto';
import { getCountryCode } from './types/PhoneNumber'; import { getCountryCode } from './types/PhoneNumber';
import { parseRemoteClientExpiration } from './util/parseRemoteClientExpiration'; import { parseRemoteClientExpiration } from './util/parseRemoteClientExpiration';
const log = createLogger('RemoteConfig'); const log = createLogger('RemoteConfig');
export type ConfigKeyType = const KnownConfigKeys = [
| 'desktop.chatFolders.alpha' 'desktop.chatFolders.alpha',
| 'desktop.chatFolders.beta' 'desktop.chatFolders.beta',
| 'desktop.chatFolders.prod' 'desktop.chatFolders.prod',
| 'desktop.clientExpiration' 'desktop.clientExpiration',
| 'desktop.backup.credentialFetch' 'desktop.backup.credentialFetch',
| 'desktop.donations' 'desktop.donations',
| 'desktop.internalUser' 'desktop.internalUser',
| 'desktop.mediaQuality.levels' 'desktop.mediaQuality.levels',
| 'desktop.messageCleanup' 'desktop.messageCleanup',
| 'desktop.retryRespondMaxAge' 'desktop.retryRespondMaxAge',
| 'desktop.senderKey.retry' 'desktop.senderKey.retry',
| 'desktop.senderKeyMaxAge' 'desktop.senderKeyMaxAge',
| 'desktop.experimentalTransport.enableAuth' 'desktop.experimentalTransport.enableAuth',
| 'desktop.experimentalTransportEnabled.alpha' 'desktop.experimentalTransportEnabled.alpha',
| 'desktop.experimentalTransportEnabled.beta' 'desktop.experimentalTransportEnabled.beta',
| 'desktop.experimentalTransportEnabled.prod.2' 'desktop.experimentalTransportEnabled.prod.2',
| 'desktop.libsignalNet.enforceMinimumTls' 'desktop.libsignalNet.enforceMinimumTls',
| 'desktop.libsignalNet.shadowUnauthChatWithNoise' 'desktop.libsignalNet.shadowUnauthChatWithNoise',
| 'desktop.libsignalNet.shadowAuthChatWithNoise' 'desktop.libsignalNet.shadowAuthChatWithNoise',
| 'desktop.cdsiViaLibsignal' 'desktop.cdsiViaLibsignal',
| 'desktop.cdsiViaLibsignal.disableNewConnectionLogic' 'desktop.cdsiViaLibsignal.disableNewConnectionLogic',
| 'desktop.funPicker' // alpha 'desktop.funPicker', // alpha
| 'desktop.funPicker.beta' 'desktop.funPicker.beta',
| 'desktop.funPicker.prod' 'desktop.funPicker.prod',
| 'desktop.usePqRatchet' 'desktop.usePqRatchet',
| 'global.attachments.maxBytes' 'global.attachments.maxBytes',
| 'global.attachments.maxReceiveBytes' 'global.attachments.maxReceiveBytes',
| 'global.backups.mediaTierFallbackCdnNumber' 'global.backups.mediaTierFallbackCdnNumber',
| 'global.calling.maxGroupCallRingSize' 'global.calling.maxGroupCallRingSize',
| 'global.groupsv2.groupSizeHardLimit' 'global.groupsv2.groupSizeHardLimit',
| 'global.groupsv2.maxGroupSize' 'global.groupsv2.maxGroupSize',
| 'global.messageQueueTimeInSeconds' 'global.messageQueueTimeInSeconds',
| 'global.nicknames.max' 'global.nicknames.max',
| 'global.nicknames.min' 'global.nicknames.min',
| 'global.textAttachmentLimitBytes'; 'global.textAttachmentLimitBytes',
] as const;
export type ConfigKeyType = (typeof KnownConfigKeys)[number];
type ConfigValueType = { type ConfigValueType = {
name: ConfigKeyType; name: ConfigKeyType;
enabled: boolean;
enabledAt?: number; enabledAt?: number;
value?: string; } & ({ enabled: true; value: string } | { enabled: false; value?: never });
};
export type ConfigMapType = { export type ConfigMapType = {
[key in ConfigKeyType]?: ConfigValueType; [key in ConfigKeyType]?: ConfigValueType;
}; };
type ConfigListenerType = (value: ConfigValueType) => unknown; export type ConfigListenerType = (value: ConfigValueType) => unknown;
type ConfigListenersMapType = { type ConfigListenersMapType = {
[key: string]: Array<ConfigListenerType>; [key: string]: Array<ConfigListenerType>;
}; };
@ -92,7 +92,13 @@ export const _refreshRemoteConfig = async (
server: WebAPIType server: WebAPIType
): Promise<void> => { ): Promise<void> => {
const now = Date.now(); 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; const serverTimeSkew = serverTimestamp - now;
@ -103,47 +109,73 @@ export const _refreshRemoteConfig = async (
); );
} }
// Process new configuration in light of the old configuration if (newConfig === 'unmodified') {
// The old configuration is not set as the initial value in reduce because log.info(
// flags may have been deleted 'remote config was unmodified; server-generated hash is %s',
const oldConfig = config; configHash
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 a flag was previously not enabled and is now enabled, return;
// record the time it was enabled }
const enabledAt: number | undefined =
previouslyEnabled && enabled ? now : get(oldConfig, [name, 'enabledAt']);
const configValue = { // Process new configuration in light of the old configuration. Since the
name: name as ConfigKeyType, // new configuration only includes enabled flags we can't distinguish betewen
enabled, // a remote flag being deleted or being disabled. We synthesize that for our
enabledAt, // known keys.
value: dropNull(value), 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 = const oldConfig = config;
previouslyEnabled !== enabled || previousValue !== configValue.value; 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 // If a flag was previously not enabled and is now enabled,
const currentListeners = listeners[name] || []; // record the time it was enabled
if (hasChanged) { const enabledAt: number | undefined =
log.info(`Remote Config: Flag ${name} has changed`); previouslyEnabled && enabled
currentListeners.forEach(listener => { ? now
listener(configValue); : get(oldConfig, [name, 'enabledAt']);
});
}
// Return new configuration object const configValue: ConfigValueType = {
return { name: name as ConfigKeyType,
...acc, enabledAt,
[name]: configValue, ...(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'); const remoteExpirationValue = getValue('desktop.clientExpiration');
if (!remoteExpirationValue) { if (!remoteExpirationValue) {
@ -165,6 +197,7 @@ export const _refreshRemoteConfig = async (
} }
await window.storage.put('remoteConfig', config); await window.storage.put('remoteConfig', config);
await window.storage.put('remoteConfigHash', configHash);
await window.storage.put('serverTimeSkew', serverTimeSkew); await window.storage.put('serverTimeSkew', serverTimeSkew);
}; };
@ -193,7 +226,7 @@ export function isEnabled(
} }
export function getValue(name: ConfigKeyType): string | undefined { export function getValue(name: ConfigKeyType): string | undefined {
return get(config, [name, 'value'], undefined); return get(config, [name, 'value']);
} }
// See isRemoteConfigBucketEnabled in selectors/items.ts // 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 // Listen for changes to the `desktop.clientExpiration` remote flag
window.Signal.RemoteConfig.onChange( window.Signal.RemoteConfig.onChange(
'desktop.clientExpiration', 'desktop.clientExpiration',
({ value }) => { ({ enabled, value }) => {
const remoteBuildExpirationTimestamp = parseRemoteClientExpiration( if (!enabled) {
value as string return;
); }
const remoteBuildExpirationTimestamp =
parseRemoteClientExpiration(value);
if (remoteBuildExpirationTimestamp) { if (remoteBuildExpirationTimestamp) {
drop( drop(
window.storage.put( window.storage.put(

View file

@ -1971,11 +1971,10 @@ describe('both/state/ducks/conversations', () => {
beforeEach(async () => { beforeEach(async () => {
await updateRemoteConfig([ await updateRemoteConfig([
{ name: 'global.groupsv2.maxGroupSize', value: '22', enabled: true }, { name: 'global.groupsv2.maxGroupSize', value: '22' },
{ {
name: 'global.groupsv2.groupSizeHardLimit', name: 'global.groupsv2.groupSizeHardLimit',
value: '33', value: '33',
enabled: true,
}, },
]); ]);
}); });
@ -2057,18 +2056,23 @@ describe('both/state/ducks/conversations', () => {
it('defaults the maximum recommended size to 151', async () => { it('defaults the maximum recommended size to 151', async () => {
for (const value of [null, 'xyz']) { for (const value of [null, 'xyz']) {
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await updateRemoteConfig([ await updateRemoteConfig(
{ [
name: 'global.groupsv2.maxGroupSize', {
value, name: 'global.groupsv2.groupSizeHardLimit',
enabled: true, value: '33',
}, },
{ ].concat(
name: 'global.groupsv2.groupSizeHardLimit', value
value: '33', ? [
enabled: true, {
}, name: 'global.groupsv2.maxGroupSize',
]); value,
},
]
: []
)
);
const state = { const state = {
...getEmptyState(), ...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 () => { it('defaults the maximum group size to 1001 if the recommended maximum is smaller', async () => {
for (const value of [null, 'xyz']) { for (const value of [null, 'xyz']) {
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await updateRemoteConfig([ await updateRemoteConfig(
{ name: 'global.groupsv2.maxGroupSize', value: '2', enabled: true }, [{ name: 'global.groupsv2.maxGroupSize', value: '2' }].concat(
{ value
name: 'global.groupsv2.groupSizeHardLimit', ? [
value, {
enabled: true, name: 'global.groupsv2.groupSizeHardLimit',
}, value,
]); },
]
: []
)
);
const state = { const state = {
...getEmptyState(), ...getEmptyState(),
@ -2169,12 +2177,10 @@ describe('both/state/ducks/conversations', () => {
{ {
name: 'global.groupsv2.maxGroupSize', name: 'global.groupsv2.maxGroupSize',
value: '1234', value: '1234',
enabled: true,
}, },
{ {
name: 'global.groupsv2.groupSizeHardLimit', name: 'global.groupsv2.groupSizeHardLimit',
value: '2', value: '2',
enabled: true,
}, },
]); ]);

View file

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

View file

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

View file

@ -2,13 +2,20 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai'; import { assert } from 'chai';
import * as sinon from 'sinon';
import { omit } from 'lodash';
import { normalizeAci } from '../util/normalizeAci'; import { normalizeAci } from '../util/normalizeAci';
import type { ConfigKeyType, ConfigListenerType } from '../RemoteConfig';
import { import {
getCountryCodeValue, getCountryCodeValue,
getBucketValue, getBucketValue,
innerIsBucketValueEnabled, innerIsBucketValueEnabled,
onChange,
getValue,
isEnabled,
} from '../RemoteConfig'; } from '../RemoteConfig';
import { updateRemoteConfig } from '../test-helpers/RemoteConfigStub';
describe('RemoteConfig', () => { describe('RemoteConfig', () => {
const aci = normalizeAci('95b9729c-51ea-4ddb-b516-652befe78062', 'test'); const aci = normalizeAci('95b9729c-51ea-4ddb-b516-652befe78062', 'test');
@ -95,4 +102,79 @@ describe('RemoteConfig', () => {
assert.strictEqual(getBucketValue(aci, flagName), 222732); 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 () => { it('returns whether there are 16 or more people in the group, if the remote config value is bogus', async () => {
await updateRemoteConfig([ await updateRemoteConfig([{ name: CONFIG_KEY, value: 'uh oh' }]);
{ name: CONFIG_KEY, value: 'uh oh', enabled: true },
]);
textMaximum(16); textMaximum(16);
}); });
it('returns whether there are 9 or more people in the group, if the remote config value is 9', async () => { 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); textMaximum(9);
}); });
}); });

View file

@ -28,9 +28,7 @@ describe('group add banned member', () => {
const clientZkGroupCipher = getClientZkGroupCipher(secretParams); const clientZkGroupCipher = getClientZkGroupCipher(secretParams);
before(async () => { before(async () => {
await updateRemoteConfig([ await updateRemoteConfig([{ name: HARD_LIMIT_KEY, value: '5' }]);
{ name: HARD_LIMIT_KEY, value: '5', enabled: true },
]);
}); });
it('should add banned member without deleting', () => { 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 () => { it('throws if the value in remote config is not a parseable integer', async () => {
await updateRemoteConfig([ await updateRemoteConfig([
{ name: RECOMMENDED_SIZE_KEY, value: 'uh oh', enabled: true }, { name: RECOMMENDED_SIZE_KEY, value: 'uh oh' },
]); ]);
assert.throws(getGroupSizeRecommendedLimit); assert.throws(getGroupSizeRecommendedLimit);
}); });
it('returns the value in remote config, parsed as an integer', async () => { it('returns the value in remote config, parsed as an integer', async () => {
await updateRemoteConfig([ await updateRemoteConfig([{ name: RECOMMENDED_SIZE_KEY, value: '123' }]);
{ name: RECOMMENDED_SIZE_KEY, value: '123', enabled: true },
]);
assert.strictEqual(getGroupSizeRecommendedLimit(), 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 () => { it('throws if the value in remote config is not a parseable integer', async () => {
await updateRemoteConfig([ await updateRemoteConfig([{ name: HARD_LIMIT_KEY, value: 'uh oh' }]);
{ name: HARD_LIMIT_KEY, value: 'uh oh', enabled: true },
]);
assert.throws(getGroupSizeHardLimit); assert.throws(getGroupSizeHardLimit);
}); });
it('returns the value in remote config, parsed as an integer', async () => { it('returns the value in remote config, parsed as an integer', async () => {
await updateRemoteConfig([ await updateRemoteConfig([{ name: HARD_LIMIT_KEY, value: '123' }]);
{ name: HARD_LIMIT_KEY, value: '123', enabled: true },
]);
assert.strictEqual(getGroupSizeHardLimit(), 123); assert.strictEqual(getGroupSizeHardLimit(), 123);
}); });
}); });

View file

@ -701,6 +701,7 @@ const CHAT_CALLS = {
boostReceiptCredentials: 'v1/subscription/boost/receipt_credentials', boostReceiptCredentials: 'v1/subscription/boost/receipt_credentials',
challenge: 'v1/challenge', challenge: 'v1/challenge',
config: 'v1/config', config: 'v1/config',
configV2: 'v2/config',
createBoost: 'v1/subscription/boost/create', createBoost: 'v1/subscription/boost/create',
deliveryCert: 'v1/certificate/delivery', deliveryCert: 'v1/certificate/delivery',
devices: 'v1/devices', devices: 'v1/devices',
@ -925,18 +926,14 @@ export type UploadAvatarHeadersOrOtherType = z.infer<
>; >;
const remoteConfigResponseZod = z.object({ const remoteConfigResponseZod = z.object({
config: z config: z.object({}).catchall(z.string()),
.object({
name: z.string(),
enabled: z.boolean(),
value: z.string().nullish(),
})
.array(),
}); });
export type RemoteConfigResponseType = z.infer<typeof remoteConfigResponseZod> & export type RemoteConfigResponseType = {
Readonly<{ config: Map<string, string> | 'unmodified';
serverTimestamp: number; } & Readonly<{
}>; serverTimestamp: number;
configHash: string;
}>;
export type ProfileType = Readonly<{ export type ProfileType = Readonly<{
identityKey?: string; identityKey?: string;
@ -1823,7 +1820,7 @@ export type WebAPIType = {
) => Promise<string>; ) => Promise<string>;
whoami: () => Promise<WhoamiResultType>; whoami: () => Promise<WhoamiResultType>;
sendChallengeResponse: (challengeResponse: ChallengeType) => Promise<void>; sendChallengeResponse: (challengeResponse: ChallengeType) => Promise<void>;
getConfig: () => Promise<RemoteConfigResponseType>; getConfig: (configHash?: string) => Promise<RemoteConfigResponseType>;
authenticate: (credentials: WebAPICredentials) => Promise<void>; authenticate: (credentials: WebAPICredentials) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
getServerAlerts: () => Array<ServerAlert>; getServerAlerts: () => Array<ServerAlert>;
@ -2468,31 +2465,64 @@ export function initialize({
void socketManager.onHasStoriesDisabledChange(newValue); void socketManager.onHasStoriesDisabledChange(newValue);
} }
async function getConfig() { async function getConfig(
configHash?: string
): Promise<RemoteConfigResponseType> {
const { data, response } = await _ajax({ const { data, response } = await _ajax({
host: 'chatService', host: 'chatService',
call: 'config', call: 'configV2',
httpType: 'GET', httpType: 'GET',
responseType: 'jsonwithdetails', 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( const serverTimestamp = safeParseNumber(
response.headers.get('x-signal-timestamp') || '' response.headers.get('x-signal-timestamp') || ''
); );
if (serverTimestamp == null) { if (serverTimestamp == null) {
throw new Error('Missing required x-signal-timestamp header'); throw new Error('Missing required x-signal-timestamp header');
} }
return { const newConfigHash = response.headers.get('etag');
...data, if (newConfigHash == null) {
serverTimestamp, throw new Error('Missing required ETag header');
config: data.config.filter( }
({ name }: { name: string }) =>
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('desktop.') ||
name.startsWith('global.') || name.startsWith('global.') ||
name.startsWith('cds.') name.startsWith('cds.')
), )
);
return {
config,
...partialResponse,
}; };
} }

View file

@ -147,6 +147,7 @@ export type StorageAccessType = {
'preferred-audio-input-device': AudioDevice | undefined; 'preferred-audio-input-device': AudioDevice | undefined;
'preferred-audio-output-device': AudioDevice | undefined; 'preferred-audio-output-device': AudioDevice | undefined;
remoteConfig: RemoteConfigType; remoteConfig: RemoteConfigType;
remoteConfigHash: string;
serverTimeSkew: number; serverTimeSkew: number;
unidentifiedDeliveryIndicators: boolean; unidentifiedDeliveryIndicators: boolean;
groupCredentials: ReadonlyArray<GroupCredentialType>; groupCredentials: ReadonlyArray<GroupCredentialType>;