Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/devtool-rotate-sessions.md
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions .changeset/feature-flag-env-vars.md
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 30 additions & 0 deletions .github/actions/setup/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/cloudflare-web-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/cloudflare-web-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion knip.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
75 changes: 75 additions & 0 deletions scripts/inject-client-config.js
Original file line number Diff line number Diff line change
@@ -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(', ')}`
);
101 changes: 100 additions & 1 deletion src/app/features/settings/developer-tools/DevelopTools.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand All @@ -26,6 +31,48 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp
const [expand, setExpend] = useState(false);
const [accountDataType, setAccountDataType] = useState<string | null>();

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.
Expand Down Expand Up @@ -110,6 +157,58 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp
)}
</Box>
{developerTools && <SyncDiagnostics />}
{developerTools && <ExperimentsPanel />}
{developerTools && (
<Box direction="Column" gap="100">
<Text size="L400">Encryption</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
focusId="rotate-encryption-sessions"
title="Rotate Encryption Sessions"
description="Discard current Megolm sessions and begin sharing new keys with all room members. Key delivery happens in the background — send a message in each affected room to confirm the bridge has received the new keys."
after={
<Button
onClick={rotateAllSessions}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
disabled={rotateState.status === AsyncStatus.Loading}
before={
rotateState.status === AsyncStatus.Loading && (
<Spinner size="100" variant="Secondary" />
)
}
>
<Text size="B300">
{rotateState.status === AsyncStatus.Loading ? 'Rotating…' : 'Rotate'}
</Text>
</Button>
}
>
{rotateState.status === AsyncStatus.Success && (
<Text size="T200" style={{ color: color.Success.Main }}>
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.
</Text>
)}
{rotateState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
{rotateState.error.message}
</Text>
)}
</SettingTile>
</SequenceCard>
</Box>
)}
{developerTools && (
<AccountData
expand={expand}
Expand Down
Loading
Loading