diff --git a/.changeset/devtool-rotate-sessions.md b/.changeset/devtool-rotate-sessions.md new file mode 100644 index 000000000..686fb292f --- /dev/null +++ b/.changeset/devtool-rotate-sessions.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Add developer tool to force-rotate outbound Megolm encryption sessions per room, useful for testing key rotation and bridge session recovery diff --git a/.changeset/feature-flag-env-vars.md b/.changeset/feature-flag-env-vars.md new file mode 100644 index 000000000..25d7d7d01 --- /dev/null +++ b/.changeset/feature-flag-env-vars.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add build-time client config overrides via environment variables, with typed deterministic experiment bucketing helpers for progressive feature rollout and A/B testing. diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 9b4c9acbb..d9a365eeb 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -34,6 +34,36 @@ runs: env: INPUTS_INSTALL_COMMAND: ${{ inputs.install-command }} + - name: Inject runtime config overrides + if: ${{ env.CLIENT_CONFIG_OVERRIDES_JSON != '' }} + shell: bash + working-directory: ${{ github.workspace }} + run: node scripts/inject-client-config.js + env: + CLIENT_CONFIG_OVERRIDES_JSON: ${{ env.CLIENT_CONFIG_OVERRIDES_JSON }} + CLIENT_CONFIG_OVERRIDES_STRICT: ${{ env.CLIENT_CONFIG_OVERRIDES_STRICT }} + + - name: Display injected config + if: ${{ env.CLIENT_CONFIG_OVERRIDES_JSON != '' }} + shell: bash + working-directory: ${{ github.workspace }} + run: | + summary_file="${GITHUB_STEP_SUMMARY:-}" + echo "::group::Injected Client Config" + experiments_json="$(jq -c '.experiments // "No experiments configured"' config.json 2>/dev/null || echo 'config.json not readable')" + echo "$experiments_json" + echo "::endgroup::" + + if [[ -n "$summary_file" ]]; then + { + echo "### Injected client config" + echo + echo "\`\`\`json" + echo "$experiments_json" + echo "\`\`\`" + } >> "$summary_file" + fi + - name: Build app if: ${{ inputs.build == 'true' }} shell: bash diff --git a/.github/workflows/cloudflare-web-deploy.yml b/.github/workflows/cloudflare-web-deploy.yml index d3d2c4461..e32dbf68e 100644 --- a/.github/workflows/cloudflare-web-deploy.yml +++ b/.github/workflows/cloudflare-web-deploy.yml @@ -40,6 +40,10 @@ jobs: plan: if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest + environment: preview + env: + CLIENT_CONFIG_OVERRIDES_JSON: ${{ vars.CLIENT_CONFIG_OVERRIDES_JSON }} + CLIENT_CONFIG_OVERRIDES_STRICT: ${{ vars.CLIENT_CONFIG_OVERRIDES_STRICT || 'false' }} permissions: contents: read pull-requests: write @@ -73,6 +77,10 @@ jobs: apply: if: (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + environment: production + env: + CLIENT_CONFIG_OVERRIDES_JSON: ${{ vars.CLIENT_CONFIG_OVERRIDES_JSON }} + CLIENT_CONFIG_OVERRIDES_STRICT: ${{ vars.CLIENT_CONFIG_OVERRIDES_STRICT || 'false' }} permissions: contents: read defaults: diff --git a/.github/workflows/cloudflare-web-preview.yml b/.github/workflows/cloudflare-web-preview.yml index 8b93a4bb9..82046559c 100644 --- a/.github/workflows/cloudflare-web-preview.yml +++ b/.github/workflows/cloudflare-web-preview.yml @@ -32,9 +32,13 @@ jobs: deploy: if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'push' runs-on: ubuntu-latest + environment: preview permissions: contents: read pull-requests: write + env: + CLIENT_CONFIG_OVERRIDES_JSON: ${{ vars.CLIENT_CONFIG_OVERRIDES_JSON }} + CLIENT_CONFIG_OVERRIDES_STRICT: ${{ vars.CLIENT_CONFIG_OVERRIDES_STRICT || 'false' }} steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/knip.json b/knip.json index 6cc8c8581..83f45fc19 100644 --- a/knip.json +++ b/knip.json @@ -1,6 +1,6 @@ { "$schema": "https://unpkg.com/knip@5/schema.json", - "entry": ["src/sw.ts", "scripts/normalize-imports.js"], + "entry": ["src/sw.ts", "scripts/normalize-imports.js", "scripts/inject-client-config.js"], "ignore": ["oxlint.config.ts", "oxfmt.config.ts"], "ignoreExportsUsedInFile": { "interface": true, diff --git a/scripts/inject-client-config.js b/scripts/inject-client-config.js new file mode 100644 index 000000000..0b5fcd3ad --- /dev/null +++ b/scripts/inject-client-config.js @@ -0,0 +1,75 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import process from 'node:process'; +import { PrefixedLogger } from './utils/console-style.js'; + +const CONFIG_PATH = 'config.json'; +const OVERRIDES_ENV = 'CLIENT_CONFIG_OVERRIDES_JSON'; +const STRICT_ENV = 'CLIENT_CONFIG_OVERRIDES_STRICT'; +const logger = new PrefixedLogger('[config-inject]'); + +const formatError = (error) => { + if (error instanceof Error) return error.stack ?? error.message; + return String(error); +}; + +const isPlainObject = (value) => + typeof value === 'object' && value !== null && !Array.isArray(value); + +// Keys that could trigger prototype pollution via bracket assignment. +const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + +const deepMerge = (target, source) => { + if (!isPlainObject(target) || !isPlainObject(source)) return source; + + const merged = { ...target }; + Object.entries(source).forEach(([key, value]) => { + if (UNSAFE_KEYS.has(key)) return; + const targetValue = merged[key]; + merged[key] = + isPlainObject(targetValue) && isPlainObject(value) ? deepMerge(targetValue, value) : value; + }); + return merged; +}; + +const failOnError = process.env[STRICT_ENV] === 'true'; +const overridesRaw = process.env[OVERRIDES_ENV]; + +if (!overridesRaw) { + logger.info(`No ${OVERRIDES_ENV} provided; leaving ${CONFIG_PATH} unchanged.`); + process.exit(0); +} + +let fileConfig; +let overrides; + +try { + const file = await readFile(CONFIG_PATH, 'utf8'); + fileConfig = JSON.parse(file); +} catch (error) { + logger.error(`Failed reading ${CONFIG_PATH}: ${formatError(error)}`); + process.exit(1); +} + +try { + overrides = JSON.parse(overridesRaw); + if (!isPlainObject(overrides)) { + throw new Error(`${OVERRIDES_ENV} must be a JSON object.`); + } +} catch (error) { + const message = `[config-inject] Invalid ${OVERRIDES_ENV}; ${ + failOnError ? 'failing build' : 'skipping overrides' + }.`; + if (failOnError) { + logger.error(`${message} ${formatError(error)}`); + process.exit(1); + } + logger.info(`[warning] ${message} ${formatError(error)}`); + process.exit(0); +} + +const mergedConfig = deepMerge(fileConfig, overrides); + +await writeFile(CONFIG_PATH, `${JSON.stringify(mergedConfig, null, 2)}\n`, 'utf8'); +logger.info( + `Applied overrides to ${CONFIG_PATH}. Top-level keys: ${Object.keys(overrides).join(', ')}` +); diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index 100119726..33a7080a2 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -1,5 +1,6 @@ import { useCallback, useState } from 'react'; -import { Box, Text, Scroll, Switch, Button } from 'folds'; +import { Box, Text, Scroll, Switch, Button, Spinner, color } from 'folds'; +import { KnownMembership } from '$types/matrix-sdk'; import { PageContent } from '$components/page'; import { SequenceCard } from '$components/sequence-card'; import { SettingTile } from '$components/setting-tile'; @@ -10,12 +11,16 @@ import type { AccountDataSubmitCallback } from '$components/AccountDataEditor'; import { AccountDataEditor } from '$components/AccountDataEditor'; import { copyToClipboard } from '$utils/dom'; import { SequenceCardStyle } from '$features/settings/styles.css'; +import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; import { SettingsSectionPage } from '../SettingsSectionPage'; import { AccountData } from './AccountData'; import { SyncDiagnostics } from './SyncDiagnostics'; +import { ExperimentsPanel } from './ExperimentsPanel'; import { DebugLogViewer } from './DebugLogViewer'; import { SentrySettings } from './SentrySettings'; +const JOIN_MEMBERSHIP: string = KnownMembership.Join; + type DeveloperToolsProps = { requestBack?: () => void; requestClose: () => void; @@ -26,6 +31,48 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp const [expand, setExpend] = useState(false); const [accountDataType, setAccountDataType] = useState(); + const [rotateState, rotateAllSessions] = useAsyncCallback< + { rotated: number; total: number }, + Error, + [] + >( + useCallback(async () => { + if ( + !window.confirm( + 'This will discard all current Megolm encryption sessions and start new ones. Continue?' + ) + ) { + throw new Error('Cancelled'); + } + + const crypto = mx.getCrypto(); + if (!crypto) throw new Error('Crypto module not available'); + + const encryptedRooms = mx + .getRooms() + .filter( + (room) => room.getMyMembership() === JOIN_MEMBERSHIP && mx.isRoomEncrypted(room.roomId) + ); + + const results = await Promise.allSettled( + encryptedRooms.map((room) => crypto.forceDiscardSession(room.roomId)) + ); + const rotated = results.filter((r) => r.status === 'fulfilled').length; + + // Proactively start session creation + key sharing with all devices + // (including bridge bots). fire-and-forget per room, but surface failures. + encryptedRooms.forEach((room) => { + Promise.resolve() + .then(() => crypto.prepareToEncrypt(room)) + .catch((error) => { + console.error('Failed to prepare room encryption', room.roomId, error); + }); + }); + + return { rotated, total: encryptedRooms.length }; + }, [mx]) + ); + const submitAccountData: AccountDataSubmitCallback = useCallback( async (type, content) => { // TODO: remove cast once account data typing is unified. @@ -110,6 +157,58 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp )} {developerTools && } + {developerTools && } + {developerTools && ( + + Encryption + + + ) + } + > + + {rotateState.status === AsyncStatus.Loading ? 'Rotating…' : 'Rotate'} + + + } + > + {rotateState.status === AsyncStatus.Success && ( + + Sessions discarded for {rotateState.data.rotated} of{' '} + {rotateState.data.total} encrypted rooms. Key sharing is starting in the + background — send a message in an affected room to confirm delivery to + bridges. + + )} + {rotateState.status === AsyncStatus.Error && ( + + {rotateState.error.message} + + )} + + + + )} {developerTools && ( ): string[] { + const fromConfig = Object.keys(configExperiments ?? {}); + const fromBuild = Object.keys(INJECTED_EXPERIMENT_FLAGS); + const fromStorage = Object.keys(localStorage) + .filter((k) => k.startsWith(EXPERIMENT_OVERRIDE_PREFIX)) + .map((k) => k.slice(EXPERIMENT_OVERRIDE_PREFIX.length)); + + return Array.from(new Set([...fromConfig, ...fromBuild, ...fromStorage])).toSorted(); +} + +function getEffectiveValue( + key: string, + configExperiments?: Record +): { value: boolean; source: 'override' | 'config' | 'build' | 'default' } { + const lsValue = localStorage.getItem(`${EXPERIMENT_OVERRIDE_PREFIX}${key}`); + if (lsValue !== null) return { value: lsValue === 'true', source: 'override' }; + if (configExperiments && key in configExperiments) + return { value: configExperiments[key] ?? false, source: 'config' }; + if (key in INJECTED_EXPERIMENT_FLAGS) + return { value: INJECTED_EXPERIMENT_FLAGS[key] ?? false, source: 'build' }; + return { value: false, source: 'default' }; +} + +export function ExperimentsPanel() { + const config = useClientConfig(); + const [, forceUpdate] = useState(0); + const refresh = useCallback(() => forceUpdate((n) => n + 1), []); + + const keys = getActiveExperimentKeys(config.experiments); + + if (keys.length === 0) { + return ( + + Experiments + + No experiment flags are defined. Set VITE_FEATURE_* env vars at build time or + add an experiments field to config.json. + + + ); + } + + return ( + + Experiments + + Override experiment flags for this session. Changes are stored in localStorage and take + effect immediately on next render. + + + {keys.map((key) => { + const { value, source } = getEffectiveValue(key, config.experiments); + const hasOverride = source === 'override'; + return ( + + {hasOverride && ( + + )} + { + setExperimentOverride(key, v); + refresh(); + }} + /> + + } + /> + ); + })} + + + ); +} diff --git a/src/app/hooks/useClientConfig.test.ts b/src/app/hooks/useClientConfig.test.ts new file mode 100644 index 000000000..5071c5f7c --- /dev/null +++ b/src/app/hooks/useClientConfig.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest'; +import { selectExperimentVariant, type ExperimentConfig } from './useClientConfig'; + +const baseExperiment: ExperimentConfig = { + enabled: true, + rolloutPercentage: 100, + controlVariant: 'control', + variants: ['alpha', 'beta'], +}; + +describe('selectExperimentVariant', () => { + it('returns control when experiment is disabled', () => { + const result = selectExperimentVariant( + 'threadUI', + { ...baseExperiment, enabled: false }, + '@alice:example.org' + ); + + expect(result.inExperiment).toBe(false); + expect(result.variant).toBe('control'); + }); + + it('returns control when subject id is missing', () => { + const result = selectExperimentVariant('threadUI', baseExperiment, undefined); + + expect(result.inExperiment).toBe(false); + expect(result.variant).toBe('control'); + }); + + it('returns control when rollout is 0', () => { + const result = selectExperimentVariant( + 'threadUI', + { ...baseExperiment, rolloutPercentage: 0 }, + '@alice:example.org' + ); + + expect(result.inExperiment).toBe(false); + expect(result.variant).toBe('control'); + expect(result.rolloutPercentage).toBe(0); + }); + + it('normalizes rollout less than 0 to 0', () => { + const result = selectExperimentVariant( + 'threadUI', + { ...baseExperiment, rolloutPercentage: -10 }, + '@alice:example.org' + ); + + expect(result.inExperiment).toBe(false); + expect(result.variant).toBe('control'); + expect(result.rolloutPercentage).toBe(0); + }); + + it('normalizes rollout greater than 100 to 100', () => { + const result = selectExperimentVariant( + 'threadUI', + { ...baseExperiment, rolloutPercentage: 999 }, + '@alice:example.org' + ); + + expect(result.inExperiment).toBe(true); + expect(result.rolloutPercentage).toBe(100); + expect(['alpha', 'beta']).toContain(result.variant); + }); + + it('falls back to control when variants are missing after filtering', () => { + const result = selectExperimentVariant( + 'threadUI', + { + ...baseExperiment, + variants: ['', 'control'], + }, + '@alice:example.org' + ); + + expect(result.inExperiment).toBe(false); + expect(result.variant).toBe('control'); + }); + + it('is deterministic for the same key and subject', () => { + const first = selectExperimentVariant('threadUI', baseExperiment, '@alice:example.org'); + const second = selectExperimentVariant('threadUI', baseExperiment, '@alice:example.org'); + + expect(second).toEqual(first); + }); + + it('uses default control variant when none is provided', () => { + const result = selectExperimentVariant( + 'threadUI', + { + enabled: true, + rolloutPercentage: 100, + variants: ['alpha'], + }, + '@alice:example.org' + ); + + expect(result.inExperiment).toBe(true); + expect(result.variant).toBe('alpha'); + }); +}); diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 6cb2a9ad3..093b7663c 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -7,6 +7,21 @@ export type HashRouterConfig = { basename?: string; }; +export type ExperimentConfig = { + enabled?: boolean; + rolloutPercentage?: number; + variants?: string[]; + controlVariant?: string; +}; + +export type ExperimentSelection = { + key: string; + enabled: boolean; + rolloutPercentage: number; + variant: string; + inExperiment: boolean; +}; + export type ClientConfig = { defaultHomeserver?: number; homeserverList?: string[]; @@ -16,6 +31,8 @@ export type ClientConfig = { disableAccountSwitcher?: boolean; hideUsernamePasswordFields?: boolean; + experiments?: Record; + pushNotificationDetails?: { pushNotifyUrl?: string; vapidPublicKey?: string; @@ -66,6 +83,84 @@ export function useOptionalClientConfig(): ClientConfig | null { return useContext(ClientConfigContext); } +const DEFAULT_CONTROL_VARIANT = 'control'; + +const normalizeRolloutPercentage = (value?: number): number => { + if (typeof value !== 'number' || Number.isNaN(value)) return 100; + if (value < 0) return 0; + if (value > 100) return 100; + return value; +}; + +const hashToUInt32 = (input: string): number => { + let hash = 0; + for (let index = 0; index < input.length; index += 1) { + hash = (hash * 131 + input.charCodeAt(index)) % 4294967291; + } + return hash; +}; + +export const selectExperimentVariant = ( + key: string, + experiment: ExperimentConfig | undefined, + subjectId: string | undefined +): ExperimentSelection => { + const controlVariant = experiment?.controlVariant ?? DEFAULT_CONTROL_VARIANT; + const variants = (experiment?.variants?.filter((variant) => variant.length > 0) ?? []).filter( + (variant) => variant !== controlVariant + ); + + const enabled = Boolean(experiment?.enabled); + const rolloutPercentage = normalizeRolloutPercentage(experiment?.rolloutPercentage); + + if (!enabled || !subjectId || variants.length === 0 || rolloutPercentage === 0) { + return { + key, + enabled, + rolloutPercentage, + variant: controlVariant, + inExperiment: false, + }; + } + + // Two independent hashes keep rollout and variant assignment stable but decorrelated. + const rolloutBucket = hashToUInt32(`${key}:rollout:${subjectId}`) % 10000; + const rolloutCutoff = Math.floor(rolloutPercentage * 100); + if (rolloutBucket >= rolloutCutoff) { + return { + key, + enabled, + rolloutPercentage, + variant: controlVariant, + inExperiment: false, + }; + } + + const variantIndex = hashToUInt32(`${key}:variant:${subjectId}`) % variants.length; + return { + key, + enabled, + rolloutPercentage, + variant: variants[variantIndex], + inExperiment: true, + }; +}; + +export const useExperimentVariant = (key: string, subjectId?: string): ExperimentSelection => { + const clientConfig = useClientConfig(); + return selectExperimentVariant(key, clientConfig.experiments?.[key], subjectId); +}; + +const EXPERIMENT_OVERRIDE_PREFIX = 'sable_exp_'; + +export const setExperimentOverride = (key: string, value: boolean | null): void => { + if (value === null) { + localStorage.removeItem(`${EXPERIMENT_OVERRIDE_PREFIX}${key}`); + } else { + localStorage.setItem(`${EXPERIMENT_OVERRIDE_PREFIX}${key}`, String(value)); + } +}; + export const clientDefaultServer = (clientConfig: ClientConfig): string => clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'matrix.org';