Round 18: security hardening, account deletion/export, Android native, UX pass #1

Merged
claude merged 17 commits from feat/round-18 into main 2026-09-07 21:36:45 +00:00
Contributor

Opening a PR for the 17-commit feat/round-18 branch, which has been sitting unmerged and contains the security hardening pass everyone flagged as the top risk for this repo, plus the native Android project.

What's in it (114 files, +3518 / -402)

Security & hardening (round 17)

  • CSP headers everywhere
  • deploy stack: tightened secret file permissions, fixed a real secret-leak gap
  • cargo audit: 0 vulnerabilities (recorded in PLAN.md)

Account / privacy

  • account deletion + data export
  • E2EE verification badges
  • Synapse rate limits, a real /health endpoint

Platform

  • Capacitor Android native project generated and building
  • fixed the stale PLAN.md entry claiming the desktop shell is still Electron (the Tauri/CEF migration already landed on main)

UX (rounds 16 & 18)

  • native alert/confirm/prompt → themed in-app dialogs; dark-mode FOUC fix
  • account registration + shareable sign-up links, login-screen theme toggle
  • role management moved into a Space Settings tab
  • "Copy invite link" in the space/room context menu
  • personal custom emoji (server-toggleable), emoji picker grouped by source
  • call screen: per-control quick-settings carets, screen-share quality control
  • Accessibility settings expanded with working features + live preview
  • desktop search/thread panel animations, channel drag-drop indicator

Notes for review

  • Branch is 1 commit behind main (only the .hub/status.json add) — trivial merge, no conflicts expected.
  • No automated test suite in this repo; verification was manual Playwright against a live Matrix server (see PLAN.md).
  • Still deliberately deferred after this: web push, the multi-tenant SaaS layer.

🤖 Opened by Claude (via the ops dashboard) — see PLAN.md rounds 16–18 for the full log.

Opening a PR for the 17-commit `feat/round-18` branch, which has been sitting unmerged and contains the security hardening pass everyone flagged as the top risk for this repo, plus the native Android project. ## What's in it (114 files, +3518 / -402) **Security & hardening (round 17)** - CSP headers everywhere - deploy stack: tightened secret file permissions, fixed a real secret-leak gap - `cargo audit`: 0 vulnerabilities (recorded in PLAN.md) **Account / privacy** - account deletion + data export - E2EE verification badges - Synapse rate limits, a real `/health` endpoint **Platform** - Capacitor **Android** native project generated and building - fixed the stale PLAN.md entry claiming the desktop shell is still Electron (the Tauri/CEF migration already landed on `main`) **UX (rounds 16 & 18)** - native `alert/confirm/prompt` → themed in-app dialogs; dark-mode FOUC fix - account registration + shareable sign-up links, login-screen theme toggle - role management moved into a Space Settings tab - "Copy invite link" in the space/room context menu - personal custom emoji (server-toggleable), emoji picker grouped by source - call screen: per-control quick-settings carets, screen-share quality control - Accessibility settings expanded with working features + live preview - desktop search/thread panel animations, channel drag-drop indicator ## Notes for review - Branch is 1 commit behind `main` (only the `.hub/status.json` add) — trivial merge, no conflicts expected. - No automated test suite in this repo; verification was manual Playwright against a live Matrix server (see PLAN.md). - Still deliberately deferred after this: web push, the multi-tenant SaaS layer. 🤖 Opened by Claude (via the ops dashboard) — see PLAN.md rounds 16–18 for the full log.
Adds a small dialog stack (dialogStore.ts + lib/dialogs.ts + DialogHost.tsx)
rendering confirm/alert/prompt as the app's own styled overlay instead of
the browser's native chrome, which looked jarring next to a Discord-style
UI and couldn't be restyled for dark mode. Every call site across the app
(moderation, roles, categories, devices, calls, room/space settings, etc.)
now awaits uiConfirm/uiAlert/uiPrompt instead of window.confirm & co.

Also fixes a one-second white flash on page load/reload while in dark
mode: theme application (themeStore.initTheme) previously only ran inside
a React useEffect, well after the browser's first paint. index.html now
sets data-theme and a matching background colour synchronously via an
inline script before anything else loads.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5kxZiKde8hWqdi5EBV8dW
Search/thread/thread-list panels used display:contents on desktop, which
made them pop in with no transition (only the mobile full-page variant had
one). Switched to a real flex box so the existing slide-in-right keyframe
can apply.

Channel reordering (space drag & drop already showed a drop-line; channel
drag & drop didn't) tracked its target category/position only in a ref
that never triggered a re-render, so the reorder worked but gave no visual
indication of where the channel would land until it snapped into place on
drop. Mirrors it into state and renders a highlighted line at the actual
drop position (including at the end of a category's list).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5kxZiKde8hWqdi5EBV8dW
- packages/matrix-core/registration.ts: probeRegistration()/registerAccount()
  drive the homeserver's UIA registration flow directly (dummy, registration
  token, terms stages - anything else, e.g. recaptcha, is surfaced as a named
  "unsupported" error rather than silently failing). Verified end-to-end
  against a real local Synapse instance.
- LoginScreen gets a third "Register" tab: probes whether the entered server
  even allows registration (and whether it needs a token) as you type, then
  drives the full sign-up. A "Copy sign-up link" button encodes the current
  homeserver + token into a ?server=&token= URL; opening that link prefills
  both and jumps straight to the register tab, so an invite link actually
  gets someone signed up instead of just showing the bare login form.
- Login/register screen now also has its own dark/light/system toggle
  (previously only available after logging in), backed by a new shared
  THEME_OPTIONS export. The whole app's default preference changes from a
  hardcoded "dark" to "system", matching what "alapból rendszer legyen" asked
  for - first-time users now get their OS preference instead of always dark.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5kxZiKde8hWqdi5EBV8dW
Roles used to live behind their own standalone SpaceRolesModal, reachable
from a header icon button and from the Members pane - so opening it while
Space Settings was already open just replaced it (this app's modal store
is single-slot), a jarring context switch for something conceptually part
of server settings. SpaceRolesModal's content is now SpaceRolesContent,
embedded as its own "roles" pane inside SpaceSettingsModal.

SettingsModalShell gains optional activePaneId/onPaneChange props so a pane
can programmatically switch to a sibling pane (used here: the "Manage
roles" button inside Members jumps straight to the Roles tab). The
spaceSettings modal type also takes an initialPaneId now, so the sidebar's
roles shortcut opens Space Settings directly on that tab instead of a
separate dialog.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5kxZiKde8hWqdi5EBV8dW
Right-clicking a space or channel to invite someone only offered a raw
"type a Matrix ID" prompt - a shareable link existed, but only two clicks
deep in Settings > Invites, easy to miss. Both context menus now also
offer a one-click "Copy invite link" right next to the existing invite
item, reusing the same getRoomInviteLink already used there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5kxZiKde8hWqdi5EBV8dW
- packages/matrix-core/personalEmoji.ts: a per-account custom emoji pack
  (account data, same storage pattern as friends.ts) usable in any room,
  independent of any single space's own emoji pack.
- spaceSettings.ts gains allow_personal_emoji (default on) so a space can
  restrict its rooms to its own emoji only, same idea as the existing
  "Use External Emoji" permission but for personal packs specifically.
  getAllUsableEmojis respects it automatically.
- EmojiPackManager now supports scope="personal" (User Settings > Personal
  emoji) alongside the existing room/space scopes, and Space Settings >
  Emoji got the new toggle next to the server emoji manager.
- RoomEmoji now carries sourceId/sourceName, and EmojiPicker's Custom tab
  groups by it - previously every joined room's custom emoji were dumped
  into one flat, unlabeled grid with no indication of which server/channel
  any given emoji actually came from.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5kxZiKde8hWqdi5EBV8dW
Mic, camera and screen-share now each have a small caret badge (Discord-
style) opening a focused popover right there instead of every device/PTT/
noise-suppression/quality setting living behind one single "call settings"
gear far from the control it affects:
- Mic caret: input device, mic volume, noise suppression, push-to-talk.
- Camera caret: input device.
- Screen share caret: resolution/framerate (new - previously not
  adjustable at all), backed by a small localStorage preference
  (lib/screenShareSettings.ts) built on livekit-client's own
  ScreenSharePresets. Changing it while already sharing restarts the
  local screen-share track with the new settings.

The original "call settings" gear (speaker device/volume, soundboard,
call sounds) is unchanged - this adds a faster path for the
most-frequently-touched settings, it doesn't replace the full one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5kxZiKde8hWqdi5EBV8dW
Previously just reduced motion + density + font scale. Restructured into
Discord's own Accessibility categories, each with genuinely functioning
(not cosmetic) controls, plus a live "Preview" card at the top (mirrors
Discord's own) - a fake message rendered with the exact same classes real
messages use, so every toggle below visibly changes it immediately:

- Text readability: font scale (existing), message line spacing (new),
  always-underline-links (new, WCAG 1.4.1 - colour alone shouldn't be the
  only thing marking a link).
- Visual density: existing compact/cozy/roomy switch.
- Color & contrast: high-contrast mode (new - flattens muted text to full
  contrast, thickens faint borders).
- Reduced motion: existing toggle, its own section now.
- Audio & screen reader: always-visible focus outlines (new, WCAG 2.4.7),
  and "announce new messages" (new) - mirrors NotificationManager's
  already-correct new-message detection into a visually-hidden aria-live
  region (lib/srAnnouncer.ts) so screen-reader users get read the same
  messages a sighted user would notice as a popup/sound.

Deliberately did NOT add a colour-blindness "simulation" filter some UIs
offer under this heading - simulating a deficiency is for people WITHOUT
it to preview what they'd miss, not something someone who actually has
that deficiency would want turned on for themselves, and doing the real
compensating (daltonization) transform correctly needs more rigor than
this pass could safely verify.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5kxZiKde8hWqdi5EBV8dW
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5kxZiKde8hWqdi5EBV8dW
Native-dialog replacement, dark-mode FOUC fix, account registration +
deep-link sign-up, login-screen theme toggle, role management moved into
Space Settings, invite-link discoverability, animated search/thread
panels, call quick-settings carets + screen-share quality, expanded
Accessibility settings with a live preview, personal custom emoji,
source-grouped emoji picker, and a channel drag-drop indicator. See
PLAN.md's "Tizenhatodik önálló kör" for the full list and the security
self-review notes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5kxZiKde8hWqdi5EBV8dW
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5kxZiKde8hWqdi5EBV8dW
Tauri (apps/desktop): security.csp was explicitly null (fully disabled).
Set a real policy scoped to what this app actually needs (WASM crypto,
any-homeserver connect/img/media, third-party embed iframes) - verified
via `cargo check` and cross-checked against Tauri's own documented
ipc:/http://ipc.localhost example for the invoke bridge. Also added
Cross-Origin-Opener-Policy/X-Content-Type-Options via the same config.
Left withGlobalTauri and the existing capabilities (already minimal: no
fs/shell/http permissions) as they are - reviewed and low-risk. Confirmed
apps/desktop/src-tauri/src/main.rs's on_navigation/is_safe_external_url
already correctly lock the webview to its own origin and only allow
https/http/mailto out to the OS browser - no changes needed there.

Caddy (deploy/caddy/Caddyfile): added baseline hardening headers (HSTS,
X-Content-Type-Options, Referrer-Policy, hidden Server header) to every
proxied domain, plus a full CSP + X-Frame-Options specifically on the
JorKonvo webapp's own domain. Verified live against the local test stack
(`caddy validate`, then an actual reload + curl showing the headers on a
real proxied response).

deploy/scripts/provision.sh: .env and every rendered config (Postgres
password, Synapse/LiveKit/TURN/MAS secrets) now end up chmod 600, owned
by whichever UID the consuming container actually runs as internally
(991 for Synapse, 65534 for coturn, 65532 for MAS - verified with `docker
top` against real running containers, not just image docs; LiveKit runs
as root so needs no chown). Caught and fixed two real bugs surfaced by
actually re-running the script three times in a row rather than trusting
the diff: a directory-wide chown made the host user unable to re-run it
at all, and a plain overwrite-in-place broke on the second run once a
file was owned by a container UID - both fixed (per-file chown instead
of `-R` on the directory; render() now writes to a temp file and renames
it into place).

Also found and fixed a real secret-leak gap: deploy/livekit.yaml was a
git-TRACKED template that provision.sh renders in place, baking the real
LIVEKIT_API_KEY/SECRET into a file one `git add -A` away from being
committed - every other config already uses a separate .template source
name for exactly this reason. Renamed to livekit.yaml.template and
gitignored the render output to match.

npm audit: clean for production deps; 3 moderate advisories exist only
in the unused-so-far Capacitor CLI's dev-only dependency chain (uuid via
xcode) - not worth a forced breaking upgrade for tooling nothing has
touched yet (native Android/iOS builds haven't been generated).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5kxZiKde8hWqdi5EBV8dW
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5kxZiKde8hWqdi5EBV8dW
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5kxZiKde8hWqdi5EBV8dW
Account deletion & data export (packages/matrix-core/accountDeletion.ts,
dataExport.ts + UserSettingsModal > Account):
- deactivateAccountOrRequireAuth/WithPassword mirror devices.ts's existing
  UIA password-confirmation pattern exactly, for the spec's
  account/deactivate endpoint (optional erase-my-messages checkbox).
- buildDataExport() gives a "download my data" JSON: profile, devices,
  room memberships, and EVERY account-data type this client has synced
  (friends, ignored users, personal emoji, settings, etc.) read straight
  from the SDK's own account-data store rather than a hand-maintained
  list. Deliberately excludes message history - a fundamentally bigger,
  different problem (years of content, E2EE decryption, no bulk API) -
  same honest-scoping precedent as automod.ts/slowmode.ts.

E2EE verification badges (verification.ts's isUserVerified/
watchUserTrustChanges, lib/useVerificationStatus.ts, VerifiedBadge.tsx):
a small shield next to a name in the member list and profile popover,
shown only once this device has actually cross-signing-verified that
user - reacts live to CryptoEvent.UserTrustStatusChanged.

deploy/synapse/homeserver.yaml.template: explicit rc_registration/
rc_login rate limits (registration and login are what THIS deployment's
own newly-added features - open registration, password login - actually
expose to abuse; rc_message/rc_joins left at Synapse's own sane defaults
since a too-strict message limit fails in the much worse direction).
Verified live against the local test stack: Synapse accepted the config
and restarted cleanly, and repeated bad-password attempts actually got
HTTP 429 after the configured burst count.

deploy/caddy/Caddyfile + README.md: found and fixed a real monitoring gap
while building the health-check idea - Synapse's own native `/health`
endpoint was being silently swallowed by the catch-all `handle` block,
so a monitor hitting it would see a 200 "JorKonvo homeserver" response
EVEN WHEN SYNAPSE ITSELF WAS DOWN. Gave it its own handle block (verified
live: /health now returns Synapse's real "OK", not the placeholder).
Documented both this and LiveKit's own health response in a new
"Monitoring" README section for pointing an uptime monitor at.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5kxZiKde8hWqdi5EBV8dW
First real Android build for JorKonvo - PLAN.md had this listed as needing
Android Studio/SDK, neither of which existed on this machine; installed
both from scratch this session (jdk17 + jdk21-openjdk via pacman, Android
cmdline-tools + platform-tools + platform 35 + build-tools 35 via
sdkmanager, @capacitor/android via npm) and got an actual signed debug
APK to build successfully end-to-end with ./gradlew assembleDebug -
verified with aapt, not just "gradle didn't error": correct package id
(dev.szabotar.jorkonvo), correct app label, correct permission list.

Along the way: jdk17 wasn't new enough (AGP needs --release 21+, matching
this Capacitor version's Java target) - jdk21 fixed that. Gradle's asset
compression step also failed hard in this sandbox specifically because it
writes worker temp files straight to the system /tmp, which isn't
writable here - worked around with -Djava.io.tmpdir pointed at a writable
scratch dir (a local environment quirk, not something to fix in the repo).

Added AndroidManifest.xml permissions the generated default didn't have:
CAMERA, RECORD_AUDIO, MODIFY_AUDIO_SETTINGS (voice/video calls - verified
by reading Capacitor's own BridgeWebChromeClient.onPermissionRequest
source: it already bridges a page's getUserMedia to a real Android
runtime-permission prompt for exactly these, PROVIDED they're declared
here first - without this a call would silently fail to get a camera/mic
track) and POST_NOTIFICATIONS (Android 13+ requirement for any
notification). Deliberately did NOT declare
FOREGROUND_SERVICE_MEDIA_PROJECTION for screen share - that needs a real
MediaProjection-backed foreground service nothing here implements yet;
declaring the permission without it would just be misleading.

android/ is now tracked in git (removed the blanket ignore in the root
.gitignore) instead of staying fully generated/disposable - it has a real
hand-edited native file now (the manifest above) that a fresh `cap add
android` wouldn't reproduce. Capacitor's own generated android/.gitignore
already correctly excludes build/, local.properties (machine-specific SDK
path), and copied web assets, so only genuine project source is tracked
(~53 files: manifest, gradle config, MainActivity.java, res/, gradle
wrapper - no build output).

Not done (needs a human): a real device/emulator run (this sandbox has no
display/KVM to verify calls actually prompt for and get camera/mic
permission, only that the APK builds and declares the right permissions),
custom app icon/branding (still Capacitor's generic default - PLAN.md
already flagged final branding as a human decision), a release keystore +
signed release build, and Play Store listing/publishing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5kxZiKde8hWqdi5EBV8dW
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5kxZiKde8hWqdi5EBV8dW
claude merged commit 5b7149572f into main 2026-09-07 21:36:45 +00:00
claude deleted branch feat/round-18 2026-09-07 21:36:45 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
thatumi/JorKonvo!1
No description provided.