Compare commits

..
10 Commits
Author SHA1 Message Date
myxelium 718f4a99f0 fix: AppImage required --no-sandbox
Queue Release Build / prepare (push) Successful in 28s
Deploy Web Apps / deploy (push) Failing after 10m14s
Queue Release Build / build-windows (push) Failing after 15m12s
Queue Release Build / build-linux (push) Successful in 43m41s
Queue Release Build / finalize (push) Skipped
Queue Release Build / build-android (push) Successful in 17m25s
Not tested enough but works on my machine
2026-08-14 03:51:23 +02:00
myxeliumandCursor e45e165a6f style: drop the remaining member-ordering disable
Same dead rule as the previous style pass, missed because this file disables
several rules on one line.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 03:21:02 +02:00
myxelium 2a88d62ddf chore(i18n): sync generated en.json with the catalog additions 2026-08-14 03:19:29 +02:00
myxelium 7e2cbcfe6c fix(messages): bound sync rounds and dedupe incoming messages
A sync round that timed out was treated as a clean one, so history gaps were
recorded as complete and never retried. Rounds are now decided by
`message-sync-round.rules`, which separates a finished round from an abandoned
one, and incoming handlers drop duplicates that arrive over two transports.
2026-08-14 03:19:29 +02:00
myxelium 3266581d3c feat(presence): live stream badges, honest ring delivery, cross-signal DMs
- Camera and screen-share badges are decided by `shouldShowStreamIndicator`,
  so a live stream is visible from outside the voice channel and clicking the
  badge joins the streamer's channel before focusing the stream.
- `ringParticipants` reports whether a `direct-call` ring reached anyone; a
  call that reached nobody shows `call.errors.ringUndelivered` instead of
  sitting in "calling" as if it were live.
- Direct messages resolve every local identity alias, so a conversation
  opened from a foreign roster entry lands in the same thread.
2026-08-14 03:19:29 +02:00
myxelium 92c2f578e2 fix(voice): route media on evidence and switch devices without dropping the call
Outgoing voice was gated on the observer's roster copy of the remote user's
voice state, which is signaling gossip. The signal server broadcasts
`user_left` for any socket it declares dead, so a suspended laptop or a flaky
hop wiped that copy and the observer detached its microphone from a peer that
never left the channel - a silent member with no way back through the UI.

`decideVoicePathRouting` now closes a path only on positive evidence: we left
voice, the peer itself reported another channel or none, or the connection is
gone. Missing gossip holds an established path instead. Opening still needs
confirmation, so a guess never starts sending; the same rule gates playback,
camera video, and the microphone a new connection puts in its first offer.
Peers are also asked for their voice state when a connection or data channel
comes up, so a rebuilt path re-confirms itself.

Alongside it, the microphone can be switched mid-call: capture moves to a
device service and rules, the live track is swapped with `replaceTrack` so
the session is never renegotiated, and the speaking indicator follows the new
stream.
2026-08-14 03:19:29 +02:00
myxelium a83f5aa750 fix(realtime): survive signal outages and per-server identities
Peer recovery burned its whole retry budget while signaling was down, then
dropped the tracker with no re-arm, so a peer stayed dead until an unrelated
roster event healed it. Recovery now waits for a usable transport before
spending an attempt.

Initiator election also compared a home actor id against foreign roster ids,
which is not antisymmetric across signal servers - both sides offered, or
neither did. Election moves into `peer-role.rules` and compares ids only
within one signal server's identity space.
2026-08-14 03:19:29 +02:00
myxelium f9e8538c80 feat(auth): recover cross-signal authorization with provision secrets
A client that could not authorize against a foreign signal server was
redirected into a dead end with no way to retry, so servers joined from
another signal route became unreachable.

The home server now stores a per-user provision secret, clients keep it in
their own store, and a recovery service records why authorization failed per
server URL. Invite, server browser, and chat room surface that reason and
offer a retry instead of silently redirecting.
2026-08-14 03:19:29 +02:00
myxelium e49b3ec112 chore: dev-stack switches, shared e2e harness, and desktop shell rules
- `LIVE_RELOAD=false npm run dev` keeps the renderer alive across a machine
  suspend; the reload client otherwise destroys the session under test.
- `dev-peer.sh` plus a separate userdata dir runs a second local peer.
- `tools/voice-probe.js` samples peer state and RTP counters from a live
  window, persisting to localStorage so a renderer reload cannot erase it.
- e2e helpers for voice pairs, peer-role election, and a TURN relay.
- Electron single-instance and dev-client-load decisions move into rules
  files with colocated specs.
2026-08-14 03:19:29 +02:00
myxelium d71e3a98da style: drop obsolete member-ordering eslint disables
`@typescript-eslint/member-ordering` is off repo-wide because bulk member
reordering breaks Angular inject()/field init order, so the per-file disables
are dead weight. Removing them left a blank first line, stripped here too.
2026-08-14 03:19:29 +02:00
219 changed files with 44314 additions and 918 deletions
+1
View File
@@ -58,6 +58,7 @@ Thumbs.db
# Environment & certs
.env
.certs/
.dev-userdata/
/server/data/variables.json
/server/data/metoyou.sqlite
dist-server/*
+5 -13
View File
@@ -1,10 +1,6 @@
# Session Handoff
> **New chat:** attach `@agents-docs/HANDOFF.md` (and only files under Changed files). Say: continue from this handoff; do not redo completed work.
>
> **Agents cannot open a new chat** — overwrite this file, then ask the user to start one.
>
> **Keep this file tiny:** always **overwrite** the whole file (never append). When the task is finished, **clear** back to `Status: none` and empty sections (see below).
> **New chat:** attach `@agents-docs/HANDOFF.md`. Say: continue from this handoff; do not redo completed work.
**Status:** none
@@ -14,16 +10,12 @@
## Changed files
## Decisions (this session only)
## Decisions
## Failed approaches
## Current issue / blockers
## Current issue
## Next steps (exact)
## Next steps
## Commands to continue
## Scope note
Default: `toju-app/` + targeted `electron/` + `.gitea/workflows/`. Out of scope unless listed here: `server/`, `e2e/`, `website/`, `docs-site/`.
## Commands
+23 -1
View File
@@ -8,12 +8,21 @@ Tags help grepping: `rg '\\[attachments\\]' agents-docs/LESSONS-INDEX.md`
## Index
- A hold-on-unknown rule needs every attach site behind it — `[voice] [webrtc] [realtime]`
- Swap a live device with `replaceTrack`; an empty device list is missing evidence — `[voice] [webrtc] [devices]`
- One owner for a toggle the UI mirrors — `[voice] [state] [ui]`
- A timed-out sync round is not a clean one — `[messages] [realtime] [verification]`
- Derive a conversation id from canonical humans, never from the ids on the wire — `[direct-message] [identity]`
- Report whether a call event was delivered before showing a live call — `[direct-call] [verification]`
- Never spend a retry budget on attempts the transport cannot deliver — `[realtime] [recovery]`
- Repair a dead data channel on the live connection before rebuilding the peer — `[realtime] [webrtc] [recovery]`
- Compare peer ids only within one signal server's identity space — `[realtime] [identity] [webrtc]`
- Reproduce initiator/glare bugs with a simultaneous reconnect, not staggered joins — `[testing] [realtime] [webrtc]`
- Keep `NgOptimizedImage` off runtime blob and data URLs — `[angular] [images]`
- Read the exact Obsidian bug note for `fix bug "…"` (one note only) — `[workflow] [bugs]`
- Run `npm run i18n:sync` after editing any `public/i18n/catalog/*.json` file — `[i18n] [testing]`
- Match direct-call recipients against every local identity alias, exactly like DMs already do — `[direct-call] [identity]`
- Resolve outbound direct-call recipient ids to the peer's connected signal identity — `[direct-call] [identity] [signaling]`
- Canonicalize direct conversation ids across cross-signal actor aliases — `[direct-message] [identity]`
- Decide attachment receive admission once at request time; never re-gate size in the chunk handler — `[attachments]`
- Re-queue attachment auto-downloads on every message/room binding event; never trust one transport's ordering — `[attachments] [realtime]`
- Scope per-user UI state by user id, not by the client database — `[persistence] [multi-user] [custom-emoji]`
@@ -57,4 +66,17 @@ Tags help grepping: `rg '\\[attachments\\]' agents-docs/LESSONS-INDEX.md`
- Prove the asked behavior; unit-green is not done — `[verification] [testing] [workflow]`
- Default to `toju-app/` + targeted `electron/` + CI; do not crawl the monorepo — `[workflow] [tokens] [scope]`
- Write/overwrite HANDOFF.md for new chats; clear it when the task finishes — `[workflow] [tokens] [handoff]`
- An outage test that only re-checks the end state is not a guard — `[testing] [verification] [webrtc]`
- coturn hands out a relay candidate but refuses loopback peers by default — `[testing] [webrtc] [turn]`
- Pin a chosen media device with `deviceId: { exact }`, never a bare string — `[webrtc] [media] [electron]`
- A second dev Electron window needs its own `--user-data-dir``[electron] [dev-shell]`
- Never answer `second-instance` by relaunching the app — `[electron] [dev-shell]`
- `ERR_FAILED (-2)` on a dev `loadURL` usually means aborted, not unreachable — `[electron] [dev-shell]`
- Never gate a presence indicator on the observer's own participation — `[ui] [voice] [webrtc]`
- Never test suspend/resume against a live-reloading dev server — `[testing] [dev-shell] [verification]`
- Keep diagnostic history outside the page you are diagnosing — `[testing] [verification]`
- Record whether the user is in a call before calling zero RTP a failure — `[testing] [voice] [verification]`
- Assert continuity when the state you broke repairs itself — `[testing] [voice] [verification]`
- Missing gossip about a peer is not evidence it left voice — `[voice] [webrtc] [realtime]`
- `app.commandLine.appendSwitch` cannot disable the Chromium sandbox — `[electron] [packaging] [linux]`
+161 -7
View File
@@ -25,6 +25,167 @@ Durable rules for AI agents working on this project.
## Lessons
### `app.commandLine.appendSwitch` cannot disable the Chromium sandbox [electron] [packaging] [linux]
- **Trigger:** a packaged Linux build shows only the window background and spams `Unable to access(W_OK|X_OK) /tmp` / `Creating shared memory in /tmp/... failed`, while the same build works when the user types `--no-sandbox`.
- **Rule:** sandbox and Ozone switches only count when they are on the real command line at process start. Never pair a runtime `appendSwitch('no-sandbox')` with `appendSwitch('disable-dev-shm-usage')` — the first is a no-op because the zygote has already forked, the second takes effect and redirects shared memory into `/tmp`, which the still-active sandbox denies forever.
- **Why:** electron-builder's AppImage `AppRun` is a bash script that execs `$APPDIR/<executableName> "$@"` and ignores the bundled desktop entry, so `linux.executableArgs` never reaches a double-click or terminal launch. Only an installed `.desktop` file passes those arguments.
- **Example:** `electron/app/linux-launcher.rules.ts` generates the launcher that `tools/after-pack.js` installs in place of the real binary (renamed `<name>-bin`); it enables `--no-sandbox` only where unprivileged user namespaces are denied (Ubuntu 24.04+ AppArmor, hardened kernels), since an AppImage payload is mounted `nosuid` and cannot fall back to the SUID helper.
### A hold-on-unknown rule needs every attach site behind it [voice] [webrtc] [realtime]
- **Trigger:** replacing a strict media gate with "hold an established path when nothing confirms the peer", while some fast path still attaches the track without asking the rule.
- **Rule:** route every attach site — including the pre-offer shortcut in `createPeerConnection` and every sibling media kind — through the same decision, and refresh all of them on each piece of new evidence.
- **Why:** an ungated attach becomes the guess the rule then protects: the track alone reads as an established path, so `hold` keeps sending indefinitely to a peer that never joined the channel. The strict gate used to erase that mistake on the next pass.
- **Example:** `MediaManager.mayOpenVoicePathToPeer()` gates the first offer in `create-peer-connection.ts`, `syncCameraRouting()` reuses `decideVoicePathRouting`, and `notePeerVoiceReport()` calls `refreshVoiceRouting()` so a departure report reaches the camera too, not just the mic.
### Missing gossip about a peer is not evidence it left voice [voice] [webrtc] [realtime]
- **Trigger:** gating an outgoing media track on the observer's store copy of the *remote* user's `voiceState`, and detaching whenever that copy is absent.
- **Rule:** close a negotiated media path only on positive evidence — we left voice, the peer itself reported leaving or another channel, or the connection is gone; treat absence as unknown and hold the path. Opening a path still requires confirmation, so a guess never starts sending the microphone.
- **Why:** the signal server broadcasts `user_left` for any socket it declares dead, so a suspend or a flaky hop wipes that copy while the peer is still in the channel. The observer then detached its mic permanently — a silent member with no way back through the UI, not even by toggling mute.
- **Example:** `decideVoicePathRouting()` in `toju-app/src/app/domains/voice-session/domain/logic/voice-path-routing.rules.ts`, used by `MediaManager.syncVoiceRouting()` for the mic and `mayHearPeerVoice()` for playback gain.
### Assert continuity when the state you broke repairs itself [testing] [voice] [verification]
- **Trigger:** proving a media cut by wiping a peer from the roster and then checking that audio still flows.
- **Rule:** when the broken state is refreshed by a periodic message, sample the victim second by second across the window instead of asserting an end state.
- **Why:** peers gossip their voice state every 5s (`VOICE_HEARTBEAT_INTERVAL_MS`), so the roster heals moments after the wipe and the mic re-attaches; the end-state check passed against the *unfixed* code and proved nothing. Only a per-second sample showed the peer losing audio.
- **Example:** `assertUninterruptedInboundAudio(peer, 10)` in `e2e/tests/voice/roster-loss-preserves-voice.spec.ts` fails on the first silent second; the earlier `assertTwoWayAudio` after the wipe did not.
### Never test suspend/resume against a live-reloading dev server [testing] [dev-shell] [verification]
- **Trigger:** suspending the machine to check whether a voice call survives sleep/wake, with the windows served by `ng serve`.
- **Rule:** disable the dev server's reload before any suspend/resume test (`LIVE_RELOAD=false npm run dev`), and treat a renderer reload in the results as an invalid run rather than a product finding.
- **Why:** `ng serve --ssl` runs Vite over HTTP/2; the suspend destroys that stream, so on resume Vite throws `The stream has been destroyed` from `viteTransformMiddleware` into the error overlay of every window, and its live-reload client reloads the page. The reload re-bootstraps the app out of the call, so the post-resume readings showed a "connected" peer with zero RTP — which looks exactly like a silently dead call but only meant the reloaded app was no longer in voice.
- **Example:** `dev.sh` appends `--live-reload=false` when `LIVE_RELOAD=false`; the first P7.4 attempt produced 20 `audio stalled` lines that proved nothing.
### Keep diagnostic history outside the page you are diagnosing [testing] [verification]
- **Trigger:** collecting samples into a `window.__probe` array in the DevTools console, then reading them back after the disruptive event.
- **Rule:** persist probe samples to `localStorage` (or outside the renderer entirely) and stamp each sample with a per-load id, so a reload keeps the history and becomes visible evidence instead of silent data loss.
- **Why:** the event under test is often the very thing that destroys in-heap state; a reload wiped every pre-suspend sample while leaving the old console lines on screen, so the probe looked loaded but `__voiceProbe` was undefined and the baseline was gone.
- **Example:** `tools/voice-probe.js` stores samples under `metoyou_voice_probe_v1` and reports `RENDERER RELOADED` when `performance.timeOrigin` changes between samples.
### Record whether the user is in a call before calling zero RTP a failure [testing] [voice] [verification]
- **Trigger:** asserting on inbound/outbound audio packets without also recording voice membership and local mic track state.
- **Rule:** capture `isVoiceConnected()` and the local audio tracks' `readyState` in the same sample as the RTP counters, and only call a stall a stall when the client is supposed to be in voice.
- **Why:** peer connections exist for chat data channels regardless of voice, so "connected with zero audio" is the normal reading outside a call; without the voice flag the two cases are indistinguishable and a healthy app looks broken.
- **Example:** `readLocalMedia()` in `tools/voice-probe.js` logs `in-voice mic=live`, and the stall check is gated on `current.voice === 'in-voice'`.
### Never answer `second-instance` by relaunching the app [electron] [dev-shell]
- **Trigger:** making a second dev launch reuse the open window by restarting the running instance (`app.relaunch(); app.exit(0)`).
- **Rule:** handle a second instance in place — focus and `webContents.reloadIgnoringCache()` — and never relaunch the process from the `second-instance` handler.
- **Why:** the relaunched successor inherits the same dev argument and asks for the single-instance lock while the dying parent still holds it, so it is refused as yet another second instance and the pair respawns forever; every generation also exits `0` instead of the launcher's handoff code, so `concurrently --kill-others` tears down `ng serve` and the API server, and an in-flight `loadURL` dies as `ERR_FAILED (-2)` that reads like an unreachable dev server.
- **Example:** `resolveSecondInstanceAction()` in `electron/app/second-instance.rules.ts` returns `'reload-existing'`, and `deep-links.ts` reloads instead of relaunching.
### Never gate a presence indicator on the observer's own participation [ui] [voice] [webrtc]
- **Trigger:** writing `if (!isUserInCurrentVoiceRoom(...)) return false` before reading a remote user's share/camera state.
- **Rule:** decide a remote indicator from the observed user's state alone; keep the observer's own session out of the input entirely.
- **Why:** a user sharing alone in a voice channel looked idle to everyone outside it, so nobody could tell there was anything to watch — while the peer plane had already delivered the announcement, because `screen-state` goes to every open data channel and not just voice participants.
- **Example:** `shouldShowStreamIndicator()` in `domains/voice-session/domain/logic/stream-indicator.rules.ts`; guarded by `e2e/tests/screen-share/outside-voice-live-indicator.spec.ts`, where the observer never joins voice.
### `ERR_FAILED (-2)` on a dev `loadURL` usually means aborted, not unreachable [electron] [dev-shell]
- **Trigger:** blaming the cert or `ng serve` when Electron logs `ERR_FAILED (-2) loading 'https://127.0.0.1:4200'`.
- **Rule:** read the rejection stack — `stopLoadingListener` means the navigation was stopped (window destroyed, app exiting), so look for whatever killed the process; `SSL=true` already appends `ignore-certificate-errors`.
- **Why:** the cert and the dev server were fine; the app was exiting underneath the load, and chasing TLS wasted the first pass at the bug.
- **Example:** `loadDevelopmentClientWithRetry()` in `electron/window/dev-client-load.rules.ts` retries and never throws, so the window still gets its listeners and shows a readable failure page.
### Pin a chosen media device with `deviceId: { exact }`, never a bare string [webrtc] [media] [electron]
- **Trigger:** the user picks a different microphone or camera and nothing changes — not mid-call, not after leaving and rejoining voice.
- **Rule:** build `getUserMedia` constraints as `deviceId: { exact: id }`, and handle `OverconstrainedError` / `NotFoundError` by retrying once with the system default.
- **Why:** a bare `deviceId: id` is an `ideal` constraint, so Chromium may satisfy it with the device it already had; the feature then looks broken while every unit test passes. `exact` makes the request fail loudly instead, which is why it needs the explicit fallback so an unplugged device degrades rather than killing the call.
- **Example:** `buildMicrophoneConstraints` in `audio-device-selection.rules.ts` plus the single retry with `SYSTEM_DEFAULT_AUDIO_DEVICE_ID` in `media.manager.ts` `captureMicrophone` and `direct-call.service.ts` `captureCallMicrophone`.
### A second dev Electron window needs its own `--user-data-dir` [electron] [dev-shell]
- **Trigger:** launching a second desktop instance for a two-user test; the existing window blinks and reloads and no second window appears.
- **Rule:** launch the peer with its own `--user-data-dir` (`npm run dev:peer`), and never launch the desktop shell from an agent shell.
- **Why:** Electron's single-instance lock is scoped to the `userData` directory, so a default-directory launch hands its argv to the running instance instead; `tools/launch-electron.js` always appends `--metoyou-dev-reload-existing`, and the `second-instance` handler in `electron/app/deep-links.ts` answers that with `app.relaunch(); app.exit(0)`. Separate data dirs are also what give the two windows separate identities.
- **Example:** `dev-peer.sh``--user-data-dir="$DIR/.dev-userdata/$PEER_NAME"`.
### An outage test that only re-checks the end state is not a guard [testing] [verification] [webrtc]
- **Trigger:** writing or trusting a test that breaks something (kills a server, closes a channel), then asserts the feature works again afterwards.
- **Rule:** also assert what must **not** have happened in between — for a call, that the `RTCPeerConnection` was never rebuilt (`countCreatedPeerConnections` unchanged) — and prove the assertion by temporarily injecting the regression.
- **Why:** re-checking only the end state passes for a client that tore the call down and rebuilt it, which the user hears as a dropped call. Injecting `peerManager.closeAllPeers()` on signaling reconnect kept every audio and peer-count assertion green; only the connection-count assertion failed.
- **Example:** `e2e/tests/voice/recovery-preserves-media.spec.ts` — "The call was never rebuilt behind the user back" compares counts captured before `testServer.kill()`.
### coturn hands out a relay candidate but refuses loopback peers by default [testing] [webrtc] [turn]
- **Trigger:** a relay-only test (`iceTransportPolicy: 'relay'`) where candidates gather fine but every peer connection ends up `closed`.
- **Rule:** run a local coturn with `--allow-loopback-peers` (plus `--log-file=stdout --verbose`, or `docker logs` stays empty and readiness cannot be observed).
- **Why:** without it coturn still allocates and Chrome still reports a `typ relay` candidate, so the failure looks like broken app code rather than a blocked relay; connectivity checks to the other 127.x browser are simply dropped.
- **Example:** `e2e/helpers/turn-server.ts``--allow-loopback-peers` next to `--relay-ip=127.0.0.1`.
### Swap a live device with `replaceTrack`; an empty device list is missing evidence [voice] [webrtc] [devices]
- **Trigger:** a settings picker changes a capture device (mic, camera) while a session is live, or code reacts to `devicechange` by re-reading `enumerateDevices()`.
- **Rule:** re-capture, then `replaceTrack` on the existing senders and stop the old track — never tear the session down and rejoin. Treat an empty (or id-less) device list as *no information*: only fall back to the system default when a populated list proves the saved id is gone. Ask for `deviceId` as a preference, not `exact`.
- **Why:** `voice-controls.component.ts` called `disconnect()` then `connect()` for a mic change, so every peer saw a leave/rejoin and the user lost the channel; the settings pickers wrote `localStorage` and applied nothing. `enumerateDevices()` returns `[]` before microphone permission is granted and Firefox never lists audio outputs, so "not in the list" would silently reset a valid choice on startup. A plain track swap on an already negotiated sender needs no SDP exchange, so the swap is invisible to peers.
- **Example:** `MediaManager.switchInputDevice()` + `resolveAudioDeviceSelection()` / `buildMicrophoneConstraints()` in `domains/voice-session/domain/logic/audio-device-selection.rules.ts`, owned by `VoiceAudioDeviceService`; proven by `e2e/tests/voice/live-input-device-change.spec.ts` (outbound audio keeps flowing, no rejoin broadcast).
### One owner for a toggle the UI mirrors [voice] [state] [ui]
- **Trigger:** two surfaces (in-channel controls and a settings modal, a tray and a window) each keep a local `signal` for the same boolean — mute, deafen, camera on.
- **Rule:** keep the state where the effect happens and let every surface read it back through a `computed`; never reset a mirror to a hardcoded value on teardown.
- **Why:** `MediaManager` owned `isMicMuted` / `isSelfDeafened`, but `voice-controls.component.ts` kept its own copies and reset them to `false` in `disconnect()`, so after leaving voice the button said unmuted while the track was still disabled — and playback was un-deafened behind the user's back.
- **Example:** `isMuted = computed(() => this.webrtcService.isMuted())` in `voice-controls.component.ts`; `disconnect()` passes the real state into `voicePlayback.updateDeafened()`.
### A timed-out sync round is not a clean one [messages] [realtime] [verification]
- **Trigger:** deciding a poll/backoff cadence (sync, presence, reconciliation) from a timeout firing with nothing received, or from a fire-and-forget send that "asked" every peer.
- **Rule:** model the round — who was actually reached, who replied, what they reported — and let only a fully answered round with nothing outstanding buy the slow cadence; re-arm the timer from the verdict of the round that just closed, never from the previous one.
- **Why:** `messages-sync.effects.ts` set `lastSyncClean = true` inside `syncTimeout$`, so a round nobody answered dropped the poll from 10s to 15min; `sendToPeer` also returned `void` and only logged when the channel was closed, so peers listed in `getConnectedPeers()` (filled at `connectionState === 'connected'`, before the data channel opens) counted as asked. On top of that, `repeat({ delay })` read the flag at emission time, so a round that discovered missing ids was already committed to a 15-minute wait.
- **Example:** `message-sync-round.rules.ts` (`createInventoryRound` / `recordInventoryReply` / `isInventoryRoundClean`) plus `messages-sync.effects.spec.ts`, which advances fake timers and asserts the fast cadence survives silence, a partial answer, an undelivered request, and a late reply reporting missing ids.
### Derive a conversation id from canonical humans, never from the ids on the wire [direct-message] [identity]
- **Trigger:** building or trusting a composite id (DM thread, call id, dedupe key) made of participant ids that arrived in a payload or came from a roster entry.
- **Rule:** resolve every id through an alias index first (`buildDirectParticipantAliasIndex``getCanonicalDirectConversationId` / `canonicalizeDirectConversationId`), and collapse already-stored alias copies on first touch instead of only fixing new ones.
- **Why:** `getDirectConversationId` sorted the raw pair, so a peer who addressed the local user by a provisioned foreign actor id produced a second thread; the recipient saw two conversations for one human and clicking the peer opened the empty one. Matching aliases for *admission* was already in place, which made the fork look like a delivery bug instead of an id bug.
- **Example:** `e2e/tests/chat/cross-signal-dm-identity.spec.ts` fails with `element(s) not found` for the peer's message the moment the self-alias group is dropped from `DirectMessageService.participantAliasIndex()`.
### Report whether a call event was delivered before showing a live call [direct-call] [verification]
- **Trigger:** calling a fire-and-forget send (`sendCallEvent`, broadcast, notify) and then moving the UI into the success state.
- **Rule:** return the transport result, ring before joining local media, and surface "reached nobody" through the same error signal the view already renders.
- **Why:** `startCall` joined voice first and dropped the boolean from `PeerDeliveryService.sendCallEvent`, so a call to an unreachable peer showed the caller in a live-looking session that would never connect.
- **Example:** `DirectCallService.ringParticipants` sets `deliveryError` (`call.errors.ringUndelivered`) and `private-call.component.ts` folds it into `callErrorMessage`; the e2e drives it with `window.simulateOffline()` on the caller.
### Never spend a retry budget on attempts the transport cannot deliver [realtime] [recovery]
- **Trigger:** writing or reviewing a bounded retry loop (peer reconnect, resync, delivery) that counts attempts before checking whether the channel it needs is even available.
- **Rule:** check the dependency first and defer without counting; spend an attempt only when it can actually reach the far side, and when the budget really does run out publish a state the UI can show and re-arm the loop when the dependency returns.
- **Why:** `peer-recovery.ts` incremented `reconnectAttempts` before `isSignalingConnected()`, so a ~60s signal outage burned all 12 attempts doing nothing, then cleared the timer and deleted the tracker entry with no user-visible state and no re-arm — the peer stayed dead until an unrelated roster event happened to heal it.
- **Example:** `schedulePeerReconnect` now defers while signaling is down, emits `peerRecoveryStatus$` `{ status: 'failed' }` at exhaustion, and `resumeStalledPeerRecovery()` re-arms from `handleSignalingConnectionStatus`.
### Repair a dead data channel on the live connection before rebuilding the peer [realtime] [webrtc] [recovery]
- **Trigger:** handling a closed/failed `RTCDataChannel` by tracking the peer as disconnected and rebuilding the whole `RTCPeerConnection`.
- **Rule:** while the connection is still `connected`, have the deterministically elected initiator create a replacement channel on that same connection (no renegotiation needed — the SCTP transport is already up) and let the other side adopt the incoming channel; rebuild only as the fallback when the replacement never opens.
- **Why:** the control channel dying took voice, camera, and screen share down with it, and `replaceDataChannel` was already implemented and wired but never called — the spec asserted `not.toHaveBeenCalled()` and the README described the soft replacement as if it shipped.
- **Example:** `e2e/tests/voice/recovery-preserves-media.spec.ts` asserts the created-`RTCPeerConnection` count stays at 1 per peer after `closeOpenDataChannels`; forcing the rebuild path makes it fail.
### Compare peer ids only within one signal server's identity space [realtime] [identity] [webrtc]
- **Trigger:** about to compare a remote `peerId` / roster `oderId` against a local id — deterministic initiator election, offer-collision politeness, reconnect election, self-filtering, or the `oderId` stamped into a voice/camera/screen payload.
- **Rule:** resolve the local id for that peer's signal server (`getLocalOderIdForSignalUrl` where the `signalUrl` is in hand, `getIdentifyCredentialsForPeer` inside the peer manager) and elect roles only through `peer-role.rules.ts`; never reach for the home credential.
- **Why:** one human has a different actor id per signal server, so a home-vs-foreign comparison is not antisymmetric — both peers offer (glare) or neither does until the 5s takeover, which is the "some users can't hear each other" report. It also makes your own foreign roster entry fail the self-check, so the client tries to peer with itself.
- **Example:** `realtime-session.service.ts` wired `getLocalOderId` to `getIdentifyCredentials()` (always home) while `shouldInitiatePeer` compared it against foreign roster ids.
### Reproduce initiator/glare bugs with a simultaneous reconnect, not staggered joins [testing] [realtime] [webrtc]
- **Trigger:** writing an e2e for peer election, glare, or "cannot hear each other" and joining clients one after another.
- **Rule:** get every client onto the roster, then reload/reconnect them with `Promise.all` so all pairs elect from the same snapshot, and assert real audio flow plus exactly one initiator per pair.
- **Why:** staggered joins let one side's 1s fallback-offer timer serialize negotiation, so a wrong comparison still converges and the test passes on broken code — three sequential-join runs passed against the known-bad wiring before the simultaneous reconnect made it fail on audio.
- **Example:** `e2e/tests/voice/cross-signal-initiator-election.spec.ts` — 4 users, 2 home signal servers, one shared voice channel, `Promise.all(reload)`.
### Interview before coding; dont guess the fix [workflow] [bugs] [tokens]
- **Trigger:** about to edit product code for a bug/feature after reading the ask or Obsidian note, while acceptance, approach, or scope is still ambiguous or has real alternatives.
@@ -88,13 +249,6 @@ Durable rules for AI agents working on this project.
- **Why:** inbound and outbound identity bugs are independent; fixing admission on the callee does not help if the ring never leaves the caller or hits the wrong `targetUserId` on the wire.
- **Example:** `PeerDeliveryService.sendViaSignaling` + `DirectCallService.resolveRoutableRecipientId`; e2e `e2e/tests/voice/dm-header-call-ring.spec.ts` (callee-home room, people-search call).
### Canonicalize direct conversation ids across cross-signal actor aliases [direct-message] [identity]
- **Trigger:** cross-signal DMs delivered to the callee but the caller never saw replies — User1 had two DM rows for User2, replies landed in the actor-id thread, and DM routes could throw "Cannot use direct messages without a current user" when `openConversation` ran before hydration.
- **Rule:** incoming DM traffic must resolve to the existing home-id conversation for the same peer (`direct-message-conversation-identity.rules.ts`), merge duplicate alias threads on load/receive, queue inbound events until `currentUser` hydrates, and guard route-driven `openConversation` calls when no owner id is available yet.
- **Why:** recipient-alias admission alone is insufficient — if `message.conversationId` still carries a foreign actor id, the client forks a second local thread and the original chat stays empty.
- **Example:** `resolveDirectConversationId` + `mergeAliasDirectConversations` in `DirectMessageService.handleIncomingMessage`; `appendCrossSignalActorAliases` + `pickDeliveryTargetIds` when a foreign-home recipient is addressed by home id but only their shared-room actor id is routed (debug pattern: `c84813bb…` home id vs `e79a95e5…` actor on `signal.toju.app`).
### Decide attachment receive admission once at request time; never re-gate size in the chunk handler [attachments]
- **Trigger:** "Sending files between users doesn't really work" — a browser user clicked Request on a 1050 MB generic file, the request gate (`canReceiveAttachment`) admitted it for in-memory receive, the sender streamed chunks, but `handleFileChunk` still had a leftover hard `size > MAX_AUTO_SAVE_SIZE_BYTES` rejection on the in-memory path, so every chunk was dropped, no ack was ever sent, the sender's `waitForAck` timed out, and the GUI never changed.
@@ -0,0 +1,213 @@
# User Story: Silent crosssignal-server account auth
> **Status:** Open (research complete — not fixed)
> **Priority / Severity:** Critical
> **Area:** authentication, realtime, server-directory
> **Last researched:** 2026-08-12
> **Related docs:** [features/authentication.md](../features/authentication.md), `toju-app/src/app/domains/authentication/`
---
## User story
**As a** signed-in Toju user
**I want** the app to automatically create (or reuse) my account on any additional signal server as soon as I need that server
**So that** I never see a login / authorize prompt again after my initial home-server login, and chat / presence / joins keep working across the whole multi-server network.
---
## Problem statement
The product supports multiple signaling servers. A user registers/logs in once on a **home** signal server. When they later interact with a **foreign** signal server (join/create a room hosted there, open an invite, activate another endpoint, etc.), the client is supposed to **silently provision** a linked account on that server using a local **provision secret**, store a per-server session credential, and continue — without interrupting the UI.
In practice, the **login / authorize screen keeps appearing** (`/login?mode=authorize&serverId=…`) even though the user is already authenticated locally. That breaks the “one login, whole app” contract and feels like the session is constantly dying.
---
## Desired behavior (acceptance criteria)
1. After a successful home login or register, the user is never prompted for credentials again solely because they touched another signal server.
2. The first time the user has business with signal server N (N ≠ home):
- The client ensures a valid per-URL credential exists (register-or-login with the provision secret).
- WebSocket `identify` and protected REST calls use that credentials actor user id + token.
- The home NgRx / local profile stays unchanged.
3. If the preferred username is already taken on the foreign server, the client silently uses the designed suffix strategy (`alice-<homeUserIdPrefix>`) and optional display-name disambiguation — still **without** opening `/login`.
4. Transient `auth_required` (message raced ahead of identify) never opens login and never tears down the home session while a valid local credential exists.
5. Rejected foreign tokens trigger **re-provision** (or credential refresh), not a home logout and not a blocking authorize form when silent provision is possible.
6. Offline / unreachable / incompatible endpoints never open `/login?mode=authorize`.
7. Session restore after app restart still silently provisions foreign servers (provision secret and credentials survive restart on desktop).
8. Settings → Network may show `Authorized` / `Needs sign-in` for diagnostics, but “Needs sign-in” must not become the default path for a normal logged-in user who simply joined a room on another host.
### Explicit non-goals (for this story)
- Changing the home-server password / register UX for first-time users.
- Merging foreign actor ids into a single global server-side identity (home id ≠ foreign provisioned id is expected).
- Removing the authorize UI entirely — it may remain as a **last resort** (e.g. true username collision exhaustion, or user-initiated “Sign in” from Network settings).
---
## Current intended architecture (as designed)
| Concept | Role |
|--------|------|
| Home session | Local profile + credential for `homeSignalServerUrl` |
| Provision secret | Per-install secret generated on home login/register; used as the password when auto-registering/logging into foreign servers |
| Per-signal credential store | `metoyou.signalServerCredentials` — token + actor userId per normalized server URL |
| Legacy token store | `metoyou.authTokens` — still used for REST interceptor / session restore fallback |
| `ensureProvisioned` | Register-or-login on a foreign URL using the provision secret |
| `ensureCredentialForServerUrl` | Gate before foreign room connect / invite / join — provision first; only then optionally navigate to authorize |
| `authorize` login mode | Manual login that only upserts a foreign credential (`authorizeSignalServer`) without resetting home state |
Primary call sites that demand a foreign credential:
- Room signaling connect (`room-signaling-connection.ts`)
- Invite / server-browser join flows
- Active endpoint health → opportunistic `ensureProvisioned`
- `provisionActiveSignalServers$` after `loadCurrentUserSuccess`
Authorize navigation is gated by `shouldNavigateToAuthorizeSignalServer`:
- Endpoint must look **online**
- Provision result is `collision` **or** `skipped` with reason `no-provision-secret`
---
## Research findings — likely causes
These are **code-backed hypotheses** ranked by how directly they produce a login prompt while the user still has a home session.
### Cause A — Missing provision secret → authorize login (primary)
**Mechanism**
1. `SignalServerAuthService.ensureProvisioned` returns `{ kind: 'skipped', reason: 'no-provision-secret' }` when `ProvisionSecretStoreService.getSecret(homeUser.id)` is null.
2. `SignalServerAuthorizeService.ensureCredentialForServerUrl` then calls `navigateToAuthorize``/login?mode=authorize`.
3. Logins authorize mode **does not** auto-redirect away when `currentUser` is set (the leave-login effect explicitly returns early in authorize mode), so the prompt stays on screen.
**Why the secret is often missing**
- Secret is created only in `prepareAuthenticatedUserStorage` via `ensureHomeProvisionSecret`, and **only when both** `user.homeSignalServerUrl` **and** `loginResponse` are present.
- Session restore (`loadCurrentUserSuccess``provisionActiveSignalServers$`) calls `ensureProvisioned` but **never** calls `ensureHomeProvisionSecret` to create a missing secret.
- Web / non-Electron fallback stores the secret in **sessionStorage** (`metoyou.provisionSecret.<userId>`), which dies when the tab/session ends.
- Accounts created before this feature, wiped Electron `userData/provision-secrets/`, or logins that never received a `loginResponse` + home URL pair never get a secret.
**Evidence in code**
- `signal-server-authorize.rules.ts``no-provision-secret` ⇒ navigate to authorize
- `signal-server-authorize.service.spec.ts` — “still provisions foreign servers and navigates to authorize when the secret is missing”
- `users.effects.ts``ensureHomeProvisionSecret` only inside `prepareAuthenticatedUserStorage` with `loginResponse`
### Cause B — Username collision exhaustion → authorize login
**Mechanism**
`SignalServerProvisionerService` tries preferred username, then suffixed candidates. If every register returns 409 and every login with the provision secret returns 401, it throws `ProvisionUsernameCollisionError``kind: 'collision'` → authorize UI.
**When it shows up**
Another user already owns those usernames on the foreign server with different passwords (not our provisioned accounts). Silent recovery is impossible without a different identity strategy or manual credentials.
### Cause C — Home session false expiry → full `/login` (not just authorize)
**Mechanism**
`signalServerAuthFailed$` clears the credential for the failing URL, then:
- `expire-home-session` if the failure is classified as the **home** server → `clearStoredCurrentUserId` + `SESSION_EXPIRED``redirectOnSessionExpired$``/login`
- `provision-foreign` otherwise → silent `ensureProvisioned` (no login UI by itself)
**False home classification risks**
- Missing / stale `homeSignalServerUrl` on the restored user → foreign failures compared with empty home URL → `isSameSignalServerUrl` is false, so this path usually prefers foreign provision; but home failures with no resolvable credential after retries still expire the session.
- Exhausted re-identify retry budget on home while credential lookup fails (empty credential store + broken legacy fallback) → `auth_required` / `auth_error` treated as unrecoverable home expiry.
- Past regressions (see lessons): identifying only from the new credential store, or treating `auth_required` as logout — partially mitigated, but restore edge cases still matter.
### Cause D — Credential present locally but identify never runs / races
**Mechanism**
Without a resolvable token for the foreign URL, the socket sends non-identify traffic → server `auth_required`. If the client then cannot re-identify or re-provision (Cause A), user-facing flows that gate on `ensureCredentialForServerUrl` open authorize login. Presence/chat then look “broken” even though the home profile still shows logged in.
Related lesson: identify must fall back to legacy `AuthTokenStoreService` for **home**; foreign servers **cannot** be reconstructed from the legacy store (actor id differs) — so foreign URLs **must** be provisioned, not guessed.
### Cause E — Opportunistic provision fails quietly; later gate opens login
**Mechanism**
`provisionActiveSignalServers$` and server health `ensureProvisioned(...).catch(() => undefined)` swallow errors. A later user action (join room) hits `ensureCredentialForServerUrl` with the same missing secret / collision and **then** navigates to authorize — so login appears mid-flow rather than at startup.
---
## User-visible scenarios
### Happy path (required)
1. Alice registers on Signal Server 1.
2. Alice browses/joins a community hosted on Signal Server 2.
3. Client silently registers `alice` (or `alice-<prefix>`) on Server 2 with the provision secret.
4. Alice lands in the room; no login modal/page; peers see her presence under the Server 2 actor id.
### Failure path today (bug)
1. Alice is logged in (user bar / local profile show her).
2. Alice opens an invite or room whose `sourceUrl` is Signal Server 2.
3. Client cannot provision (no secret / collision).
4. App navigates to `/login?mode=authorize&serverId=…&returnUrl=…`.
5. Alice believes she was logged out; re-entering home credentials may even bind the wrong server if she is not careful with the server picker.
### Restart path (required)
1. Alice fully quits the desktop app and reopens.
2. Home session restores from local DB + token stores.
3. Touching Server 2 again still silent-provisions or reuses the stored foreign credential — no authorize prompt.
---
## Proof of done (when implementing)
Prefer behavior-level proof over mocks shaped like the provisioner:
1. **Integration / focused effect+service tests**
- Missing secret on restore → secret is ensured, then foreign provision succeeds, **and** `Router.navigate(['/login'])` is never called.
- Foreign `auth_error` with home session intact → re-provision + re-identify; no `SESSION_EXPIRED`.
- Online foreign endpoint + successful provision → `ensureCredentialForServerUrl` returns `true`.
2. **Manual / E2E**
- Two live signal servers; register on #1; join room on #2 without typing a password again; reload app; rejoin still silent.
3. **Negative**
- Offline foreign endpoint must not open authorize login.
---
## Likely fix directions (for a later interview — not approved yet)
| Option | Idea | Tradeoff |
|--------|------|----------|
| **A (recommended)** | On session restore / before any foreign `ensureProvisioned`, call `ensureHomeProvisionSecret` so a missing secret is generated once and persisted; keep authorize UI only for true collision / user-initiated sign-in | New secret cannot unlock accounts previously provisioned with an old lost secret — may need re-register with suffix or collision path |
| **B** | Stop navigating to authorize on `no-provision-secret`; surface a non-blocking Network badge / toast and retry when secret becomes available | User may join without credential and hit silent presence failures |
| **C** | Derive a stable provision secret from a durable local key (not sessionStorage) on web so restarts keep the same secret | Crypto/key-storage design; still need migration for existing installs |
| **D** | For collisions, auto-pick a stronger unique username (e.g. always include fuller home user id) before opening authorize | Reduces but does not eliminate collision UX |
---
## Key files
- `toju-app/src/app/domains/authentication/application/services/signal-server-authorize.service.ts`
- `toju-app/src/app/domains/authentication/application/services/signal-server-auth.service.ts`
- `toju-app/src/app/domains/authentication/application/services/signal-server-provisioner.service.ts`
- `toju-app/src/app/domains/authentication/application/services/provision-secret-store.service.ts`
- `toju-app/src/app/domains/authentication/domain/logic/signal-server-authorize.rules.ts`
- `toju-app/src/app/domains/authentication/domain/logic/signal-server-auth-failure.rules.ts`
- `toju-app/src/app/store/users/users.effects.ts` (`signalServerAuthFailed$`, `provisionActiveSignalServers$`, `redirectOnSessionExpired$`, `prepareAuthenticatedUserStorage`)
- `toju-app/src/app/store/rooms/room-signaling-connection.ts`
- `electron/api/provision-secret-store.ts`
- `agents-docs/features/authentication.md`
---
## Lessons already adjacent
- Identify must fall back to the legacy session token (home restore).
- Keep per-signal-URL identify credentials resolvable from the store.
- Persisted local user state still requires a session token.
- Do not open authorize login for offline endpoints.
- Distinguish `auth_required` vs `auth_error` so home session is not falsely expired.
Executable
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Launch one more Electron window against an already-running dev stack.
#
# Electron's single-instance lock is scoped to the userData directory, so a peer
# window only gets its own lock — and its own identity — when it gets its own
# --user-data-dir. Without that, tools/launch-electron.js hands its argv to the
# running instance and the dev-reload path just reloads window A instead.
set -e
DIR="$(cd "$(dirname "$0")" && pwd)"
PEER_NAME="${1:-peer}"
PEER_DATA_DIR="$DIR/.dev-userdata/$PEER_NAME"
if [ -f "$DIR/.env" ]; then
set -a
source "$DIR/.env"
set +a
fi
SSL="${SSL:-false}"
if [ "$SSL" = "true" ]; then
CLIENT_URL="https://127.0.0.1:4200"
export NODE_TLS_REJECT_UNAUTHORIZED=0
else
CLIENT_URL="http://127.0.0.1:4200"
fi
if ! npx wait-on --timeout 5000 "$CLIENT_URL" >/dev/null 2>&1; then
echo "No dev client at $CLIENT_URL — start the stack with 'npm run dev' first." >&2
exit 1
fi
mkdir -p "$PEER_DATA_DIR"
echo "Launching peer window '$PEER_NAME' (data dir: $PEER_DATA_DIR)"
echo "Reminder: a fresh peer needs https://localhost:3001 added under Settings -> signal servers."
exec npx cross-env NODE_ENV=development SSL="$SSL" node tools/launch-electron.js . \
--no-sandbox \
--disable-dev-shm-usage \
--user-data-dir="$PEER_DATA_DIR"
+14 -2
View File
@@ -12,6 +12,18 @@ if [ -f "$DIR/.env" ]; then
fi
SSL="${SSL:-false}"
LIVE_RELOAD="${LIVE_RELOAD:-true}"
# Suspending the machine tears down the dev-server connection. The live-reload client
# answers the reconnect by reloading the page, which destroys the very session under
# test (voice call, peer connections, console state). Set LIVE_RELOAD=false to keep the
# renderer alive across a suspend. Editing client files then has no effect until restart.
NG_RELOAD_FLAG=""
if [ "$LIVE_RELOAD" != "true" ]; then
NG_RELOAD_FLAG=" --live-reload=false"
echo "Live reload disabled: client edits will NOT reach the running window."
fi
if [ "$SSL" = "true" ]; then
# Ensure certs exist
@@ -20,13 +32,13 @@ if [ "$SSL" = "true" ]; then
"$DIR/generate-cert.sh"
fi
NG_SERVE="cd toju-app && npx ng serve --host=0.0.0.0 --ssl --ssl-cert=../.certs/localhost.crt --ssl-key=../.certs/localhost.key"
NG_SERVE="cd toju-app && npx ng serve --host=0.0.0.0 --ssl --ssl-cert=../.certs/localhost.crt --ssl-key=../.certs/localhost.key$NG_RELOAD_FLAG"
# Use 127.0.0.1 so wait-on does not hit a stale HTTP listener on localhost (::1).
WAIT_URL="https://127.0.0.1:4200"
HEALTH_URL="https://127.0.0.1:3001/api/health"
export NODE_TLS_REJECT_UNAUTHORIZED=0
else
NG_SERVE="cd toju-app && npx ng serve --host=0.0.0.0"
NG_SERVE="cd toju-app && npx ng serve --host=0.0.0.0$NG_RELOAD_FLAG"
WAIT_URL="http://127.0.0.1:4200"
HEALTH_URL="http://127.0.0.1:3001/api/health"
fi
+112
View File
@@ -0,0 +1,112 @@
import { expect, type Page } from '@playwright/test';
export interface PeerRoleEdge {
/** Remote peer id, in the identity space of the signal server routing that peer. */
peerId: string;
/** Our own actor id in that same identity space. */
localActorId: string | null;
isInitiator: boolean;
connectionState: string;
}
/**
* Read who elected themselves initiator for every active peer, together with the local
* actor id in that peer's identity space. Both halves of a pair must describe the same
* two ids, which is what makes the election comparable in the first place.
*/
export async function readPeerRoleEdges(page: Page): Promise<PeerRoleEdge[]> {
return await page.evaluate(() => {
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
interface PeerDataShape {
connection?: { connectionState?: string };
isInitiator?: boolean;
}
interface RealtimeShape {
peerManager?: { activePeerConnections?: Map<string, PeerDataShape> };
signalingTransportHandler?: {
getIdentifyCredentialsForPeer?: (peerId: string) => { oderId?: string } | null;
};
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return [];
}
const realtime = debugApi.getComponent(host)['realtime'] as RealtimeShape | undefined;
const peers = realtime?.peerManager?.activePeerConnections;
if (!peers) {
return [];
}
const edges: PeerRoleEdge[] = [];
peers.forEach((peerData, peerId) => {
const credentials = realtime?.signalingTransportHandler?.getIdentifyCredentialsForPeer?.(peerId);
edges.push({
connectionState: peerData.connection?.connectionState ?? 'unknown',
isInitiator: peerData.isInitiator === true,
localActorId: credentials?.oderId ?? null,
peerId
});
});
return edges;
});
}
/**
* How many RTCPeerConnections this page has created since load. A clean session creates
* exactly one per remote peer; a rebuilt peer - for example a non-initiator that gave up
* waiting for an offer that was never elected to be sent - adds another.
*/
export async function countCreatedPeerConnections(page: Page): Promise<number> {
return await page.evaluate(() =>
((window as unknown as { __rtcConnections?: RTCPeerConnection[] }).__rtcConnections ?? []).length
);
}
/**
* Every connected pair must have exactly one initiator. Comparing ids from two different
* identity spaces breaks the antisymmetry of the election, so both peers offer (glare) or
* neither does until a takeover timer fires.
*/
export function expectExactlyOneInitiatorPerPair(edgesByClient: Record<string, PeerRoleEdge[]>): void {
const directed = new Map<string, boolean>();
const pairs = new Set<string>();
for (const [clientName, edges] of Object.entries(edgesByClient)) {
for (const edge of edges) {
expect(
edge.localActorId,
`${clientName} has no local actor id in the identity space of peer ${edge.peerId}`
).toBeTruthy();
const localActorId = edge.localActorId as string;
directed.set(`${localActorId}->${edge.peerId}`, edge.isInitiator);
pairs.add([localActorId, edge.peerId].sort().join('<->'));
}
}
expect(pairs.size, 'expected at least one peer pair').toBeGreaterThan(0);
for (const pair of pairs) {
const [first, second] = pair.split('<->');
const forward = directed.get(`${first}->${second}`);
const backward = directed.get(`${second}->${first}`);
expect(forward, `missing peer connection ${first} -> ${second}`).not.toBeUndefined();
expect(backward, `missing peer connection ${second} -> ${first}`).not.toBeUndefined();
expect(
[forward, backward].filter(Boolean),
`expected exactly one initiator for pair ${pair}`
).toHaveLength(1);
}
}
+9 -1
View File
@@ -22,7 +22,11 @@ const SERVER_ENTRY = existsSync(SERVER_DIST_ENTRY) ? SERVER_DIST_ENTRY : SERVER_
const USE_COMPILED_SERVER = SERVER_ENTRY === SERVER_DIST_ENTRY;
// ── Create isolated temp data directory ──────────────────────────────
const tmpDir = mkdtempSync(join(tmpdir(), 'metoyou-e2e-'));
// The Playwright helper supplies a durable directory when a test needs to
// restart the signaling process on the same port without losing its database.
const suppliedTmpDir = process.env.TEST_SERVER_DATA_DIR;
const ownsTmpDir = !suppliedTmpDir;
const tmpDir = suppliedTmpDir || mkdtempSync(join(tmpdir(), 'metoyou-e2e-'));
const dataDir = join(tmpDir, 'data');
mkdirSync(dataDir, { recursive: true });
@@ -81,6 +85,10 @@ child.on('exit', (code) => {
// ── Cleanup on signals ───────────────────────────────────────────────
function cleanup() {
if (!ownsTmpDir) {
return;
}
try {
rmSync(tmpDir, { recursive: true, force: true });
console.log(`[E2E Server] Cleaned up temp dir: ${tmpDir}`);
+83 -16
View File
@@ -1,11 +1,18 @@
import { spawn, type ChildProcess } from 'node:child_process';
import { once } from 'node:events';
import { mkdtemp, rm } from 'node:fs/promises';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
export interface TestServerHandle {
port: number;
url: string;
restart: () => Promise<void>;
/** Kill the process but keep the port and data dir, so `start()` can bring it back. */
kill: () => Promise<void>;
/** Start the server again on the same port and data dir after `kill()`. */
start: () => Promise<void>;
stop: () => Promise<void>;
}
@@ -15,10 +22,85 @@ const START_SERVER_SCRIPT = join(E2E_DIR, 'helpers', 'start-test-server.js');
export async function startTestServer(retries = 3): Promise<TestServerHandle> {
for (let attempt = 1; attempt <= retries; attempt++) {
const port = await allocatePort();
const dataDir = await mkdtemp(join(tmpdir(), 'metoyou-e2e-handle-'));
let child: ChildProcess | null = null;
let stopped = false;
try {
child = await spawnTestServer(port, dataDir);
} catch (error) {
await rm(dataDir, { recursive: true, force: true });
if (attempt < retries) {
console.log(`[E2E Server] Attempt ${attempt} failed, retrying...`);
continue;
}
throw error;
}
return {
port,
url: `http://localhost:${port}`,
restart: async () => {
if (stopped) {
throw new Error('Cannot restart a stopped test server');
}
if (child) {
await stopServer(child);
}
child = await spawnTestServer(port, dataDir);
},
kill: async () => {
if (stopped) {
throw new Error('Cannot kill a stopped test server');
}
if (child) {
await stopServer(child);
child = null;
}
},
start: async () => {
if (stopped) {
throw new Error('Cannot start a stopped test server');
}
if (child) {
return;
}
child = await spawnTestServer(port, dataDir);
},
stop: async () => {
if (stopped) {
return;
}
stopped = true;
if (child) {
await stopServer(child);
child = null;
}
await rm(dataDir, { recursive: true, force: true });
}
};
}
throw new Error('startTestServer: unreachable');
}
async function spawnTestServer(port: number, dataDir: string): Promise<ChildProcess> {
const child = spawn(process.execPath, [START_SERVER_SCRIPT], {
cwd: E2E_DIR,
env: {
...process.env,
TEST_SERVER_DATA_DIR: dataDir,
TEST_SERVER_PORT: String(port)
},
stdio: 'pipe'
@@ -36,25 +118,10 @@ export async function startTestServer(retries = 3): Promise<TestServerHandle> {
await waitForServerReady(port, child);
} catch (error) {
await stopServer(child);
if (attempt < retries) {
console.log(`[E2E Server] Attempt ${attempt} failed, retrying...`);
continue;
}
throw error;
}
return {
port,
url: `http://localhost:${port}`,
stop: async () => {
await stopServer(child);
}
};
}
throw new Error('startTestServer: unreachable');
return child;
}
async function allocatePort(): Promise<number> {
+188
View File
@@ -0,0 +1,188 @@
import { type BrowserContext, type Page } from '@playwright/test';
import type { WebRtcTestHarnessWindow } from './webrtc-test-window.types';
/** Same shape `IceServerSettingsService` persists under `metoyou_ice_servers`. */
interface StoredIceServerEntry {
id: string;
type: 'stun' | 'turn';
urls: string;
username?: string;
credential?: string;
}
export interface TurnCredentials {
urls: string;
username: string;
credential: string;
}
const ICE_SERVERS_STORAGE_KEY = 'metoyou_ice_servers';
/**
* Configure the app with a single TURN server, the way a user would in
* Settings -> ICE servers. Nothing test-specific reads this back: the app loads
* it through `IceServerSettingsService`, so the call really is configured the
* product way.
*
* Call BEFORE any `goto()`.
*/
export async function seedTurnOnlyIceServers(
target: BrowserContext | Page,
turn: TurnCredentials
): Promise<void> {
const entries: StoredIceServerEntry[] = [
{
credential: turn.credential,
id: 'e2e-turn',
type: 'turn',
urls: turn.urls,
username: turn.username
}
];
await target.addInitScript(
([key, value]) => {
localStorage.setItem(key, value);
},
[ICE_SERVERS_STORAGE_KEY, JSON.stringify(entries)] as const
);
}
/**
* Take away the direct path. Every `RTCPeerConnection` is built with
* `iceTransportPolicy: 'relay'`, so host and server-reflexive candidates are
* discarded and the call can only succeed by relaying through the configured
* TURN server - which is what a user behind symmetric NAT is forced to do.
*
* Install AFTER `installWebRTCTracking` (it wraps whatever constructor is
* current) and BEFORE any `goto()`.
*/
export async function forceRelayOnlyIce(target: BrowserContext | Page): Promise<void> {
await target.addInitScript(() => {
const harness = window as unknown as WebRtcTestHarnessWindow & {
__relayIceConfigs?: RTCConfiguration[];
};
const Wrapped = harness.RTCPeerConnection;
harness.__relayIceConfigs = [];
const RelayOnly = function(this: RTCPeerConnection, config?: RTCConfiguration) {
const relayConfig: RTCConfiguration = { ...config, iceTransportPolicy: 'relay' };
harness.__relayIceConfigs?.push(relayConfig);
return new Wrapped(relayConfig);
} as unknown as typeof RTCPeerConnection;
RelayOnly.prototype = Wrapped.prototype;
Object.setPrototypeOf(RelayOnly, Wrapped);
harness.RTCPeerConnection = RelayOnly;
});
}
/**
* The configuration each peer connection was actually built with. A relay-only
* run that connects nothing usually means the app handed over no TURN server at
* all, which looks identical to a broken relay from the outside.
*/
export async function getRelayIceConfigs(page: Page): Promise<RTCConfiguration[]> {
return await page.evaluate(() =>
(window as unknown as { __relayIceConfigs?: RTCConfiguration[] }).__relayIceConfigs ?? []
);
}
export interface SelectedCandidatePair {
localCandidateType: string;
remoteCandidateType: string;
}
/**
* The candidate pair each connection actually settled on. `relay` on the local
* side means our packets left through the TURN server rather than going direct.
*/
export async function getSelectedCandidatePairs(page: Page): Promise<SelectedCandidatePair[]> {
return await page.evaluate(async () => {
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections ?? [];
const pairs: SelectedCandidatePair[] = [];
for (const pc of connections) {
let stats: RTCStatsReport;
try {
stats = await pc.getStats();
} catch {
continue;
}
const candidates = new Map<string, string>();
let selected: { localCandidateId?: string; remoteCandidateId?: string } | null = null;
stats.forEach((report) => {
if (report.type === 'local-candidate' || report.type === 'remote-candidate') {
candidates.set(report.id as string, (report as { candidateType?: string }).candidateType ?? 'unknown');
}
});
stats.forEach((report) => {
if (report.type !== 'candidate-pair') {
return;
}
const pair = report as unknown as {
state?: string;
nominated?: boolean;
selected?: boolean;
localCandidateId?: string;
remoteCandidateId?: string;
};
if (pair.state === 'succeeded' && (pair.nominated || pair.selected)) {
selected = pair;
}
});
if (!selected) {
continue;
}
const pair = selected as { localCandidateId?: string; remoteCandidateId?: string };
pairs.push({
localCandidateType: candidates.get(pair.localCandidateId ?? '') ?? 'unknown',
remoteCandidateType: candidates.get(pair.remoteCandidateId ?? '') ?? 'unknown'
});
}
return pairs;
});
}
/**
* Wait until `expectedPairs` connections report a settled candidate pair whose
* local candidate is a TURN relay.
*/
export async function waitForRelayedCandidatePairs(
page: Page,
expectedPairs: number,
timeoutMs = 60_000
): Promise<SelectedCandidatePair[]> {
const deadline = Date.now() + timeoutMs;
let latest: SelectedCandidatePair[] = [];
while (Date.now() < deadline) {
latest = await getSelectedCandidatePairs(page);
const relayed = latest.filter((pair) => pair.localCandidateType === 'relay');
if (relayed.length >= expectedPairs) {
return latest;
}
await page.waitForTimeout(1_000);
}
throw new Error(
`Timed out waiting for ${expectedPairs} relayed candidate pairs. Last seen: ${JSON.stringify(latest)}`
);
}
+137
View File
@@ -0,0 +1,137 @@
import { execFile } from 'node:child_process';
import { createServer } from 'node:net';
import { promisify } from 'node:util';
const run = promisify(execFile);
export interface TurnServerHandle {
urls: string;
username: string;
credential: string;
stop: () => Promise<void>;
}
const IMAGE = 'coturn/coturn:latest';
const CONTAINER_NAME = 'metoyou-e2e-turn';
const USERNAME = 'e2e';
const CREDENTIAL = 'e2epass';
const RELAY_MIN_PORT = 49_160;
const RELAY_MAX_PORT = 49_200;
/** Whether a working Docker daemon is reachable, so a spec can skip instead of failing. */
export async function isDockerAvailable(): Promise<boolean> {
try {
await run('docker', ['info'], { timeout: 15_000 });
return true;
} catch {
return false;
}
}
/**
* Run a throwaway coturn on the loopback interface. Relay-only tests need a real
* TURN server: `iceTransportPolicy: 'relay'` discards every other candidate, so
* without one there is no path at all and the test would prove nothing.
*/
export async function startTurnServer(): Promise<TurnServerHandle> {
await removeContainer();
const port = await allocatePort();
await run('docker', [
'run',
'--detach',
'--name',
CONTAINER_NAME,
'--network',
'host',
IMAGE,
'-n',
`--listening-port=${port}`,
'--listening-ip=127.0.0.1',
'--relay-ip=127.0.0.1',
`--min-port=${RELAY_MIN_PORT}`,
`--max-port=${RELAY_MAX_PORT}`,
'--lt-cred-mech',
`--user=${USERNAME}:${CREDENTIAL}`,
// Both browsers are on this machine. Without this coturn still hands out a
// relay candidate but refuses to forward to a 127.x peer, so ICE fails in a
// way that looks like a broken app rather than a blocked relay.
'--allow-loopback-peers',
'--realm=metoyou.test',
'--fingerprint',
'--no-tls',
'--no-dtls',
// Readiness is read back off `docker logs`: coturn logs to a file inside the
// container unless pointed at stdout, and the per-listener lines only appear
// at verbose level.
'--log-file=stdout',
'--verbose'
], { timeout: 120_000 });
await waitForTurnPort(port);
return {
credential: CREDENTIAL,
stop: removeContainer,
urls: `turn:127.0.0.1:${port}?transport=udp`,
username: USERNAME
};
}
async function removeContainer(): Promise<void> {
try {
await run('docker', [
'rm',
'--force',
CONTAINER_NAME
], { timeout: 30_000 });
} catch {
// No such container - nothing to clean up.
}
}
async function waitForTurnPort(port: number, timeoutMs = 20_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const { stdout, stderr } = await run('docker', ['logs', CONTAINER_NAME], { timeout: 10_000 })
.catch(() => ({ stderr: '', stdout: '' }));
if (`${stdout}${stderr}`.includes(`UDP listener opened on: 127.0.0.1:${port}`)) {
return;
}
await delay(250);
}
throw new Error(`coturn did not open a UDP listener on 127.0.0.1:${port}`);
}
/** coturn binds this itself, so only probe for a free port and hand it over. */
async function allocatePort(): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const probe = createServer();
probe.once('error', reject);
probe.listen(0, '127.0.0.1', () => {
const address = probe.address();
if (!address || typeof address === 'string') {
probe.close();
reject(new Error('Failed to resolve an ephemeral TURN port'));
return;
}
const { port } = address;
probe.close((error) => (error ? reject(error) : resolve(port)));
});
});
}
function delay(durationMs: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, durationMs);
});
}
+214
View File
@@ -0,0 +1,214 @@
import { expect, type Page } from '@playwright/test';
import type { Client } from '../fixtures/multi-client';
import { ChatRoomPage } from '../pages/chat-room.page';
import { RegisterPage } from '../pages/register.page';
import { ServerSearchPage } from '../pages/server-search.page';
import {
installAutoResumeAudioContext,
installWebRTCTracking,
waitForAudioStatsPresent,
waitForConnectedPeerCount,
waitForOpenDataChannelCount
} from './webrtc-helpers';
const PAIR_PASSWORD = 'TestPass123!';
export interface VoicePairClient extends Client {
displayName: string;
username: string;
}
/**
* Register two fresh users, put them in a new server, and connect both to one voice
* channel with the WebRTC tracking harness installed. Returns once both sides report a
* connected peer, an open data channel, and live audio stats.
*/
export async function createVoicePairInNewServer(
createClient: () => Promise<Client>,
serverName: string,
options: { channelName?: string; namePrefix?: string } = {}
): Promise<VoicePairClient[]> {
const channelName = options.channelName ?? 'General';
const namePrefix = options.namePrefix ?? 'Voice Pair';
const uniqueSuffix = Date.now();
const clients: VoicePairClient[] = [];
for (let index = 0; index < 2; index++) {
const client = await createClient();
await installDeterministicVoiceSettings(client.page);
await installWebRTCTracking(client.page);
await installAutoResumeAudioContext(client.page);
clients.push({
...client,
displayName: `${namePrefix} ${index + 1}`,
username: `voice_pair_${uniqueSuffix}_${index + 1}`
});
}
for (const client of clients) {
const registerPage = new RegisterPage(client.page);
await registerPage.goto();
await registerPage.register(client.username, client.displayName, PAIR_PASSWORD);
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
}
await new ServerSearchPage(clients[0].page).createServer(serverName, { description: `${namePrefix} voice session` });
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
await new ServerSearchPage(clients[1].page).joinServerFromSearch(serverName);
await expect(clients[1].page).toHaveURL(/\/room\//, { timeout: 20_000 });
await new ChatRoomPage(clients[0].page).ensureVoiceChannelExists(channelName);
for (const client of clients) {
const room = new ChatRoomPage(client.page);
await room.joinVoiceChannel(channelName);
await expect(room.voiceControls).toBeVisible({ timeout: 20_000 });
}
for (const client of clients) {
await waitForConnectedPeerCount(client.page, 1, 90_000);
await waitForOpenDataChannelCount(client.page, 1, 90_000);
await waitForAudioStatsPresent(client.page, 30_000);
}
return clients;
}
/** Pin voice settings so audio levels and codecs do not vary between runs. */
export async function installDeterministicVoiceSettings(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem('metoyou_voice_settings', JSON.stringify({
inputVolume: 100,
outputVolume: 100,
audioBitrate: 96,
latencyProfile: 'balanced',
includeSystemAudio: false,
noiseReduction: false,
screenShareQuality: 'balanced',
askScreenShareQuality: false
}));
});
}
export async function joinRoomFromSearch(page: Page, roomName: string): Promise<void> {
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
const searchInput = page.getByPlaceholder('Search servers...');
await expect(searchInput).toBeVisible({ timeout: 20_000 });
await searchInput.fill(roomName);
const roomCard = page.locator('div[title]', { hasText: roomName }).first();
await expect(roomCard).toBeVisible({ timeout: 20_000 });
await roomCard.dblclick();
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
await waitForCurrentRoomName(page, roomName);
}
export async function openSavedRoomByName(page: Page, roomName: string): Promise<void> {
const roomButton = page.locator(`button[title="${roomName}"]`);
await expect(roomButton).toBeVisible({ timeout: 20_000 });
await roomButton.click();
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
await waitForCurrentRoomName(page, roomName);
}
export async function waitForCurrentRoomName(page: Page, roomName: string, timeout = 20_000): Promise<void> {
await page.waitForFunction(
(expectedRoomName) => {
interface RoomShape { name?: string }
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return false;
}
const component = debugApi.getComponent(host);
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
return currentRoom?.name === expectedRoomName;
},
roomName,
{ timeout }
);
}
export async function joinVoiceChannelUntilConnected(
page: Page,
channelName: string,
attempts = 3
): Promise<void> {
const room = new ChatRoomPage(page);
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt++) {
await room.joinVoiceChannel(channelName);
try {
await waitForLocalVoiceChannelConnection(page, channelName, 20_000);
await expect(room.muteButton).toBeVisible({ timeout: 10_000 });
return;
} catch (error) {
lastError = error;
await page.waitForTimeout(1_000);
}
}
const lastErrorMessage = lastError instanceof Error
? `Last error: ${lastError.message}`
: 'Last error: unavailable';
throw new Error(`Failed to connect ${page.url()} to voice channel ${channelName}.\n${lastErrorMessage}`);
}
export async function waitForLocalVoiceChannelConnection(
page: Page,
channelName: string,
timeout = 20_000
): Promise<void> {
await page.waitForFunction(
(name) => {
interface VoiceStateShape { isConnected?: boolean; roomId?: string; serverId?: string }
interface UserShape { voiceState?: VoiceStateShape }
interface ChannelShape { id: string; name: string; type: 'text' | 'voice' }
interface RoomShape { id: string; channels?: ChannelShape[] }
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return false;
}
const component = debugApi.getComponent(host);
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
const currentUser = (component['currentUser'] as (() => UserShape | null) | undefined)?.() ?? null;
const voiceChannel = currentRoom?.channels?.find((ch) => ch.type === 'voice' && ch.name === name);
const voiceState = currentUser?.voiceState;
return !!voiceChannel
&& voiceState?.isConnected === true
&& voiceState.roomId === voiceChannel.id
&& voiceState.serverId === currentRoom.id;
},
channelName,
{ timeout }
);
}
+422 -1
View File
@@ -1,15 +1,19 @@
import { expect } from '@playwright/test';
import { expect, type Page } from '@playwright/test';
import { test } from '../../fixtures/multi-client';
import { openSettingsFromMenu } from '../../helpers/app-menu';
import { expectDashboardReady } from '../../helpers/dashboard';
import { installTestServerEndpoints } from '../../helpers/seed-test-endpoint';
import { startTestServer } from '../../helpers/test-server';
import {
authHeaders,
readAuthTokenFromPage,
readSignalServerCredentialFromPage,
registerTestUser
} from '../../helpers/auth-api';
import { expectServerPeerVisible } from '../../helpers/multi-device-session';
import { LoginPage } from '../../pages/login.page';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
const PRIMARY_ENDPOINT_ID = 'e2e-multi-auth-primary';
const USER_PASSWORD = 'TestPass123!';
@@ -108,4 +112,421 @@ test.describe('Multi-signal-server authentication', () => {
await secondaryServer.stop();
}
});
test('restored session recreates a missing secret, provisions silently, and joins foreign presence', async ({ createClient }) => {
const primaryServer = await startTestServer();
const secondaryServer = await startTestServer();
try {
const alice = await createClient();
const bob = await createClient();
const suffix = `restore_auth_${Date.now()}`;
const aliceUsername = `alice_${suffix}`;
const bobUsername = `bob_${suffix}`;
const serverName = `Foreign Restore ${suffix}`;
await installTestServerEndpoints(alice.context, [
{
id: PRIMARY_ENDPOINT_ID,
name: 'E2E Primary Signal',
url: primaryServer.url,
isActive: true,
status: 'online'
}
]);
await installTestServerEndpoints(bob.context, [
{
id: 'e2e-multi-auth-secondary',
name: 'E2E Secondary Signal',
url: secondaryServer.url,
isActive: true,
status: 'online'
}
]);
await test.step('Bob creates the foreign-hosted server', async () => {
const register = new RegisterPage(bob.page);
await register.goto();
await register.register(bobUsername, 'Bob Restore', USER_PASSWORD);
await expectDashboardReady(bob.page);
await new ServerSearchPage(bob.page).createServer(serverName, {
description: 'Restore-safe foreign authentication coverage'
});
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 20_000 });
});
await test.step('Alice registers only on her home signal server', async () => {
const register = new RegisterPage(alice.page);
await register.goto();
await register.register(aliceUsername, 'Alice Restore', USER_PASSWORD);
await expectDashboardReady(alice.page);
});
await test.step('A restored tab has no provision secret when the foreign endpoint appears', async () => {
await alice.page.evaluate(() => {
for (let index = sessionStorage.length - 1; index >= 0; index -= 1) {
const key = sessionStorage.key(index);
if (key?.startsWith('metoyou.provisionSecret.')) {
sessionStorage.removeItem(key);
}
}
});
await installTestServerEndpoints(alice.context, [
{
id: PRIMARY_ENDPOINT_ID,
name: 'E2E Primary Signal',
url: primaryServer.url,
isActive: true,
status: 'online'
},
{
id: 'e2e-multi-auth-secondary',
name: 'E2E Secondary Signal',
url: secondaryServer.url,
isActive: true,
status: 'online'
}
]);
await alice.page.reload({ waitUntil: 'domcontentloaded' });
await expectDashboardReady(alice.page);
await expect(alice.page).not.toHaveURL(/\/login/);
await expect.poll(async () =>
await readSignalServerCredentialFromPage(alice.page, secondaryServer.url),
{ timeout: 30_000 }
).not.toBeNull();
});
await test.step('Alice joins the foreign server and both users see mutual presence', async () => {
await new ServerSearchPage(alice.page).joinServerFromSearch(serverName);
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 20_000 });
await expectServerPeerVisible(alice.page, 'Bob Restore');
await expectServerPeerVisible(bob.page, 'Alice Restore');
});
await test.step('Both users restore mutual presence after the foreign signal server restarts', async () => {
await Promise.all([installRestartSignalingTrace(alice.page), installRestartSignalingTrace(bob.page)]);
await secondaryServer.restart();
await expect.poll(async () =>
await hasRestartPresenceRecovery(alice.page, 'Bob Restore'),
{ timeout: 30_000 }
).toBe(true);
await expect.poll(async () =>
await hasRestartPresenceRecovery(bob.page, 'Alice Restore'),
{ timeout: 30_000 }
).toBe(true);
await expectServerPeerVisible(alice.page, 'Bob Restore');
await expectServerPeerVisible(bob.page, 'Alice Restore');
});
} finally {
await primaryServer.stop();
await secondaryServer.stop();
}
});
test('two devices of the same human share one account on a foreign signal server', async ({ createClient }) => {
const primaryServer = await startTestServer();
const secondaryServer = await startTestServer();
try {
const suffix = `one_identity_${Date.now()}`;
const username = `alice_${suffix}`;
const endpoints = [
{
id: PRIMARY_ENDPOINT_ID,
name: 'E2E Primary Signal',
url: primaryServer.url,
isActive: true,
status: 'online' as const
},
{
id: 'e2e-multi-auth-secondary',
name: 'E2E Secondary Signal',
url: secondaryServer.url,
isActive: true,
status: 'online' as const
}
];
const laptop = await createClient();
await installTestServerEndpoints(laptop.context, endpoints);
await test.step('Alice signs in on her laptop and provisions the foreign server', async () => {
const register = new RegisterPage(laptop.page);
await register.goto();
await register.register(username, 'Alice One Identity', USER_PASSWORD);
await expectDashboardReady(laptop.page);
await restartApp(laptop.page);
});
const laptopCredential = await waitForForeignCredential(laptop.page, secondaryServer.url);
const phone = await createClient();
await installTestServerEndpoints(phone.context, endpoints);
await test.step('Alice signs in on a second device with no shared local storage', async () => {
const login = new LoginPage(phone.page);
await login.goto();
await login.login(username, USER_PASSWORD);
await expectDashboardReady(phone.page);
await restartApp(phone.page);
});
const phoneCredential = await waitForForeignCredential(phone.page, secondaryServer.url);
// One human must be one actor on the foreign server. A per-device secret
// made the second device register `alice-<shortHomeId>` instead, which is
// what showed the same person twice to everybody else.
expect(phoneCredential?.userId).toBe(laptopCredential?.userId);
expect(phoneCredential?.username).toBe(username);
expect(laptopCredential?.username).toBe(username);
} finally {
await primaryServer.stop();
await secondaryServer.stop();
}
});
test('lost foreign secret shows contextual retry without logging out the home session', async ({ createClient, request }) => {
const primaryServer = await startTestServer();
const secondaryServer = await startTestServer();
try {
const alice = await createClient();
const suffix = `lost_secret_${Date.now()}`;
const username = `alice_${suffix}`;
await installTestServerEndpoints(alice.context, [
{
id: PRIMARY_ENDPOINT_ID,
name: 'E2E Primary Signal',
url: primaryServer.url,
isActive: true,
status: 'online'
}
]);
const register = new RegisterPage(alice.page);
await register.goto();
await register.register(username, 'Alice Lost Secret', USER_PASSWORD);
await expectDashboardReady(alice.page);
const homeUserId = await alice.page.evaluate(() =>
localStorage.getItem('metoyou_currentUserId')
);
if (!homeUserId) {
throw new Error('Expected restored home user id');
}
const shortHomeId = homeUserId.replace(/-/g, '').slice(0, 6)
.toLowerCase();
const oldForeignAccount = await registerTestUser(
request,
secondaryServer.url,
username,
'OldForeignSecret123!',
'Alice Lost Secret'
);
await registerTestUser(
request,
secondaryServer.url,
`${username}-${shortHomeId}`,
'OldForeignSecret123!',
'Alice Lost Secret'
);
const serverName = `Lost Secret Recovery ${suffix}`;
const createResponse = await request.post(`${secondaryServer.url}/api/servers`, {
headers: authHeaders(oldForeignAccount.token),
data: {
name: serverName,
description: 'Contextual auth recovery coverage',
ownerId: oldForeignAccount.id,
ownerPublicKey: oldForeignAccount.id
}
});
expect(createResponse.ok(), await createResponse.text()).toBe(true);
await alice.page.evaluate(() => {
for (let index = sessionStorage.length - 1; index >= 0; index -= 1) {
const key = sessionStorage.key(index);
if (key?.startsWith('metoyou.provisionSecret.')) {
sessionStorage.removeItem(key);
}
}
});
await installTestServerEndpoints(alice.context, [
{
id: PRIMARY_ENDPOINT_ID,
name: 'E2E Primary Signal',
url: primaryServer.url,
isActive: true,
status: 'online'
},
{
id: 'e2e-multi-auth-secondary',
name: 'E2E Secondary Signal',
url: secondaryServer.url,
isActive: true,
status: 'online'
}
]);
await alice.page.reload({ waitUntil: 'domcontentloaded' });
await expectDashboardReady(alice.page);
await new ServerSearchPage(alice.page).joinServerFromSearch(serverName);
const recovery = alice.page.getByTestId('signal-server-auth-recovery');
await expect(recovery).toBeVisible({ timeout: 20_000 });
await expect(recovery).toContainText('Reconnect to');
await expect(alice.page).not.toHaveURL(/\/login/);
await recovery.getByTestId('signal-server-auth-retry').click();
await expect(recovery).toBeVisible({ timeout: 20_000 });
await expect(alice.page).not.toHaveURL(/\/login/);
} finally {
await primaryServer.stop();
await secondaryServer.stop();
}
});
});
/** Foreign endpoints are provisioned on the bootstrap path, so reload to reach it. */
async function restartApp(page: Page): Promise<void> {
await page.reload({ waitUntil: 'domcontentloaded' });
await expectDashboardReady(page);
}
async function waitForForeignCredential(page: Page, serverUrl: string) {
await expect.poll(async () =>
await readSignalServerCredentialFromPage(page, serverUrl),
{ timeout: 30_000 }
).not.toBeNull();
return await readSignalServerCredentialFromPage(page, serverUrl);
}
interface RestartSignalingTraceEvent {
displayName?: string;
direction: 'inbound' | 'outbound';
type: string;
users?: string[];
}
async function installRestartSignalingTrace(page: Page): Promise<void> {
await page.evaluate(() => {
const tracedWindow = window as typeof window & {
__restartSignalingTrace?: RestartSignalingTraceEvent[];
};
const OriginalWebSocket = window.WebSocket;
const trace: RestartSignalingTraceEvent[] = [];
const TrackedWebSocket = function(
this: WebSocket,
url: string | URL,
protocols?: string | string[]
): WebSocket {
const socket = protocols === undefined
? new OriginalWebSocket(url)
: new OriginalWebSocket(url, protocols);
const originalSend = socket.send.bind(socket);
socket.send = (data: string | ArrayBufferLike | Blob | ArrayBufferView): void => {
if (typeof data === 'string') {
try {
const message = JSON.parse(data) as { type?: unknown };
if (typeof message.type === 'string') {
trace.push({ direction: 'outbound', type: message.type });
}
} catch {
// Ignore non-JSON websocket traffic.
}
}
originalSend(data);
};
socket.addEventListener('message', (event) => {
if (typeof event.data !== 'string') {
return;
}
try {
const message = JSON.parse(event.data) as {
displayName?: unknown;
type?: unknown;
users?: { displayName?: unknown }[];
};
if (typeof message.type === 'string') {
trace.push({
displayName: typeof message.displayName === 'string'
? message.displayName
: undefined,
direction: 'inbound',
type: message.type,
users: Array.isArray(message.users)
? message.users
.map((user) => user.displayName)
.filter((displayName): displayName is string => typeof displayName === 'string')
: undefined
});
}
} catch {
// Ignore non-JSON websocket traffic.
}
});
return socket;
};
Object.setPrototypeOf(TrackedWebSocket, OriginalWebSocket);
TrackedWebSocket.prototype = OriginalWebSocket.prototype;
tracedWindow.__restartSignalingTrace = trace;
tracedWindow.WebSocket = TrackedWebSocket as unknown as typeof WebSocket;
});
}
async function hasRestartPresenceRecovery(page: Page, expectedPeerName: string): Promise<boolean> {
return await page.evaluate((peerName) => {
const trace = (window as typeof window & {
__restartSignalingTrace?: RestartSignalingTraceEvent[];
}).__restartSignalingTrace ?? [];
const identifyIndex = trace.findIndex((event) =>
event.direction === 'outbound' && event.type === 'identify'
);
const joinIndex = trace.findIndex((event) =>
event.direction === 'outbound' && event.type === 'join_server'
);
const receivedPeerPresence = trace.some((event) =>
event.direction === 'inbound' && (
(event.type === 'server_users' && event.users?.includes(peerName))
|| (event.type === 'user_joined' && event.displayName === peerName)
)
);
return identifyIndex >= 0
&& joinIndex > identifyIndex
&& receivedPeerPresence;
}, expectedPeerName);
}
@@ -0,0 +1,207 @@
import { expect, type Page } from '@playwright/test';
import { test } from '../../fixtures/multi-client';
import { installTestServerEndpoints } from '../../helpers/seed-test-endpoint';
import { startTestServer } from '../../helpers/test-server';
import { readSignalServerCredentialFromPage } from '../../helpers/auth-api';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatMessagesPage } from '../../pages/chat-messages.page';
/**
* P4 coverage: one human must own one DM thread, and a call that reached
* nobody must not look live.
*
* The fork this guards against: a peer on another signal server addresses the
* local user by their provisioned actor id, so the inbound conversation id does
* not match the id the local user builds from their home identity. Before the
* canonicalization the recipient ended up with two threads for the same human -
* one holding the peer's messages, one empty - and clicking the peer opened the
* empty one.
*/
const USER_PASSWORD = 'TestPass123!';
const PRIMARY_SIGNAL_ID = 'e2e-dm-identity-primary';
const SECONDARY_SIGNAL_ID = 'e2e-dm-identity-secondary';
test.describe('Cross-signal direct message identity', () => {
test.describe.configure({ timeout: 240_000 });
test('keeps one DM thread when the peer addresses the local user by a provisioned actor id', async ({
createClient,
testServer
}) => {
const secondaryServer = await startTestServer();
try {
const suffix = uniqueName('xsig-dm');
const serverName = `Cross Signal DM ${suffix}`;
const message = `cross signal hello ${suffix}`;
const alice = await createClient();
const bob = await createClient();
const endpoints = [
{
id: PRIMARY_SIGNAL_ID,
name: 'E2E DM Signal A',
url: testServer.url,
isActive: true,
status: 'online'
},
{
id: SECONDARY_SIGNAL_ID,
name: 'E2E DM Signal B',
url: secondaryServer.url,
isActive: true,
status: 'online'
}
];
await installTestServerEndpoints(alice.context, endpoints);
await installTestServerEndpoints(bob.context, endpoints);
await test.step('Alice is home on signal A, Bob on signal B', async () => {
await registerOn(alice.page, PRIMARY_SIGNAL_ID, `alice_${suffix}`, 'Alice');
await registerOn(bob.page, SECONDARY_SIGNAL_ID, `bob_${suffix}`, 'Bob');
});
await test.step('They meet in a room on signal A, so Bob acts through a provisioned identity', async () => {
await new ServerSearchPage(alice.page).createServer(serverName, {
description: 'Cross-signal DM identity coverage',
sourceId: PRIMARY_SIGNAL_ID
});
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 20_000 });
await new ChatMessagesPage(alice.page).waitForReady();
await new ServerSearchPage(bob.page).joinServerFromSearch(serverName);
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 20_000 });
await new ChatMessagesPage(bob.page).waitForReady();
await expect
.poll(async () => await readSignalServerCredentialFromPage(bob.page, testServer.url), { timeout: 30_000 })
.not.toBeNull();
});
await test.step('Alice sends Bob a DM addressed to his provisioned actor id', async () => {
await openDmFromRoomUserCard(alice.page, 'Bob');
await alice.page.getByTestId('dm-input').fill(message);
await alice.page.getByTestId('dm-input').press('Enter');
// Bob stores the thread before he ever opens the DM view, so the
// inbound conversation id is the only id his device knows.
await expect
.poll(async () => await countStoredConversations(bob.page), { timeout: 30_000 })
.toBeGreaterThan(0);
});
await test.step('Bob opens Alice and finds one thread holding her message', async () => {
await openDmFromRoomUserCard(bob.page, 'Alice');
await expect(bob.page.locator('app-dm-chat').getByText(message)).toBeVisible({ timeout: 20_000 });
await expect(bob.page.locator('app-dm-conversation-item')).toHaveCount(1, { timeout: 20_000 });
expect(await countStoredConversations(bob.page)).toBe(1);
});
} finally {
await secondaryServer.stop();
}
});
test('surfaces an undelivered ring instead of a call that looks live', async ({ createClient }) => {
const suffix = uniqueName('undelivered-ring');
const serverName = `Undelivered Ring ${suffix}`;
const alice = await createClient();
const bob = await createClient();
await test.step('Alice and Bob meet in a room', async () => {
await registerOn(alice.page, null, `alice_${suffix}`, 'Alice');
await registerOn(bob.page, null, `bob_${suffix}`, 'Bob');
await new ServerSearchPage(alice.page).createServer(serverName, {
description: 'Undelivered call ring coverage'
});
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 20_000 });
await new ChatMessagesPage(alice.page).waitForReady();
await new ServerSearchPage(bob.page).joinServerFromSearch(serverName);
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 20_000 });
await new ChatMessagesPage(bob.page).waitForReady();
});
await test.step('Alice calls Bob with no transport that can carry the ring', async () => {
await openDmFromRoomUserCard(alice.page, 'Bob');
await alice.page.evaluate(() => window.simulateOffline?.());
const callButton = alice.page.locator('app-dm-chat header').getByRole('button', { name: 'Call Bob' });
await expect(callButton).toBeEnabled({ timeout: 20_000 });
await callButton.click();
await expect(alice.page).toHaveURL(/\/call\//, { timeout: 20_000 });
});
await test.step('The call view says the ring reached nobody', async () => {
await expect(alice.page.getByTestId('private-call-error')).toContainText('Could not reach anyone', {
timeout: 20_000
});
await expect(bob.page.getByRole('dialog', { name: /is calling/ })).toBeHidden();
});
});
});
async function registerOn(
page: Page,
signalServerId: string | null,
username: string,
displayName: string
): Promise<void> {
const registerPage = new RegisterPage(page);
await registerPage.goto();
if (signalServerId) {
await registerPage.serverSelect.selectOption(signalServerId);
}
await registerPage.register(username, displayName, USER_PASSWORD);
await expect(page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
}
async function openDmFromRoomUserCard(page: Page, displayName: string): Promise<void> {
const userCard = page.locator('[data-testid^="room-user-card-"]', { hasText: displayName }).first();
await expect(userCard).toBeVisible({ timeout: 20_000 });
await userCard.getByRole('button', { name: `Message ${displayName}` }).click();
await expect(page).toHaveURL(/\/dm\//, { timeout: 20_000 });
await expect(page.getByRole('heading', { name: displayName })).toBeVisible({ timeout: 20_000 });
}
/** Count stored DM threads for whichever user owns this browser profile. */
async function countStoredConversations(page: Page): Promise<number> {
return await page.evaluate(() => {
const prefix = 'metoyou_direct_message_conversations:';
let total = 0;
for (let index = 0; index < localStorage.length; index++) {
const key = localStorage.key(index);
if (!key?.startsWith(prefix)) {
continue;
}
try {
const parsed = JSON.parse(localStorage.getItem(key) ?? '[]') as unknown[];
total += Array.isArray(parsed) ? parsed.length : 0;
} catch {
// A half-written entry is not a thread.
}
}
return total;
});
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36)
.slice(2, 8)}`;
}
@@ -0,0 +1,130 @@
import { expect, type Page } from '@playwright/test';
import { test, type Client } from '../../fixtures/multi-client';
import { installDeterministicVoiceSettings } from '../../helpers/voice-session';
import {
dumpRtcDiagnostics,
installAutoResumeAudioContext,
installWebRTCTracking,
waitForAudioStatsPresent,
waitForConnectedPeerCount,
waitForInboundVideoFlow,
waitForOutboundVideoFlow
} from '../../helpers/webrtc-helpers';
import { ChatRoomPage } from '../../pages/chat-room.page';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
const USER_PASSWORD = 'TestPass123!';
const VOICE_CHANNEL = 'General';
/**
* Screen share is pull-based: the sharer only attaches tracks to peers that asked
* for them. This covers the case the request model has to get right - a viewer who
* arrives after the share already started.
*/
test.describe('Late joiner screen share', () => {
test('a user who joins voice mid-share still receives the screen', async ({ createClient }) => {
test.setTimeout(240_000);
const serverName = `Late Share ${Date.now()}`;
const sharer = await createVoiceClient(createClient, 'sharer');
const viewer = await createVoiceClient(createClient, 'viewer');
await test.step('Both users register and join the server', async () => {
await new ServerSearchPage(sharer.page).createServer(serverName, {
description: 'Late joiner screen share test'
});
await expect(sharer.page).toHaveURL(/\/room\//, { timeout: 20_000 });
await new ServerSearchPage(viewer.page).joinServerFromSearch(serverName);
await expect(viewer.page).toHaveURL(/\/room\//, { timeout: 20_000 });
});
await test.step('The sharer starts sharing while alone in voice', async () => {
const room = new ChatRoomPage(sharer.page);
await room.ensureVoiceChannelExists(VOICE_CHANNEL);
await room.joinVoiceChannel(VOICE_CHANNEL);
await expect(room.voiceControls).toBeVisible({ timeout: 20_000 });
await openVoiceWorkspace(sharer.page);
await room.startScreenShare();
await expect(room.isScreenShareActive).toBeVisible({ timeout: 15_000 });
});
await test.step('The viewer joins voice after the share is already running', async () => {
const room = new ChatRoomPage(viewer.page);
await room.joinVoiceChannel(VOICE_CHANNEL);
await expect(room.voiceControls).toBeVisible({ timeout: 20_000 });
await waitForConnectedPeerCount(viewer.page, 1, 90_000);
await waitForConnectedPeerCount(sharer.page, 1, 90_000);
await waitForAudioStatsPresent(viewer.page, 30_000);
await openVoiceWorkspace(viewer.page);
});
await test.step('The in-progress screen reaches the late joiner', async () => {
try {
const outbound = await waitForOutboundVideoFlow(sharer.page, 60_000);
const inbound = await waitForInboundVideoFlow(viewer.page, 60_000);
expect(
outbound.outboundBytesDelta > 0 || outbound.outboundPacketsDelta > 0,
'The sharer never sent screen video to the late joiner'
).toBe(true);
expect(
inbound.inboundBytesDelta > 0 || inbound.inboundPacketsDelta > 0,
'The late joiner never received the in-progress screen share'
).toBe(true);
} catch (error) {
console.log(`[sharer RTC]\n${await dumpRtcDiagnostics(sharer.page)}`);
console.log(`[viewer RTC]\n${await dumpRtcDiagnostics(viewer.page)}`);
throw error;
}
});
await test.step('The late joiner renders a remote screen tile', async () => {
await expect(viewer.page.locator('app-voice-workspace-stream-tile').first())
.toBeVisible({ timeout: 30_000 });
});
});
});
/** Expand the voice workspace, which is what turns on remote screen-share requests. */
async function openVoiceWorkspace(page: Page): Promise<void> {
const viewButton = page.locator('app-rooms-side-panel')
.getByRole('button', { name: /view/i })
.first();
await expect(viewButton).toBeVisible({ timeout: 20_000 });
await viewButton.click();
await expect(page.locator('app-voice-workspace')).toBeVisible({ timeout: 20_000 });
}
async function createVoiceClient(
createClient: () => Promise<Client>,
role: string
): Promise<Client> {
const client = await createClient();
await installDeterministicVoiceSettings(client.page);
await installWebRTCTracking(client.page);
await installAutoResumeAudioContext(client.page);
const registerPage = new RegisterPage(client.page);
await registerPage.goto();
await registerPage.register(
`late_share_${role}_${Date.now()}`,
`Late Share ${role}`,
USER_PASSWORD
);
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
return client;
}
@@ -0,0 +1,95 @@
import { expect } from '@playwright/test';
import { test, type Client } from '../../fixtures/multi-client';
import { installDeterministicVoiceSettings } from '../../helpers/voice-session';
import { installAutoResumeAudioContext, installWebRTCTracking } from '../../helpers/webrtc-helpers';
import { ChatRoomPage } from '../../pages/chat-room.page';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
const USER_PASSWORD = 'TestPass123!';
const VOICE_CHANNEL = 'General';
/**
* A user sharing alone in a voice channel used to look idle to everyone else,
* because the LIVE badge was gated on the observer's own voice connection. The
* observer here never joins voice, so the badge can only appear if sharing
* presence reaches a non-participant.
*/
test.describe('Screen share visibility from outside the channel', () => {
test('a user who is not in voice sees the LIVE badge of someone sharing alone', async ({ createClient }) => {
test.setTimeout(240_000);
const serverName = `Outside Share ${Date.now()}`;
const sharer = await createVoiceClient(createClient, 'sharer');
const observer = await createVoiceClient(createClient, 'observer');
const sharerRoom = new ChatRoomPage(sharer.page);
const observerRoom = new ChatRoomPage(observer.page);
await test.step('Both users register and join the server', async () => {
await new ServerSearchPage(sharer.page).createServer(serverName, {
description: 'Live badge visibility test'
});
await expect(sharer.page).toHaveURL(/\/room\//, { timeout: 20_000 });
await new ServerSearchPage(observer.page).joinServerFromSearch(serverName);
await expect(observer.page).toHaveURL(/\/room\//, { timeout: 20_000 });
});
await test.step('The sharer shares while alone in the voice channel', async () => {
await sharerRoom.ensureVoiceChannelExists(VOICE_CHANNEL);
await sharerRoom.joinVoiceChannel(VOICE_CHANNEL);
await expect(sharerRoom.voiceControls).toBeVisible({ timeout: 20_000 });
await sharerRoom.startScreenShare();
await expect(sharerRoom.isScreenShareActive).toBeVisible({ timeout: 15_000 });
});
await test.step('The observer stays out of voice and still sees the badge', async () => {
await expect(observerRoom.channelsSidePanel).toBeVisible({ timeout: 20_000 });
// Proves the observer never joined: the disconnect control only shows in voice.
await expect(observerRoom.disconnectButton).toBeHidden();
await expect(liveBadge(observerRoom))
.toBeVisible({ timeout: 60_000 });
});
await test.step('The badge disappears when the share stops', async () => {
await sharerRoom.stopScreenShare();
await expect(liveBadge(observerRoom))
.toBeHidden({ timeout: 60_000 });
});
});
});
function liveBadge(room: ChatRoomPage) {
return room.channelsSidePanel
.locator('[data-testid="voice-user-live"]')
.first();
}
async function createVoiceClient(
createClient: () => Promise<Client>,
role: string
): Promise<Client> {
const client = await createClient();
await installDeterministicVoiceSettings(client.page);
await installWebRTCTracking(client.page);
await installAutoResumeAudioContext(client.page);
const registerPage = new RegisterPage(client.page);
await registerPage.goto();
await registerPage.register(
`outside_share_${role}_${Date.now()}`,
`Outside Share ${role}`,
USER_PASSWORD
);
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
return client;
}
@@ -0,0 +1,188 @@
import { expect } from '@playwright/test';
import { test, type Client } from '../../fixtures/multi-client';
import { expectDashboardReady } from '../../helpers/dashboard';
import {
countCreatedPeerConnections,
expectExactlyOneInitiatorPerPair,
readPeerRoleEdges,
type PeerRoleEdge
} from '../../helpers/peer-role';
import { installTestServerEndpoints, type SeededEndpointInput } from '../../helpers/seed-test-endpoint';
import { startTestServer } from '../../helpers/test-server';
import {
installDeterministicVoiceSettings,
joinRoomFromSearch,
joinVoiceChannelUntilConnected,
openSavedRoomByName
} from '../../helpers/voice-session';
import { waitForVoiceRosterCount } from '../../helpers/voice-roster';
import {
dumpRtcDiagnostics,
installAutoResumeAudioContext,
installWebRTCTracking,
waitForAllPeerAudioFlow,
waitForAudioStatsPresent,
waitForPeerConnected
} from '../../helpers/webrtc-helpers';
import { ChatRoomPage } from '../../pages/chat-room.page';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
const SIGNAL_A_ID = 'e2e-cross-signal-a';
const SIGNAL_B_ID = 'e2e-cross-signal-b';
const VOICE_CHANNEL = 'General';
const USER_PASSWORD = 'TestPass123!';
const USER_COUNT = 4;
const EXPECTED_REMOTE_PEERS = USER_COUNT - 1;
interface TestUser {
username: string;
displayName: string;
/** Signal server this human registered on - their home identity space. */
homeSignalId: string;
}
type TestClient = Client & { user: TestUser };
test.describe('Cross-signal WebRTC identity', () => {
test.describe.configure({ timeout: 600_000 });
test('elects exactly one initiator per pair when peers have different home signal servers', async ({
createClient,
testServer
}) => {
const signalB = await startTestServer();
try {
const suffix = `cross_signal_${Date.now()}`;
const roomName = `Cross Signal Voice ${suffix}`;
const endpoints: SeededEndpointInput[] = [
{
id: SIGNAL_A_ID,
name: 'E2E Signal A',
url: testServer.url,
isActive: true,
status: 'online'
},
{
id: SIGNAL_B_ID,
name: 'E2E Signal B',
url: signalB.url,
isActive: true,
status: 'online'
}
];
// The room is hosted on signal B. Two humans are at home there and two are
// foreign guests, so most pairs must compare a foreign actor id against a
// foreign actor id - never a home id against one.
const users: TestUser[] = [
{ username: `host_${suffix}`, displayName: 'Cross Host', homeSignalId: SIGNAL_B_ID },
{ username: `native_${suffix}`, displayName: 'Cross Native', homeSignalId: SIGNAL_B_ID },
{ username: `guest_a_${suffix}`, displayName: 'Cross Guest A', homeSignalId: SIGNAL_A_ID },
{ username: `guest_b_${suffix}`, displayName: 'Cross Guest B', homeSignalId: SIGNAL_A_ID }
];
const clients: TestClient[] = [];
for (const user of users) {
const client = await createClient();
await installTestServerEndpoints(client.context, endpoints);
await installDeterministicVoiceSettings(client.page);
await installWebRTCTracking(client.context);
await installAutoResumeAudioContext(client.page);
clients.push({ ...client, user });
}
const [host] = clients;
await test.step('Each human registers on their own home signal server', async () => {
for (const client of clients) {
const register = new RegisterPage(client.page);
await register.goto();
await register.serverSelect.selectOption(client.user.homeSignalId);
await register.register(client.user.username, client.user.displayName, USER_PASSWORD);
await expectDashboardReady(client.page);
}
});
await test.step('The host creates the voice room on signal B', async () => {
await new ServerSearchPage(host.page).createServer(roomName, {
description: 'Cross-signal initiator election coverage',
sourceId: SIGNAL_B_ID
});
await expect(host.page).toHaveURL(/\/room\//, { timeout: 20_000 });
await new ChatRoomPage(host.page).ensureVoiceChannelExists(VOICE_CHANNEL);
});
await test.step('Everyone else joins the room, provisioning a foreign account when needed', async () => {
for (const client of clients.slice(1)) {
await joinRoomFromSearch(client.page, roomName);
}
await openSavedRoomByName(host.page, roomName);
});
// Everyone reconnects at once, so every pair elects its initiator from the same
// roster snapshot. Staggered arrivals let one side's 1s fallback-offer timer
// serialize negotiation, which hides a wrong comparison; a reconnect storm - a
// signal blip, or a channel everyone piles into - does not.
await test.step('All four reconnect simultaneously', async () => {
await Promise.all(clients.map(async (client) => {
await client.page.reload({ waitUntil: 'domcontentloaded' });
await openSavedRoomByName(client.page, roomName);
}));
});
await test.step('All four join the same voice channel simultaneously', async () => {
await Promise.all(clients.map((client) =>
joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL)
));
for (const client of clients) {
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
}
});
await test.step('Every pair carries bidirectional audio', async () => {
await Promise.all(clients.map((client) => waitForPeerConnected(client.page, 90_000)));
await Promise.all(clients.map((client) => waitForAudioStatsPresent(client.page, 30_000)));
for (const client of clients) {
try {
await waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 120_000);
} catch (error) {
console.log(`[${client.user.displayName} RTC]\n${await dumpRtcDiagnostics(client.page)}`);
throw error;
}
}
});
await test.step('Exactly one side of every pair elected itself initiator', async () => {
const edgesByClient: Record<string, PeerRoleEdge[]> = {};
for (const client of clients) {
edgesByClient[client.user.displayName] = (await readPeerRoleEdges(client.page))
.filter((edge) => edge.connectionState === 'connected');
}
// Comparing a home id against a foreign actor id is not antisymmetric, so both
// peers could offer (glare) or neither could until a takeover timer fired.
expectExactlyOneInitiatorPerPair(edgesByClient);
});
await test.step('No peer had to be rebuilt to reach that state', async () => {
for (const client of clients) {
expect(
await countCreatedPeerConnections(client.page),
`${client.user.displayName} rebuilt a peer connection instead of connecting on the first offer`
).toBe(EXPECTED_REMOTE_PEERS);
}
});
} finally {
await signalB.stop();
}
});
});
@@ -0,0 +1,293 @@
import { expect, type Page } from '@playwright/test';
import { test, type Client } from '../../fixtures/multi-client';
import { countCreatedPeerConnections } from '../../helpers/peer-role';
import { openSettingsDetailPage } from '../../helpers/settings-modal';
import { installDeterministicVoiceSettings } from '../../helpers/voice-session';
import {
dumpRtcDiagnostics,
installAutoResumeAudioContext,
installWebRTCTracking,
waitForAllPeerAudioFlow,
waitForAudioStatsPresent,
waitForConnectedPeerCount,
waitForOpenDataChannelCount
} from '../../helpers/webrtc-helpers';
import { ChatRoomPage } from '../../pages/chat-room.page';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
interface VoiceClient extends Client {
displayName: string;
username: string;
}
const USER_PASSWORD = 'TestPass123!';
const VOICE_CHANNEL = 'General';
test.describe('Live audio device change', () => {
test('switching the microphone mid-call keeps both directions of audio alive', async ({ createClient }) => {
test.setTimeout(240_000);
const clients = await createVoicePair(createClient, `Mic Swap ${Date.now()}`);
const [alice, bob] = clients;
await assertMeshAudio(clients, 'initial two-user voice');
const connectionsBefore = {
alice: await countCreatedPeerConnections(alice.page),
bob: await countCreatedPeerConnections(bob.page)
};
const sentTracksBefore = await readOutboundAudioTrackIds(alice.page);
expect(sentTracksBefore, 'Alice should be sending audio before the switch').toHaveLength(1);
await test.step('Alice picks a different microphone from voice settings', async () => {
await openVoiceSettings(alice.page);
const alternateDeviceId = await readAlternateInputDeviceId(alice.page);
await startVoiceStateWatch(alice.page);
await alice.page.getByTestId('voice-settings-input-device').selectOption(alternateDeviceId);
// The swap re-captures the microphone; give it a moment before reading senders.
await expect
.poll(async () => (await readOutboundAudioTrackIds(alice.page))[0], { timeout: 20_000 })
.not.toBe(sentTracksBefore[0]);
});
await test.step('The session was never interrupted', async () => {
const drops = await stopVoiceStateWatch(alice.page);
expect(drops, 'Alice left and rejoined voice instead of swapping the track').toBe(0);
expect(
await countCreatedPeerConnections(alice.page),
'Alice rebuilt her peer connection to change microphone'
).toBe(connectionsBefore.alice);
expect(
await countCreatedPeerConnections(bob.page),
'Bob rebuilt his peer connection because Alice changed microphone'
).toBe(connectionsBefore.bob);
});
await test.step('Audio still flows both ways on the new microphone', async () => {
await waitForConnectedPeerCount(alice.page, 1, 30_000);
await waitForConnectedPeerCount(bob.page, 1, 30_000);
await assertMeshAudio(clients, 'after microphone switch');
});
});
test('switching the speaker mid-call keeps remote audio playing', async ({ createClient }) => {
test.setTimeout(240_000);
const clients = await createVoicePair(createClient, `Speaker Swap ${Date.now()}`);
const [alice] = clients;
await assertMeshAudio(clients, 'initial two-user voice');
await openVoiceSettings(alice.page);
const alternateDeviceId = await readAlternateOutputDeviceId(alice.page);
test.skip(alternateDeviceId === null, 'This browser exposes no audio output devices');
await startVoiceStateWatch(alice.page);
await alice.page.getByTestId('voice-settings-output-device').selectOption(alternateDeviceId as string);
await expect
.poll(async () => readPreferredOutputDeviceId(alice.page), { timeout: 20_000 })
.toBe(alternateDeviceId === '' ? 'default' : alternateDeviceId);
expect(await stopVoiceStateWatch(alice.page), 'Changing the speaker dropped Alice out of voice').toBe(0);
await assertMeshAudio(clients, 'after speaker switch');
});
});
async function openVoiceSettings(page: Page): Promise<void> {
await openSettingsDetailPage(page, 'voice');
await expect(page.getByTestId('voice-settings-input-device')).toBeVisible({ timeout: 10_000 });
}
/** The picker value to switch to: any real device, else the system-default entry. */
async function readAlternateInputDeviceId(page: Page): Promise<string> {
const select = page.getByTestId('voice-settings-input-device');
const currentValue = await select.inputValue();
const values = await select.locator('option').evaluateAll(
(options) => options.map((option) => (option as HTMLOptionElement).value)
);
const alternate = values.find((value) => value !== currentValue);
if (alternate === undefined) {
throw new Error(`The microphone picker only offers "${currentValue}", so no switch can be made`);
}
return alternate;
}
async function readAlternateOutputDeviceId(page: Page): Promise<string | null> {
const select = page.getByTestId('voice-settings-output-device');
const currentValue = await select.inputValue();
const values = await select.locator('option').evaluateAll(
(options) => options.map((option) => (option as HTMLOptionElement).value)
);
return values.find((value) => value !== currentValue) ?? null;
}
/** The audio track ids this page is currently sending, one per peer connection. */
async function readOutboundAudioTrackIds(page: Page): Promise<(string | null)[]> {
return await page.evaluate(() => {
const connections = (window as unknown as { __rtcConnections?: RTCPeerConnection[] }).__rtcConnections ?? [];
return connections
.filter((connection) => connection.connectionState === 'connected')
.map((connection) => {
const audioSender = connection
.getSenders()
.find((sender) => sender.track?.kind === 'audio');
return audioSender?.track?.id ?? null;
});
});
}
async function readPreferredOutputDeviceId(page: Page): Promise<string | null> {
return await page.evaluate(() => {
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
interface PlaybackShape { preferredOutputDeviceId?: string }
const host = document.querySelector('app-voice-settings');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return null;
}
const playback = debugApi.getComponent(host)['voicePlayback'] as PlaybackShape | undefined;
return playback?.preferredOutputDeviceId ?? null;
});
}
/**
* Start counting moments where this client considered itself out of voice.
* A device change that tears the session down and rebuilds it registers here,
* even when the end state looks healthy again.
*/
async function startVoiceStateWatch(page: Page): Promise<void> {
await page.evaluate(() => {
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
interface VoiceStateShape { isConnected?: boolean }
interface UserShape { voiceState?: VoiceStateShape }
const watchWindow = window as unknown as { __voiceDrops?: number; __voiceWatch?: number };
watchWindow.__voiceDrops = 0;
watchWindow.__voiceWatch = window.setInterval(() => {
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return;
}
const component = debugApi.getComponent(host);
const currentUser = (component['currentUser'] as (() => UserShape | null) | undefined)?.() ?? null;
if (currentUser?.voiceState?.isConnected === false) {
watchWindow.__voiceDrops = (watchWindow.__voiceDrops ?? 0) + 1;
}
}, 100);
});
}
async function stopVoiceStateWatch(page: Page): Promise<number> {
return await page.evaluate(() => {
const watchWindow = window as unknown as { __voiceDrops?: number; __voiceWatch?: number };
if (watchWindow.__voiceWatch !== undefined) {
window.clearInterval(watchWindow.__voiceWatch);
watchWindow.__voiceWatch = undefined;
}
return watchWindow.__voiceDrops ?? 0;
});
}
async function createVoicePair(
createClient: () => Promise<Client>,
serverName: string
): Promise<VoiceClient[]> {
const clients: VoiceClient[] = [];
for (let index = 0; index < 2; index++) {
const client = await createClient();
await installDeterministicVoiceSettings(client.page);
await installWebRTCTracking(client.page);
await installAutoResumeAudioContext(client.page);
clients.push({
...client,
displayName: `Device Voice ${index + 1}`,
username: `device_voice_${Date.now()}_${index + 1}`
});
}
await test.step('Register both clients', async () => {
for (const client of clients) {
const registerPage = new RegisterPage(client.page);
await registerPage.goto();
await registerPage.register(client.username, client.displayName, USER_PASSWORD);
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
}
});
await test.step('Create and join the server', async () => {
await new ServerSearchPage(clients[0].page).createServer(serverName, {
description: 'Live audio device change test'
});
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
await new ServerSearchPage(clients[1].page).joinServerFromSearch(serverName);
await expect(clients[1].page).toHaveURL(/\/room\//, { timeout: 20_000 });
});
await test.step('Join both clients to voice', async () => {
await new ChatRoomPage(clients[0].page).ensureVoiceChannelExists(VOICE_CHANNEL);
for (const client of clients) {
const room = new ChatRoomPage(client.page);
await room.joinVoiceChannel(VOICE_CHANNEL);
await expect(room.voiceControls).toBeVisible({ timeout: 20_000 });
}
for (const client of clients) {
await waitForConnectedPeerCount(client.page, 1, 90_000);
await waitForOpenDataChannelCount(client.page, 1, 90_000);
await waitForAudioStatsPresent(client.page, 30_000);
}
});
return clients;
}
async function assertMeshAudio(clients: readonly VoiceClient[], label: string): Promise<void> {
for (const client of clients) {
try {
await waitForAllPeerAudioFlow(client.page, 1, 60_000);
} catch (error) {
console.log(`[${client.displayName} ${label} RTC]\n${await dumpRtcDiagnostics(client.page)}`);
throw error;
}
}
}
@@ -0,0 +1,151 @@
import { expect, type Page } from '@playwright/test';
import { test } from '../../fixtures/multi-client';
import { countCreatedPeerConnections } from '../../helpers/peer-role';
import { createVoicePairInNewServer, type VoicePairClient } from '../../helpers/voice-session';
import {
dumpRtcDiagnostics,
getOpenDataChannelCount,
getPerPeerAudioStats
} from '../../helpers/webrtc-helpers';
type PeerAudioStats = Awaited<ReturnType<typeof getPerPeerAudioStats>>;
/** Override for a quick check or a longer leak hunt: `SOAK_MINUTES=2 npx playwright test ...`. */
const SOAK_MINUTES = Number(process.env['SOAK_MINUTES'] ?? 30);
const SAMPLE_INTERVAL_MS = 30_000;
interface ResourceSnapshot {
audioElements: number;
heapMb: number;
remoteTracks: number;
}
/**
* A long call must not accumulate anything. Structural counters are the honest leak
* signal here - a churning recovery loop shows up as extra remote tracks or audio
* elements long before heap bytes say anything conclusive.
*/
async function readResources(page: Page): Promise<ResourceSnapshot> {
return page.evaluate(() => {
interface HeapCapablePerformance extends Performance {
memory?: { usedJSHeapSize: number };
}
const usedHeap = (performance as HeapCapablePerformance).memory?.usedJSHeapSize ?? 0;
const remoteTracks = (window as unknown as { __rtcRemoteTracks?: unknown[] }).__rtcRemoteTracks ?? [];
return {
audioElements: document.querySelectorAll('audio').length,
heapMb: Math.round(usedHeap / (1_024 * 1_024)),
remoteTracks: remoteTracks.length
};
});
}
function describeStats(stats: PeerAudioStats): string {
return stats
.map((stat) => `${stat.connectionState} in=${stat.inboundPackets} out=${stat.outboundPackets}`)
.join(' | ') || 'no peers';
}
test.describe('Long voice session', () => {
test(`carries audio for ${SOAK_MINUTES} minutes without stalling, rebuilding, or accumulating`, async ({
createClient
}) => {
const soakMs = SOAK_MINUTES * 60_000;
test.setTimeout(soakMs + 300_000);
const clients = await createVoicePairInNewServer(createClient, `Voice Soak ${Date.now()}`, {
namePrefix: 'Soak Voice'
});
const baselineConnections = await Promise.all(clients.map((client) => countCreatedPeerConnections(client.page)));
expect(baselineConnections, 'each client should start with exactly one peer connection').toEqual([1, 1]);
const baselineResources = await Promise.all(clients.map((client) => readResources(client.page)));
const previousStats: PeerAudioStats[] = await Promise.all(
clients.map((client) => getPerPeerAudioStats(client.page))
);
const deadline = Date.now() + soakMs;
const startedAt = Date.now();
let sampleIndex = 0;
while (Date.now() < deadline) {
await clients[0].page.waitForTimeout(SAMPLE_INTERVAL_MS);
sampleIndex++;
const elapsedSeconds = Math.round((Date.now() - startedAt) / 1_000);
for (let index = 0; index < clients.length; index++) {
const client = clients[index];
await assertClientStillHealthy(client, previousStats[index], elapsedSeconds);
previousStats[index] = await getPerPeerAudioStats(client.page);
}
const resources = await Promise.all(clients.map((current) => readResources(current.page)));
console.log(
`[soak] sample ${sampleIndex} at +${elapsedSeconds}s: `
+ clients
.map((client, index) => `${client.displayName} heap=${resources[index].heapMb}MB`
+ ` audio=${resources[index].audioElements} tracks=${resources[index].remoteTracks}`)
.join(', ')
);
}
await test.step('Nothing accumulated over the session', async () => {
const finalResources = await Promise.all(clients.map((client) => readResources(client.page)));
for (let index = 0; index < clients.length; index++) {
const baseline = baselineResources[index];
const final = finalResources[index];
const label = clients[index].displayName;
// A stable call fires `track` once per remote track; repeats mean the media path
// was torn down and rebuilt behind the assertions above.
expect(final.remoteTracks, `${label} gained remote tracks during the soak`).toBe(baseline.remoteTracks);
expect(final.audioElements, `${label} accumulated audio elements`).toBeLessThanOrEqual(baseline.audioElements + 1);
expect(
final.heapMb,
`${label} heap grew from ${baseline.heapMb}MB to ${final.heapMb}MB`
).toBeLessThan(baseline.heapMb * 3 + 200);
}
});
});
});
async function assertClientStillHealthy(
client: VoicePairClient,
previous: PeerAudioStats,
elapsedSeconds: number
): Promise<void> {
const label = `${client.displayName} at +${elapsedSeconds}s`;
try {
const current = await getPerPeerAudioStats(client.page);
const connected = current.filter((stat) => stat.connectionState === 'connected');
expect(connected, `${label}: expected exactly one connected peer, saw ${describeStats(current)}`).toHaveLength(1);
const before = previous[0];
const now = current[0];
expect(now.inboundPackets, `${label}: inbound audio stalled`).toBeGreaterThan(before.inboundPackets);
expect(now.outboundPackets, `${label}: outbound audio stalled`).toBeGreaterThan(before.outboundPackets);
// A rebuild would restore audio within a sample or two, so the flow assertions above
// cannot see it. Only the creation count can.
expect(
await countCreatedPeerConnections(client.page),
`${label}: the peer connection was rebuilt mid-call`
).toBe(1);
expect(await getOpenDataChannelCount(client.page), `${label}: the control channel is not open`).toBe(1);
} catch (error) {
console.log(`[soak] ${label} diagnostics:\n${await dumpRtcDiagnostics(client.page)}`);
throw error;
}
}
@@ -0,0 +1,230 @@
import { expect, type Page } from '@playwright/test';
import { test, type Client } from '../../fixtures/multi-client';
import { countCreatedPeerConnections } from '../../helpers/peer-role';
import { installDeterministicVoiceSettings } from '../../helpers/voice-session';
import {
closeOpenDataChannels,
dumpRtcDiagnostics,
getOpenDataChannelCount,
installAutoResumeAudioContext,
installWebRTCTracking,
waitForAllPeerAudioFlow,
waitForAudioStatsPresent,
waitForConnectedPeerCount,
waitForOpenDataChannelCount
} from '../../helpers/webrtc-helpers';
import { ChatRoomPage } from '../../pages/chat-room.page';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
interface VoiceClient extends Client {
displayName: string;
username: string;
}
const USER_PASSWORD = 'TestPass123!';
const VOICE_CHANNEL = 'General';
/** 12 reconnect attempts at 5s - the whole budget fits inside this outage. */
const OUTAGE_HOLD_MS = 70_000;
test.describe('Recovery preserves live media', () => {
test('replaces a dead control channel without rebuilding the peer connection', async ({ createClient }) => {
test.setTimeout(240_000);
const clients = await createVoicePair(createClient, `DC Soft Replace ${Date.now()}`);
const [alice, bob] = clients;
await assertMeshAudio(clients, 'initial two-user voice');
const connectionsBefore = {
alice: await countCreatedPeerConnections(alice.page),
bob: await countCreatedPeerConnections(bob.page)
};
expect(connectionsBefore.alice).toBe(1);
expect(connectionsBefore.bob).toBe(1);
await test.step('The control channel is replaced on the same connection', async () => {
const closed = await closeOpenDataChannels(alice.page);
expect(closed).toBeGreaterThan(0);
await waitForOpenDataChannelCount(alice.page, 1, 60_000);
await waitForOpenDataChannelCount(bob.page, 1, 60_000);
// A rebuild would construct a second RTCPeerConnection on both sides, taking voice,
// camera, and screen share down with the control channel.
expect(
await countCreatedPeerConnections(alice.page),
'Alice rebuilt her peer connection instead of replacing the control channel'
).toBe(connectionsBefore.alice);
expect(
await countCreatedPeerConnections(bob.page),
'Bob rebuilt his peer connection instead of adopting the replacement control channel'
).toBe(connectionsBefore.bob);
});
await test.step('Audio never had to be renegotiated', async () => {
await waitForConnectedPeerCount(alice.page, 1, 30_000);
await waitForConnectedPeerCount(bob.page, 1, 30_000);
await assertMeshAudio(clients, 'after control-channel replacement');
});
});
// This covers the user-visible half: an outage that outlives the 12-attempt reconnect
// budget must not end the call. It cannot isolate the attempt accounting, because the
// roster resync on signaling reconnect re-peers anyway - `peer-recovery.spec.ts` owns
// the deterministic proof that a deferred attempt costs nothing.
test('keeps voice alive across a signal outage longer than the reconnect budget', async ({
createClient,
testServer
}) => {
test.setTimeout(480_000);
const clients = await createVoicePair(createClient, `Signal Outage Voice ${Date.now()}`);
await assertMeshAudio(clients, 'initial two-user voice');
const connectionsBefore = await Promise.all(clients.map((client) => countCreatedPeerConnections(client.page)));
expect(connectionsBefore).toEqual([1, 1]);
await test.step('The signal server goes away for longer than the reconnect budget', async () => {
await testServer.kill();
for (const client of clients) {
await waitForSignalingConnected(client.page, false, 60_000);
}
await clients[0].page.waitForTimeout(OUTAGE_HOLD_MS);
});
await test.step('Peer media is unaffected by the signaling outage', async () => {
await assertMeshAudio(clients, 'during signal outage');
});
await test.step('Voice is still healthy once signaling returns', async () => {
await testServer.start();
for (const client of clients) {
await waitForSignalingConnected(client.page, true, 120_000);
}
for (const client of clients) {
await waitForConnectedPeerCount(client.page, 1, 90_000);
await waitForOpenDataChannelCount(client.page, 1, 90_000);
}
await assertMeshAudio(clients, 'after signaling recovery');
});
await test.step('The call was never rebuilt behind the user back', async () => {
// Media never depended on the signal server, so the roster resync must adopt the
// living peer connection. Rebuilding it would drop audio for a beat and reset
// screen share - invisible to the assertions above, which only re-check the end state.
const connectionsAfter = await Promise.all(clients.map((client) => countCreatedPeerConnections(client.page)));
expect(connectionsAfter, 'a client rebuilt its peer connection when signaling came back').toEqual(
connectionsBefore
);
});
});
});
async function createVoicePair(
createClient: () => Promise<Client>,
serverName: string
): Promise<VoiceClient[]> {
const clients: VoiceClient[] = [];
for (let index = 0; index < 2; index++) {
const client = await createClient();
await installDeterministicVoiceSettings(client.page);
await installWebRTCTracking(client.page);
await installAutoResumeAudioContext(client.page);
clients.push({
...client,
displayName: `Recovery Voice ${index + 1}`,
username: `recovery_voice_${Date.now()}_${index + 1}`
});
}
await test.step('Register both clients', async () => {
for (const client of clients) {
const registerPage = new RegisterPage(client.page);
await registerPage.goto();
await registerPage.register(client.username, client.displayName, USER_PASSWORD);
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
}
});
await test.step('Create and join the server', async () => {
await new ServerSearchPage(clients[0].page).createServer(serverName, {
description: 'Recovery keeps live media test'
});
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
await new ServerSearchPage(clients[1].page).joinServerFromSearch(serverName);
await expect(clients[1].page).toHaveURL(/\/room\//, { timeout: 20_000 });
});
await test.step('Join both clients to voice', async () => {
await new ChatRoomPage(clients[0].page).ensureVoiceChannelExists(VOICE_CHANNEL);
for (const client of clients) {
const room = new ChatRoomPage(client.page);
await room.joinVoiceChannel(VOICE_CHANNEL);
await expect(room.voiceControls).toBeVisible({ timeout: 20_000 });
}
for (const client of clients) {
await waitForConnectedPeerCount(client.page, 1, 90_000);
await waitForOpenDataChannelCount(client.page, 1, 90_000);
await waitForAudioStatsPresent(client.page, 30_000);
}
});
return clients;
}
async function assertMeshAudio(clients: readonly VoiceClient[], label: string): Promise<void> {
for (const client of clients) {
try {
await waitForAllPeerAudioFlow(client.page, 1, 60_000);
} catch (error) {
console.log(`[${client.displayName} ${label} data channels] ${await getOpenDataChannelCount(client.page)}`);
console.log(`[${client.displayName} ${label} RTC]\n${await dumpRtcDiagnostics(client.page)}`);
throw error;
}
}
}
/** Wait until the client's own view of its signaling connection matches `connected`. */
async function waitForSignalingConnected(page: Page, connected: boolean, timeout: number): Promise<void> {
await page.waitForFunction(
(expected) => {
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return false;
}
const realtime = debugApi.getComponent(host)['realtime'] as { isConnected?: () => boolean } | undefined;
return realtime?.isConnected?.() === expected;
},
connected,
{ timeout }
);
}
@@ -0,0 +1,252 @@
import { type Page } from '@playwright/test';
import { test } from '../../fixtures/multi-client';
import { createVoicePairInNewServer, type VoicePairClient } from '../../helpers/voice-session';
import {
dumpRtcDiagnostics,
getAudioStatsDelta,
waitForConnectedPeerCount,
waitForOpenDataChannelCount
} from '../../helpers/webrtc-helpers';
/**
* The signal server pings every 30s and gives up on a socket 45s after the last pong,
* so it needs up to 75s to declare a client dead and broadcast `user_left`.
*/
const DEAD_SOCKET_HOLD_MS = 95_000;
/**
* Outgoing voice used to be gated on the observer's roster copy of the remote user's
* voice state, which is signaling gossip. The signal server broadcasts `user_left` for
* any socket it declares dead, so a sleeping laptop, a flaky wifi hop, or a dropped
* socket wiped that copy - and the observer cut its microphone to a peer that never
* left the channel.
*/
test.describe('Losing a peer from the roster must not silence the call', () => {
// The roster wipe is injected directly, because reproducing it through a real outage
// depends on whether the observer notices the dead transport before `user_left`
// arrives - the reducer keeps the voice state while a live peer transport exists.
test('keeps sending to a peer the roster forgot', async ({ createClient }) => {
test.setTimeout(300_000);
const clients = await createVoicePairInNewServer(
createClient,
`Roster Wipe Voice ${Date.now()}`,
{ namePrefix: 'Roster Wipe' }
);
const [peer, observer] = clients;
for (const client of clients) {
await assertTwoWayAudio(client, 'before the roster wipe');
}
await test.step('The observer is told the peer left the server', async () => {
const wipedUserId = await wipeRemoteVoiceMembersFromRoster(observer.page);
test.info().annotations.push({ type: 'wiped user', description: wipedUserId });
await waitForNoRemoteVoiceMembersInRoster(observer.page, 15_000);
});
// Nothing about the media plane changed, so the peer must not lose a single second of
// audio. Checking only the end state would hide the cut: the peer keeps sending voice
// heartbeats, so the roster heals itself moments later.
await test.step('The peer never stops receiving the observer microphone', async () => {
await assertUninterruptedInboundAudio(peer, 10);
});
});
/**
* The sleep/wake shape without a suspend: one client loses its signal socket long
* enough for the server to declare it dead, and its peer connections die with it. When
* everything returns the peer re-identifies with no voice state attached, so asking the
* peer over the rebuilt data channel is the only thing that can confirm it is still in
* our channel.
*
* `recovery-preserves-media.spec.ts` cannot reach this: killing the server leaves
* nobody to broadcast `user_left`.
*/
test('restores two-way voice after the server declares one client dead', async ({ createClient }) => {
test.setTimeout(600_000);
const clients = await createVoicePairInNewServer(
createClient,
`Roster Loss Voice ${Date.now()}`,
{ namePrefix: 'Roster Loss' }
);
const [droppedClient, observer] = clients;
for (const client of clients) {
await assertTwoWayAudio(client, 'before the outage');
}
await test.step('One client loses its signal socket and its peer connections', async () => {
await droppedClient.context.setOffline(true);
await closeTrackedPeerConnections(droppedClient.page);
await observer.page.waitForTimeout(DEAD_SOCKET_HOLD_MS);
});
await test.step('Both clients are two-way again once the socket returns', async () => {
await droppedClient.context.setOffline(false);
for (const client of clients) {
await waitForConnectedPeerCount(client.page, 1, 180_000);
await waitForOpenDataChannelCount(client.page, 1, 180_000);
}
for (const client of clients) {
await assertTwoWayAudio(client, 'after the socket returned', 90_000);
}
});
});
});
/** Fail unless the client both sends and receives voice packets within the timeout. */
async function assertTwoWayAudio(
client: VoicePairClient,
label: string,
timeoutMs = 60_000
): Promise<void> {
const deadline = Date.now() + timeoutMs;
let outboundPacketsDelta = 0;
let inboundPacketsDelta = 0;
while (Date.now() < deadline) {
({ outboundPacketsDelta, inboundPacketsDelta } = await getAudioStatsDelta(client.page, 3_000));
if (outboundPacketsDelta > 0 && inboundPacketsDelta > 0) {
return;
}
}
throw new Error(
`${client.displayName} is not two-way ${label}: sent ${outboundPacketsDelta}, `
+ `received ${inboundPacketsDelta} packets in the last sample.\n`
+ await dumpRtcDiagnostics(client.page)
);
}
/**
* Fail if the client goes even one second without receiving voice packets. Peers gossip
* their voice state every 5s, so a torn-down microphone comes back on its own - only a
* continuous sample can tell that the audio never stopped.
*/
async function assertUninterruptedInboundAudio(
client: VoicePairClient,
seconds: number
): Promise<void> {
for (let sample = 1; sample <= seconds; sample++) {
const { inboundPacketsDelta } = await getAudioStatsDelta(client.page, 1_000);
if (inboundPacketsDelta === 0) {
throw new Error(
`${client.displayName} stopped receiving voice ${sample}s after the roster wipe.\n`
+ await dumpRtcDiagnostics(client.page)
);
}
}
}
/** Kill the media plane the way a suspend does, leaving the peer to notice on its own. */
async function closeTrackedPeerConnections(page: Page): Promise<void> {
await page.evaluate(() => {
const connections = (window as { __rtcConnections?: RTCPeerConnection[] }).__rtcConnections ?? [];
for (const connection of connections) {
connection.close();
}
});
}
/**
* Replay what the signal server does when it declares a socket dead: tell this client the
* remote user left the server, with no live transport recorded. Returns the wiped user id.
*/
async function wipeRemoteVoiceMembersFromRoster(page: Page): Promise<string> {
return page.evaluate(() => {
interface RosterUser {
id?: string;
oderId?: string;
peerId?: string;
voiceState?: { isConnected?: boolean };
}
interface StoreLike {
dispatch: (action: { type: string } & Record<string, unknown>) => void;
}
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
throw new Error('Angular debug API is unavailable, cannot reach the store');
}
const component = debugApi.getComponent(host);
const store = component['store'] as StoreLike | undefined;
const users = (component['onlineUsers'] as (() => RosterUser[]) | undefined)?.() ?? [];
const currentUser = (component['currentUser'] as (() => RosterUser | null) | undefined)?.() ?? null;
const currentRoom = (component['currentRoom'] as (() => { id?: string } | null) | undefined)?.() ?? null;
const remoteVoiceUser = users.find((user) =>
user.voiceState?.isConnected === true
&& user.id !== currentUser?.id
&& user.oderId !== currentUser?.oderId);
if (!store || !remoteVoiceUser?.id || !currentRoom?.id) {
throw new Error('No remote voice member to wipe from the roster');
}
store.dispatch({
type: '[Users] User Left',
userId: remoteVoiceUser.id,
serverId: currentRoom.id,
connectedPeerIds: []
});
return remoteVoiceUser.id;
});
}
/** Wait until no remote user in the client's roster claims to be in voice. */
async function waitForNoRemoteVoiceMembersInRoster(page: Page, timeout: number): Promise<void> {
await page.waitForFunction(
() => {
interface RosterUser {
id?: string;
oderId?: string;
peerId?: string;
voiceState?: { isConnected?: boolean };
}
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return false;
}
const component = debugApi.getComponent(host);
const users = (component['onlineUsers'] as (() => RosterUser[]) | undefined)?.() ?? [];
const currentUser = (component['currentUser'] as (() => RosterUser | null) | undefined)?.() ?? null;
const selfIds = new Set([
currentUser?.id,
currentUser?.oderId,
currentUser?.peerId
].filter(Boolean));
return users
.filter((user) => ![
user.id,
user.oderId,
user.peerId
].some((id) => !!id && selfIds.has(id)))
.every((user) => user.voiceState?.isConnected !== true);
},
undefined,
{ timeout }
);
}
@@ -0,0 +1,156 @@
import { expect } from '@playwright/test';
import { test, type Client } from '../../fixtures/multi-client';
import {
forceRelayOnlyIce,
getRelayIceConfigs,
seedTurnOnlyIceServers,
waitForRelayedCandidatePairs,
type TurnCredentials
} from '../../helpers/turn-relay';
import {
isDockerAvailable,
startTurnServer,
type TurnServerHandle
} from '../../helpers/turn-server';
import { installDeterministicVoiceSettings } from '../../helpers/voice-session';
import {
installAutoResumeAudioContext,
installWebRTCTracking,
waitForAllPeerAudioFlow,
waitForAudioStatsPresent,
waitForConnectedPeerCount,
waitForOpenDataChannelCount
} from '../../helpers/webrtc-helpers';
import { ChatRoomPage } from '../../pages/chat-room.page';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
const USER_PASSWORD = 'TestPass123!';
const VOICE_CHANNEL = 'General';
/**
* Symmetric NAT gives a browser no usable direct path, so the whole call has to
* ride a TURN relay. `iceTransportPolicy: 'relay'` reproduces that without any
* network trickery: host and server-reflexive candidates are thrown away, and
* only the TURN server the app was configured with is left.
*/
test.describe('Relay-only voice', () => {
let turnServer: TurnServerHandle | null = null;
test.beforeAll(async () => {
if (!await isDockerAvailable()) {
return;
}
turnServer = await startTurnServer();
});
test.afterAll(async () => {
await turnServer?.stop();
turnServer = null;
});
test('two users hear each other with every direct path removed', async ({ createClient }) => {
test.skip(!turnServer, 'Relay-only voice needs Docker to run a local coturn.');
test.setTimeout(240_000);
const turn = turnServer as TurnServerHandle;
const clients = await createRelayOnlyVoicePair(createClient, turn, `Relay Only Voice ${Date.now()}`);
await test.step('Both ends settled on a TURN relay, not a direct path', async () => {
for (const client of clients) {
const pairs = await waitForRelayedCandidatePairs(client.page, 1, 60_000);
// A direct pair here would mean the policy leaked and the test proved nothing.
for (const pair of pairs) {
expect(pair.localCandidateType, 'a peer connection escaped the relay-only policy').toBe('relay');
}
}
});
await test.step('Audio flows both ways through the relay', async () => {
for (const client of clients) {
await waitForAllPeerAudioFlow(client.page, 1, 60_000);
}
});
});
});
async function createRelayOnlyVoicePair(
createClient: () => Promise<Client>,
turn: TurnCredentials,
serverName: string
): Promise<Client[]> {
const clients: Client[] = [];
const credentials: { username: string; displayName: string }[] = [];
for (let index = 0; index < 2; index++) {
const client = await createClient();
await installDeterministicVoiceSettings(client.page);
await installWebRTCTracking(client.page);
await forceRelayOnlyIce(client.page);
await seedTurnOnlyIceServers(client.page, turn);
await installAutoResumeAudioContext(client.page);
clients.push(client);
credentials.push({
displayName: `Relay Voice ${index + 1}`,
username: `relay_voice_${Date.now()}_${index + 1}`
});
}
await test.step('Register both clients', async () => {
for (const [index, client] of clients.entries()) {
const registerPage = new RegisterPage(client.page);
await registerPage.goto();
await registerPage.register(
credentials[index].username,
credentials[index].displayName,
USER_PASSWORD
);
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
}
});
await test.step('Create and join the server', async () => {
await new ServerSearchPage(clients[0].page).createServer(serverName, {
description: 'Relay-only voice test'
});
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
await new ServerSearchPage(clients[1].page).joinServerFromSearch(serverName);
await expect(clients[1].page).toHaveURL(/\/room\//, { timeout: 20_000 });
});
await test.step('Join both clients to voice', async () => {
await new ChatRoomPage(clients[0].page).ensureVoiceChannelExists(VOICE_CHANNEL);
for (const client of clients) {
const room = new ChatRoomPage(client.page);
await room.joinVoiceChannel(VOICE_CHANNEL);
await expect(room.voiceControls).toBeVisible({ timeout: 20_000 });
}
for (const [index, client] of clients.entries()) {
try {
await waitForConnectedPeerCount(client.page, 1, 90_000);
await waitForOpenDataChannelCount(client.page, 1, 90_000);
await waitForAudioStatsPresent(client.page, 30_000);
} catch (error) {
// No TURN server in the config looks exactly like a failed relay from the
// outside, so show what the app actually handed to WebRTC.
const configs = await getRelayIceConfigs(client.page);
console.log(`[relay client ${index + 1} ice configs] ${JSON.stringify(configs.slice(0, 3))}`);
throw error;
}
}
});
return clients;
}
+2
View File
@@ -21,6 +21,7 @@ Owns the desktop runtime: the Electron main process, the preload bridge that exp
| **Local API server** | An in-process HTTP server (`electron/api/local-api-server.ts`) that serves the prebuilt Docusaurus docs and OpenAPI views to the renderer over `http://localhost:<port>/`. | "internal API" |
| **Plugin library** | The plugin loader (`electron/plugin-library.ts`) — resolves manifests, validates entry points, and prepares the sandbox the renderer mounts plugins into. | "plugin manager" |
| **Data archive** | The export/import format implemented in `electron/data-archive.ts` for moving a user's local database between installs. | "backup" |
| **Linux launcher** | The shell script installed as the packaged Linux executable by `tools/after-pack.js`; built by `electron/app/linux-launcher.rules.ts`, it picks the sandbox switches and hands over to the renamed real binary `<executableName>-bin`. | "wrapper", "AppRun" |
## Relationships
@@ -49,6 +50,7 @@ Owns the desktop runtime: the Electron main process, the preload bridge that exp
- Every schema change is accompanied by a **TypeORM migration**; the database is never mutated outside the migration system.
- IPC handler errors are translated to typed error envelopes before crossing back into the renderer — the renderer never sees a raw `Error` from main.
- The **Preload bridge** exposes a frozen, allow-listed set of methods; adding a method requires touching both `preload.ts` and the matching handler.
- Chromium sandbox and Ozone switches are only ever set on the real command line — the **Linux launcher** for packaged builds, the launch scripts in development. `app.commandLine.appendSwitch` runs too late for them and must not be used to fake it.
## Flagged ambiguities
+27 -6
View File
@@ -1,11 +1,11 @@
import { app } from 'electron';
import * as path from 'path';
import { createWindow, getMainWindow } from '../window/create-window';
import { resolveSecondInstanceAction } from './second-instance.rules';
const CUSTOM_PROTOCOL = 'toju';
const DEEP_LINK_PREFIX = `${CUSTOM_PROTOCOL}://`;
const DEV_SINGLE_INSTANCE_EXIT_CODE_ENV = 'METOYOU_SINGLE_INSTANCE_EXIT_CODE';
const DEV_RELOAD_EXISTING_ARG = '--metoyou-dev-reload-existing';
let pendingDeepLink: string | null = null;
@@ -42,6 +42,24 @@ function focusMainWindow(): void {
mainWindow.focus();
}
function reloadMainWindow(): void {
const mainWindow = getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) {
void createWindow();
return;
}
focusMainWindow();
if (mainWindow.webContents.isLoadingMainFrame()) {
return;
}
mainWindow.webContents.reloadIgnoringCache();
}
function forwardDeepLink(url: string): void {
const mainWindow = getMainWindow();
@@ -96,13 +114,16 @@ export function initializeDeepLinkHandling(): boolean {
}
app.on('second-instance', (_event, argv) => {
if (resolveDevSingleInstanceExitCode() != null && argv.includes(DEV_RELOAD_EXISTING_ARG)) {
app.relaunch();
app.exit(0);
return;
}
const action = resolveSecondInstanceAction({
argv,
devSingleInstanceExitCode: resolveDevSingleInstanceExitCode()
});
if (action === 'reload-existing') {
reloadMainWindow();
} else {
focusMainWindow();
}
const deepLink = extractDeepLink(argv);
+4 -15
View File
@@ -4,7 +4,6 @@ import { readDesktopSettings } from '../desktop-settings';
export function configureAppFlags(): void {
configureDesktopBranding();
linuxSpecificFlags();
networkFlags();
setupGpuEncodingFlags();
chromiumFlags();
@@ -21,6 +20,10 @@ function chromiumFlags(): void {
const enabledFeatures: string[] = [];
if (process.platform === 'linux') {
// Sandbox and Ozone platform selection happen before this file runs. The
// packaged launcher script and the dev launch scripts pass those switches
// on the real command line instead.
// PipeWire-based audio pipeline for screen share audio capture
enabledFeatures.push('AudioServiceOutOfProcess');
// PipeWire-based screen capture so the xdg-desktop-portal system picker works
@@ -38,20 +41,6 @@ function chromiumFlags(): void {
}
}
function linuxSpecificFlags(): void {
if (process.platform !== 'linux') {
return;
}
// Disable sandbox on Linux to avoid SUID / /tmp shared-memory issues
app.commandLine.appendSwitch('no-sandbox');
app.commandLine.appendSwitch('disable-dev-shm-usage');
// Chromium chooses the Linux Ozone platform before Electron runs this file.
// The launch scripts pass `--ozone-platform=wayland` up front for Wayland
// sessions so the browser process selects the correct backend early enough.
}
function networkFlags(): void {
// Accept self-signed certificates in development (for --ssl dev server)
if (process.env['SSL'] === 'true') {
+147
View File
@@ -0,0 +1,147 @@
import { execFileSync } from 'node:child_process';
import {
mkdtempSync,
rmSync,
writeFileSync
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
afterEach,
beforeEach,
describe,
expect,
it
} from 'vitest';
import { buildLinuxLauncherScript, resolveLinuxLauncherNames } from './linux-launcher.rules';
interface KernelFlags {
apparmorRestriction: string;
unprivilegedUsernsClone: string;
maxUserNamespaces: string;
}
const PERMISSIVE_KERNEL: KernelFlags = {
apparmorRestriction: '0',
unprivilegedUsernsClone: '1',
maxUserNamespaces: '15000'
};
let workspace = '';
function writeKernelFlags(flags: KernelFlags): Record<keyof KernelFlags, string> {
const paths = {
apparmorRestriction: join(workspace, 'apparmor_restrict_unprivileged_userns'),
unprivilegedUsernsClone: join(workspace, 'unprivileged_userns_clone'),
maxUserNamespaces: join(workspace, 'max_user_namespaces')
};
for (const key of Object.keys(paths) as (keyof KernelFlags)[]) {
writeFileSync(paths[key], `${flags[key]}\n`, 'utf8');
}
return paths;
}
function runLauncher(flags: KernelFlags, args: string[] = []): string {
const paths = writeKernelFlags(flags);
const { launcherFileName, binaryFileName } = resolveLinuxLauncherNames('toju');
const launcherPath = join(workspace, launcherFileName);
const binaryPath = join(workspace, binaryFileName);
writeFileSync(binaryPath, '#!/bin/sh\nprintf \'%s\\n\' "$@"\n', { encoding: 'utf8', mode: 0o755 });
writeFileSync(
launcherPath,
buildLinuxLauncherScript({
binaryFileName,
apparmorRestrictionPath: paths.apparmorRestriction,
unprivilegedUsernsClonePath: paths.unprivilegedUsernsClone,
maxUserNamespacesPath: paths.maxUserNamespaces
}),
{ encoding: 'utf8', mode: 0o755 }
);
return execFileSync(launcherPath, args, { encoding: 'utf8' }).trim();
}
describe('buildLinuxLauncherScript', () => {
beforeEach(() => {
workspace = mkdtempSync(join(tmpdir(), 'toju-launcher-'));
});
afterEach(() => {
rmSync(workspace, { force: true, recursive: true });
});
it('keeps the sandbox on when the kernel allows unprivileged user namespaces', () => {
expect(runLauncher(PERMISSIVE_KERNEL)).toBe('');
});
it('disables the sandbox when AppArmor confines unprivileged user namespaces', () => {
const output = runLauncher({ ...PERMISSIVE_KERNEL, apparmorRestriction: '1' });
expect(output).toBe('--no-sandbox');
});
it('disables the sandbox when the kernel forbids unprivileged namespace cloning', () => {
const output = runLauncher({ ...PERMISSIVE_KERNEL, unprivilegedUsernsClone: '0' });
expect(output).toBe('--no-sandbox');
});
it('disables the sandbox when no user namespaces are available at all', () => {
const output = runLauncher({ ...PERMISSIVE_KERNEL, maxUserNamespaces: '0' });
expect(output).toBe('--no-sandbox');
});
it('forwards launch arguments to the real binary', () => {
const output = runLauncher(PERMISSIVE_KERNEL, ['toju://invite/abc', '--ozone-platform=wayland']);
expect(output.split('\n')).toEqual(['toju://invite/abc', '--ozone-platform=wayland']);
});
it('never repeats a sandbox switch the caller already supplied', () => {
const output = runLauncher(
{ ...PERMISSIVE_KERNEL, apparmorRestriction: '1' },
['--no-sandbox', '%U']
);
expect(output.split('\n')).toEqual(['--no-sandbox', '%U']);
});
it('assumes a blocked sandbox is fine when the kernel switches are unreadable', () => {
const { launcherFileName, binaryFileName } = resolveLinuxLauncherNames('toju');
const launcherPath = join(workspace, launcherFileName);
writeFileSync(
join(workspace, binaryFileName),
'#!/bin/sh\nprintf \'%s\\n\' "$@"\n',
{ encoding: 'utf8', mode: 0o755 }
);
writeFileSync(
launcherPath,
buildLinuxLauncherScript({
binaryFileName,
apparmorRestrictionPath: join(workspace, 'missing-apparmor'),
unprivilegedUsernsClonePath: join(workspace, 'missing-clone'),
maxUserNamespacesPath: join(workspace, 'missing-max')
}),
{ encoding: 'utf8', mode: 0o755 }
);
expect(execFileSync(launcherPath, { encoding: 'utf8' }).trim()).toBe('');
});
});
describe('resolveLinuxLauncherNames', () => {
it('keeps the published executable name for the launcher and renames the binary', () => {
expect(resolveLinuxLauncherNames('toju')).toEqual({
launcherFileName: 'toju',
binaryFileName: 'toju-bin'
});
});
});
+97
View File
@@ -0,0 +1,97 @@
export const LINUX_LAUNCHER_BINARY_SUFFIX = '-bin';
export const APPARMOR_USERNS_RESTRICTION_PATH = '/proc/sys/kernel/apparmor_restrict_unprivileged_userns';
export const UNPRIVILEGED_USERNS_CLONE_PATH = '/proc/sys/kernel/unprivileged_userns_clone';
export const MAX_USER_NAMESPACES_PATH = '/proc/sys/user/max_user_namespaces';
export interface LinuxLauncherNames {
launcherFileName: string;
binaryFileName: string;
}
export interface LinuxLauncherScriptOptions {
binaryFileName: string;
apparmorRestrictionPath?: string;
unprivilegedUsernsClonePath?: string;
maxUserNamespacesPath?: string;
}
export function resolveLinuxLauncherNames(executableName: string): LinuxLauncherNames {
return {
launcherFileName: executableName,
binaryFileName: `${executableName}${LINUX_LAUNCHER_BINARY_SUFFIX}`
};
}
/**
* Chromium reads `--no-sandbox` while the browser process boots, long before
* the main script runs, so `app.commandLine.appendSwitch` cannot influence it.
* The packaged executable is therefore this script, which decides before
* handing over to the real binary.
*
* The sandbox stays on wherever the kernel can host it. It is dropped only
* where unprivileged user namespaces are denied - Ubuntu 24.04+ confines
* unconfined binaries through AppArmor, and hardened kernels disable the
* namespaces outright. An AppImage cannot fall back to the SUID helper because
* its payload is mounted `nosuid`, so without this the app aborts at startup.
*/
export function buildLinuxLauncherScript(options: LinuxLauncherScriptOptions): string {
const apparmorRestrictionPath = options.apparmorRestrictionPath ?? APPARMOR_USERNS_RESTRICTION_PATH;
const unprivilegedUsernsClonePath = options.unprivilegedUsernsClonePath ?? UNPRIVILEGED_USERNS_CLONE_PATH;
const maxUserNamespacesPath = options.maxUserNamespacesPath ?? MAX_USER_NAMESPACES_PATH;
return [
'#!/bin/sh',
'# Generated during packaging. Chromium only honours --no-sandbox when it is',
'# present on the real command line, so the decision happens here.',
'set -eu',
'',
'launcher_path="$0"',
'',
'case "$launcher_path" in',
' */*) ;;',
' *) launcher_path="$(command -v -- "$launcher_path" 2>/dev/null || printf \'%s\' "$launcher_path")" ;;',
'esac',
'',
'launcher_path="$(readlink -f -- "$launcher_path" 2>/dev/null || printf \'%s\' "$launcher_path")"',
`binary_path="$(dirname -- "$launcher_path")/${options.binaryFileName}"`,
'',
'read_kernel_flag() {',
' if [ ! -r "$1" ]; then',
' printf \'%s\' "$2"',
' return 0',
' fi',
'',
' cat -- "$1" 2>/dev/null || printf \'%s\' "$2"',
'}',
'',
'sandbox_is_blocked() {',
` if [ "$(read_kernel_flag ${apparmorRestrictionPath} 0)" = "1" ]; then`,
' return 0',
' fi',
'',
` if [ "$(read_kernel_flag ${unprivilegedUsernsClonePath} 1)" = "0" ]; then`,
' return 0',
' fi',
'',
` if [ "$(read_kernel_flag ${maxUserNamespacesPath} 1)" = "0" ]; then`,
' return 0',
' fi',
'',
' return 1',
'}',
'',
'for launcher_arg in "$@"; do',
' case "$launcher_arg" in',
' --no-sandbox) exec "$binary_path" "$@" ;;',
' esac',
'done',
'',
'if sandbox_is_blocked; then',
' exec "$binary_path" --no-sandbox "$@"',
'fi',
'',
'exec "$binary_path" "$@"',
''
].join('\n');
}
@@ -0,0 +1,44 @@
import {
describe,
expect,
it
} from 'vitest';
import { DEV_RELOAD_EXISTING_ARG, resolveSecondInstanceAction } from './second-instance.rules';
describe('resolveSecondInstanceAction', () => {
it('reloads the open window when a dev launch asks to reuse it', () => {
const action = resolveSecondInstanceAction({
argv: [
'electron',
'.',
DEV_RELOAD_EXISTING_ARG
],
devSingleInstanceExitCode: 23
});
expect(action).toBe('reload-existing');
});
it('never asks a packaged instance to reload, even with the dev argument', () => {
const action = resolveSecondInstanceAction({
argv: ['metoyou', DEV_RELOAD_EXISTING_ARG],
devSingleInstanceExitCode: null
});
expect(action).toBe('focus');
});
it('focuses the open window for an ordinary second launch', () => {
const action = resolveSecondInstanceAction({
argv: [
'electron',
'.',
'toju://invite/abc'
],
devSingleInstanceExitCode: 23
});
expect(action).toBe('focus');
});
});
+23
View File
@@ -0,0 +1,23 @@
export const DEV_RELOAD_EXISTING_ARG = '--metoyou-dev-reload-existing';
export type SecondInstanceAction = 'reload-existing' | 'focus';
export interface SecondInstanceInput {
argv: string[];
devSingleInstanceExitCode: number | null;
}
/**
* A dev launch always carries `--metoyou-dev-reload-existing`, so the running
* instance reloads in place. It must never answer by relaunching itself: the
* successor inherits the same argument and asks for the single-instance lock
* while the dying parent still holds it, so the refused successor fires
* `second-instance` again and the pair respawns forever.
*/
export function resolveSecondInstanceAction(input: SecondInstanceInput): SecondInstanceAction {
const isDevelopmentLaunch = input.devSingleInstanceExitCode != null;
return isDevelopmentLaunch && input.argv.includes(DEV_RELOAD_EXISTING_ARG)
? 'reload-existing'
: 'focus';
}
+48 -1
View File
@@ -11,6 +11,7 @@ import * as fs from 'fs';
import * as path from 'path';
import { DESKTOP_APP_DISPLAY_NAME } from '../app/desktop-branding.rules';
import { readDesktopSettings } from '../desktop-settings';
import { DEV_CLIENT_LOAD_ATTEMPTS, loadDevelopmentClientWithRetry } from './dev-client-load.rules';
import { resolveDevelopmentClientUrl } from './dev-client-url.rules';
import { shouldRegisterDisplayMediaHandler } from './display-media-handler.rules';
@@ -261,6 +262,52 @@ function ensureDisplayMediaRequestHandler(): void {
);
}
function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function buildDevClientFailurePage(url: string, reason: string): string {
const escapedReason = reason.replace(/&/g, '&amp;').replace(/</g, '&lt;');
return `<!doctype html>
<html><body style="background:#0a0a0f;color:#e5e7eb;font:14px system-ui;padding:48px">
<h1 style="font-size:18px">The dev client did not load</h1>
<p>Could not load <code>${url}</code> after ${DEV_CLIENT_LOAD_ATTEMPTS} attempts.</p>
<p style="color:#f87171"><code>${escapedReason}</code></p>
<p>Check that <code>npm run dev</code> is still running, then reload with Ctrl+R.</p>
</body></html>`;
}
async function loadDevelopmentClient(window: BrowserWindow, url: string): Promise<void> {
let lastError: unknown = null;
const outcome = await loadDevelopmentClientWithRetry({
isAborted: () => window.isDestroyed(),
load: () => window.loadURL(url),
onGiveUp: (attempts, error) => {
lastError = error;
console.error(`[Window] Dev client at ${url} failed after ${attempts} attempts: ${describeError(error)}`);
},
onRetry: (attempt, error) => {
lastError = error;
console.warn(`[Window] Dev client at ${url} not ready (attempt ${attempt}): ${describeError(error)}. Retrying.`);
},
wait: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs))
});
if (outcome !== 'failed' || window.isDestroyed()) {
return;
}
const failurePage = buildDevClientFailurePage(url, describeError(lastError));
try {
await window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(failurePage)}`);
} catch (error) {
console.error(`[Window] Could not show the dev client failure page: ${describeError(error)}`);
}
}
export async function createWindow(): Promise<void> {
const windowIconPath = getWindowIconPath();
@@ -290,7 +337,7 @@ export async function createWindow(): Promise<void> {
ensureDisplayMediaRequestHandler();
if (process.env['NODE_ENV'] === 'development') {
await mainWindow.loadURL(resolveDevelopmentClientUrl(process.env['SSL'] === 'true'));
await loadDevelopmentClient(mainWindow, resolveDevelopmentClientUrl(process.env['SSL'] === 'true'));
if (process.env['DEBUG_DEVTOOLS'] === '1') {
mainWindow.webContents.openDevTools();
@@ -0,0 +1,72 @@
import {
describe,
expect,
it,
vi
} from 'vitest';
import {
DEV_CLIENT_LOAD_ATTEMPTS,
DevClientLoadDeps,
loadDevelopmentClientWithRetry
} from './dev-client-load.rules';
function createDeps(overrides: Partial<DevClientLoadDeps> = {}): DevClientLoadDeps {
return {
isAborted: () => false,
load: vi.fn().mockResolvedValue(undefined),
onGiveUp: vi.fn(),
onRetry: vi.fn(),
wait: vi.fn().mockResolvedValue(undefined),
...overrides
};
}
describe('loadDevelopmentClientWithRetry', () => {
it('loads once when the dev server answers', async () => {
const deps = createDeps();
await expect(loadDevelopmentClientWithRetry(deps)).resolves.toBe('loaded');
expect(deps.load).toHaveBeenCalledTimes(1);
expect(deps.onRetry).not.toHaveBeenCalled();
});
it('retries a rebuild gap and reports the recovered load', async () => {
const load = vi.fn()
.mockRejectedValueOnce(new Error('ERR_CONNECTION_REFUSED (-102)'))
.mockResolvedValue(undefined);
const deps = createDeps({ load });
await expect(loadDevelopmentClientWithRetry(deps)).resolves.toBe('loaded');
expect(load).toHaveBeenCalledTimes(2);
expect(deps.onRetry).toHaveBeenCalledTimes(1);
expect(deps.wait).toHaveBeenCalledTimes(1);
});
it('reports failure instead of throwing so the caller still wires the window', async () => {
const error = new Error('ERR_FAILED (-2)');
const deps = createDeps({ load: vi.fn().mockRejectedValue(error) });
await expect(loadDevelopmentClientWithRetry(deps)).resolves.toBe('failed');
expect(deps.load).toHaveBeenCalledTimes(DEV_CLIENT_LOAD_ATTEMPTS);
expect(deps.onGiveUp).toHaveBeenCalledWith(DEV_CLIENT_LOAD_ATTEMPTS, error);
});
it('stops retrying once the window is gone', async () => {
let windowAlive = true;
const load = vi.fn().mockImplementation(() => {
windowAlive = false;
return Promise.reject(new Error('ERR_FAILED (-2)'));
});
const deps = createDeps({
isAborted: () => !windowAlive,
load
});
await expect(loadDevelopmentClientWithRetry(deps)).resolves.toBe('aborted');
expect(load).toHaveBeenCalledTimes(1);
expect(deps.onGiveUp).not.toHaveBeenCalled();
});
});
+49
View File
@@ -0,0 +1,49 @@
export const DEV_CLIENT_LOAD_ATTEMPTS = 10;
export const DEV_CLIENT_RETRY_DELAY_MS = 500;
export type DevClientLoadOutcome = 'loaded' | 'aborted' | 'failed';
export interface DevClientLoadDeps {
load: () => Promise<void>;
isAborted: () => boolean;
wait: (delayMs: number) => Promise<void>;
onRetry: (attempt: number, error: unknown) => void;
onGiveUp: (attempts: number, error: unknown) => void;
}
/**
* The dev client is served by a watch-mode build, so a load can fail for
* reasons that resolve on their own: a rebuild in flight, or a shutdown that
* aborted the navigation. Failing hard skipped every window listener
* registered after the load and left a blank window with no message, so this
* retries and always reports instead of throwing.
*/
export async function loadDevelopmentClientWithRetry(deps: DevClientLoadDeps): Promise<DevClientLoadOutcome> {
for (let attempt = 1; attempt <= DEV_CLIENT_LOAD_ATTEMPTS; attempt += 1) {
if (deps.isAborted()) {
return 'aborted';
}
try {
await deps.load();
return 'loaded';
} catch (error) {
if (deps.isAborted()) {
return 'aborted';
}
if (attempt === DEV_CLIENT_LOAD_ATTEMPTS) {
deps.onGiveUp(attempt, error);
return 'failed';
}
deps.onRetry(attempt, error);
await deps.wait(DEV_CLIENT_RETRY_DELAY_MS);
}
}
return 'failed';
}
+74
View File
@@ -0,0 +1,74 @@
# Emergency Fix Pack — MetoYou / Toju
> **Purpose:** Give other agents a single, user-first map of what this app is, how critical features are supposed to work, where the code lies, and the exact order to fix failures.
>
> **Constraint for this pack:** Analysis and planning only. Product code was not changed when this pack was authored (2026-08-12).
>
> **Primary surface:** `toju-app/` + targeted `electron/` (auth secret store / IPC). Expand to `server/` / `e2e/` only when a packet says so.
---
## How to use this pack
1. Read **this file** + `01-product-overview.md`.
2. Pick a work packet from `12-agent-work-packets.md` (do not invent parallel scope).
3. Open the matching issue file (`04``09`) for user story, intended behavior, failure modes, code paths, and proof.
4. Follow priority order in `11-fix-priority-plan.md` unless the user overrides.
5. Before coding: run the repo **interview-before-implement** ritual (choices A/B/C) unless the user says “just fix it” / “no interview” or an active handoff already records approved decisions.
6. When a packet finishes: update `11-fix-priority-plan.md` status checkboxes and clear or rewrite `agents-docs/HANDOFF.md` per `.cursor/rules/handoff.mdc`.
---
## File index
| File | Contents |
|------|----------|
| `00-README.md` | This index and rules of engagement |
| `01-product-overview.md` | What MetoYou/Toju is (user + architecture) |
| `02-user-journeys.md` | End-to-end journeys: login → rooms → chat → voice → multi-server |
| `03-architecture-map.md` | Domains, transports, identity model, key paths |
| `04-auth-login-bugs.md` | Login / authorize / silent provision failures |
| `05-signaling-multi-server.md` | Signal servers, WS drops, room affinity, presence |
| `06-voice-webrtc-bugs.md` | Voice, camera, screen-share, peer negotiation |
| `07-data-channel-drops.md` | Control-plane DC failures and recovery gaps |
| `08-messaging-visibility.md` | “Messages not seen” / sync / fallback |
| `09-identity-cross-signal.md` | Home vs foreign actor ids (calls, DMs, voice routing) |
| `10-code-lies-doc-debt.md` | Docs/lessons that disagree with the tree |
| `11-fix-priority-plan.md` | Ordered waves, acceptance, proof |
| `12-agent-work-packets.md` | Copy-paste packets for new chats |
| `13-validated-findings-fable-5-handoff.md` | Code-validated findings, corrected priorities, E2E limits, and implementation handoff |
| `e2e-failures/` | Latest full Playwright e2e run baseline (errors + slow tests) |
---
## Product in one paragraph (user view)
MetoYou (product client **Toju**) is a **desktop-first P2P chat app**: you log in once to a **signal server**, join **chat-servers** (communities with text + voice channels), talk in text, join voice, share screen/camera, and DM / call people. Media and most chat go **peer-to-peer over WebRTC**. The signal server only authenticates you, tracks who is in which room, and relays WebRTC offers / narrow chat & DM fallbacks. You may use **many signal servers**; the app is supposed to create a linked account on each new host **silently** after the first home login.
---
## Critical bug themes (user language)
1. **“It keeps asking me to log in”** even though Im already signed in (especially when joining something on another signal server).
2. **“Voice doesnt work / one-way / connecting forever”** — often after reconnect or when people registered on different signal servers.
3. **“Connection / data channel drops”** — chat, attachments, emoji, and sometimes voice die; recovery is silent or tears everything down.
4. **“I sent a message but they dont see it”** (or only some devices see it) — presence missing, P2P sync dead, or wrong identity.
5. **“Call rings nobody / Im In Voice alone”** — cross-signal identity mismatch on outbound or inbound paths.
6. **Different signal servers** make all of the above worse because **home user id ≠ foreign provisioned actor id**, and signaling is **not federated**.
---
## Related existing research (do not redo)
- Active research story: `agents-docs/user-stories/silent-cross-signal-server-auth.md`
- Feature contracts: `agents-docs/features/{authentication,signaling,voice-webrtc,messaging,server-directory}.md`
- Realtime deep dive: `toju-app/src/app/infrastructure/realtime/README.md`
- Lessons index: `agents-docs/LESSONS-INDEX.md` — tags `[auth] [realtime] [direct-call] [direct-message] [identity] [signaling]`
---
## Non-goals of this pack
- Implementing fixes (separate chats / packets).
- Rewriting the whole monorepo.
- Treating unit-green alone as “done” — each packet lists user-visible proof.
+118
View File
@@ -0,0 +1,118 @@
# 01 — Product overview (user + system)
## What the user thinks this app is
**Toju / MetoYou** is a Discord-like desktop chat app with:
- **Accounts** on a signal (signaling) server
- **Chat-servers** (communities) with text channels and voice channels
- **Live text chat** in those channels
- **Voice / camera / screen share** in voice channels
- **Direct messages** and **private calls**
- **Friends**, profile cards, custom emoji, GIFs, file attachments
- **Multiple signal servers** in the network settings (home + others)
The marketing promise implied by the product design:
> Log in once. Join communities anywhere on the network. Chat and voice “just work.” Switching signal hosts should not feel like logging into a second product.
---
## What the system actually is
| Layer | Role |
|-------|------|
| **Angular client** (`toju-app/`) | All UX, NgRx state, domain logic, WebRTC + WebSocket clients |
| **Electron shell** (`electron/`) | Desktop window, SQLite persistence, IPC (`window.api`), provision-secret safeStorage, screen capture helpers |
| **Signaling server** (`server/`) | Auth tokens, public server directory REST, WebSocket identify/join/presence, RTC offer/answer/ICE relay, narrow chat/DM/voice_state fallbacks, multi-device `account_sync`. **Does not store chat history.** |
| **Web / Capacitor** | Same Angular app; weaker secret storage (sessionStorage); mobile voice/UI constraints |
### Transport split (critical mental model)
| Transport | What the user experiences | What it actually carries |
|-----------|---------------------------|--------------------------|
| **WebSocket to signal server** | “Im online / in this server / someone joined voice” | Identity, room membership, presence, SDP/ICE relay, `chat_message` + DM fallbacks, `voice_state`, `account_sync` |
| **WebRTC media** | Hearing / seeing people | Mic, camera, screen tracks (never through the signal server) |
| **WebRTC ordered data channel** | Messages syncing, files, emoji, many “live” features | Chat events, inventory sync, attachments, avatar/emoji chunks, voice/screen control, plugin bus |
If the **socket** is wrong or unauthenticated → user is invisible; no relay; chat fallback may fail.
If the **data channel** is dead → live text may still limp via `chat_message`, but **history sync, attachments, emoji, many controls** fail.
If **media** is routed wrong or peer never connects → voice UI lies (“In Voice”) with silence.
---
## Core vocabulary (user ↔ engineering)
| User says | Engineering term |
|-----------|------------------|
| “My account / login” | Home session on `homeSignalServerUrl` + local profile in SQLite/IndexedDB |
| “Another network / signal host” | Foreign `ServerEndpoint` URL; needs per-URL credential |
| “A server / community” | Saved **Room** (chat-server) with `sourceUrl` / `sourceId` = which signal hosts it |
| “Text channel / voice channel” | Channels inside a room; text vs voice types |
| “Im in voice” | Local `VoiceSession` + `voice_state` with `isConnected` + mic ownership via `clientInstanceId` |
| “DM / call someone” | Direct-message conversation + optional `direct-call` session |
| “It logged me out” | Often `/login` or `/login?mode=authorize` — may be **foreign authorize**, not true home logout |
---
## Multi-signal identity (the footgun)
1. User registers on **Signal A** → home user id `H`.
2. User later needs **Signal B** → client is supposed to **auto-register/login** with a local **provision secret**, creating actor id `A_B` on B (username may be suffixed).
3. Peers on Signal B see the user as `A_B`, not `H`.
4. WebRTC peer map keys, `targetUserId` on relay, DM conversation ids, and call participant lists may mix `H` and `A_B`.
**Invariant the product claims:** one human, one local profile, many per-server actor credentials — UX never asks for password again except last-resort authorize / real home expiry.
**Invariant signaling claims:** non-federated — peers in the same room must share the **same signal endpoint** to discover each other. Cross-room “same person” is a client-side identity problem.
---
## Feature inventory (what “done product” includes)
Must work for a release-quality emergency fix:
1. Home register / login / logout / session restore
2. Silent foreign provision + Network settings badges
3. Discover / create / join / leave chat-servers (public, password, invite, moderated)
4. Text channels: send, edit, delete, react, typing, unread, sync after late join
5. Voice channels: join/leave, mute/deafen, speaking indicators, multi-device takeover, move between channels
6. Camera + screen share in voice / calls
7. DMs + friends + delivery states
8. Direct / group calls (ring, answer, decline, DND)
9. Attachments over data channel
10. Multi-device `account_sync` for saved rooms / chat batches / friends / avatar / emoji
11. Endpoint health, version compatibility, room signal affinity + fallback
Secondary (do not block P0 voice/auth/chat): plugins, game activity, themes, KLIPY, link previews, experimental media.
---
## Platforms
| Platform | Notes for bugs |
|----------|----------------|
| Electron desktop | Primary; provision secret in safeStorage; SQLite; best screen share |
| Browser | sessionStorage provision secret dies with tab; IndexedDB |
| Capacitor mobile | Auth routing, mic permissions, no reliable screen share; title bar hidden |
---
## Trust boundaries (short)
- REST mutations and WS `identify` require bearer/session token **per signal URL**.
- Actor user ids in request bodies are ignored server-side; token wins.
- Message bodies are **not E2E encrypted** beyond DTLS on WebRTC and TLS on WS.
- Signaling server is **not** the source of truth for chat history.
---
## Where truth lives (for agents)
| Kind of truth | Prefer |
|---------------|--------|
| Wire WebSocket types | `agents-docs/features/signaling.md` + `server/src/websocket/handler.ts`**not** `shared-kernel/signaling-contracts.ts` |
| Auth multi-server | `agents-docs/features/authentication.md` + `emergency-fix/04-*.md` |
| Voice/WebRTC plumbing | `toju-app/.../realtime/README.md` — verify against code (see `10-code-lies`) |
| Domain UX | `toju-app/src/app/domains/<name>/README.md` |
| Known past bugs | `agents-docs/LESSONS.md` — verify symbols still exist (some lessons describe **desired** fixes as if shipped) |
+256
View File
@@ -0,0 +1,256 @@
# 02 — User journeys (how it is supposed to feel)
Each journey: **steps the user takes**, **what should happen**, **failure symptoms**, **where to dig**.
---
## J1 — First-time home login
**User steps**
1. Open app → login/register.
2. Pick (or accept default) signal server.
3. Register or log in with username/password.
4. Land on dashboard / saved servers.
**Supposed to happen**
- Session token stored for that URL (`metoyou.authTokens` + credential store).
- Local user profile scoped in DB; NgRx `currentUser` set with `homeSignalServerUrl`.
- **Provision secret** created and persisted (Electron safeStorage / web sessionStorage).
- Signing public key registered on **home** server when possible.
- WebSocket connects, `identify` with home token + `clientInstanceId`, ready for joins.
**Broken looks like**
- Stuck on login; bounce back after “success”.
- Dashboard with no token → later `SESSION_EXPIRED`.
- Later foreign joins always open authorize (missing secret).
**Code**
- `domains/authentication/` (`AuthenticationService`, login/register UI)
- `store/users/users.effects.ts` (`authenticateUser`, `prepareAuthenticatedUserStorage`, `ensureHomeProvisionSecret`)
- Feature: `agents-docs/features/authentication.md`
---
## J2 — Restart app still logged in
**User steps**
1. Quit Electron fully; reopen.
2. Expect same user without typing password.
**Supposed to happen**
- Load user from local DB + valid home token (credential store or legacy token fallback).
- Identify on home (and later foreign) sockets.
- Opportunistic `ensureProvisioned` for active endpoints **without** login UI.
**Broken looks like**
- Flash of dashboard then `/login`.
- Profile restored but chat/presence dead (“alone”).
- Foreign rooms immediately open `/login?mode=authorize`.
**Code**
- `loadCurrentUser$`, `hasValidPersistedSession`, `migrateHomeCredential`
- Lessons: identify legacy token fallback; persisted user still needs token
---
## J3 — Join a community on the *same* signal server
**User steps**
1. Discover or invite → Join.
2. Open a text channel; send “hello”.
3. Join a voice channel; talk.
**Supposed to happen**
- REST join with bearer; then WS `join_server`.
- Receive `server_users` / `user_joined`; peer mesh forms.
- Text: local add + DC `chat-message` + WS `chat_message` fallback.
- Voice: `voice_state` broadcast; WebRTC offer/answer; same-channel audio routing.
**Broken looks like**
- Joined in UI but not in others member lists (identify/join race).
- Messages only on sender device.
- Voice tile appears, no audio.
---
## J4 — Join a community on a *different* signal server (critical)
**User steps**
1. Already logged into home Signal A.
2. Open invite / browser card whose `sourceUrl` is Signal B.
3. Join and chat/voice.
**Supposed to happen**
1. `ensureCredentialForServerUrl(B)` → silent provision (or reuse credential).
2. REST + WS use **actor id on B**, not home id.
3. No authorize login page.
4. Peers on B see actor display name (may show `#prefix` disambiguation).
5. Voice/chat use Bs WebSocket for that rooms affinity.
**Broken looks like**
- Redirect to `/login?mode=authorize&serverId=…` while user bar still shows logged in.
- Join “succeeds” locally but invisible on B.
- Can see members but WebRTC never connects (initiator/identity mismatch).
- DMs/calls to that person later miss rings or fork conversations.
**Code**
- `SignalServerAuthorizeService`, `SignalServerProvisionerService`, `room-signaling-connection.ts`
- Story: `agents-docs/user-stories/silent-cross-signal-server-auth.md`
- Pack: `04-auth-login-bugs.md`, `09-identity-cross-signal.md`
---
## J5 — Send a text message (server channel)
**User steps**
1. In text channel, type and send.
2. Peer in same room should see it live; late joiner should catch up after connecting.
**Supposed to happen**
- Optimistic local message with stable id (attachments bind to that id).
- Broadcast on data channel; also relay `chat_message` on signaling for peers without DC.
- Edits/deletes primarily P2P (+ `account_sync` to sibling devices).
- On peer connect: inventory ↔ sync-batch (up to 20k recent msgs, chunks of 200).
**Broken looks like**
- Sender sees it; others dont (no presence / no join / DC+fallback both fail).
- Others see live but not history (DC inventory never ran).
- Multi-device: one device has history, another empty (`account_sync` / identify).
- Attachments “Waiting for image…” forever (announce vs message ordering).
**Pack:** `08-messaging-visibility.md`
---
## J6 — Join voice in a channel
**User steps**
1. Click a voice channel.
2. Grant mic if prompted.
3. Hear others; they hear you; speaking indicators; optional camera/screen.
**Supposed to happen**
- Leave any previous voice/call first (exclusive).
- Publish `voice_state` with channel/server ids + `clientInstanceId`.
- Only one device owns mic (others passive; Join = takeover).
- Peer connections already for chat mesh; mic tracks attached only to same-channel peers.
- Playback only for peers in same voice channel.
**Broken looks like**
- “Connecting” forever.
- One-way audio.
- UI shows peers in channel but silent.
- After network blip: forever dead until full app restart.
- Works same-home, fails cross-home (initiator uses home id vs peer actor id).
**Pack:** `06-voice-webrtc-bugs.md`
---
## J7 — Call someone from DM / people card
**User steps**
1. Open DM or people card → Call.
2. Callee hears ring / sees modal (unless DND).
3. Answer → private call UI with optional chat panel.
**Supposed to happen**
- `direct-call` event delivered via PeerDelivery (DC then signaling).
- `targetUserId` = callees **currently connected signal identity**.
- Callee admission checks **all local aliases** (home + every provisioned actor id).
- Caller never sits “In Voice” if ring could not be delivered.
**Broken looks like**
- Caller In Voice; callee silent (outbound route null / wrong id).
- Callee never notified (inbound alias filter — partially fixed).
- Cross-signal: two DM threads; replies land in the “wrong” empty one.
**Pack:** `09-identity-cross-signal.md`
---
## J8 — Network blip / signal server restart
**User steps**
1. In voice + chat; WiFi blips or signal process restarts.
2. Continue without manual reconnect.
**Supposed to happen**
- WS reconnect with backoff; health probe forces fresh socket on instance change.
- `reIdentifyAndRejoin` then room resync.
- Peer disconnect grace 10s; then reconnect loop (~12 × 5s).
- DC close triggers repair; chat fallback covers live text meanwhile.
- Voice presence clears on dead voice-active disconnect server-side.
**Broken looks like**
- Zombie “online” with no events.
- Give-up after ~60s with **no error UI**.
- Docs promise soft DC replace; code tears down full peer (audio drop).
- Identify skipped → alone forever until manual leave/rejoin.
**Pack:** `05-signaling-multi-server.md`, `07-data-channel-drops.md`
---
## J9 — Two devices, same account
**User steps**
1. Desktop in voice; phone/browser also logged in.
2. Second device shows “in voice on another device”; can Takeover.
3. Chat/history appears on both.
**Supposed to happen**
- Distinct `clientInstanceId` per tab (sessionStorage).
- Broadcasts reach sibling connections; `account_sync` for owned state.
- Voice exclusive; takeover yields mic on old owner.
**Broken looks like**
- Tabs evict each other (shared clientInstanceId in localStorage — lesson says use sessionStorage).
- Second device never gets chat batches.
- Both think they own voice / neither transmits.
---
## J10 — Logout
**User steps**
1. Title-bar Logout (desktop) or Settings → Logout (mobile).
**Supposed to happen**
- Disconnect sockets; clear current user id; reset rooms/users/messages; `/login`.
**Broken looks like**
- Stale credentials for foreign URLs linger and confuse next account (verify credential clear scope when fixing auth).
+154
View File
@@ -0,0 +1,154 @@
# 03 — Architecture map (for agents)
## Bounded contexts (product client)
See `toju-app/src/app/domains/README.md`. Emergency-relevant domains:
| Domain | Owns |
|--------|------|
| `authentication` | Login/register HTTP, provision secret, per-URL credentials, authorize navigation |
| `server-directory` | Endpoints, health, discovery, invites, room metadata affinity |
| `chat` | Message rules, sync rules, chat UI |
| `direct-message` | DMs, friends, offline queue, PeerDelivery usage |
| `direct-call` | Private call sessions / rings |
| `voice-session` | Join/leave bookkeeping, floating controls, settings storage |
| `voice-connection` | Facade over realtime for mic/camera/playback/VAD |
| `screen-share` | Picker / quality |
| `attachment` | Chunked P2P files |
| `access-control` | Permissions / bans |
**Infrastructure (not a domain):** `infrastructure/realtime/` (WebRTC + signaling), `infrastructure/persistence/`, `infrastructure/mobile/`.
**Global NgRx:** `store/users`, `store/rooms`, `store/messages` — orchestration across domains.
---
## Identity & credentials
```
Home profile (NgRx User)
id / username / displayName / homeSignalServerUrl
├─ AuthTokenStore (legacy per-URL token) metoyou.authTokens
├─ SignalServerCredentialStore metoyou.signalServerCredentials
│ { serverUrl → userId, token, provisioned? }
└─ ProvisionSecretStore (per home user id)
Electron safeStorage | web sessionStorage
```
**Actor resolution for a room:** `SignalServerAuthService.resolveActorUserIdForServer(sourceUrl, homeOderId)` — foreign URL must use provisioned `userId`.
**Identify on socket:** `SignalingTransportHandler.getIdentifyCredentialsForSignalUrl(url)` must resolve token+actor for that URL (store fallback).
**Danger:** `getIdentifyCredentials()` returns **home** credential — used for some localOderId / polite-peer paths → cross-signal initiator bugs (`06`, `09`).
---
## Room ↔ signal affinity
```
Room { id, channels[], sourceUrl, sourceId, ... }
RoomSignalingConnection
ensureCredentialForServerUrl(sourceUrl)
identify(actor for sourceUrl)
join_server / view_server on that WS
ServerSignalingCoordinator
maps serverId ↔ signalUrl ↔ peers
```
Fallback: try other online compatible endpoints on outage; **do not** treat Cloudflare 521/522 as “client incompatible”. Non-federated: peers must converge on same endpoint for that room.
---
## Realtime composition root
`WebRTCService` (`realtime-session.service.ts`) wires:
- `SignalingTransportHandler` → many `SignalingManager` (one WS per URL)
- `PeerConnectionManager` → negotiation, DC, recovery
- `MediaManager` / noise / screen share
- `WebRtcStateController` (signals)
Inbound WS → `SignalingMessageHandler` → users/rooms/voice/chat effects.
---
## Message send path (server channel)
```
Composer → MessagesActions.sendMessage({ id? })
→ local DB + NgRx
→ DC broadcast chat-message (+ message-revision)
→ WS chat_message fallback (room members)
→ account_sync to sibling devices
```
Receive gates: room must be current or saved; channel scoping for text.
---
## Voice join path
```
UI join channel
→ VoiceSessionFacade startSession
→ leave previous exclusive target
→ enableVoice / heartbeat
→ voice_state on WS (+ DC control)
→ MediaManager.syncVoiceRouting (same channel only)
→ VoicePlaybackService for remote same-channel peers
```
Peer PC may already exist from presence mesh; join mainly attaches tracks + announces state.
---
## Call / DM delivery path
```
DirectCallService / DirectMessageService
→ PeerDeliveryService
1) data channel if open
2) signaling forward (targetUserId)
3) offline queue (DM) / silent fail (call if ignored)
```
Inbound call admission: `direct-call-participant-identity.rules.ts` (aliases — implemented).
Outbound routable id pick: **documented in LESSONS as fixed; symbols not in tree** — see `10-code-lies`.
---
## Recovery constants (cheat sheet)
| Constant | Value |
|----------|-------|
| WS reconnect backoff | 1s → 30s |
| Connect timeout | 5s |
| Keepalive interval / ack timeout | 25s / 10s |
| Health probe | 5s |
| Peer disconnect grace | 10s |
| Peer reconnect | 5s × 12 then silent abandon |
| DC recovery grace (closing) | 2.5s |
| Non-initiator give-up | 5s |
| Offer-sent grace | 20s |
| Inventory / full sync limit | 20_000 |
| Sync batch | 200 |
| Sync poll | 10s fast / 15 min slow |
Source: `realtime.constants.ts`, `message-sync.rules.ts`.
---
## Server responsibilities (only when needed)
Default agent scope excludes `server/` unless packet expands. Know this:
- Serializes WS handlers per connection (identify-before-join).
- Relays RTC only when peers share membership (DM types exempt).
- No message persistence.
- `voiceActive` routes offers to owning connection.
Canonical envelopes: `agents-docs/features/signaling.md`.
+119
View File
@@ -0,0 +1,119 @@
# 04 — Auth & login bugs
> **User theme:** “Im logged in but it keeps asking me to log in / authorize.”
> **Severity:** P0 — blocks multi-server chat, voice, presence.
> **Existing research:** `agents-docs/user-stories/silent-cross-signal-server-auth.md` (do not redo; implement after interview).
---
## How auth is supposed to work (user view)
1. Register or log in **once** on a home signal server.
2. Stay signed in across app restarts (desktop).
3. When touching another signal server (join, invite, create room, activate endpoint): the app **silently** creates or reuses an account there.
4. You only see a login form again if:
- true home session expired / missing token, or
- last-resort: username collision exhaustion / user clicked Sign in in Network settings.
5. Offline / dead endpoints never bounce you to authorize login.
Settings → Network may show `Authorized` / `Needs sign-in` as diagnostics — not as the default path for normal joins.
---
## How auth is supposed to work (system)
| Concept | Role |
|---------|------|
| Home credential | Token + user id for `homeSignalServerUrl` |
| Foreign credential | Separate user id + token per URL in `SignalServerCredentialStore` |
| Provision secret | Password used only for auto register/login on foreign hosts |
| `ensureProvisioned` | Register-or-login with secret; suffix username on collision |
| `ensureCredentialForServerUrl` | Gate before foreign room connect; navigate authorize only for `collision` or `no-provision-secret` when endpoint online |
| `authorize` mode | Manual login that upserts foreign credential **without** resetting home profile |
| `auth_required` vs `auth_error` | Race vs rejected token — must not falsely expire home on foreign races |
Authorize nav rule: `shouldNavigateToAuthorizeSignalServer` in `signal-server-authorize.rules.ts`.
---
## Failure modes (code-backed)
### A — Missing provision secret → authorize UI (primary)
**Symptom:** Logged-in user opens foreign room/invite → `/login?mode=authorize`.
**Mechanism**
1. `ensureProvisioned``{ kind: 'skipped', reason: 'no-provision-secret' }`.
2. `ensureCredentialForServerUrl` navigates to authorize.
3. Authorize mode does not auto-leave when `currentUser` exists → stuck prompt.
**Why secret missing**
- Created only in `prepareAuthenticatedUserStorage` when `homeSignalServerUrl` + `loginResponse` present.
- Session restore calls `ensureProvisioned` but **does not** ensure secret exists first.
- Web: sessionStorage secret dies with tab.
- Old installs / wiped Electron `userData/provision-secrets/`.
**Files**
- `signal-server-auth.service.ts`, `signal-server-authorize.service.ts`
- `provision-secret-store.service.ts`, `electron/api/provision-secret-store.ts`
- `users.effects.ts`
### B — Username collision exhaustion
All register candidates 409 + login 401 → `collision` → authorize. Rare but real on crowded foreign servers.
### C — False home session expiry
`signalServerAuthFailed$` may `SESSION_EXPIRED` → full `/login` when home classification / retry budget wrong. Distinguish `auth_required` (re-identify) vs `auth_error` (clear credential; foreign re-provision vs home expire).
### D — Credential missing → unauthenticated join → invisible user
Without token, socket never identifies; `join_server` dropped; user alone; later gates open authorize. Cascades into “messages not seen” and “voice empty”.
### E — Opportunistic provision swallows errors
`ensureProvisioned(...).catch(() => undefined)` on health/startup → failure deferred until mid-join authorize popup.
### F — Others?
---
## Doc / README lies in this area
| Claim | Reality |
|-------|---------|
| Auth domain README sequence: `POST /api/auth/login` | Real paths: `/api/users/login`, `/api/users/register` (`AuthenticationService`) |
| Feature doc: offline must not open authorize | Code path exists; still fails open on missing secret when “online” |
---
## Fix directions (interview choices — not approved)
From user story (recommend **A**):
| Option | Idea |
|--------|------|
| **A** | On restore / before foreign provision, always `ensureHomeProvisionSecret`; keep authorize only for collision / manual |
| **B** | Never navigate on `no-provision-secret`; toast + Network badge |
| **C** | Durable web secret (not sessionStorage) |
| **D** | Stronger unique usernames before collision UI |
---
## Proof of done
1. Two live signal servers: home register on A → join room on B → **no** `/login` navigation.
2. Full Electron restart → foreign rejoin still silent.
3. Offline foreign URL → no authorize navigation.
4. Focused tests: missing secret on restore → secret created → provision → `Router.navigate(['/login'])` never called.
5. Foreign `auth_error` with valid home → re-provision, not `SESSION_EXPIRED`.
---
## Agent scope
- Default: `toju-app/domains/authentication`, `store/users`, `store/rooms/room-signaling-connection.ts`, server-directory call sites.
- Electron: provision-secret store/IPC only if persistence fix needs it.
- Ask before deep `server/` auth changes.
+112
View File
@@ -0,0 +1,112 @@
# 05 — Signaling & multi-signal-server connection
> **User theme:** “Im connected but nobody sees me / rooms flicker / different signal servers break everything.”
> **Severity:** P0 for presence and as root cause of voice + chat visibility.
---
## How signaling is supposed to work (user view)
- When you open a community, you appear in the member list for everyone in that community.
- Leaving / going offline shows you as offline (without duplicate leave spam for multi-device).
- Switching communities updates who you see without wiping presence for other saved servers.
- If the signal host restarts or WiFi blips, you come back automatically.
- Communities hosted on different signal hosts still work as long as your client is authorized on that host (see `04`).
- Users should be able to see/sync chats/files/profile images/server states info change/plugin states and events/talk without losing connectivity or only some users are seen and other aren't.
---
## How signaling is supposed to work (system)
1. **One WebSocket per signal URL** (`SignalingManager`).
2. **Identify first** with that URLs token + actor `oderId` + `clientInstanceId`.
3. Then `join_server` / `view_server` for rooms on that URL.
4. Server serializes handlers per connection so join cannot race mid-identify.
5. Client `reIdentifyAndRejoin` on reconnect; rooms effects resync as safety net.
6. Room traffic prefers room `sourceUrl`; temporary fallback to other compatible endpoints on outage.
7. **Non-federated:** Signal A does not share peer registry with Signal B. Same human on two hosts = two actor ids (client glue).
Canonical catalog: `agents-docs/features/signaling.md`.
Client map: `infrastructure/realtime/README.md` + `signaling/`.
---
## Failure modes
### 1 — Identify / join race or skipped identify
**Symptom:** Local UI shows room open; others dont see you; you miss `user_joined` / `chat_message`; “alone”.
**Causes**
- Join sent before identify (fixed server-side serialization + client reIdentify — regressions still dangerous).
- `getIdentifyCredentialsForSignalUrl` returns null (no credential) → no identify.
- Identify cache empty on fresh socket without store fallback (lesson: must fall back to credential store).
### 2 — Wrong socket / wrong affinity
**Symptom:** Presence or RTC relay never reaches peers in the room; works only when everyone shares the same endpoint URL alias.
**Causes**
- Room `sourceUrl` stale vs directory `serverInstanceId` canonicalization.
- Fallback broadcast regressions (raw room messages must not spam every manager when route unknown — README describes current intended behavior).
- Cold start reconnect before health probes collapse aliases.
### 3 — Multi-signal leave tears down wrong peers
**Symptom:** Leaving a room on signal-sweden drops a peer still shared via signal.toju.app.
**Intended:** `user_left` subtracts only that clusters shared servers; preserve routes while P2P still live.
### 4 — Half-open / zombie sockets
**Symptom:** UI “connected”; no events after server process restart.
**Mitigations in tree:** keepalive ack timeout after first ack; `/api/health` probe; `serverInstanceId` change forces new WS. Regressions: skipping first heartbeat tick, treating 521/522 as incompatible.
### 5 — Auth failure cascade
Foreign `auth_required` / `auth_error` without silent re-provision → authorize UI or invisible membership (`04`).
### 6 — Multi-device eviction loop
Shared `clientInstanceId` in localStorage across tabs → server evicts sibling on identify. Must stay in **sessionStorage**.
---
## Different signal servers — user-visible contract
| Situation | Expected |
|-----------|----------|
| Two users, same room, same signal URL | Discover each other; RTC relay OK |
| Two users, “same” community mirrored on different signals | **Not supported as one mesh** — they are different rooms unless directory/affinity converges them |
| One user, rooms on A and B | Two sockets; two actor ids; local profile one |
| DM/call across people who met on foreign rooms | Must address **connected** actor id (see `09`) |
---
## Key files
- `signaling.manager.ts`, `signaling-transport-handler.ts`, `server-signaling-coordinator.ts`
- `signaling-message-handler.ts`, `server-membership-signaling-handler.ts`
- `room-signaling-connection.ts`, `rooms.effects.ts`
- `server-directory` health / canonical endpoint rules
- Server: `server/src/websocket/handler.ts` (out of default scope — ask)
---
## Proof of done (signaling)
1. Two clients join same room: both appear in member lists within seconds of identify.
2. Kill signal process; both recover presence without manual leave/rejoin.
3. User with rooms on two URLs: leave on URL A does not remove voice peer still shared only via URL B.
4. Fresh connect: first outbound after open is identify; join never accepted unauthenticated (server log / test).
5. Alias URLs with same `serverInstanceId` collapse before room reconnect.
---
## Fix order notes
Usually fix **auth provision (`04`)** before deep signaling surgery — many “connection” bugs are missing identify. Then harden identify credential resolution and affinity. Instrument `connectionScope`, actor id, and signal URL on every join in debug builds.
+130
View File
@@ -0,0 +1,130 @@
# 06 — Voice & WebRTC bugs
> **User theme:** “Voice is broken / one-way / connecting forever / works until reconnect.”
> **Severity:** P0.
---
## How voice is supposed to work (user view)
1. Click a voice channel (or answer a call).
2. Mic turns on (unless muted); you appear in the channel roster for others.
3. You hear everyone in **that** channel; they hear you.
4. Mute / deafen / camera / screen share behave Discord-like.
5. Navigate away → floating controls; voice continues.
6. Joining another voice target auto-leaves the previous one.
7. Second device shows youre in voice elsewhere; Join takes over.
8. After a short network blip, voice returns without restarting the app.
Voice is **not** supposed to depend on the data channel for audio itself — but DC carries control/state; broken DC recovery currently **rebuilds the whole peer**, which drops media too (`07`).
The users should always be able to hear each other and see each other in joined calls and voice channels. (Not 1 out of 6 cant be heard for 3 users!)
---
## How voice is supposed to work (system)
| Piece | Responsibility |
|-------|----------------|
| `voice-session` | Session metadata, floating UI, settings, exclusivity, takeover rules |
| `voice-connection` | Facade, VAD, per-peer playback gain |
| `MediaManager` | getUserMedia, RNNoise, gain, same-channel track routing |
| `PeerConnectionManager` | RTCPeerConnection, negotiation, ICE |
| Signaling | `offer`/`answer`/`ice_candidate` relay; `voice_state`; `voice_client_takeover` |
| ICE | STUN defaults; TURN optional via settings — **no bundled TURN** |
Initiator election: deterministic compare of local id vs peer `oderId` so only one side offers.
Audio routing: attach/detach mic based on matching `voiceState.roomId` + `serverId`. Playback similarly scoped.
---
## Failure modes
### 1 — Cross-signal initiator / politeness uses home id
**Symptom:** Peers never connect or glare forever when users have different home servers / foreign actor ids in the room.
**Mechanism**
- Presence peer ids = **per-server actor** `oderId`.
- `getLocalOderId` / polite-peer path often uses `getIdentifyCredentials()`**home** id (`signaling-transport-handler.ts`, `realtime-session.service.ts`, `negotiation.ts`).
- Election `localOderId < peerId` inconsistent across clients → dual offer, dual wait, or stuck non-initiator.
**Fix direction:** elect and politeness using **per-signal-url** identify credentials (`getIdentifyCredentialsForSignalUrl(peerSignalUrl)` / room source URL), not home-only.
### 2 — Voice allow-list misses peer map key → one-way / silence
**Symptom:** Connected peer, speaking UI maybe wrong, no audio out or in.
**Mechanism:** `syncOutgoingVoiceRouting` / playback only recognizes certain aliases (`id` / `oderId` / `peerId`). If `activePeerConnections` key is another alias → track detached.
### 3 — Silent give-up after reconnect budget
**Symptom:** After ~60s of failures, voice never returns; no error toast.
**Constants:** `PEER_RECONNECT_MAX_ATTEMPTS = 12`, interval 5s. Tracker deleted; no UI.
### 4 — Transient signaling drops offers during WS reconnect
Offers/ICE classified transient may be deferred/dropped while socket reconnecting → half-open peers; depends on fallback offer timers (`USER_JOINED_FALLBACK`, non-initiator give-up 5s).
### 5 — DC recovery tears down media
Closed control channel → `removePeer` + full reconnect (`peer-recovery.ts`). Docs claim soft `replaceDataChannel` preserving AV — **code does not do that** (`07`, `10`).
### 6 — Auth / presence missing
If identify/join failed (`04`/`05`), no RTC relay eligibility / no peer discovery → empty voice.
### 7 — Same-channel filter false negatives
Remote `voice_state` missing/stale channel ids → locally mute peer while UI still lists them in channel.
### 8 — No TURN by default
Symmetric NAT / strict firewalls fail ICE with STUN-only. User-configurable TURN exists; many installs never set it. Product decision needed: ship defaults vs document limitation.
### 9 — Multi-device ownership races
Takeover / heartbeat / `voiceActive` routing wrong → offers hit passive device; active device silent.
---
## Code vs docs
| Doc claim | Code |
|-----------|------|
| Soft DC renegotiation preserves media | Always full peer recreate on closed DC |
| Deterministic initiator from logical peer ids | Uses home identify credentials in several paths |
| TURN supported | Configurable only; not default |
---
## Key files
- `domains/voice-session/`, `domains/voice-connection/`
- `infrastructure/realtime/media/media.manager.ts`
- `peer-connection-manager/**`, `negotiation.ts`, `peer-recovery.ts`
- `signaling-message-handler.ts` (server_users / user_joined offers)
- `ice-server-settings.service.ts`, `realtime.constants.ts`
- Feature: `agents-docs/features/voice-webrtc.md`
---
## Proof of done
1. Two users same home signal, same voice channel: bidirectional audio < 5s after both join.
2. Two users **different home signals**, same foreign-hosted room (both provisioned): bidirectional audio.
3. Toggle mute/deafen; camera; screen share request path.
4. Kill WiFi 15s: recovers or shows actionable error (not silent forever).
5. Second client takeover: first stops transmitting; second owns mic.
6. Regression test for initiator election with mismatched home vs actor ids.
---
## Interview prompts (when implementing)
- Soft DC replace vs keep full rebuild but fix media reattach + UX error?
- Ship public TURN defaults or document “requires open NAT / user TURN”?
- Instrument-only first week vs behavior fix first?
+99
View File
@@ -0,0 +1,99 @@
# 07 — Data channel drops & recovery
> **User theme:** “Connection drops / chat and files die / voice dies after a blip.”
> **Severity:** P0/P1 — shared control plane for chat sync, attachments, emoji, screen control, and currently media (because recovery rebuilds the whole PC).
---
## How the data channel is supposed to work (user view)
- Once youre in a community with other people, messages, files, emoji, and many live updates “just sync.”
- Brief network glitches should self-heal.
- You should not need to restart the app to get chat syncing again.
- Voice should ideally survive control-plane blips (product docs claim this; code currently does not).
- Users should sync all but only load a portion into ram for viewing so the app doesn't crash of high ram usage.
---
## How the data channel is supposed to work (system)
- Single **ordered** RTCDataChannel per peer pair (label typically chat/control).
- Carries: chat events, inventory sync, attachments, avatar/emoji chunks, voice/screen control, pings, plugin bus, game activity, etc.
- Back-pressure: high 4MB / low 1MB watermarks.
- Ping every 5s for RTT.
- On failure, recovery should restore control **and** ensure inventory/resync runs when channel reopens.
### Documented recovery (README / voice-webrtc.md)
1. Non-fatal error on **open** channel → request voice-state snapshot on same channel.
2. **Closed** channel → initiator renegotiates **new DC on existing PC** (preserve AV); non-initiator waits then full rebuild if missing.
3. Closing-but-not-closed → short grace (2.5s).
4. `replaceDataChannel` adopts the new channel.
### Actual recovery (`peer-recovery.ts`)
1. Closed → `repairUnavailableDataChannel``removePeer` + `attemptPeerReconnect` / `schedulePeerReconnect` (**full PC teardown**).
2. Closing → wait `DATA_CHANNEL_RECOVERY_GRACE_MS` (2.5s) → same full recreate.
3. `replaceDataChannel` exists on the manager and is wired into handlers, but **recovery path never calls it**; specs assert it is **not** called in several cases.
4. After 12 reconnect attempts (~60s): abandon **silently**.
**This is a documented lie** — treat README paragraph as aspirational until code matches or docs are corrected in the same PR as a deliberate decision.
---
## Failure modes
### 1 — Full rebuild drops audio/video on every DC close
User hears a “drop” even when ICE media might have survived. Cascades into voice bug reports.
### 2 — Silent abandon
No toast, no “Reconnect” CTA, no automatic retry on later `user_joined`. Mesh looks permanently broken until navigation/restart.
### 3 — Live chat limp vs history dead
While DC down, `chat_message` WS fallback may still deliver **live** text. Inventory sync is **DC-only** → late joiners / catch-up fail until P2P returns (`08`).
### 4 — Large payloads kill shared channel
Custom emoji / attachment floods can stress or close the shared ordered channel (lessons). One feature outage becomes total control-plane outage.
### 5 — Attachment announce vs message ordering
`file-announce` on DC can beat `chat-message` on WS → auto-download gives up unless re-queued on message bind (lesson; verify still present when touching attachments).
### 6 — Replacement channel race
If soft-replace is reintroduced, must close old channel to release SCTP (voice-webrtc changelog). Current full rebuild avoids that class but at higher cost.
---
## Key files
- `peer-connection-manager/messaging/data-channel.ts`
- `peer-connection-manager/recovery/peer-recovery.ts` (+ specs)
- `peer-connection.manager.ts` (`replaceDataChannel`)
- `realtime.constants.ts`
- Consumers: chat sync effects, attachment transfer, custom emoji chunking, screen-share request
---
## Fix directions (interview)
| Option | Idea | Tradeoff |
|--------|------|----------|
| **A (align code to docs)** | Implement true soft DC replace on connected PC; full rebuild only if PC not connected | Harder; matches user expectation for voice survival |
| **B (align docs to code)** | Keep full rebuild; fix reattach + force inventory on reopen; **surface give-up UX** | Faster; still interrupts voice |
| **C** | Separate unreliable channel for bulk (files/emoji) vs reliable small control | Larger design |
Recommend starting with **instrumentation + Bs UX/resync**, then **A** if voice drop rate stays high.
---
## Proof of done
1. Force-close DC in debug: control messages resume; inventory runs; user sees progress or success.
2. If soft-replace chosen: audio continues through DC replace (automated or manual with metrics).
3. After max attempts: visible error + manual retry works.
4. Attachment + emoji transfer during recovery does not deadlock the mesh.
5. Update `realtime/README.md` + `voice-webrtc.md` in the same change set so they match behavior.
+110
View File
@@ -0,0 +1,110 @@
# 08 — Messaging visibility (“messages not seen”)
> **User theme:** “I sent a message but they dont see it / history missing / only some devices have chat.”
> **Severity:** P0.
---
## How messaging is supposed to work (user view)
### Server text channels
- Send in a text channel → everyone currently in that community sees it quickly.
- If someone was offline or just joined, they still get recent history after connecting to peers (or from their other logged-in device).
- Edits, deletes, reactions converge.
- Typing indicators are ephemeral.
- The **signal server does not keep a chat log** — history lives on clients.
### Direct messages
- 1:1 and group DMs deliver even without a shared community when signaling can reach the peer.
- Delivery ticks: queued → sent → delivered → acknowledged (monotonic).
- Offline: queue until peer/network returns.
### Multi-device
- Second device receives live + catch-up via `account_sync` when siblings are online.
---
## How messaging is supposed to work (system)
| Path | Role |
|------|------|
| P2P `chat-message` / revisions | Primary live + sync plane |
| WS `chat_message` | Narrow **live** fallback to room members |
| Inventory / sync-batch | Catch-up on DC (limit 20_000, chunk 200) |
| `account_sync` chat batches | Sibling devices |
| DM PeerDelivery | DC → signaling → offline queue |
Feature contract: `agents-docs/features/messaging.md`.
Domain: `domains/chat/`, `domains/direct-message/`, `store/messages/`.
---
## Failure modes
### 1 — Invisible membership → no live fallback
If identify/join failed (`04`/`05`), user is not in server membership → server never broadcasts `chat_message` to them; peers may not offer DC. **Classic “chats dont sync for multi-client users”** root cause (serialized identify).
### 2 — Live works, history doesnt
DC down: live WS fallback OK; inventory never runs → “they only see new messages after refresh if a peer happens to sync later” / empty history for late joiners.
### 3 — Sync poll too slow after “clean” cycle
Fast poll 10s while catching up; **15 min** when clean. A false “clean” leaves long windows without repair.
### 4 — Cross-signal identity forks DMs
Incoming DM `conversationId` carries foreign actor id → second empty thread; replies invisible on the thread the user is watching (`09`). Lessons claim canonicalize/merge — **symbols not in tree**.
### 5 — Recipient alias miss
DM/call ignored if local admission only checks home id. Inbound DM aliases largely fixed; verify call + any new surfaces.
### 6 — Attachment-only emptiness
Message text arrives; image stuck “Waiting…” — announce/bind race (lesson). Looks like “message incomplete / not really received.”
### 7 — NgRx prune confusion
Inactive rooms pruned to 100 messages in memory; DB still has more. User switching rooms may think history vanished until reload/sync — document vs bug.
### 8 — Doc lie on inventory cap
Chat domain README still says **1000**; code `INVENTORY_LIMIT = 20_000`.
---
## Investigation checklist (agents)
1. Did both users `identify` + `join_server` on the **same** signal URL as the room?
2. Is there an open DC between them? (`connectedPeers`, debug metrics)
3. Does live send emit both DC and `chat_message`?
4. On receive, is `roomId` in saved/current rooms?
5. For DM: conversation id aliases; delivery state machine stuck at QUEUED?
6. Multi-device: `account_sync_peer_online` fired; batches received?
---
## Proof of done
1. Two clients: send N messages with DC disabled (force) → live still appears via WS.
2. Re-enable DC → inventory brings missing history.
3. Third client late join → receives recent history from a peer with DB.
4. Cross-home users in foreign room: messages visible both ways without authorize prompt.
5. Cross-signal DM: single conversation thread; replies visible to both.
6. Multi-device: second device gets `chat-sync-batch` after identify.
7. Regression covering identify-before-join (presence + chat broadcast).
---
## Fix order
1. Auth + identify (`04`/`05`) — without presence, messaging “fixes” are theater.
2. Ensure fallback + DC resync on repair (`07`).
3. Identity canonicalize for DMs (`09`).
4. Attachment re-queue invariants.
5. Correct stale chat README inventory number.
+113
View File
@@ -0,0 +1,113 @@
# 09 — Cross-signal identity (calls, DMs, voice routing)
> **User theme:** “Calls dont ring / Im In Voice alone / DMs fork / voice fails only with people on other homes.”
> **Severity:** P0.
> **Coupling:** Depends on silent provision (`04`) creating foreign actor ids at all.
---
## How identity is supposed to work (user view)
- You have one profile on this device.
- People may see slightly different usernames on other signal hosts (suffix / `#prefix` tag) — still you.
- Calling or DMing someone you met in any community should reach **them**, not a ghost.
- You should never end up with two chat threads that are secretly the same person.
- Voice in a shared room should work even if you registered on different home signal servers.
---
## How identity is supposed to work (system)
| Id | Meaning |
|----|---------|
| Home user id | Local profile / NgRx `User.id` |
| Foreign actor id | Credential `userId` for that signal URL |
| Peer map key | Usually the `oderId` seen on that signals presence |
| `targetUserId` on WS relay | Must equal callees **connected** `oderId` on that server |
| DM `conversationId` | Should canonicalize to one thread per human pair on this device |
Self-admission for inbound events must accept **all** aliases: home id, entity id, peer id, every valid provisioned credential user id.
Outbound delivery must pick a **routable** id (one the signaling server can map to an open connection), not merely the home id stored on a people card.
---
## What is implemented vs claimed
| Capability | Status (2026-08-12 tree) |
|------------|--------------------------|
| Inbound direct-call alias admission + normalize | **Present**`direct-call-participant-identity.rules.ts` |
| DM self-id alias sets | **Present**`direct-message-identity.rules.ts` (narrower than lessons conversation merge) |
| Outbound `collectRecipientDeliveryCandidateIds` / `pickRoutableRecipientId` | **Absent** — lesson describes as if shipped |
| `DirectCallService.resolveRoutableRecipientId` / `recipientUnreachable` UX | **Absent** |
| `resolveDirectConversationId` / `mergeAliasDirectConversations` | **Absent** |
| Room join uses `resolveActorUserIdForServer` | **Present**`room-signaling-connection.ts` |
| Initiator election uses per-signal actor id | **Weak / home-biased** — see `06` |
**Treat LESSONS entries for outbound call routing and DM canonicalize as specifications of unfinished work**, not as completed history.
---
## Failure modes
### 1 — Outbound call: silent “In Voice”
Caller joins call session; `PeerDeliveryService` cannot resolve signaling peer id (home id ≠ connected actor id); ring never sent or wrong `targetUserId`; delivery result ignored.
### 2 — Inbound call: dropped ring (historical)
Fixed for admission aliases; still verify e2e `dm-header-call-ring` across secondary signal registration. Do not assume outbound is fixed because inbound is.
### 3 — DM thread fork
Incoming messages keyed by foreign actor conversation id → UI shows empty home-id thread; replies land elsewhere.
### 4 — Voice peer election / routing alias miss
Home vs actor mismatch → no PC or one-way audio (`06`).
### 5 — People search / friends store home ids only
Cards display home identity; without alias expansion at send time, every cross-signal action is fragile.
---
## Key files
- `domains/direct-call/` (+ `direct-call-participant-identity.rules.ts`)
- `domains/direct-message/` (+ `direct-message-identity.rules.ts`, `PeerDeliveryService`)
- `domains/authentication/` credential store + `resolveActorUserIdForServer`
- `infrastructure/realtime/signaling-transport-handler.ts` (home vs per-URL credentials)
- Lessons (aspirational): outbound routing + DM canonicalize sections in `LESSONS.md`
- E2E intent: `e2e/tests/voice/dm-header-call-ring.spec.ts` (scope expand if needed)
---
## Recommended implementation sequence
1. **Instrument** one cross-home repro: log home id, actor ids, peer map keys, `targetUserId`, conversation ids.
2. **Outbound delivery** — implement lesson APIs for real: collect aliases, pick routable, always attempt send, surface unreachable error (stop fake In Voice).
3. **DM canonicalize + merge** — single thread; remap inbound; merge duplicates on load.
4. **Voice initiator / routing** — per-signal local actor id (`06`).
5. Rewrite lesson examples if names differ so future agents dont hunt phantom files.
---
## Proof of done
1. User1 home A, User2 home B, meet in room on A: DM-header call rings Bs modal; B answers; both hear audio.
2. Same pair: DM replies appear in **one** thread on both clients.
3. People-card call when only shared presence is under actor id: still rings or shows **unreachable** (never silent In Voice).
4. Unit tests for alias collect/pick and conversation merge.
5. E2E cross-signal call + DM (expand scope with user approval for `e2e/`).
---
## Interview choices
| Option | Focus first |
|--------|-------------|
| **A (recommended)** | Outbound routable id + unreachable UX |
| **B** | DM canonicalize/merge first |
| **C** | Voice initiator per-signal id first |
| **D** | Full identity service refactor (large — avoid in emergency) |
+101
View File
@@ -0,0 +1,101 @@
# 10 — Code lies & documentation debt
> Agents must **verify symbols in the tree** before trusting lessons or READMEs. This file lists known mismatches found 2026-08-12.
---
## Critical lies (behavior-affecting)
### L1 — Soft data-channel replace (docs yes, code no)
**Claims:** `toju-app/.../realtime/README.md` (Data channel section), `agents-docs/features/voice-webrtc.md`.
**Reality:** `repairUnavailableDataChannel` always `removePeer` + reconnect. `replaceDataChannel` not used by recovery; specs expect it not called.
**Action:** Either implement soft replace or rewrite docs in the same PR as the recovery decision (`07`).
### L2 — LESSONS outbound call routing “fixed”
**Claims:** `LESSONS.md``peer-delivery-identity.rules.ts`, `collectRecipientDeliveryCandidateIds`, `pickRoutableRecipientId`, `resolveRoutableRecipientId`, `call.errors.recipientUnreachable`.
**Reality:** **No matches** in `toju-app/`. Phantom APIs.
**Action:** Implement (`09`) or rewrite lesson as “desired / unfinished” with Status.
### L3 — LESSONS DM conversation canonicalize “fixed”
**Claims:** `resolveDirectConversationId`, `mergeAliasDirectConversations`, `direct-message-conversation-identity.rules.ts`, etc.
**Reality:** **Absent.** Only narrower `direct-message-identity.rules.ts`.
**Action:** Same as L2.
### L4 — Home identify credentials used as “local peer id” for negotiation
**Claims:** Deterministic initiator from logical peer ids (implies per-room identity).
**Reality:** `getIdentifyCredentials()` returns **home** credential; used for polite peer / localOderId paths while presence uses actor ids.
**Action:** Fix in voice/identity packets (`06`/`09`); update README after.
---
## Medium lies (wrong paths / stale numbers)
### L5 — Authentication domain README API paths
**Claims:** `POST /api/auth/login`, `/api/auth/register`.
**Reality:** `/api/users/login`, `/api/users/register`.
### L6 — Chat domain README inventory cap
**Claims:** capped at **1 000** messages.
**Reality:** `INVENTORY_LIMIT = 20_000` (messaging feature doc correct).
### L7 — `shared-kernel/signaling-contracts.ts` as wire authority
**Claims (implicit):** types like `join` / `leave` / `chat` / `ice-candidate`.
**Reality:** Feature `signaling.md` correctly says **do not treat as authoritative**. Live types use `join_server`, `chat_message`, `ice_candidate`, etc.
**Action:** Mark file deprecated or align types; never generate client sends from it blindly.
---
## Soft / incomplete docs (not lies, but traps)
| Topic | Note |
|-------|------|
| Auth feature doc vs missing secret | Doc describes intended offline/authorize rules; primary bug is still open (user story). |
| TURN | Documented STUN-only defaults — accurate; users may think voice “should always work on restrictive NAT”. |
| Domain auth README | Oversimplified sequence diagram (no provision secret / multi-credential). |
| Handoff silent auth | Accurate research; not a code lie — unfinished work. |
---
## Verification commands for agents
```bash
# Phantom lesson APIs (should be empty until implemented)
rg -n 'pickRoutableRecipientId|resolveDirectConversationId|mergeAliasDirectConversations|peer-delivery-identity' toju-app
# DC recovery behavior
rg -n 'repairUnavailableDataChannel|replaceDataChannel' toju-app/src/app/infrastructure/realtime
# Inventory limit
rg -n 'INVENTORY_LIMIT' toju-app/src/app/domains/chat
# Auth paths
rg -n 'users/login|auth/login' toju-app/src/app/domains/authentication
```
---
## Policy for this emergency
1. Prefer **feature docs + code** over domain README diagrams when they conflict.
2. Prefer **code + specs** over LESSONS examples when symbols missing.
3. When fixing behavior, **fix the lying doc in the same PR**.
4. When a lesson was aspirational, relabel it explicitly so the next agent does not skip the work.
+153
View File
@@ -0,0 +1,153 @@
# 11 — Fix priority plan (systematic waves)
> Execute in order unless the user explicitly reprioritizes.
> Each wave lists **user outcome**, **work**, **depends on**, **proof**.
> Checkboxes are for agents to update when a wave is done.
---
## Guiding principles
1. **User-visible done** > unit-green alone.
2. Fix **identity + auth** before chasing random WebRTC knobs — many voice/chat bugs are “never identified / wrong id.”
3. **Interview before implement** on each wave (repo rule) unless opted out / handoff already approved.
4. Stay in `toju-app` + targeted `electron`; ask once before `server/` or full `e2e/`.
5. Correct lying docs in the same change (`10`).
---
## Wave 0 — Shared baseline (half day)
- [ ] **0.1** Read `00-README.md`, `01`, `03`, `10`.
- [ ] **0.2** Build a **cross-home repro matrix** (manual or scripted):
- Same home, same room
- Different homes, room on home A
- Different homes, room on foreign B (provision required)
- DM + call between different homes
- [ ] **0.3** Enable WebRTC/signaling debug logs; capture home id, actor ids, peer keys, signal URLs on failure.
- [ ] **0.4** Confirm whether provision secret exists after fresh login vs after restart (Electron + browser).
**Proof:** Written repro notes attached to the next handoff (short).
---
## Wave 1 — Silent multi-signal auth (P0)
**User outcome:** Never bounced to authorize login just for touching another signal server.
- [ ] **1.1** Interview + implement provision-secret ensure on restore (see `04`, user story options AD; recommend A).
- [ ] **1.2** Harden `auth_required` / `auth_error` home vs foreign handling (no false `SESSION_EXPIRED`).
- [ ] **1.3** Offline endpoints never navigate authorize (regression test).
- [ ] **1.4** Prove J4 + restart path (`02`).
**Depends on:** Wave 0.
**Pack:** `04`, story `agents-docs/user-stories/silent-cross-signal-server-auth.md`.
---
## Wave 2 — Presence identify/join integrity (P0)
**User outcome:** When you open a room, others see you; you receive live chat/voice roster.
- [ ] **2.1** Verify `getIdentifyCredentialsForSignalUrl` store fallback on every reconnect path.
- [ ] **2.2** Ensure room connect always identify → join with **actor** id for `sourceUrl`.
- [ ] **2.3** Alias canonicalization / cold-start health wait still correct.
- [ ] **2.4** Prove two-client presence + live `chat_message` after signal restart.
**Depends on:** Wave 1 (foreign rooms).
**Pack:** `05`, `08`.
---
## Wave 3 — Cross-signal outbound identity (P0)
**User outcome:** Calls ring the real person or show a clear error; DMs stay one thread.
- [ ] **3.1** Implement outbound routable recipient selection + unreachable UX (`09`, lesson L2).
- [ ] **3.2** Implement DM conversation canonicalize + merge (`09`, lesson L3).
- [ ] **3.3** Relabel/fix LESSONS so examples match shipped symbols.
- [ ] **3.4** Prove cross-home call + DM reply visibility.
**Depends on:** Wave 1. Can parallelize lightly with Wave 2 if staffing allows.
**Pack:** `09`.
---
## Wave 4 — Voice negotiation & routing (P0)
**User outcome:** Bidirectional voice in shared channels, including cross-home users; reconnect either works or errors visibly.
- [ ] **4.1** Per-signal-url local actor id for initiator election + polite peer (`06` failure 1).
- [ ] **4.2** Voice allow-list includes all peer-map aliases.
- [ ] **4.3** After reconnect budget: user-visible failure + retry action.
- [ ] **4.4** Decide TURN product stance (document vs defaults).
- [ ] **4.5** Prove same-home + cross-home voice; takeover; blip recovery.
**Depends on:** Waves 12; benefits from 3.
**Pack:** `06`.
---
## Wave 5 — Data channel recovery honesty (P0/P1)
**User outcome:** Control plane self-heals; chat history catch-up resumes; voice doesnt mysteriously die without explanation.
- [ ] **5.1** Interview: soft replace (A) vs full rebuild + UX/resync (B) (`07`).
- [ ] **5.2** Implement choice; force inventory/resync on DC reopen.
- [ ] **5.3** Align `realtime/README.md` + `voice-webrtc.md` with code (`10` L1).
- [ ] **5.4** Prove DC force-close recovery + no silent abandon.
**Depends on:** Wave 4 preferred (so voice metrics make sense).
**Pack:** `07`, `08`.
---
## Wave 6 — Messaging catch-up & attachments (P1)
**User outcome:** Late joiners get history; images/files dont stick on “Waiting…”.
- [ ] **6.1** Verify/fix inventory trigger on peer + DC repair.
- [ ] **6.2** Re-verify attachment announce/bind re-queue invariants.
- [ ] **6.3** Fix chat README inventory cap (`10` L6).
- [ ] **6.4** Prove late-join history + image sync under reorder stress.
**Depends on:** Waves 2 + 5.
**Pack:** `08`.
---
## Wave 7 — Doc & contract cleanup (P2, continuous)
- [ ] **7.1** Fix auth domain README paths (`10` L5).
- [ ] **7.2** Deprecate or fix `signaling-contracts.ts` warning banner.
- [ ] **7.3** Sync domain READMEs that oversimplify multi-credential auth.
- [ ] **7.4** Clear or refresh `agents-docs/HANDOFF.md` after each finished wave.
---
## Suggested staffing (multiple agents)
| Agent | Wave |
|-------|------|
| Auth agent | 1 |
| Realtime/signaling agent | 2, 5 |
| Identity/DM/call agent | 3 |
| Voice agent | 4 |
| Chat/attachments agent | 6 |
| Docs agent | 7 (or same PR as each fix) |
Do **not** start Wave 4 “TURN tuning” before Waves 13 — it wastes time on NAT while identity is wrong.
---
## Definition of “emergency over”
All of the following true on a two-signal-server manual matrix:
1. No spurious authorize/login while home session valid.
2. Presence mutual in shared rooms.
3. Bidirectional voice same-home and cross-home.
4. Live text + catch-up history.
5. Cross-home DM + call succeed or fail loudly.
6. DC/WS blip recovers or shows retry UI within ~1 minute.
7. Docs for recovery/identity match code (`10` critical lies cleared).
+129
View File
@@ -0,0 +1,129 @@
# 12 — Agent work packets (copy into new chats)
> Each packet is one new chat. Attach `@emergency-fix/00-README.md` plus the listed files.
> Say: **continue this packet; interview before implement; do not expand scope.**
---
## Packet A — Silent foreign auth
**Attach**
- `@emergency-fix/00-README.md`
- `@emergency-fix/04-auth-login-bugs.md`
- `@emergency-fix/11-fix-priority-plan.md` (Wave 1)
- `@agents-docs/user-stories/silent-cross-signal-server-auth.md`
**Goal:** Wave 1 — never show authorize login for normal foreign joins when home session is valid.
**Out of scope:** Voice negotiation, DM canonicalize, server password hashing changes.
**First message expectation:** Short interview (options AD from user story); wait.
---
## Packet B — Presence / identify-join
**Attach**
- `@emergency-fix/00-README.md`
- `@emergency-fix/05-signaling-multi-server.md`
- `@emergency-fix/08-messaging-visibility.md` (membership section)
- `@emergency-fix/11-fix-priority-plan.md` (Wave 2)
**Goal:** Mutual presence + live chat broadcast after connect/reconnect; foreign actor id on join.
**Depends:** Packet A ideally done (or mock credentials present).
**Out of scope:** Soft DC replace, TURN.
---
## Packet C — Outbound call + DM identity
**Attach**
- `@emergency-fix/00-README.md`
- `@emergency-fix/09-identity-cross-signal.md`
- `@emergency-fix/10-code-lies-doc-debt.md` (L2, L3)
- `@emergency-fix/11-fix-priority-plan.md` (Wave 3)
**Goal:** Routable outbound `targetUserId`; unreachable UX; single DM thread across aliases; fix phantom LESSON references when shipping.
**Out of scope:** Auth provision secret (Packet A); full voice ICE redesign.
**Note:** LESSONS claim these fixes already exist — **they do not**. Implement, dont search forever.
---
## Packet D — Voice initiator & routing
**Attach**
- `@emergency-fix/00-README.md`
- `@emergency-fix/06-voice-webrtc-bugs.md`
- `@emergency-fix/03-architecture-map.md`
- `@emergency-fix/11-fix-priority-plan.md` (Wave 4)
**Goal:** Cross-home bidirectional voice; per-signal actor initiator election; reconnect UX; TURN product decision.
**Depends:** A + B strongly; C helpful.
**Out of scope:** Rewriting entire PeerConnectionManager; server relay policy changes unless proven necessary (ask).
---
## Packet E — Data channel recovery
**Attach**
- `@emergency-fix/00-README.md`
- `@emergency-fix/07-data-channel-drops.md`
- `@emergency-fix/10-code-lies-doc-debt.md` (L1)
- `@emergency-fix/11-fix-priority-plan.md` (Wave 5)
**Goal:** Honest recovery (soft replace **or** documented full rebuild + UX + forced resync); docs match code.
**Interview required:** Option A vs B in `07`.
---
## Packet F — Messaging catch-up & attachments
**Attach**
- `@emergency-fix/00-README.md`
- `@emergency-fix/08-messaging-visibility.md`
- `@emergency-fix/11-fix-priority-plan.md` (Wave 6)
- Optional: `agents-docs/features/messaging.md`, `agents-docs/features/attachments.md`
**Goal:** Late-join history; attachment waiting stuck fixed under reorder; README inventory cap corrected.
**Depends:** B + E.
---
## Packet G — Doc debt sweep
**Attach**
- `@emergency-fix/10-code-lies-doc-debt.md`
- `@emergency-fix/11-fix-priority-plan.md` (Wave 7)
**Goal:** Clear remaining L5L7 and any lies left after AF; no behavior changes unless a one-line comment/doc only.
---
## Handoff one-liner templates
After finishing a packet, overwrite `agents-docs/HANDOFF.md` and ask the user to start a new chat with:
```text
Continue from @agents-docs/HANDOFF.md and @emergency-fix/11-fix-priority-plan.md — next unchecked wave only.
```
Or jump packets:
```text
Start @emergency-fix/12-agent-work-packets.md Packet C — interview before implement.
```
@@ -0,0 +1,485 @@
# Validated emergency findings — Fable 5 implementation handoff
**Date:** 2026-08-12
**Scope:** `emergency-fix/`, `emergency-fix/e2e-failures/`, Angular client, targeted Electron/auth bridge, signaling server, and relevant Playwright tests.
**Method:** Read-only code and test audit. No product code was changed and the E2E suite was not rerun for this report.
## Executive verdict
The emergency pack is directionally correct, but it mixes active defects, already-shipped mitigations, hypotheses, and documentation debt. The current app is **not yet demonstrated to be Discord-reliable** despite a green `65/65` Playwright run.
The highest-confidence failure chain is:
1. Restored sessions do not ensure a provision secret.
2. A foreign signal credential cannot be created.
3. The user is sent to authorize login or never identifies/joins.
4. Presence is missing, so WebRTC discovery and WebSocket chat fallback fail.
5. When peers do connect, negotiation still compares home ids against foreign actor ids.
6. A data-channel failure tears down the entire peer connection, including media.
7. Reconnect attempts can be exhausted while signaling is offline, then stop silently.
Independent P0/P1 defects also exist in direct-message identity, call delivery, and message synchronization:
- inbound DMs trust an actor-id conversation id and can fork a second local thread;
- an outbound call enters local voice state before delivery is known, and delivery failure is ignored;
- a timed-out inventory cycle is marked “clean” without proving convergence, delaying the next poll for 15 minutes.
## Acceptance target
This emergency is not complete until two users with different home signal servers can:
- join the same foreign-hosted community without another login prompt;
- see each other online and in the correct voice channel;
- exchange live and catch-up text messages in both directions;
- place and answer a private call with bidirectional audio;
- keep one DM thread for the same human across actor aliases;
- transfer a file and verify received bytes;
- use camera and screen share while voice remains healthy;
- recover from a signal-server restart and a data-channel failure, or receive a visible retry action within one minute.
## Finding classification
- **Confirmed:** direct current-code mechanism.
- **Partial:** some mechanism exists, but the report overstates or misattributes it.
- **Mitigated:** code already contains the reported fix; retain as a regression test.
- **Unproven:** plausible but needs reproduction evidence.
## Confirmed findings
### F1 — Restored sessions can lack a provision secret
**Severity:** P0
**User symptom:** A logged-in user opens a foreign room or invite and is shown `/login?mode=authorize`, or remains invisible on that signal server.
`loadCurrentUserSuccess` migrates the home credential and immediately attempts foreign provisioning:
- [`users.effects.ts`](../toju-app/src/app/store/users/users.effects.ts), lines 199216
The restore path does **not** call `ensureHomeProvisionSecret`. That call currently exists only when storage is prepared from a fresh login response:
- [`users.effects.ts`](../toju-app/src/app/store/users/users.effects.ts), lines 288306
Provisioning exits when the secret is absent:
- [`signal-server-auth.service.ts`](../toju-app/src/app/domains/authentication/application/services/signal-server-auth.service.ts), lines 154168
The web store uses `sessionStorage`, so a new tab/session can lose the secret even while other persisted identity data remains. Electron storage is durable, but old installs or cleared user data can still have no secret.
**Required fix:** ensure or deliberately recover the home provision secret before any restore-time or join-time foreign provision. Do not silently swallow restore provisioning errors.
**Important design caveat:** generating a new secret cannot authenticate foreign accounts created with a lost old secret. The implementation must define recovery for that case instead of looping register/login forever.
### F2 — Missing foreign credentials block presence and chat fallback
**Severity:** P0
**User symptom:** The room opens locally, but other users do not see the user; chat is sender-only; voice is empty.
The room connection refuses to proceed without a credential. The signaling transport skips identify if credentials cannot be resolved. The server rejects all non-keepalive, non-identify messages from unauthenticated connections.
Live WebSocket channel chat is also membership-gated:
- [`server/src/websocket/handler.ts`](../server/src/websocket/handler.ts), lines 539552
Therefore auth/presence must be repaired before treating chat or voice as isolated transport bugs.
**Required fix:** prove the complete sequence `credential -> identify(actor id) -> join_server -> mutual roster`, including reconnect.
### F3 — WebRTC initiator and glare logic use the wrong identity space
**Severity:** P0
**User symptom:** Cross-home peers connect intermittently, both offer, both wait, or voice remains one-way/connecting.
Per-signal credentials exist through `getIdentifyCredentialsForSignalUrl`, but the generic getter prefers the home credential:
- [`signaling-transport-handler.ts`](../toju-app/src/app/infrastructure/realtime/signaling/signaling-transport-handler.ts), lines 3571
The peer manager and incoming signaling handler receive the generic home-biased getter:
- [`realtime-session.service.ts`](../toju-app/src/app/infrastructure/realtime/realtime-session.service.ts), lines 198208 and 232234
Offer collision handling compares that id with the remote signal actor id:
- [`negotiation.ts`](../toju-app/src/app/infrastructure/realtime/peer-connection-manager/connection/negotiation.ts), lines 106126
Initiator election performs the same lexical comparison:
- [`signaling-message-handler.ts`](../toju-app/src/app/infrastructure/realtime/signaling/signaling-message-handler.ts), lines 528535
**Required fix:** carry connection/signal scope into initiator election, polite-peer handling, reconnect, and P2P voice-state payloads. Both peers must compare actor ids from the same signal URL.
### F4 — Reconnect budget is consumed while signaling is unavailable
**Severity:** P0
**User symptom:** A signal outage lasts about one minute; signaling returns, but the peer mesh does not retry and no error is shown.
Each timer tick increments `reconnectAttempts` before checking signaling connectivity. At the limit, the timer and tracker are deleted:
- [`peer-recovery.ts`](../toju-app/src/app/infrastructure/realtime/peer-connection-manager/recovery/peer-recovery.ts), lines 282321
This means the client can spend all 12 attempts doing no actual reconnection work.
**Required fix:** do not consume an attempt until an offer/reconnect is actually attempted. On exhaustion, publish an explicit failed state with a user-visible Retry action. A later presence or signaling recovery event must be able to re-arm repair.
### F5 — A closed data channel tears down media
**Severity:** P0
**User symptom:** Voice, camera, or screen share drops when chat/file control transport fails.
The active recovery path calls `removePeer`, then creates a new peer transport:
- [`peer-recovery.ts`](../toju-app/src/app/infrastructure/realtime/peer-connection-manager/recovery/peer-recovery.ts), lines 208235
The realtime README claims the initiator creates a replacement data channel on the existing `RTCPeerConnection`:
- [`realtime/README.md`](../toju-app/src/app/infrastructure/realtime/README.md), line 263
That claim is false for the current recovery path. `replaceDataChannel` exists but is not the implemented repair.
**Required decision:**
- **Recommended emergency step:** keep full rebuild initially, but force resync, preserve/reapply media state, expose progress/failure, and fix the docs.
- **Follow-up reliability step:** implement a true soft data-channel replacement on a healthy peer connection, with full rebuild as fallback.
### F6 — Direct messages can fork by home id versus actor id
**Severity:** P0
**User symptom:** Two threads represent the same person; a reply lands in the thread the sender is not viewing.
The pair id is a literal sorted pair of supplied ids:
- [`direct-message.logic.ts`](../toju-app/src/app/domains/direct-message/domain/logic/direct-message.logic.ts), lines 1721
Inbound handling trusts `payload.message.conversationId` before deriving a local id and performs no alias merge:
- [`direct-message.service.ts`](../toju-app/src/app/domains/direct-message/application/services/direct-message.service.ts), lines 511541
The lesson symbols `resolveDirectConversationId` and `mergeAliasDirectConversations` do not exist in the product tree.
**Required fix:** define a canonical local human identity, remap inbound actor ids, merge duplicate conversations transactionally, and preserve messages/unread/status ordering.
### F7 — Outbound private calls can fail silently after local join
**Severity:** P0
**User symptom:** Caller sees “In Voice”; callee never rings.
`startCall` joins the call locally before sending the ring:
- [`direct-call.service.ts`](../toju-app/src/app/domains/direct-call/application/services/direct-call.service.ts), lines 220235
`sendCallEvent` does not await or inspect delivery:
- [`direct-call.service.ts`](../toju-app/src/app/domains/direct-call/application/services/direct-call.service.ts), lines 759773
Candidate expansion in `PeerDeliveryService` helps when a matching NgRx user alias exists, but there is no complete credential/presence-based routable-id selection and no unreachable UX.
**Required fix:** resolve the connected actor id before committing the caller to the session, return a delivery result, and transition to `recipientUnreachable`/Retry when no route accepts the ring.
### F8 — Message synchronization can falsely become “clean”
**Severity:** P1, potentially P0 for long missing-history windows
**User symptom:** Live chat works, but old messages remain missing for up to 15 minutes.
Periodic sync sends inventory requests and dispatches `startSync`:
- [`messages-sync.effects.ts`](../toju-app/src/app/store/messages/messages-sync.effects.ts), lines 166213
Five seconds later, if the reducer still says syncing, the effect unconditionally sets `lastSyncClean = true` and dispatches `syncComplete`:
- [`messages-sync.effects.ts`](../toju-app/src/app/store/messages/messages-sync.effects.ts), lines 215232
No evidence of an empty inventory result is required. The next interval can therefore change from 10 seconds to 15 minutes after timeout rather than convergence.
**Required fix:** model an inventory round explicitly. Mark clean only when all expected peers have replied and no missing ids remain. Timeouts and newly received messages must remain dirty.
### F9 — Live chat fallback and history repair are separate
**Severity:** P1
**User symptom:** New text arrives while older text, edits, files, and emoji remain absent.
The WebSocket `chat_message` path handles live channel messages for joined members. Inventory and sync batches are data-channel-only. `message-revision` has no equivalent WebSocket fallback.
`onPeerConnected` kicks inventory:
- [`messages-sync.effects.ts`](../toju-app/src/app/store/messages/messages-sync.effects.ts), lines 6494
That signal can occur at peer-connection state `connected` before the data channel is open; a second data-channel-open signal usually retries, but there is no explicit durable “control channel reopened and inventory completed” state.
**Required fix:** trigger and track reconciliation from data-channel open, not only generic peer connected. Queue failed inventory sends and retry on channel open.
### F10 — STUN-only defaults cannot guarantee Discord-like reachability
**Severity:** P1 product limitation
**User symptom:** Voice works on friendly networks but fails on symmetric NAT, enterprise Wi-Fi, carrier networks, or restrictive firewalls.
TURN can be configured by the user, but no managed/default TURN service is supplied. Localhost E2E cannot validate NAT traversal.
**Required product decision:** operate a credentialed TURN service with expiry/abuse controls, or explicitly reject the requirement that voice “always works.” Documentation alone does not satisfy the stated target.
## Claims that should not be treated as active defects
### Identify cache fallback — mitigated
`getIdentifyCredentialsForSignalUrl` falls back to the credential store when the per-URL cache is empty:
- [`signaling-transport-handler.ts`](../toju-app/src/app/infrastructure/realtime/signaling/signaling-transport-handler.ts), lines 4771
Keep this as a regression invariant. Add a focused spec; do not rewrite this path without a failing reproduction.
### Join-before-identify race — substantially mitigated
Client reconnect sends identify before rejoin, and the server serializes message handling per connection. The remaining P0 case is missing credentials, not evidence that server serialization is currently broken.
### Shared `clientInstanceId` across tabs — fixed
Current code uses `sessionStorage` and clears the legacy local-storage value. Retain the existing regression test.
### Multi-signal `user_left` teardown — mitigated
Current client/server paths carry server membership information and preserve peers still shared elsewhere. This needs multi-signal integration coverage, but the pack should not call it a confirmed current failure.
### Voice allow-list alias miss — partial
The voice playback service already collects `id`, `oderId`, and `peerId`. More likely active failures are stale/missing `voiceState`, a peer key absent from all three aliases, or the broader home/actor negotiation mismatch. Instrument before changing routing filters.
### “No retry after abandon” — overstated
Presence fallback timers can create a later offer. The confirmed defect is that the explicit reconnect tracker is silently discarded and signaling-down ticks consume its budget.
### Attachment announce/message race — fixed, retain
The incoming message handler re-queues auto-download after binding message to room, with unit coverage. Add reorder/large-file E2E rather than reimplementing the fix.
## E2E baseline assessment
The archived run is internally consistent:
- 65 passed
- 0 failed
- 0 flaky
- 0 skipped
- about 13.4 minutes
Sources:
- [`e2e-failures/README.md`](e2e-failures/README.md)
- [`e2e-failures/00-summary.md`](e2e-failures/00-summary.md)
- `e2e-failures/parsed-report.json`
- `e2e-failures/full-run.log`
### What it proves
- Scripted flows passed once in Chromium with retries disabled.
- The Angular development server, ephemeral localhost signaling servers, fake mic/camera, and synthetic screen capture can support the covered scenarios.
- Strong existing scenarios include two-user voice, eight-user local multi-signal voice, synthetic data-channel recovery, direct-call answer/audio on a same-route setup, chat sync, and several attachment regressions.
### What it does not prove
The Playwright configuration runs only Chromium against `ng serve`:
- [`e2e/playwright.config.ts`](../e2e/playwright.config.ts), lines 339
It does not run Electron, a packaged production build, real desktop safeStorage/SQLite behavior, real screen capture, real TURN/NAT, sleep/wake, or a production signal deployment.
The cross-signal auth test adds the second endpoint manually before checking provision:
- [`multi-signal-server-auth.spec.ts`](../e2e/tests/auth/multi-signal-server-auth.spec.ts), lines 2070
It does not cover restore with a missing secret or joining an unknown foreign invite.
The cross-signal call test proves only that the callee sees a ring:
- [`dm-header-call-ring.spec.ts`](../e2e/tests/voice/dm-header-call-ring.spec.ts), lines 92184
It does not answer the call, verify audio, exercise people-card home-id routing, or prove one DM thread.
The offline DM test proves only local `QUEUED` state:
- [`dm-flow.spec.ts`](../e2e/tests/chat/dm-flow.spec.ts), lines 1536
It never reconnects or proves recipient delivery.
The mesh helper permits fewer than the calculated expected connections:
- [`signal-manager.ts`](../e2e/helpers/signal-manager.ts), lines 2850
This can let a partial dual-signal mesh pass. Debug inspection also depends on Angular's dev-only `window.ng`.
The data-channel test proves that audio eventually resumes after a synthetic close:
- [`data-channel-recovery.spec.ts`](../e2e/tests/voice/data-channel-recovery.spec.ts), lines 2754
It does not prove uninterrupted audio, which is consistent with the current full peer teardown.
## Revised implementation order
Do not tune codecs, ICE timers, or TURN before identity and membership are correct.
### Packet 0 — Reproduction and observability
**Goal:** produce one deterministic two-signal matrix and enough correlated data to prove each later fix.
Log in debug builds:
- home user id;
- credential actor id per normalized signal URL;
- room id/source URL;
- peer-map key and peer signal URL;
- initiator/polite decision inputs;
- call `targetUserId` and delivery outcome;
- DM incoming and resolved conversation ids;
- data-channel generation and reconnect attempt reason.
Do not log tokens, passwords, provision secrets, SDP bodies, or message contents.
### Packet 1 — Restore-safe silent foreign auth
**Primary files:**
- `toju-app/src/app/store/users/users.effects.ts`
- `toju-app/src/app/domains/authentication/application/services/signal-server-auth.service.ts`
- `signal-server-authorize.service.ts`
- provision-secret stores; targeted Electron bridge only if the persistence contract changes
**Tests:**
- restored home session + absent secret;
- restored Electron session;
- web new-tab behavior;
- old foreign account whose previous secret is lost;
- offline/unknown endpoint never opens authorize;
- foreign `auth_error` does not expire the valid home session.
### Packet 2 — Identify/join/presence integrity
**Primary files:**
- `room-signaling-connection.ts`
- `signaling-transport-handler.ts`
- `signaling.manager.ts`
- targeted `server/src/websocket/handler.ts` only if a failing test proves a server change is needed
**Tests:**
- credential-store fallback on a fresh socket;
- identify is first authenticated message before join;
- signal-server restart restores both users to the roster;
- leaving one signal scope does not remove a peer shared through another.
### Packet 3 — Cross-signal identity for calls and DMs
**Primary files:**
- `domains/direct-message/**`
- `domains/direct-call/**`
- `peer-delivery.service.ts`
- authentication credential/alias readers
**Tests:**
- candidate collection from home id, actor id, peer id, and known presence routes;
- unreachable call never leaves caller silently in voice;
- merge two alias conversations without message/status loss;
- cross-signal DM reply remains in one thread;
- cross-signal call rings, answers, and carries bidirectional audio.
### Packet 4 — Per-signal WebRTC identity
**Primary files:**
- `realtime-session.service.ts`
- `signaling-message-handler.ts`
- `connection/negotiation.ts`
- `recovery/peer-recovery.ts`
- voice-state data-channel payload construction
**Tests:**
- home-id/actor-id matrix elects exactly one initiator;
- polite-peer collision decision uses the same scoped ids;
- same-home and cross-home voice are bidirectional;
- stale voice state cannot leave a connected peer permanently muted without recovery.
### Packet 5 — Honest data-channel and reconnect recovery
**Primary files:**
- `recovery/peer-recovery.ts`
- `messaging/data-channel.ts`
- `peer-connection.manager.ts`
- user-visible connectivity state/components
**Tests:**
- signaling-down ticks do not consume reconnect attempts;
- max real attempts exposes Retry;
- Retry re-arms recovery;
- data-channel reopen forces inventory;
- media state is reapplied after full rebuild;
- if soft replacement is implemented, audio remains continuously flowing.
### Packet 6 — Message convergence and file integrity
**Primary files:**
- `store/messages/messages-sync.effects.ts`
- message sync reducers/rules/handlers
- attachment transfer only where a failing test identifies a gap
**Tests:**
- timeout remains dirty;
- only complete, empty inventory rounds select the 15-minute cadence;
- late join catches up;
- edit/delete converge after DC recovery;
- offline DM reconnects and delivers;
- large-file download SHA-256 and size match the source;
- attachment announce/message order is stressed in both channel and DM paths.
### Packet 7 — Production reliability proof
Add separate suites rather than weakening existing fast PR coverage:
- Electron smoke: login restore, foreign provision, text, file, voice;
- signal restart during active voice and DM;
- TURN-backed relay-only test;
- sleep/wake or network-interface-change test;
- 30-minute scheduled voice/screen-share soak;
- exact mesh assertions where topology guarantees the count.
## Documentation corrections required with fixes
1. Replace the false soft-data-channel paragraph in `realtime/README.md` unless soft replacement ships.
2. Relabel the LESSONS outbound-call and DM-canonicalization entries as unfinished specifications until real symbols and tests exist.
3. Correct authentication README endpoints from `/api/auth/*` to `/api/users/*`.
4. Correct chat README inventory cap from 1,000 to 20,000.
5. Mark `shared-kernel/signaling-contracts.ts` non-authoritative or align it with live wire names.
6. Correct `e2e/CONTEXT.md` if it claims Electron coverage; the current suite is browser + `ng serve`.
## Decisions required from the product owner before implementation
1. **Lost provision secret recovery**
- Recommended: automatically create a secret when none has ever existed; when an existing foreign account rejects the new secret, show a targeted per-server recovery action instead of a generic login loop.
2. **Web secret durability**
- Recommended emergency default: keep session-scoped storage for security, but make the new-tab limitation explicit and recover without misclassifying home logout.
3. **Data-channel strategy**
- Recommended: ship visible full-rebuild recovery + forced resync first, then soft replacement as a measured follow-up.
4. **TURN**
- Recommended for the stated Discord-quality goal: managed credentialed TURN, not user-only configuration.
5. **History availability**
- The signal server stores no chat history. Decide whether “offline users always recover history” may depend on another online client, or whether an encrypted durable mailbox/history service is required.
6. **Presence semantics**
- Define online/idle/DND/offline and stale timeout behavior. Current emergency tests mostly prove connectivity, not Discord-like status semantics.
## Definition of done for Fable 5
For every packet:
1. Start from a behavior-level failing test or deterministic reproduction.
2. Change only the packet's owned surfaces.
3. Record actor ids and signal scope in test diagnostics without secrets.
4. Run focused unit/integration tests, then the relevant Playwright subset.
5. Run lint/build for touched packages.
6. Update lying docs in the same change.
7. Do not mark complete from unit-green alone; attach the user-visible proof.
The green 65-test archive is a useful baseline, not release certification. Release confidence requires the two-signal cross-home matrix, real restart recovery, Electron coverage, and TURN/NAT validation described above.
+19
View File
@@ -0,0 +1,19 @@
# E2E summary
- Run: 2026-08-12T10:58:08Z → 2026-08-12T11:11:34Z UTC
- **65 passed / 0 failed / 0 flaky / 0 timeouts**
- Wall: ~13.4 min
## Errors
None.
## Soft signals (passed but slow)
- 128.2s — `voice/mixed-signal-config-voice.spec.ts` — 8 users with different signal configs can voice, mute, deafen, and chat concurrently
- 125.1s — `voice/multi-signal-eight-user-voice.spec.ts` — keeps 8 users on 2 signal apis while voice, mute, and deafen stay consistent for 20+ seconds
- 53.4s — `auth/user-session-data-isolation.spec.ts` — gives a new user a blank slate and restores only that user local data after account switches
- 33.7s — `chat/chat-message-features.spec.ts` — shows per-server channel lists on first saved-server click
- 30.3s — `chat/multi-client-chat-sync.spec.ts` — syncs messages between same-user devices and late-joining users after offline gaps
See `README.md` for full duration table.
+152
View File
@@ -0,0 +1,152 @@
# E2E Playwright run — emergency-fix baseline
- **Started (UTC):** 2026-08-12T10:58:08Z
- **Finished (UTC):** 2026-08-12T11:11:34Z
- **Wall duration:** 804.6s (~13.4 min)
- **Command:** `npm run test:e2e -- --reporter=list --reporter=json`
- **Result:** 65 passed, 0 failed, 0 flaky, 0 skipped
## Verdict
**No hard errors.** The suite completed green: 65/65 expected, 0 unexpected, 0 timeouts, 0 flakes.
There is nothing to triage as a failing assertion in this run. Slow cases below are the only signals worth watching if “timeouts shouldnt happen / app should be fast.”
## Errors / failures
_None in this run._
Stale note: `test-results/.last-run.json` and `test-results/html-report/` still show an old July failure; this run overrode reporters with list+json and did not refresh those artifacts.
## Slow tests (≥30s) — soft concern
| Duration | File | Test |
| --- | --- | --- |
| 128.2s | `voice/mixed-signal-config-voice.spec.ts` | 8 users with different signal configs can voice, mute, deafen, and chat concurrently |
| 125.1s | `voice/multi-signal-eight-user-voice.spec.ts` | keeps 8 users on 2 signal apis while voice, mute, and deafen stay consistent for 20+ seconds |
| 53.4s | `auth/user-session-data-isolation.spec.ts` | gives a new user a blank slate and restores only that user local data after account switches |
| 33.7s | `chat/chat-message-features.spec.ts` | shows per-server channel lists on first saved-server click |
| 30.3s | `chat/multi-client-chat-sync.spec.ts` | syncs messages between same-user devices and late-joining users after offline gaps |
These passed, but they are the closest thing to “timeout risk” in a green suite. Multi-user voice specs dominate wall time.
## Per-file totals
| File | Tests | Total | Max |
| --- | ---: | ---: | ---: |
| `voice/mixed-signal-config-voice.spec.ts` | 1 | 128.2s | 128.2s |
| `voice/multi-signal-eight-user-voice.spec.ts` | 1 | 125.1s | 125.1s |
| `chat/chat-message-features.spec.ts` | 9 | 84.2s | 33.7s |
| `auth/user-session-data-isolation.spec.ts` | 2 | 73.2s | 53.4s |
| `screen-share/screen-share.spec.ts` | 3 | 49.8s | 23.4s |
| `voice/direct-call.spec.ts` | 3 | 31.8s | 17.5s |
| `chat/multi-client-chat-sync.spec.ts` | 1 | 30.3s | 30.3s |
| `plugins/plugin-api-two-users.spec.ts` | 1 | 27.4s | 27.4s |
| `chat/notifications.spec.ts` | 2 | 25.6s | 20.9s |
| `voice/data-channel-recovery.spec.ts` | 2 | 22.1s | 12.1s |
| `chat/profile-avatar-sync.spec.ts` | 2 | 21.5s | 11.9s |
| `settings/connectivity-warning.spec.ts` | 1 | 21.3s | 21.3s |
| `voice/voice-full-journey.spec.ts` | 1 | 21.1s | 21.1s |
| `chat/server-icon-sync.spec.ts` | 1 | 14.2s | 14.2s |
| `chat/dm-flow.spec.ts` | 3 | 11.8s | 4.0s |
| `settings/stun-turn-fallback.spec.ts` | 1 | 11.5s | 11.5s |
| `auth/login-return-url.spec.ts` | 3 | 9.4s | 3.5s |
| `voice/dm-header-call-ring.spec.ts` | 2 | 9.1s | 4.8s |
| `auth/multi-device-session.spec.ts` | 1 | 8.6s | 8.6s |
| `chat/multi-device-attachment-sharing.spec.ts` | 2 | 8.6s | 4.3s |
| `voice/voice-mute-state-reset.spec.ts` | 1 | 6.9s | 6.9s |
| `servers/server-discovery-default.spec.ts` | 2 | 6.3s | 3.1s |
| `settings/ice-server-settings.spec.ts` | 2 | 6.2s | 3.9s |
| `chat/large-generic-file-transfer.spec.ts` | 1 | 5.7s | 5.7s |
| `plugins/plugin-manager-ui.spec.ts` | 1 | 4.6s | 4.6s |
| `chat/custom-emoji-user-binding.spec.ts` | 1 | 3.9s | 3.9s |
| `auth/multi-signal-server-auth.spec.ts` | 1 | 3.8s | 3.8s |
| `auth/offline-signal-server-no-login-loop.spec.ts` | 1 | 3.7s | 3.7s |
| `chat/local-attachment-persistence.spec.ts` | 1 | 2.8s | 2.8s |
| `chat/attachment-only-message-grouping.spec.ts` | 1 | 2.5s | 2.5s |
| `mobile/mobile-login-on-startup.spec.ts` | 2 | 2.4s | 1.2s |
| `chat/multi-image-gallery.spec.ts` | 1 | 2.3s | 2.3s |
| `plugins/plugin-support-api.spec.ts` | 1 | 1.9s | 1.9s |
| `mobile/mobile-settings-logout.spec.ts` | 1 | 1.7s | 1.7s |
| `mobile/android-app-icon.spec.ts` | 6 | 0.0s | 0.0s |
## All tests by duration
| Duration | Status | File | Test |
| --- | --- | --- | --- |
| 128.2s | passed | `voice/mixed-signal-config-voice.spec.ts` | 8 users with different signal configs can voice, mute, deafen, and chat concurrently |
| 125.1s | passed | `voice/multi-signal-eight-user-voice.spec.ts` | keeps 8 users on 2 signal apis while voice, mute, and deafen stay consistent for 20+ seconds |
| 53.4s | passed | `auth/user-session-data-isolation.spec.ts` | gives a new user a blank slate and restores only that user local data after account switches |
| 33.7s | passed | `chat/chat-message-features.spec.ts` | shows per-server channel lists on first saved-server click |
| 30.3s | passed | `chat/multi-client-chat-sync.spec.ts` | syncs messages between same-user devices and late-joining users after offline gaps |
| 27.4s | passed | `plugins/plugin-api-two-users.spec.ts` | runs chat, embed, soundboard, and profile APIs between two users |
| 23.4s | passed | `screen-share/screen-share.spec.ts` | screen share connection stays stable for 10+ seconds |
| 21.3s | passed | `settings/connectivity-warning.spec.ts` | shows warning icon when a peer loses all connections |
| 21.1s | passed | `voice/voice-full-journey.spec.ts` | two users register, create server, join voice, and stay connected 10+ seconds with audio |
| 20.9s | passed | `chat/notifications.spec.ts` | keeps unread badges visible when a muted channel suppresses desktop popups |
| 19.7s | passed | `auth/user-session-data-isolation.spec.ts` | preserves a user saved rooms and local history across app restarts |
| 19.6s | passed | `chat/chat-message-features.spec.ts` | syncs messages in a newly created text channel |
| 17.5s | passed | `voice/direct-call.spec.ts` | two users can ring, answer, chat, see self voice indicators, and exchange audio |
| 15.4s | passed | `screen-share/screen-share.spec.ts` | single user screen share: video and audio flow to receiver, voice audio continues |
| 14.2s | passed | `chat/server-icon-sync.spec.ts` | loads the chat-server image for online, late-joining, restarted, and discovery users |
| 12.1s | passed | `voice/data-channel-recovery.spec.ts` | heals a three-user voice mesh when one client loses every data channel |
| 11.9s | passed | `chat/profile-avatar-sync.spec.ts` | syncs display name and description changes for online and late-joining users and persists after restart |
| 11.5s | passed | `settings/stun-turn-fallback.spec.ts` | users with different ICE configs can voice chat together |
| 11.1s | passed | `screen-share/screen-share.spec.ts` | multiple users screen share simultaneously |
| 10.0s | passed | `voice/data-channel-recovery.spec.ts` | keeps two users hearing each other after a data-channel error and close |
| 9.6s | passed | `chat/profile-avatar-sync.spec.ts` | syncs avatar changes for online and late-joining users and persists after restart |
| 8.9s | passed | `voice/direct-call.spec.ts` | keeps private-call audio flowing after the data channel closes |
| 8.6s | passed | `auth/multi-device-session.spec.ts` | covers identity, chat sync, typing exclusion, and voice exclusivity |
| 6.9s | passed | `voice/voice-mute-state-reset.spec.ts` | clears stale mute state after abrupt disconnect and voice rejoin |
| 5.7s | passed | `chat/large-generic-file-transfer.spec.ts` | browser receiver can request and download a generic file above the auto-save cap |
| 5.5s | passed | `chat/chat-message-features.spec.ts` | edits and removes messages for both users |
| 5.3s | passed | `voice/direct-call.spec.ts` | missing and ended private calls do not leave stale call controls behind |
| 4.8s | passed | `voice/dm-header-call-ring.spec.ts` | callee homed on another signal server is notified when called via their provisioned actor id |
| 4.8s | passed | `chat/notifications.spec.ts` | shows desktop notifications and unread badges for inactive channels |
| 4.6s | passed | `chat/chat-message-features.spec.ts` | sends KLIPY GIF messages with mocked API responses |
| 4.6s | passed | `plugins/plugin-manager-ui.spec.ts` | installs, grants, activates, and logs an all-API test plugin |
| 4.4s | passed | `chat/chat-message-features.spec.ts` | syncs image and file attachments between users |
| 4.4s | passed | `chat/chat-message-features.spec.ts` | syncs multi-chunk image attachments byte-identical between users |
| 4.3s | passed | `chat/multi-device-attachment-sharing.spec.ts` | only the uploading device claims "Shared from your device"; the second same-user device can request it |
| 4.3s | passed | `chat/chat-message-features.spec.ts` | renders link embeds for shared links |
| 4.3s | passed | `chat/multi-device-attachment-sharing.spec.ts` | relays file-announce metadata to a sibling device that is already online during upload |
| 4.2s | passed | `voice/dm-header-call-ring.spec.ts` | callee is notified when the caller starts the call from the DM chat header |
| 4.1s | passed | `chat/chat-message-features.spec.ts` | shows typing indicators to other users |
| 4.0s | passed | `chat/dm-flow.spec.ts` | delivers a live DM to the recipient conversation |
| 3.9s | passed | `chat/custom-emoji-user-binding.spec.ts` | a second user on the same client does not inherit the first user library |
| 3.9s | passed | `chat/dm-flow.spec.ts` | opens a DM from a user card and queues messages while offline |
| 3.9s | passed | `settings/ice-server-settings.spec.ts` | allows adding, removing, and reordering ICE servers |
| 3.8s | passed | `chat/dm-flow.spec.ts` | shows friend and message actions on the search people list |
| 3.8s | passed | `auth/multi-signal-server-auth.spec.ts` | auto-provisions a foreign signal server when a new endpoint is added |
| 3.7s | passed | `auth/offline-signal-server-no-login-loop.spec.ts` | does not redirect to authorize login after a foreign server goes offline |
| 3.6s | passed | `chat/chat-message-features.spec.ts` | shows local room history on first saved-server click |
| 3.5s | passed | `auth/login-return-url.spec.ts` | unwraps nested login returnUrl chains after successful login |
| 3.1s | passed | `servers/server-discovery-default.spec.ts` | a fresh account sees public servers in Popular Servers without searching |
| 3.1s | passed | `servers/server-discovery-default.spec.ts` | discovery falls back to the public listing when featured/trending routes 404 |
| 3.1s | passed | `auth/login-return-url.spec.ts` | redirects unauthenticated /servers visits to login and returns there after login |
| 2.8s | passed | `auth/login-return-url.spec.ts` | lets a returning user log back in after an expired session redirect |
| 2.8s | passed | `chat/local-attachment-persistence.spec.ts` | remembers sent image and file across a page reload with no peer connected |
| 2.5s | passed | `chat/attachment-only-message-grouping.spec.ts` | each caption-less attachment keeps its own message bubble and preview |
| 2.3s | passed | `settings/ice-server-settings.spec.ts` | validates TURN entries require credentials |
| 2.3s | passed | `chat/multi-image-gallery.spec.ts` | groups three images in one message bubble with a visible grid |
| 1.9s | passed | `plugins/plugin-support-api.spec.ts` | covers plugin requirement, event, data, and websocket APIs with the fixture plugin |
| 1.7s | passed | `mobile/mobile-settings-logout.spec.ts` | exposes logout in the settings menu on mobile viewports |
| 1.2s | passed | `mobile/mobile-login-on-startup.spec.ts` | greets a signed-out mobile visitor on /dashboard with the login screen |
| 1.2s | passed | `mobile/mobile-login-on-startup.spec.ts` | greets a signed-out mobile visitor on the app root with the login screen |
| 0.0s | passed | `mobile/android-app-icon.spec.ts` | renders the brand mark (white cat on a purple disc) in the launcher bitmap |
| 0.0s | passed | `mobile/android-app-icon.spec.ts` | renders the splash art as the brand mark centred on a purple field |
| 0.0s | passed | `mobile/android-app-icon.spec.ts` | insets the adaptive foreground so launcher masks do not clip the cat face |
| 0.0s | passed | `mobile/android-app-icon.spec.ts` | replaces every stock Capacitor placeholder with the brand asset |
| 0.0s | passed | `mobile/android-app-icon.spec.ts` | ships a launcher icon and splash for every required density |
| 0.0s | passed | `mobile/android-app-icon.spec.ts` | uses the brand purple as the adaptive-icon background |
## Files in this folder
| Path | Purpose |
| --- | --- |
| `README.md` | This summary |
| `00-summary.md` | Short status for handoff/skimming |
| `slow-tests.md` | Slow-test detail only |
| `parsed-report.json` | Machine-readable extract |
| `full-run.log` | Raw Playwright JSON report (+ npm banner) |
| `run-started.txt` / `run-finished.txt` | UTC timestamps |
File diff suppressed because one or more lines are too long
@@ -0,0 +1,516 @@
{
"stats": {
"startTime": "2026-08-12T10:58:09.830Z",
"duration": 804598.4550000001,
"expected": 65,
"skipped": 0,
"unexpected": 0,
"flaky": 0
},
"failures": [],
"timeouts": [],
"slow": [
{
"title": "8 users with different signal configs can voice, mute, deafen, and chat concurrently",
"file": "voice/mixed-signal-config-voice.spec.ts",
"status": "passed",
"duration": 128215,
"error": null,
"errors": [],
"timeoutHint": false
},
{
"title": "keeps 8 users on 2 signal apis while voice, mute, and deafen stay consistent for 20+ seconds",
"file": "voice/multi-signal-eight-user-voice.spec.ts",
"status": "passed",
"duration": 125136,
"error": null,
"errors": [],
"timeoutHint": false
},
{
"title": "gives a new user a blank slate and restores only that user local data after account switches",
"file": "auth/user-session-data-isolation.spec.ts",
"status": "passed",
"duration": 53448,
"error": null,
"errors": [],
"timeoutHint": false
},
{
"title": "shows per-server channel lists on first saved-server click",
"file": "chat/chat-message-features.spec.ts",
"status": "passed",
"duration": 33665,
"error": null,
"errors": [],
"timeoutHint": false
},
{
"title": "syncs messages between same-user devices and late-joining users after offline gaps",
"file": "chat/multi-client-chat-sync.spec.ts",
"status": "passed",
"duration": 30290,
"error": null,
"errors": [],
"timeoutHint": false
}
],
"all": [
{
"title": "unwraps nested login returnUrl chains after successful login",
"file": "auth/login-return-url.spec.ts",
"status": "passed",
"duration": 3506,
"timeoutHint": false
},
{
"title": "redirects unauthenticated /servers visits to login and returns there after login",
"file": "auth/login-return-url.spec.ts",
"status": "passed",
"duration": 3088,
"timeoutHint": false
},
{
"title": "lets a returning user log back in after an expired session redirect",
"file": "auth/login-return-url.spec.ts",
"status": "passed",
"duration": 2811,
"timeoutHint": false
},
{
"title": "covers identity, chat sync, typing exclusion, and voice exclusivity",
"file": "auth/multi-device-session.spec.ts",
"status": "passed",
"duration": 8640,
"timeoutHint": false
},
{
"title": "auto-provisions a foreign signal server when a new endpoint is added",
"file": "auth/multi-signal-server-auth.spec.ts",
"status": "passed",
"duration": 3836,
"timeoutHint": false
},
{
"title": "does not redirect to authorize login after a foreign server goes offline",
"file": "auth/offline-signal-server-no-login-loop.spec.ts",
"status": "passed",
"duration": 3727,
"timeoutHint": false
},
{
"title": "preserves a user saved rooms and local history across app restarts",
"file": "auth/user-session-data-isolation.spec.ts",
"status": "passed",
"duration": 19725,
"timeoutHint": false
},
{
"title": "gives a new user a blank slate and restores only that user local data after account switches",
"file": "auth/user-session-data-isolation.spec.ts",
"status": "passed",
"duration": 53448,
"timeoutHint": false
},
{
"title": "each caption-less attachment keeps its own message bubble and preview",
"file": "chat/attachment-only-message-grouping.spec.ts",
"status": "passed",
"duration": 2507,
"timeoutHint": false
},
{
"title": "shows per-server channel lists on first saved-server click",
"file": "chat/chat-message-features.spec.ts",
"status": "passed",
"duration": 33665,
"timeoutHint": false
},
{
"title": "shows local room history on first saved-server click",
"file": "chat/chat-message-features.spec.ts",
"status": "passed",
"duration": 3598,
"timeoutHint": false
},
{
"title": "syncs messages in a newly created text channel",
"file": "chat/chat-message-features.spec.ts",
"status": "passed",
"duration": 19613,
"timeoutHint": false
},
{
"title": "shows typing indicators to other users",
"file": "chat/chat-message-features.spec.ts",
"status": "passed",
"duration": 4090,
"timeoutHint": false
},
{
"title": "edits and removes messages for both users",
"file": "chat/chat-message-features.spec.ts",
"status": "passed",
"duration": 5534,
"timeoutHint": false
},
{
"title": "syncs image and file attachments between users",
"file": "chat/chat-message-features.spec.ts",
"status": "passed",
"duration": 4442,
"timeoutHint": false
},
{
"title": "syncs multi-chunk image attachments byte-identical between users",
"file": "chat/chat-message-features.spec.ts",
"status": "passed",
"duration": 4369,
"timeoutHint": false
},
{
"title": "renders link embeds for shared links",
"file": "chat/chat-message-features.spec.ts",
"status": "passed",
"duration": 4303,
"timeoutHint": false
},
{
"title": "sends KLIPY GIF messages with mocked API responses",
"file": "chat/chat-message-features.spec.ts",
"status": "passed",
"duration": 4610,
"timeoutHint": false
},
{
"title": "a second user on the same client does not inherit the first user library",
"file": "chat/custom-emoji-user-binding.spec.ts",
"status": "passed",
"duration": 3929,
"timeoutHint": false
},
{
"title": "opens a DM from a user card and queues messages while offline",
"file": "chat/dm-flow.spec.ts",
"status": "passed",
"duration": 3882,
"timeoutHint": false
},
{
"title": "delivers a live DM to the recipient conversation",
"file": "chat/dm-flow.spec.ts",
"status": "passed",
"duration": 4024,
"timeoutHint": false
},
{
"title": "shows friend and message actions on the search people list",
"file": "chat/dm-flow.spec.ts",
"status": "passed",
"duration": 3849,
"timeoutHint": false
},
{
"title": "browser receiver can request and download a generic file above the auto-save cap",
"file": "chat/large-generic-file-transfer.spec.ts",
"status": "passed",
"duration": 5690,
"timeoutHint": false
},
{
"title": "remembers sent image and file across a page reload with no peer connected",
"file": "chat/local-attachment-persistence.spec.ts",
"status": "passed",
"duration": 2793,
"timeoutHint": false
},
{
"title": "syncs messages between same-user devices and late-joining users after offline gaps",
"file": "chat/multi-client-chat-sync.spec.ts",
"status": "passed",
"duration": 30290,
"timeoutHint": false
},
{
"title": "only the uploading device claims \"Shared from your device\"; the second same-user device can request it",
"file": "chat/multi-device-attachment-sharing.spec.ts",
"status": "passed",
"duration": 4333,
"timeoutHint": false
},
{
"title": "relays file-announce metadata to a sibling device that is already online during upload",
"file": "chat/multi-device-attachment-sharing.spec.ts",
"status": "passed",
"duration": 4266,
"timeoutHint": false
},
{
"title": "groups three images in one message bubble with a visible grid",
"file": "chat/multi-image-gallery.spec.ts",
"status": "passed",
"duration": 2294,
"timeoutHint": false
},
{
"title": "shows desktop notifications and unread badges for inactive channels",
"file": "chat/notifications.spec.ts",
"status": "passed",
"duration": 4758,
"timeoutHint": false
},
{
"title": "keeps unread badges visible when a muted channel suppresses desktop popups",
"file": "chat/notifications.spec.ts",
"status": "passed",
"duration": 20875,
"timeoutHint": false
},
{
"title": "syncs avatar changes for online and late-joining users and persists after restart",
"file": "chat/profile-avatar-sync.spec.ts",
"status": "passed",
"duration": 9605,
"timeoutHint": false
},
{
"title": "syncs display name and description changes for online and late-joining users and persists after restart",
"file": "chat/profile-avatar-sync.spec.ts",
"status": "passed",
"duration": 11864,
"timeoutHint": false
},
{
"title": "loads the chat-server image for online, late-joining, restarted, and discovery users",
"file": "chat/server-icon-sync.spec.ts",
"status": "passed",
"duration": 14204,
"timeoutHint": false
},
{
"title": "ships a launcher icon and splash for every required density",
"file": "mobile/android-app-icon.spec.ts",
"status": "passed",
"duration": 1,
"timeoutHint": false
},
{
"title": "replaces every stock Capacitor placeholder with the brand asset",
"file": "mobile/android-app-icon.spec.ts",
"status": "passed",
"duration": 2,
"timeoutHint": false
},
{
"title": "uses the brand purple as the adaptive-icon background",
"file": "mobile/android-app-icon.spec.ts",
"status": "passed",
"duration": 1,
"timeoutHint": false
},
{
"title": "renders the brand mark (white cat on a purple disc) in the launcher bitmap",
"file": "mobile/android-app-icon.spec.ts",
"status": "passed",
"duration": 13,
"timeoutHint": false
},
{
"title": "renders the splash art as the brand mark centred on a purple field",
"file": "mobile/android-app-icon.spec.ts",
"status": "passed",
"duration": 7,
"timeoutHint": false
},
{
"title": "insets the adaptive foreground so launcher masks do not clip the cat face",
"file": "mobile/android-app-icon.spec.ts",
"status": "passed",
"duration": 3,
"timeoutHint": false
},
{
"title": "greets a signed-out mobile visitor on /dashboard with the login screen",
"file": "mobile/mobile-login-on-startup.spec.ts",
"status": "passed",
"duration": 1217,
"timeoutHint": false
},
{
"title": "greets a signed-out mobile visitor on the app root with the login screen",
"file": "mobile/mobile-login-on-startup.spec.ts",
"status": "passed",
"duration": 1181,
"timeoutHint": false
},
{
"title": "exposes logout in the settings menu on mobile viewports",
"file": "mobile/mobile-settings-logout.spec.ts",
"status": "passed",
"duration": 1704,
"timeoutHint": false
},
{
"title": "runs chat, embed, soundboard, and profile APIs between two users",
"file": "plugins/plugin-api-two-users.spec.ts",
"status": "passed",
"duration": 27382,
"timeoutHint": false
},
{
"title": "installs, grants, activates, and logs an all-API test plugin",
"file": "plugins/plugin-manager-ui.spec.ts",
"status": "passed",
"duration": 4564,
"timeoutHint": false
},
{
"title": "covers plugin requirement, event, data, and websocket APIs with the fixture plugin",
"file": "plugins/plugin-support-api.spec.ts",
"status": "passed",
"duration": 1913,
"timeoutHint": false
},
{
"title": "single user screen share: video and audio flow to receiver, voice audio continues",
"file": "screen-share/screen-share.spec.ts",
"status": "passed",
"duration": 15375,
"timeoutHint": false
},
{
"title": "multiple users screen share simultaneously",
"file": "screen-share/screen-share.spec.ts",
"status": "passed",
"duration": 11093,
"timeoutHint": false
},
{
"title": "screen share connection stays stable for 10+ seconds",
"file": "screen-share/screen-share.spec.ts",
"status": "passed",
"duration": 23371,
"timeoutHint": false
},
{
"title": "a fresh account sees public servers in Popular Servers without searching",
"file": "servers/server-discovery-default.spec.ts",
"status": "passed",
"duration": 3149,
"timeoutHint": false
},
{
"title": "discovery falls back to the public listing when featured/trending routes 404",
"file": "servers/server-discovery-default.spec.ts",
"status": "passed",
"duration": 3110,
"timeoutHint": false
},
{
"title": "shows warning icon when a peer loses all connections",
"file": "settings/connectivity-warning.spec.ts",
"status": "passed",
"duration": 21313,
"timeoutHint": false
},
{
"title": "allows adding, removing, and reordering ICE servers",
"file": "settings/ice-server-settings.spec.ts",
"status": "passed",
"duration": 3855,
"timeoutHint": false
},
{
"title": "validates TURN entries require credentials",
"file": "settings/ice-server-settings.spec.ts",
"status": "passed",
"duration": 2311,
"timeoutHint": false
},
{
"title": "users with different ICE configs can voice chat together",
"file": "settings/stun-turn-fallback.spec.ts",
"status": "passed",
"duration": 11549,
"timeoutHint": false
},
{
"title": "keeps two users hearing each other after a data-channel error and close",
"file": "voice/data-channel-recovery.spec.ts",
"status": "passed",
"duration": 10035,
"timeoutHint": false
},
{
"title": "heals a three-user voice mesh when one client loses every data channel",
"file": "voice/data-channel-recovery.spec.ts",
"status": "passed",
"duration": 12070,
"timeoutHint": false
},
{
"title": "two users can ring, answer, chat, see self voice indicators, and exchange audio",
"file": "voice/direct-call.spec.ts",
"status": "passed",
"duration": 17480,
"timeoutHint": false
},
{
"title": "keeps private-call audio flowing after the data channel closes",
"file": "voice/direct-call.spec.ts",
"status": "passed",
"duration": 8950,
"timeoutHint": false
},
{
"title": "missing and ended private calls do not leave stale call controls behind",
"file": "voice/direct-call.spec.ts",
"status": "passed",
"duration": 5345,
"timeoutHint": false
},
{
"title": "callee is notified when the caller starts the call from the DM chat header",
"file": "voice/dm-header-call-ring.spec.ts",
"status": "passed",
"duration": 4235,
"timeoutHint": false
},
{
"title": "callee homed on another signal server is notified when called via their provisioned actor id",
"file": "voice/dm-header-call-ring.spec.ts",
"status": "passed",
"duration": 4815,
"timeoutHint": false
},
{
"title": "8 users with different signal configs can voice, mute, deafen, and chat concurrently",
"file": "voice/mixed-signal-config-voice.spec.ts",
"status": "passed",
"duration": 128215,
"timeoutHint": false
},
{
"title": "keeps 8 users on 2 signal apis while voice, mute, and deafen stay consistent for 20+ seconds",
"file": "voice/multi-signal-eight-user-voice.spec.ts",
"status": "passed",
"duration": 125136,
"timeoutHint": false
},
{
"title": "two users register, create server, join voice, and stay connected 10+ seconds with audio",
"file": "voice/voice-full-journey.spec.ts",
"status": "passed",
"duration": 21107,
"timeoutHint": false
},
{
"title": "clears stale mute state after abrupt disconnect and voice rejoin",
"file": "voice/voice-mute-state-reset.spec.ts",
"status": "passed",
"duration": 6888,
"timeoutHint": false
}
]
}
@@ -0,0 +1 @@
2026-08-12T11:11:34Z
@@ -0,0 +1 @@
2026-08-12T10:58:08Z
+17
View File
@@ -0,0 +1,17 @@
# Slow E2E tests (≥30s)
All of these **passed**. Listed because the product ask is that timeouts should not happen and the app should stay fast.
| Duration | File | Test |
| --- | --- | --- |
| 128.2s | `voice/mixed-signal-config-voice.spec.ts` | 8 users with different signal configs can voice, mute, deafen, and chat concurrently |
| 125.1s | `voice/multi-signal-eight-user-voice.spec.ts` | keeps 8 users on 2 signal apis while voice, mute, and deafen stay consistent for 20+ seconds |
| 53.4s | `auth/user-session-data-isolation.spec.ts` | gives a new user a blank slate and restores only that user local data after account switches |
| 33.7s | `chat/chat-message-features.spec.ts` | shows per-server channel lists on first saved-server click |
| 30.3s | `chat/multi-client-chat-sync.spec.ts` | syncs messages between same-user devices and late-joining users after offline gaps |
## Notes
- `mixed-signal-config-voice` and `multi-signal-eight-user-voice` are intentionally heavy (8 users / multi-signal). ~2 minutes each is suite design cost more than a single-user app hang.
- `user-session-data-isolation` (~53s) and channel/chat sync (~3034s) are closer to “app feels slow” candidates if they regress upward.
- Playwright config `timeout` is 90s per test; none of the failures/timeouts hit that ceiling in this run.
+2
View File
@@ -39,6 +39,7 @@
"build:prod:win": "npm run build:prod:all && electron-builder --win",
"dev": "npm run build:electron && npm run electron:full",
"dev:app": "npm run electron:dev",
"dev:peer": "./dev-peer.sh",
"lint": "eslint .",
"lint:fix": "npm run format && npm run sort:props && eslint . --fix",
"format": "prettier --write \"toju-app/src/app/**/*.html\"",
@@ -180,6 +181,7 @@
"directories": {
"output": "dist-electron"
},
"afterPack": "tools/after-pack.js",
"files": [
"!node_modules",
"dist/client/**/*",
@@ -0,0 +1,12 @@
import { DataSource } from 'typeorm';
import { AuthUserEntity } from '../../../entities';
export async function handleUpdateUserProvisionSecret(
dataSource: DataSource,
userId: string,
provisionSecret: string
): Promise<void> {
const repo = dataSource.getRepository(AuthUserEntity);
await repo.update({ id: userId }, { provisionSecret });
}
+4
View File
@@ -22,6 +22,7 @@ import { handleGetJoinRequestById } from './queries/handlers/getJoinRequestById'
import { handleGetPendingRequestsForServer } from './queries/handlers/getPendingRequestsForServer';
import { handleUpdateUserPasswordHash } from './commands/handlers/updateUserPasswordHash';
import { handleUpdateUserSigningPublicKey } from './commands/handlers/updateUserSigningPublicKey';
import { handleUpdateUserProvisionSecret } from './commands/handlers/updateUserProvisionSecret';
export const registerUser = (user: AuthUserPayload) =>
handleRegisterUser({ type: CommandType.RegisterUser, payload: { user } }, getDataSource());
@@ -70,3 +71,6 @@ export const updateUserPasswordHash = (userId: string, passwordHash: string) =>
export const updateUserSigningPublicKey = (userId: string, signingPublicKey: string) =>
handleUpdateUserSigningPublicKey(getDataSource(), userId, signingPublicKey);
export const updateUserProvisionSecret = (userId: string, provisionSecret: string) =>
handleUpdateUserProvisionSecret(getDataSource(), userId, provisionSecret);
+2 -1
View File
@@ -15,7 +15,8 @@ export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
passwordHash: row.passwordHash,
displayName: row.displayName,
createdAt: row.createdAt,
signingPublicKey: row.signingPublicKey ?? null
signingPublicKey: row.signingPublicKey ?? null,
provisionSecret: row.provisionSecret ?? null
};
}
+1
View File
@@ -29,6 +29,7 @@ export interface AuthUserPayload {
displayName: string;
createdAt: number;
signingPublicKey?: string | null;
provisionSecret?: string | null;
}
export type ServerChannelType = 'text' | 'voice';
+8
View File
@@ -23,4 +23,12 @@ export class AuthUserEntity {
@Column('text', { nullable: true })
signingPublicKey!: string | null;
/**
* Password this account uses when it provisions linked accounts on foreign
* signal servers. Held here so every device of the same human resolves to
* one foreign account instead of registering a duplicate.
*/
@Column('text', { nullable: true })
provisionSecret!: string | null;
}
@@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class ProvisionSecret1000000000013 implements MigrationInterface {
name = 'ProvisionSecret1000000000013';
async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE "users" ADD COLUMN "provisionSecret" text');
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE "users" DROP COLUMN "provisionSecret"');
}
}
+3 -1
View File
@@ -11,6 +11,7 @@ import { ServerIcons1000000000009 } from './1000000000009-ServerIcons';
import { DeviceTokens1000000000010 } from './1000000000010-DeviceTokens';
import { SessionTokens1000000000011 } from './1000000000011-SessionTokens';
import { SigningPublicKey1000000000012 } from './1000000000012-SigningPublicKey';
import { ProvisionSecret1000000000013 } from './1000000000013-ProvisionSecret';
export const serverMigrations = [
InitialSchema1000000000000,
@@ -25,5 +26,6 @@ export const serverMigrations = [
ServerIcons1000000000009,
DeviceTokens1000000000010,
SessionTokens1000000000011,
SigningPublicKey1000000000012
SigningPublicKey1000000000012,
ProvisionSecret1000000000013
];
+35
View File
@@ -8,6 +8,7 @@ import {
updateUserSigningPublicKey
} from '../cqrs';
import { hashPasswordForStorage, verifyPassword } from '../services/password-auth.service';
import { resolveProvisionSecret } from '../services/provision-secret.service';
import { issueSessionToken, revokeSessionToken } from '../services/session-auth.service';
import { getAuthenticatedUserId, requireAuth } from '../middleware/require-auth';
import { isDuplicateUsernameError } from './user-registration.rules';
@@ -80,6 +81,40 @@ router.post('/login', async (req, res) => {
res.json(buildAuthResponse(user, session.token, session.expiresAt));
});
/**
* Returns the caller's provision secret, creating it on first use. Every
* device of this account gets the same value, which is what keeps a person to
* a single linked account on each foreign signal server.
*/
router.get('/me/provision-secret', requireAuth, async (req, res) => {
const userId = getAuthenticatedUserId(req);
const provisionSecret = await resolveProvisionSecret(userId);
if (!provisionSecret) {
return res.status(404).json({ error: 'User not found', errorCode: 'USER_NOT_FOUND' });
}
res.json({ provisionSecret });
});
/**
* Rotates the caller's password on this server. Used by clients to move a
* linked account created with a legacy per-device secret onto the account's
* canonical provision secret, so the user's other devices can sign in to it.
*/
router.post('/me/password', requireAuth, async (req, res) => {
const { newPassword } = req.body;
const userId = getAuthenticatedUserId(req);
if (typeof newPassword !== 'string' || newPassword.length < 8) {
return res.status(400).json({ error: 'Invalid password', errorCode: 'INVALID_PASSWORD' });
}
await updateUserPasswordHash(userId, await hashPasswordForStorage(newPassword));
res.json({ ok: true });
});
router.put('/me/signing-key', requireAuth, async (req, res) => {
const { publicKeyJwk } = req.body;
const userId = getAuthenticatedUserId(req);
@@ -0,0 +1,76 @@
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
const findOne = vi.fn();
const update = vi.fn();
vi.mock('../db/database', () => ({
getDataSource: () => ({
getRepository: () => ({
findOne,
update
})
})
}));
const { generateProvisionSecret, isUsableProvisionSecret, resolveProvisionSecret } =
await import('./provision-secret.service');
describe('provision-secret.service', () => {
beforeEach(() => {
findOne.mockReset();
update.mockReset();
});
it('generates a 64 character hex secret', () => {
expect(generateProvisionSecret()).toMatch(/^[a-f0-9]{64}$/);
});
it('rejects blank secrets', () => {
expect(isUsableProvisionSecret(null)).toBe(false);
expect(isUsableProvisionSecret(' ')).toBe(false);
expect(isUsableProvisionSecret('secret')).toBe(true);
});
it('returns the same stored secret on every call so all devices match', async () => {
findOne.mockResolvedValue({ id: 'user-1', provisionSecret: 'stored-secret' });
await expect(resolveProvisionSecret('user-1')).resolves.toBe('stored-secret');
await expect(resolveProvisionSecret('user-1')).resolves.toBe('stored-secret');
expect(update).not.toHaveBeenCalled();
});
it('creates the secret on first use and returns the persisted value', async () => {
findOne
.mockResolvedValueOnce({ id: 'user-1', provisionSecret: null })
.mockResolvedValueOnce({ id: 'user-1', provisionSecret: 'created-secret' });
await expect(resolveProvisionSecret('user-1')).resolves.toBe('created-secret');
expect(update).toHaveBeenCalledOnce();
});
it('only writes while the column is empty so concurrent callers converge', async () => {
findOne
.mockResolvedValueOnce({ id: 'user-1', provisionSecret: null })
.mockResolvedValueOnce({ id: 'user-1', provisionSecret: 'winning-secret' });
await resolveProvisionSecret('user-1');
const [criteria] = update.mock.calls[0];
expect(criteria).toMatchObject({ id: 'user-1' });
expect(criteria.provisionSecret).toBeDefined();
});
it('returns null for an unknown user', async () => {
findOne.mockResolvedValue(null);
await expect(resolveProvisionSecret('missing')).resolves.toBeNull();
expect(update).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,46 @@
import { randomBytes } from 'crypto';
import { IsNull } from 'typeorm';
import { getDataSource } from '../db/database';
import { AuthUserEntity } from '../entities';
/**
* The provision secret is the password this account uses when it creates its
* linked accounts on foreign signal servers. It must be identical on every
* device of the same human: a per-device secret makes the second device fail
* to log in to the existing foreign account and register a duplicate one, so
* the same person shows up twice to everyone else.
*/
export function generateProvisionSecret(): string {
return randomBytes(32).toString('hex');
}
export function isUsableProvisionSecret(secret: string | null | undefined): secret is string {
return typeof secret === 'string' && secret.trim().length > 0;
}
/**
* Returns the account's provision secret, creating it on first use. Concurrent
* callers converge on one value: the insert only applies while the column is
* still empty, and the stored value is re-read before returning.
*/
export async function resolveProvisionSecret(userId: string): Promise<string | null> {
const repo = getDataSource().getRepository(AuthUserEntity);
const existing = await repo.findOne({ where: { id: userId } });
if (!existing) {
return null;
}
if (isUsableProvisionSecret(existing.provisionSecret)) {
return existing.provisionSecret;
}
await repo.update(
{ id: userId, provisionSecret: IsNull() },
{ provisionSecret: generateProvisionSecret() }
);
const stored = await repo.findOne({ where: { id: userId } });
return isUsableProvisionSecret(stored?.provisionSecret) ? stored.provisionSecret : null;
}
+5
View File
@@ -37,6 +37,11 @@
"defaultServerName": "Signal Server"
},
"provision": {
"credentialsRejected": "This server already has an account that the restored session cannot unlock. Your home account is still signed in.",
"reconnectTitle": "Reconnect to {{serverName}}",
"retry": "Retry",
"retrying": "Retrying…",
"serverUnavailable": "This server is currently unavailable. Retry when the connection is restored.",
"usernameCollision": "Username {{preferredUsername}} was taken on {{serverName}}. Created {{provisionedUsername}} instead."
}
}
+2 -1
View File
@@ -49,7 +49,8 @@
"microphonePermissionDenied": "Microphone access is blocked. Allow the microphone permission in system settings to join calls.",
"microphoneUnavailable": "Could not start the microphone. Close other apps that use it and try again.",
"cameraPermissionDenied": "Camera access is blocked. Allow the camera permission in system settings to share video.",
"cameraUnavailable": "Could not start the camera. Close other apps that use it and try again."
"cameraUnavailable": "Could not start the camera. Close other apps that use it and try again.",
"ringUndelivered": "Could not reach anyone in this call. They may be offline or on another server."
}
}
}
+2 -1
View File
@@ -181,7 +181,8 @@
"microphone": "Microphone",
"speaker": "Speaker",
"microphoneFallback": "Microphone {{index}}",
"speakerFallback": "Speaker {{index}}"
"speakerFallback": "Speaker {{index}}",
"systemDefault": "System default"
},
"volume": {
"title": "Volume",
+4
View File
@@ -7,6 +7,10 @@
"retry": "Retry",
"failedConnect": "Failed to connect voice session."
},
"devices": {
"inputFellBack": "Your microphone was disconnected. Switched to the system default.",
"outputFellBack": "Your speaker was disconnected. Switched to the system default."
},
"floating": {
"backToServer": "Back to {{server}}",
"voiceFallback": "Voice",
+13 -2
View File
@@ -80,6 +80,11 @@
"defaultServerName": "Signal Server"
},
"provision": {
"credentialsRejected": "This server already has an account that the restored session cannot unlock. Your home account is still signed in.",
"reconnectTitle": "Reconnect to {{serverName}}",
"retry": "Retry",
"retrying": "Retrying…",
"serverUnavailable": "This server is currently unavailable. Retry when the connection is restored.",
"usernameCollision": "Username {{preferredUsername}} was taken on {{serverName}}. Created {{provisionedUsername}} instead."
}
},
@@ -133,7 +138,8 @@
"microphonePermissionDenied": "Microphone access is blocked. Allow the microphone permission in system settings to join calls.",
"microphoneUnavailable": "Could not start the microphone. Close other apps that use it and try again.",
"cameraPermissionDenied": "Camera access is blocked. Allow the camera permission in system settings to share video.",
"cameraUnavailable": "Could not start the camera. Close other apps that use it and try again."
"cameraUnavailable": "Could not start the camera. Close other apps that use it and try again.",
"ringUndelivered": "Could not reach anyone in this call. They may be offline or on another server."
}
},
"chat": {
@@ -1256,7 +1262,8 @@
"microphone": "Microphone",
"speaker": "Speaker",
"microphoneFallback": "Microphone {{index}}",
"speakerFallback": "Speaker {{index}}"
"speakerFallback": "Speaker {{index}}",
"systemDefault": "System default"
},
"volume": {
"title": "Volume",
@@ -2264,6 +2271,10 @@
"retry": "Retry",
"failedConnect": "Failed to connect voice session."
},
"devices": {
"inputFellBack": "Your microphone was disconnected. Switched to the system default.",
"outputFellBack": "Your speaker was disconnected. Switched to the system default."
},
"floating": {
"backToServer": "Back to {{server}}",
"voiceFallback": "Voice",
@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, signal } from '@angular/core';
/**
@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Injectable,
signal,
@@ -1,6 +1,6 @@
# Authentication Domain
Handles user authentication (login and registration) against the configured server endpoint. Provides the login, register, and user-bar UI components.
Handles the durable home session plus per-signal-server credentials used for cross-server identity. Provides login, registration, silent foreign provisioning, contextual recovery, and user-bar UI.
## Module map
@@ -8,7 +8,11 @@ Handles user authentication (login and registration) against the configured serv
authentication/
├── application/
│ └── services/
── authentication.service.ts HTTP login/register against the active server endpoint
── authentication.service.ts HTTP login/register against the active endpoint
│ ├── signal-server-auth.service.ts Home migration and silent foreign provisioning
│ ├── signal-server-authorize.service.ts Explicit authorization and credential checks
│ ├── signal-server-auth-recovery.service.ts Contextual per-server recovery state
│ └── home-provision-secret.service.ts Account-wide secret issued by the home server
├── domain/
│ └── models/
@@ -26,6 +30,8 @@ authentication/
`AuthenticationService` resolves the API base URL from `ServerDirectoryFacade`, then makes POST requests for login and registration. It does not hold session state itself; after a successful login the calling component dispatches `UsersActions.authenticateUser`, and the users effects prepare the local persistence boundary before exposing the new user in the NgRx store.
`SignalServerAuthService` keeps one credential per normalized signal-server URL. Provision failures never expire the valid home session or automatically redirect to generic login. They publish a contextual issue rendered on server/join surfaces with Retry.
```mermaid
graph TD
Login[LoginComponent]
@@ -64,7 +70,7 @@ sequenceDiagram
Login->>Auth: login(username, password)
Auth->>SD: getApiBaseUrl()
SD-->>Auth: https://server/api
Auth->>API: POST /api/auth/login
Auth->>API: POST /api/users/login
API-->>Auth: { userId, displayName }
Auth-->>Login: success
Login->>Store: UsersActions.authenticateUser
@@ -75,7 +81,32 @@ sequenceDiagram
## Registration flow
Registration follows the same pattern but posts to `/api/auth/register` with an additional `displayName` field. On success the user is treated as logged in and the same authenticated-user transition runs, switching the browser persistence layer to that user's local scope before the app reloads rooms and user state.
Registration follows the same pattern but posts to `/api/users/register` with an additional `displayName` field. On success the user is treated as logged in and the same authenticated-user transition runs, switching the browser persistence layer to that user's local scope before the app reloads rooms and user state.
## One human, one account per signal server
A linked account on a foreign signal server is an ordinary account whose password is the user's **provision secret**. That secret is issued and stored by the **home** signal server (`GET /api/users/me/provision-secret`, created on first use), so it is identical on every device the person signs in from. `HomeProvisionSecretService` fetches it with the home session token and caches it in memory only.
This matters because the secret decides identity. Older builds generated a secret per device; the second device could not sign in to the account the first one had created, fell through to the `username-<shortHomeId>` candidate, and registered a **second account with the same display name**. Everyone else then saw that person twice, DM threads forked, and a 1:1 call looked like a group call.
`buildProvisionPlan` therefore orders attempts so a duplicate cannot happen by accident:
1. register the preferred username;
2. on conflict, sign in with the canonical secret — this is another device of ours;
3. then sign in with the legacy device-local secret — an account this device made before canonical secrets existed, which is immediately rotated onto the canonical secret via `POST /api/users/me/password`;
4. only once the preferred name is proven to belong to somebody else, repeat for `username-<shortHomeId>`.
Registering the suffixed name requires a canonical secret. Without one the client cannot distinguish "another human owns this name" from "our own account whose secret this device never had", so it raises a contextual recovery issue instead of guessing.
## Restore and foreign-server recovery
1. Restore validates the home session token and migrates the home credential.
2. Active foreign endpoints call `ensureProvisioned`.
3. Provisioning resolves the canonical secret from the home server, then follows the plan above.
4. Successful provisioning stores the foreign actor credential; room connection then identifies and joins with that actor id.
5. Rejected credentials or an unavailable endpoint publish per-server recovery state. The home session remains active; Retry re-runs provisioning and reconnects the current room after success.
Diagnostics record only home user id, foreign actor id, normalized server URL, and outcome. Tokens, passwords, provision secrets, SDP, and message contents must never be logged.
## User bar
@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, tap } from 'rxjs';
@@ -0,0 +1,99 @@
import '@angular/compiler';
import { HttpClient } from '@angular/common/http';
import { Injector, runInInjectionContext } from '@angular/core';
import { of, throwError } from 'rxjs';
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import { AuthTokenStoreService } from './auth-token-store.service';
import { HomeProvisionSecretService } from './home-provision-secret.service';
import { ProvisionSecretStoreService } from './provision-secret-store.service';
const HOME_URL = 'https://signal.toju.app';
const homeUser = { id: 'home-user-1', homeSignalServerUrl: HOME_URL };
describe('HomeProvisionSecretService', () => {
let httpGet: ReturnType<typeof vi.fn>;
let getToken: ReturnType<typeof vi.fn>;
let getSecret: ReturnType<typeof vi.fn>;
let service: HomeProvisionSecretService;
function createService(): HomeProvisionSecretService {
const injector = Injector.create({
providers: [
HomeProvisionSecretService,
{ provide: HttpClient, useValue: { get: httpGet } },
{ provide: AuthTokenStoreService, useValue: { getToken } },
{ provide: ProvisionSecretStoreService, useValue: { getSecret } }
]
});
return runInInjectionContext(injector, () => injector.get(HomeProvisionSecretService));
}
beforeEach(() => {
httpGet = vi.fn(() => of({ provisionSecret: 'canonical-secret' }));
getToken = vi.fn(() => 'home-token');
getSecret = vi.fn(() => Promise.resolve(null));
service = createService();
});
it('reads the account-wide secret from the home server with the home session token', async () => {
await expect(service.resolveCanonicalSecret(homeUser)).resolves.toBe('canonical-secret');
expect(httpGet).toHaveBeenCalledWith(
`${HOME_URL}/api/users/me/provision-secret`,
{ headers: { Authorization: 'Bearer home-token' } }
);
});
it('caches the secret so repeated provisioning does not re-query the home server', async () => {
await service.resolveCanonicalSecret(homeUser);
await service.resolveCanonicalSecret(homeUser);
expect(httpGet).toHaveBeenCalledOnce();
});
it('collapses concurrent lookups into one request', async () => {
const [first, second] = await Promise.all([service.resolveCanonicalSecret(homeUser), service.resolveCanonicalSecret(homeUser)]);
expect(first).toBe('canonical-secret');
expect(second).toBe('canonical-secret');
expect(httpGet).toHaveBeenCalledOnce();
});
it('re-queries after the cached secret is forgotten', async () => {
await service.resolveCanonicalSecret(homeUser);
service.forget(homeUser.id);
await service.resolveCanonicalSecret(homeUser);
expect(httpGet).toHaveBeenCalledTimes(2);
});
it('returns no canonical secret when the home server is unreachable or too old', async () => {
httpGet.mockReturnValue(throwError(() => new Error('offline')));
await expect(service.resolveCanonicalSecret(homeUser)).resolves.toBeNull();
});
it('returns no canonical secret without a home session token', async () => {
getToken.mockReturnValue(null);
await expect(service.resolveCanonicalSecret(homeUser)).resolves.toBeNull();
expect(httpGet).not.toHaveBeenCalled();
});
it('reports the legacy device secret alongside the canonical one', async () => {
getSecret.mockResolvedValue('legacy-secret');
await expect(service.resolveSecrets(homeUser)).resolves.toEqual({
canonical: 'canonical-secret',
deviceLocal: 'legacy-secret'
});
});
});
@@ -0,0 +1,106 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import type { User } from '../../../../shared-kernel';
import type { ProvisionSecrets } from '../../domain/logic/signal-server-provision.rules';
import { AuthTokenStoreService } from './auth-token-store.service';
import { ProvisionSecretStoreService } from './provision-secret-store.service';
interface ProvisionSecretResponse {
provisionSecret: string;
}
/**
* Resolves the secret used to provision linked accounts on foreign signal
* servers.
*
* The canonical secret is issued and stored by the home signal server, so it
* is the same on every device the human signs in from. That is what keeps one
* person to one account per foreign server. It is cached in memory only: it is
* re-fetchable whenever the home session is valid, and keeping another copy on
* disk would only widen the blast radius of a stolen device.
*
* `deviceLocal` is the legacy per-device secret written by older builds. It is
* read-only now and exists purely so accounts created with it can be reclaimed
* and moved onto the canonical secret.
*/
@Injectable({ providedIn: 'root' })
export class HomeProvisionSecretService {
private readonly http = inject(HttpClient);
private readonly authTokenStore = inject(AuthTokenStoreService);
private readonly secretStore = inject(ProvisionSecretStoreService);
private readonly canonicalByHomeUserId = new Map<string, string>();
private readonly inFlight = new Map<string, Promise<string | null>>();
async resolveSecrets(homeUser: Pick<User, 'id' | 'homeSignalServerUrl'>): Promise<ProvisionSecrets> {
const [canonical, deviceLocal] = await Promise.all([this.resolveCanonicalSecret(homeUser), this.secretStore.getSecret(homeUser.id)]);
return { canonical, deviceLocal };
}
async resolveCanonicalSecret(homeUser: Pick<User, 'id' | 'homeSignalServerUrl'>): Promise<string | null> {
const cached = this.canonicalByHomeUserId.get(homeUser.id);
if (cached) {
return cached;
}
const inFlight = this.inFlight.get(homeUser.id);
if (inFlight) {
return inFlight;
}
const request = this.fetchCanonicalSecret(homeUser);
this.inFlight.set(homeUser.id, request);
try {
return await request;
} finally {
this.inFlight.delete(homeUser.id);
}
}
/** Drops the cached secret, e.g. after logout or a home session change. */
forget(homeUserId: string): void {
this.canonicalByHomeUserId.delete(homeUserId);
}
private async fetchCanonicalSecret(
homeUser: Pick<User, 'id' | 'homeSignalServerUrl'>
): Promise<string | null> {
const homeUrl = homeUser.homeSignalServerUrl?.trim().replace(/\/+$/, '');
if (!homeUrl) {
return null;
}
const token = this.authTokenStore.getToken(homeUrl);
if (!token) {
return null;
}
try {
const response = await firstValueFrom(
this.http.get<ProvisionSecretResponse>(`${homeUrl}/api/users/me/provision-secret`, {
headers: { Authorization: `Bearer ${token}` }
})
);
const secret = response?.provisionSecret?.trim();
if (!secret) {
return null;
}
this.canonicalByHomeUserId.set(homeUser.id, secret);
return secret;
} catch {
// Home server offline or too old to issue secrets. Callers degrade to
// the legacy secret and must not fork a second foreign account.
return null;
}
}
}
@@ -3,6 +3,15 @@ import { ElectronBridgeService } from '../../../../core/platform/electron/electr
const SESSION_STORAGE_PREFIX = 'metoyou.provisionSecret.';
/**
* Storage for the legacy per-device provision secret.
*
* New provisioning uses the account-wide secret issued by the home signal
* server (`HomeProvisionSecretService`); a per-device secret cannot unlock the
* foreign accounts the user's other devices created. This slot is kept so
* accounts registered by older builds can still be reclaimed and moved onto
* the canonical secret. Nothing should write a freshly generated secret here.
*/
@Injectable({ providedIn: 'root' })
export class ProvisionSecretStoreService {
private readonly electronBridge: ElectronBridgeService;
@@ -42,11 +51,3 @@ export class ProvisionSecretStoreService {
return `${SESSION_STORAGE_PREFIX}${homeUserId}`;
}
}
export function generateProvisionSecret(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
}
@@ -0,0 +1,51 @@
import { Injectable, signal } from '@angular/core';
export type SignalServerAuthRecoveryReason = 'credentials-rejected' | 'unavailable';
export interface SignalServerAuthRecoveryIssue {
serverName: string;
serverUrl: string;
reason: SignalServerAuthRecoveryReason;
}
@Injectable({ providedIn: 'root' })
export class SignalServerAuthRecoveryService {
readonly issues = signal<readonly SignalServerAuthRecoveryIssue[]>([]);
publish(issue: SignalServerAuthRecoveryIssue): void {
const normalizedUrl = this.normalizeServerUrl(issue.serverUrl);
this.issues.update((issues) => [
...issues.filter((candidate) => this.normalizeServerUrl(candidate.serverUrl) !== normalizedUrl),
{
...issue,
serverUrl: normalizedUrl
}
]);
}
clear(serverUrl: string): void {
const normalizedUrl = this.normalizeServerUrl(serverUrl);
this.issues.update((issues) =>
issues.filter((issue) => this.normalizeServerUrl(issue.serverUrl) !== normalizedUrl)
);
}
getIssue(serverUrl: string | null | undefined): SignalServerAuthRecoveryIssue | null {
if (!serverUrl?.trim()) {
return null;
}
const normalizedUrl = this.normalizeServerUrl(serverUrl);
return this.issues().find((issue) =>
this.normalizeServerUrl(issue.serverUrl) === normalizedUrl
) ?? null;
}
private normalizeServerUrl(serverUrl: string): string {
return serverUrl.trim().replace(/^ws/i, 'http')
.replace(/\/+$/, '');
}
}
@@ -0,0 +1,152 @@
import '@angular/compiler';
import { Injector, runInInjectionContext } from '@angular/core';
import { Store } from '@ngrx/store';
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import { DebuggingService } from '../../../../core/services/debugging/debugging.service';
import { AuthTokenStoreService } from './auth-token-store.service';
import { HomeProvisionSecretService } from './home-provision-secret.service';
import { SignalServerAuthRecoveryService } from './signal-server-auth-recovery.service';
import { SignalServerAuthService } from './signal-server-auth.service';
import { SignalServerCredentialStoreService } from './signal-server-credential-store.service';
import { SignalServerProvisionerService } from './signal-server-provisioner.service';
import { SignalServerProvisionNoticeService } from './signal-server-provision-notice.service';
import { ProvisionUsernameCollisionError } from '../../domain/logic/signal-server-provision.rules';
const FOREIGN_URL = 'https://signal-sweden.toju.app';
const homeUser = {
id: 'home-user-1',
oderId: 'home-user-1',
username: 'alice',
displayName: 'Alice',
status: 'online' as const,
role: 'member' as const,
joinedAt: 1,
homeSignalServerUrl: 'https://signal.toju.app'
};
describe('SignalServerAuthService', () => {
let credentialStore: {
getCredential: ReturnType<typeof vi.fn>;
hasValidCredential: ReturnType<typeof vi.fn>;
};
let homeProvisionSecret: {
resolveSecrets: ReturnType<typeof vi.fn>;
};
let provisioner: {
provisionOnServer: ReturnType<typeof vi.fn>;
};
let recovery: {
clear: ReturnType<typeof vi.fn>;
publish: ReturnType<typeof vi.fn>;
};
let service: SignalServerAuthService;
beforeEach(() => {
credentialStore = {
getCredential: vi.fn(() => null),
hasValidCredential: vi.fn(() => false)
};
homeProvisionSecret = {
resolveSecrets: vi.fn(() => Promise.resolve({
canonical: 'canonical-secret',
deviceLocal: null
}))
};
provisioner = {
provisionOnServer: vi.fn(() => Promise.resolve({
credential: {
serverUrl: FOREIGN_URL,
userId: 'foreign-user-1',
username: 'alice',
displayName: 'Alice',
token: 'foreign-token',
expiresAt: Date.now() + 60_000,
provisioned: true
},
username: 'alice',
usedSuffix: false
}))
};
recovery = {
clear: vi.fn(),
publish: vi.fn()
};
const injector = Injector.create({
providers: [
SignalServerAuthService,
{ provide: Store, useValue: { select: vi.fn() } },
{ provide: SignalServerCredentialStoreService, useValue: credentialStore },
{ provide: AuthTokenStoreService, useValue: {} },
{ provide: HomeProvisionSecretService, useValue: homeProvisionSecret },
{ provide: SignalServerProvisionerService, useValue: provisioner },
{ provide: SignalServerAuthRecoveryService, useValue: recovery },
{ provide: DebuggingService, useValue: { info: vi.fn() } },
{ provide: SignalServerProvisionNoticeService, useValue: { publish: vi.fn() } }
]
});
service = runInInjectionContext(injector, () => injector.get(SignalServerAuthService));
});
it('provisions a restored session with the account-wide secret from the home server', async () => {
const result = await service.ensureProvisioned(FOREIGN_URL, homeUser);
expect(result.kind).toBe('provisioned');
expect(homeProvisionSecret.resolveSecrets).toHaveBeenCalledWith(homeUser);
expect(provisioner.provisionOnServer).toHaveBeenCalledWith({
serverUrl: FOREIGN_URL,
homeUser,
secrets: { canonical: 'canonical-secret', deviceLocal: null }
});
});
it('passes the legacy device secret through so old foreign accounts can be reclaimed', async () => {
homeProvisionSecret.resolveSecrets.mockResolvedValue({
canonical: 'canonical-secret',
deviceLocal: 'legacy-secret'
});
await service.ensureProvisioned(FOREIGN_URL, homeUser);
expect(provisioner.provisionOnServer).toHaveBeenCalledWith(expect.objectContaining({
secrets: { canonical: 'canonical-secret', deviceLocal: 'legacy-secret' }
}));
});
it('publishes contextual recovery when no candidate account can be reclaimed', async () => {
provisioner.provisionOnServer.mockRejectedValue(
new ProvisionUsernameCollisionError(FOREIGN_URL, ['alice', 'alice-homeus'])
);
const result = await service.ensureProvisioned(FOREIGN_URL, homeUser);
expect(result.kind).toBe('collision');
expect(recovery.publish).toHaveBeenCalledWith({
serverName: 'signal-sweden.toju.app',
serverUrl: FOREIGN_URL,
reason: 'credentials-rejected'
});
});
it('publishes a non-blocking unavailable issue without expiring the home session', async () => {
provisioner.provisionOnServer.mockRejectedValue(new Error('connect ECONNREFUSED'));
await expect(service.ensureProvisioned(FOREIGN_URL, homeUser)).rejects.toThrow('ECONNREFUSED');
expect(recovery.publish).toHaveBeenCalledWith({
serverName: 'signal-sweden.toju.app',
serverUrl: FOREIGN_URL,
reason: 'unavailable'
});
});
});
@@ -1,6 +1,7 @@
import { Injectable, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { firstValueFrom } from 'rxjs';
import { DebuggingService } from '../../../../core/services/debugging/debugging.service';
import type { User } from '../../../../shared-kernel';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
import type { LoginResponse } from '../../domain/models/authentication.model';
@@ -9,7 +10,8 @@ import { ProvisionUsernameCollisionError } from '../../domain/logic/signal-serve
import { type ResolvedSignalIdentity, resolveSignalIdentity } from '../../domain/logic/signal-server-credential-resolution.rules';
import { resolveSelfPresenceUserIds } from '../../domain/logic/self-presence-identity.rules';
import { AuthTokenStoreService } from './auth-token-store.service';
import { ProvisionSecretStoreService, generateProvisionSecret } from './provision-secret-store.service';
import { HomeProvisionSecretService } from './home-provision-secret.service';
import { SignalServerAuthRecoveryService } from './signal-server-auth-recovery.service';
import { SignalServerCredentialStoreService } from './signal-server-credential-store.service';
import { SignalServerProvisionerService, type ProvisionResult } from './signal-server-provisioner.service';
import { SignalServerProvisionNoticeService } from './signal-server-provision-notice.service';
@@ -17,7 +19,7 @@ import { SignalServerProvisionNoticeService } from './signal-server-provision-no
export type EnsureProvisionedResult =
| { kind: 'existing'; credential: SignalServerCredential }
| { kind: 'provisioned'; result: ProvisionResult }
| { kind: 'skipped'; reason: 'no-home-user' | 'no-provision-secret' | 'already-valid' }
| { kind: 'skipped'; reason: 'no-home-user' | 'already-valid' }
| { kind: 'collision'; error: ProvisionUsernameCollisionError };
@Injectable({ providedIn: 'root' })
@@ -25,9 +27,11 @@ export class SignalServerAuthService {
private readonly store = inject(Store);
private readonly credentialStore = inject(SignalServerCredentialStoreService);
private readonly authTokenStore = inject(AuthTokenStoreService);
private readonly provisionSecretStore = inject(ProvisionSecretStoreService);
private readonly homeProvisionSecret = inject(HomeProvisionSecretService);
private readonly provisioner = inject(SignalServerProvisionerService);
private readonly provisionNotice = inject(SignalServerProvisionNoticeService);
private readonly recovery = inject(SignalServerAuthRecoveryService);
private readonly debugging = inject(DebuggingService);
private readonly provisionInFlight = new Map<string, Promise<EnsureProvisionedResult>>();
getCredential(serverUrl: string): SignalServerCredential | null {
@@ -74,25 +78,14 @@ export class SignalServerAuthService {
});
}
async ensureHomeProvisionSecret(homeUser: Pick<User, 'id'>, existingSecret?: string | null): Promise<string> {
const stored = existingSecret ?? await this.provisionSecretStore.getSecret(homeUser.id);
if (stored) {
return stored;
}
const generated = generateProvisionSecret();
await this.provisionSecretStore.storeSecret(homeUser.id, generated);
return generated;
}
async ensureProvisioned(serverUrl: string, homeUser?: User | null): Promise<EnsureProvisionedResult> {
const normalizedUrl = this.normalizeServerUrl(serverUrl);
const existing = this.credentialStore.getCredential(normalizedUrl);
if (existing) {
this.recovery.clear(normalizedUrl);
this.logProvisionOutcome('credential-existing', normalizedUrl, existing.userId, homeUser?.id);
return { kind: 'existing', credential: existing };
}
@@ -161,17 +154,12 @@ export class SignalServerAuthService {
return { kind: 'skipped', reason: 'no-home-user' };
}
const provisionSecret = await this.provisionSecretStore.getSecret(user.id);
if (!provisionSecret) {
return { kind: 'skipped', reason: 'no-provision-secret' };
}
try {
const secrets = await this.homeProvisionSecret.resolveSecrets(user);
const result = await this.provisioner.provisionOnServer({
serverUrl: normalizedUrl,
homeUser: user,
provisionSecret
secrets
});
if (result.usedSuffix) {
@@ -182,16 +170,49 @@ export class SignalServerAuthService {
});
}
this.recovery.clear(normalizedUrl);
this.logProvisionOutcome('credential-provisioned', normalizedUrl, result.credential.userId, user.id);
return { kind: 'provisioned', result };
} catch (error) {
if (error instanceof ProvisionUsernameCollisionError) {
this.publishRecovery(normalizedUrl, 'credentials-rejected');
this.logProvisionOutcome('credential-rejected', normalizedUrl, undefined, user.id);
return { kind: 'collision', error };
}
this.publishRecovery(normalizedUrl, 'unavailable');
this.logProvisionOutcome('server-unavailable', normalizedUrl, undefined, user.id);
throw error;
}
}
private publishRecovery(
serverUrl: string,
reason: 'credentials-rejected' | 'unavailable'
): void {
this.recovery.publish({
serverName: this.resolveServerDisplayName(serverUrl),
serverUrl,
reason
});
}
private logProvisionOutcome(
outcome: string,
serverUrl: string,
actorUserId: string | undefined,
homeUserId: string | undefined
): void {
this.debugging.info('signal-server-auth', outcome, {
actorUserId,
homeUserId,
serverUrl
});
}
private normalizeServerUrl(serverUrl: string): string {
return serverUrl.trim().replace(/\/+$/, '');
}
@@ -51,7 +51,7 @@ describe('SignalServerAuthorizeService', () => {
};
signalServerAuth = {
ensureProvisioned: vi.fn(() => Promise.resolve({ kind: 'skipped', reason: 'no-provision-secret' })),
ensureProvisioned: vi.fn(() => Promise.resolve({ kind: 'skipped', reason: 'no-home-user' })),
hasValidCredential: vi.fn(() => false),
migrateHomeCredential: vi.fn()
};
@@ -102,13 +102,11 @@ describe('SignalServerAuthorizeService', () => {
expect(router.navigate).not.toHaveBeenCalled();
});
it('still provisions foreign servers and navigates to authorize when the secret is missing', async () => {
it('keeps the home session active when automatic foreign provisioning cannot recover', async () => {
await expect(service.ensureCredentialForServerUrl(FOREIGN_URL)).resolves.toBe(false);
expect(signalServerAuth.ensureProvisioned).toHaveBeenCalledWith(FOREIGN_URL, homeUser);
expect(router.navigate).toHaveBeenCalledWith(['/login'], expect.objectContaining({
queryParams: expect.objectContaining({ mode: 'authorize' })
}));
expect(router.navigate).not.toHaveBeenCalled();
});
it('returns true when foreign provisioning succeeds', async () => {
@@ -5,8 +5,6 @@ import { firstValueFrom } from 'rxjs';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { ServerDirectoryFacade } from '../../../server-directory';
import { AUTH_MODE_AUTHORIZE, buildLoginReturnQueryParams } from '../../domain/logic/auth-navigation.rules';
import { isEndpointOnlineForConnection } from '../../../server-directory/domain/logic/server-endpoint-connectivity.rules';
import { shouldNavigateToAuthorizeSignalServer } from '../../domain/logic/signal-server-authorize.rules';
import { isSameSignalServerUrl } from '../../domain/logic/signal-server-auth-failure.rules';
import { SignalServerAuthService } from './signal-server-auth.service';
@@ -50,31 +48,13 @@ export class SignalServerAuthorizeService {
return true;
}
const endpointStatus = await this.resolveEndpointStatusForAuthorize(serverUrl);
if (shouldNavigateToAuthorizeSignalServer(endpointStatus, result)) {
await this.navigateToAuthorize(serverUrl, this.router.url);
}
// Automatic recovery must never turn a healthy home session into a generic
// foreign-server login redirect. The contextual caller renders the
// per-server recovery action; explicit authorization remains available in
// Network settings.
return false;
}
private async resolveEndpointStatusForAuthorize(serverUrl: string) {
const endpoint = this.serverDirectory.findServerByUrl(serverUrl);
if (!endpoint) {
return null;
}
if (isEndpointOnlineForConnection(endpoint.status) || endpoint.status === 'offline' || endpoint.status === 'incompatible') {
return endpoint.status;
}
await this.serverDirectory.testServer(endpoint.id);
return this.serverDirectory.servers().find((candidate) => candidate.id === endpoint.id)?.status ?? endpoint.status;
}
async navigateToAuthorize(serverUrl: string, returnUrl: string): Promise<void> {
const endpoint = this.serverDirectory.ensureServerEndpoint({
name: this.buildEndpointName(serverUrl),
@@ -13,6 +13,10 @@ import { SignalServerCredentialStoreService } from './signal-server-credential-s
import { ProvisionUsernameCollisionError } from '../../domain/logic/signal-server-provision.rules';
import type { User } from '../../../../shared-kernel';
const FOREIGN_URL = 'https://foreign.example.com';
const CANONICAL_SECRET = 'canonical-secret';
const LEGACY_SECRET = 'legacy-device-secret';
describe('SignalServerProvisionerService', () => {
let service: SignalServerProvisionerService;
let httpPost: ReturnType<typeof vi.fn>;
@@ -29,6 +33,24 @@ describe('SignalServerProvisionerService', () => {
homeSignalServerUrl: 'https://home.example.com'
};
function foreignAccount(id: string, username: string, token = 'foreign-token') {
return of({
id,
username,
displayName: 'Alice',
token,
expiresAt: Date.now() + 60_000
});
}
function httpError(status: number) {
return throwError(() => new HttpErrorResponse({ status }));
}
function provision(secrets: { canonical: string | null; deviceLocal: string | null }) {
return service.provisionOnServer({ serverUrl: FOREIGN_URL, homeUser, secrets });
}
beforeEach(() => {
const storage = new Map<string, string>();
@@ -48,109 +70,127 @@ describe('SignalServerProvisionerService', () => {
});
it('registers on a foreign server when the preferred username is available', async () => {
httpPost.mockReturnValue(of({
id: 'foreign-user-1',
username: 'alice',
displayName: 'Alice',
token: 'foreign-token',
expiresAt: Date.now() + 60_000
}));
httpPost.mockReturnValue(foreignAccount('foreign-user-1', 'alice'));
const result = await service.provisionOnServer({
serverUrl: 'https://foreign.example.com',
homeUser,
provisionSecret: 'provision-secret'
});
const result = await provision({ canonical: CANONICAL_SECRET, deviceLocal: null });
expect(result.username).toBe('alice');
expect(result.usedSuffix).toBe(false);
expect(credentialStore.getCredential('https://foreign.example.com')?.userId).toBe('foreign-user-1');
expect(httpPost).toHaveBeenCalledWith(
'https://foreign.example.com/api/users/register',
{
expect(credentialStore.getCredential(FOREIGN_URL)?.userId).toBe('foreign-user-1');
expect(httpPost).toHaveBeenCalledWith(`${FOREIGN_URL}/api/users/register`, {
username: 'alice',
password: 'provision-secret',
password: CANONICAL_SECRET,
displayName: 'Alice'
}
);
});
});
it('logs in when the preferred username was provisioned earlier', async () => {
it('signs a second device in to the account the first device created', async () => {
httpPost
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 409 })))
.mockReturnValueOnce(of({
id: 'foreign-user-1',
username: 'alice',
displayName: 'Alice',
token: 'foreign-token',
expiresAt: Date.now() + 60_000
}));
.mockReturnValueOnce(httpError(409))
.mockReturnValueOnce(foreignAccount('foreign-user-1', 'alice'));
const result = await service.provisionOnServer({
serverUrl: 'https://foreign.example.com',
homeUser,
provisionSecret: 'provision-secret'
});
const result = await provision({ canonical: CANONICAL_SECRET, deviceLocal: null });
expect(result.username).toBe('alice');
expect(httpPost).toHaveBeenNthCalledWith(
2,
'https://foreign.example.com/api/users/login',
{
expect(result.usedSuffix).toBe(false);
expect(credentialStore.getCredential(FOREIGN_URL)?.userId).toBe('foreign-user-1');
expect(httpPost).toHaveBeenNthCalledWith(2, `${FOREIGN_URL}/api/users/login`, {
username: 'alice',
password: 'provision-secret'
}
password: CANONICAL_SECRET
});
});
it('reclaims an account created with the legacy secret and moves it to the canonical one', async () => {
httpPost
.mockReturnValueOnce(httpError(409))
.mockReturnValueOnce(httpError(401))
.mockReturnValueOnce(foreignAccount('foreign-user-1', 'alice', 'legacy-session'))
.mockReturnValueOnce(of({ ok: true }));
const result = await provision({ canonical: CANONICAL_SECRET, deviceLocal: LEGACY_SECRET });
expect(result.username).toBe('alice');
expect(result.usedSuffix).toBe(false);
expect(httpPost).toHaveBeenNthCalledWith(3, `${FOREIGN_URL}/api/users/login`, {
username: 'alice',
password: LEGACY_SECRET
});
expect(httpPost).toHaveBeenNthCalledWith(
4,
`${FOREIGN_URL}/api/users/me/password`,
{ newPassword: CANONICAL_SECRET },
{ headers: { Authorization: 'Bearer legacy-session' } }
);
});
it('registers with a suffixed username when the preferred name belongs to someone else', async () => {
it('keeps the reclaimed credential when the server cannot rotate the password', async () => {
httpPost
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 409 })))
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 401 })))
.mockReturnValueOnce(of({
id: 'foreign-user-2',
username: 'alice-a3f2b1',
displayName: 'Alice',
token: 'foreign-token-2',
expiresAt: Date.now() + 60_000
}));
.mockReturnValueOnce(httpError(409))
.mockReturnValueOnce(httpError(401))
.mockReturnValueOnce(foreignAccount('foreign-user-1', 'alice', 'legacy-session'))
.mockReturnValueOnce(httpError(404));
const result = await service.provisionOnServer({
serverUrl: 'https://foreign.example.com',
homeUser,
provisionSecret: 'provision-secret'
const result = await provision({ canonical: CANONICAL_SECRET, deviceLocal: LEGACY_SECRET });
expect(result.username).toBe('alice');
expect(credentialStore.getCredential(FOREIGN_URL)?.userId).toBe('foreign-user-1');
});
it('registers a suffixed username only when the preferred name belongs to someone else', async () => {
httpPost
.mockReturnValueOnce(httpError(409))
.mockReturnValueOnce(httpError(401))
.mockReturnValueOnce(foreignAccount('foreign-user-2', 'alice-a3f2b1', 'foreign-token-2'));
const result = await provision({ canonical: CANONICAL_SECRET, deviceLocal: null });
expect(result.username).toBe('alice-a3f2b1');
expect(result.usedSuffix).toBe(true);
expect(httpPost).toHaveBeenNthCalledWith(
3,
'https://foreign.example.com/api/users/register',
{
expect(httpPost).toHaveBeenNthCalledWith(3, `${FOREIGN_URL}/api/users/register`, {
username: 'alice-a3f2b1',
password: 'provision-secret',
password: CANONICAL_SECRET,
displayName: 'Alice'
}
);
});
});
it('throws when all username candidates are exhausted', async () => {
it('never registers a duplicate when the home server cannot issue a canonical secret', async () => {
httpPost
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 409 })))
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 401 })))
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 409 })))
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 401 })));
.mockReturnValueOnce(httpError(409))
.mockReturnValueOnce(httpError(401))
.mockReturnValueOnce(httpError(401));
await expect(service.provisionOnServer({
serverUrl: 'https://foreign.example.com',
homeUser,
provisionSecret: 'provision-secret'
})).rejects.toBeInstanceOf(ProvisionUsernameCollisionError);
await expect(provision({ canonical: null, deviceLocal: LEGACY_SECRET }))
.rejects.toBeInstanceOf(ProvisionUsernameCollisionError);
const attemptedUrls = httpPost.mock.calls.map(([url]) => url);
expect(attemptedUrls.filter((url) => url.endsWith('/register'))).toHaveLength(1);
});
it('fails with a collision instead of guessing when every candidate rejects us', async () => {
httpPost
.mockReturnValueOnce(httpError(409))
.mockReturnValueOnce(httpError(401))
.mockReturnValueOnce(httpError(409))
.mockReturnValueOnce(httpError(401));
await expect(provision({ canonical: CANONICAL_SECRET, deviceLocal: null }))
.rejects.toBeInstanceOf(ProvisionUsernameCollisionError);
});
it('surfaces unexpected server failures instead of trying the next candidate', async () => {
httpPost.mockReturnValueOnce(httpError(500));
await expect(provision({ canonical: CANONICAL_SECRET, deviceLocal: null }))
.rejects.toBeInstanceOf(HttpErrorResponse);
expect(httpPost).toHaveBeenCalledOnce();
});
it('returns an existing credential without making network calls', async () => {
credentialStore.upsertCredential({
serverUrl: 'https://foreign.example.com',
serverUrl: FOREIGN_URL,
userId: 'foreign-user-1',
username: 'alice',
displayName: 'Alice',
@@ -159,11 +199,7 @@ describe('SignalServerProvisionerService', () => {
provisioned: true
});
const result = await service.provisionOnServer({
serverUrl: 'https://foreign.example.com',
homeUser,
provisionSecret: 'provision-secret'
});
const result = await provision({ canonical: CANONICAL_SECRET, deviceLocal: null });
expect(result.username).toBe('alice');
expect(httpPost).not.toHaveBeenCalled();
@@ -4,7 +4,14 @@ import { firstValueFrom } from 'rxjs';
import type { User } from '../../../../shared-kernel';
import type { LoginResponse } from '../../domain/models/authentication.model';
import type { SignalServerCredential } from '../../domain/models/signal-server-credential.model';
import { ProvisionUsernameCollisionError, buildProvisionUsernameCandidates } from '../../domain/logic/signal-server-provision.rules';
import {
type ProvisionAttempt,
type ProvisionSecrets,
ProvisionUsernameCollisionError,
buildProvisionPlan,
buildProvisionUsernameCandidates,
shouldAdoptCanonicalSecret
} from '../../domain/logic/signal-server-provision.rules';
import { SignalServerCredentialStoreService } from './signal-server-credential-store.service';
export interface ProvisionResult {
@@ -29,7 +36,7 @@ export class SignalServerProvisionerService {
async provisionOnServer(params: {
serverUrl: string;
homeUser: Pick<User, 'id' | 'username' | 'displayName'>;
provisionSecret: string;
secrets: ProvisionSecrets;
}): Promise<ProvisionResult> {
const normalizedUrl = this.normalizeServerUrl(params.serverUrl);
const existing = this.credentialStore.getCredential(normalizedUrl);
@@ -42,34 +49,32 @@ export class SignalServerProvisionerService {
};
}
const candidates = buildProvisionUsernameCandidates(params.homeUser.username, params.homeUser.id);
const attempts = buildProvisionPlan({
preferredUsername: params.homeUser.username,
homeUserId: params.homeUser.id,
secrets: params.secrets
});
for (let index = 0; index < candidates.length; index += 1) {
const candidate = candidates[index];
const usedSuffix = index > 0;
for (const attempt of attempts) {
const response = await this.runProvisionAttempt(normalizedUrl, attempt, params.homeUser.displayName);
try {
const response = await this.register(normalizedUrl, candidate, params.provisionSecret, params.homeUser.displayName);
return this.persistProvisionResult(normalizedUrl, response, usedSuffix);
} catch (error) {
if (!this.isHttpStatus(error, 409)) {
throw error;
if (!response) {
continue;
}
try {
const response = await this.login(normalizedUrl, candidate, params.provisionSecret);
const result = this.persistProvisionResult(normalizedUrl, response, attempt.usedSuffix);
return this.persistProvisionResult(normalizedUrl, response, usedSuffix);
} catch (loginError) {
if (!this.isHttpStatus(loginError, 401)) {
throw loginError;
}
}
}
if (shouldAdoptCanonicalSecret(attempt, params.secrets.canonical)) {
await this.adoptCanonicalSecret(normalizedUrl, response.token, params.secrets.canonical);
}
throw new ProvisionUsernameCollisionError(normalizedUrl, candidates);
return result;
}
throw new ProvisionUsernameCollisionError(
normalizedUrl,
buildProvisionUsernameCandidates(params.homeUser.username, params.homeUser.id)
);
}
upsertManualCredential(
@@ -91,6 +96,57 @@ export class SignalServerProvisionerService {
return credential;
}
/**
* Runs one planned attempt. Returns `null` for the two "this name is not
* ours to take this way" outcomes so the plan can continue; anything else
* is a real transport or server fault and must surface.
*/
private async runProvisionAttempt(
serverUrl: string,
attempt: ProvisionAttempt,
displayName: string
): Promise<LoginResponse | null> {
try {
return attempt.kind === 'register'
? await this.register(serverUrl, attempt.username, attempt.secret, displayName)
: await this.login(serverUrl, attempt.username, attempt.secret);
} catch (error) {
const expected = attempt.kind === 'register'
? this.isHttpStatus(error, 409)
: this.isHttpStatus(error, 401) || this.isHttpStatus(error, 404);
if (!expected) {
throw error;
}
return null;
}
}
/**
* Moves a linked account created with a legacy per-device secret onto the
* account-wide secret, so the user's other devices can sign in to it instead
* of registering a second account. Best effort: this device already holds a
* working credential either way.
*/
private async adoptCanonicalSecret(
serverUrl: string,
token: string,
canonicalSecret: string
): Promise<void> {
try {
await firstValueFrom(
this.http.post(
`${serverUrl}/api/users/me/password`,
{ newPassword: canonicalSecret },
{ headers: { Authorization: `Bearer ${token}` } }
)
);
} catch {
// Older servers have no rotation endpoint; keep the working credential.
}
}
private async register(
serverUrl: string,
username: string,
@@ -1,44 +0,0 @@
import {
describe,
expect,
it
} from 'vitest';
import { shouldNavigateToAuthorizeSignalServer } from './signal-server-authorize.rules';
describe('signal-server-authorize rules', () => {
it('does not navigate to authorize when the signal server is offline', () => {
expect(shouldNavigateToAuthorizeSignalServer('offline', {
kind: 'skipped',
reason: 'no-provision-secret'
})).toBe(false);
expect(shouldNavigateToAuthorizeSignalServer('offline', {
kind: 'collision',
error: new Error('collision') as never
})).toBe(false);
});
it('navigates to authorize on online servers that need manual sign-in', () => {
expect(shouldNavigateToAuthorizeSignalServer('online', {
kind: 'skipped',
reason: 'no-provision-secret'
})).toBe(true);
expect(shouldNavigateToAuthorizeSignalServer('online', {
kind: 'collision',
error: new Error('collision') as never
})).toBe(true);
});
it('does not navigate for unknown endpoint status or non-authorize provision outcomes', () => {
expect(shouldNavigateToAuthorizeSignalServer('unknown', {
kind: 'skipped',
reason: 'no-provision-secret'
})).toBe(false);
expect(shouldNavigateToAuthorizeSignalServer('online', {
kind: 'skipped',
reason: 'no-home-user'
})).toBe(false);
});
});
@@ -1,18 +0,0 @@
import type { EnsureProvisionedResult } from '../../application/services/signal-server-auth.service';
import type { ServerEndpointStatus } from '../../../server-directory/domain/models/server-directory.model';
import { isEndpointOnlineForConnection } from '../../../server-directory/domain/logic/server-endpoint-connectivity.rules';
export function shouldNavigateToAuthorizeSignalServer(
endpointStatus: ServerEndpointStatus | undefined | null,
provisionResult: EnsureProvisionedResult
): boolean {
if (!isEndpointOnlineForConnection(endpointStatus)) {
return false;
}
if (provisionResult.kind === 'collision') {
return true;
}
return provisionResult.kind === 'skipped' && provisionResult.reason === 'no-provision-secret';
}
@@ -5,10 +5,22 @@ import {
} from 'vitest';
import {
ProvisionUsernameCollisionError,
buildProvisionPlan,
buildProvisionUsernameCandidates,
shortHomeUserId
shortHomeUserId,
shouldAdoptCanonicalSecret
} from './signal-server-provision.rules';
const HOME_USER_ID = 'a3f2b1c4-5678-90ab-cdef-1234567890ab';
function plan(canonical: string | null, deviceLocal: string | null) {
return buildProvisionPlan({
preferredUsername: 'alice',
homeUserId: HOME_USER_ID,
secrets: { canonical, deviceLocal }
}).map((attempt) => `${attempt.kind}:${attempt.username}:${attempt.secretSource}`);
}
describe('signal-server-provision.rules', () => {
it('derives a stable short id from a home user uuid', () => {
expect(shortHomeUserId('a3f2b1c4-5678-90ab-cdef-1234567890ab')).toBe('a3f2b1');
@@ -26,6 +38,54 @@ describe('signal-server-provision.rules', () => {
).toEqual(['alice-a3f2b1']);
});
it('signs in to the preferred username before ever trying the suffixed one', () => {
expect(plan('canonical-secret', null)).toEqual([
'register:alice:canonical',
'login:alice:canonical',
'register:alice-a3f2b1:canonical',
'login:alice-a3f2b1:canonical'
]);
});
it('tries the legacy device secret before giving the username up as someone else\'s', () => {
expect(plan('canonical-secret', 'legacy-secret')).toEqual([
'register:alice:canonical',
'login:alice:canonical',
'login:alice:device-local',
'register:alice-a3f2b1:canonical',
'login:alice-a3f2b1:canonical',
'login:alice-a3f2b1:device-local'
]);
});
it('never registers a suffixed duplicate without a canonical secret', () => {
expect(plan(null, 'legacy-secret')).toEqual([
'register:alice:device-local',
'login:alice:device-local',
'login:alice-a3f2b1:device-local'
]);
});
it('produces no attempts when no secret is available at all', () => {
expect(plan(null, null)).toEqual([]);
});
it('does not repeat the canonical secret as a legacy attempt', () => {
expect(plan('same-secret', 'same-secret')).toEqual([
'register:alice:canonical',
'login:alice:canonical',
'register:alice-a3f2b1:canonical',
'login:alice-a3f2b1:canonical'
]);
});
it('adopts the canonical secret only after a legacy login', () => {
expect(shouldAdoptCanonicalSecret({ kind: 'login', secretSource: 'device-local' }, 'canonical')).toBe(true);
expect(shouldAdoptCanonicalSecret({ kind: 'login', secretSource: 'canonical' }, 'canonical')).toBe(false);
expect(shouldAdoptCanonicalSecret({ kind: 'register', secretSource: 'device-local' }, 'canonical')).toBe(false);
expect(shouldAdoptCanonicalSecret({ kind: 'login', secretSource: 'device-local' }, null)).toBe(false);
});
it('exposes attempted usernames on collision errors', () => {
const error = new ProvisionUsernameCollisionError('https://signal.example.com', ['alice', 'alice-a3f2b1']);
@@ -32,3 +32,104 @@ export function buildProvisionUsernameCandidates(
return [...new Set(candidates)];
}
/**
* `canonical` is the account-wide secret issued by the home signal server, so
* it is identical on every device of the same human. `device-local` is the
* legacy secret that older builds generated per device; it only ever unlocks
* accounts that this one device created.
*/
export type ProvisionSecretSource = 'canonical' | 'device-local';
export interface ProvisionSecrets {
canonical: string | null;
deviceLocal: string | null;
}
export interface ProvisionAttempt {
kind: 'register' | 'login';
username: string;
secret: string;
secretSource: ProvisionSecretSource;
usedSuffix: boolean;
}
/**
* Orders the provisioning attempts for one foreign signal server.
*
* The ordering exists to guarantee that a human never ends up with two
* accounts on the same server. For each username we first try to claim it,
* then to sign in with the canonical secret (another device of ours already
* claimed it), then with the device-local secret (this device claimed it
* before canonical secrets existed). Only once a username is proven to belong
* to somebody else do we move on to the suffixed name.
*
* Registering the suffixed name requires a canonical secret. Without one we
* cannot tell "another human owns this name" apart from "our own account whose
* secret this device never had", and guessing wrong forks the user's identity.
*/
export function buildProvisionPlan(input: {
preferredUsername: string;
homeUserId: string;
secrets: ProvisionSecrets;
}): ProvisionAttempt[] {
const candidates = buildProvisionUsernameCandidates(input.preferredUsername, input.homeUserId);
const { canonical, deviceLocal } = input.secrets;
const primary = canonical ?? deviceLocal;
if (!primary) {
return [];
}
const attempts: ProvisionAttempt[] = [];
candidates.forEach((username, index) => {
const usedSuffix = index > 0;
if (!usedSuffix || canonical) {
attempts.push({
kind: 'register',
username,
secret: primary,
secretSource: canonical ? 'canonical' : 'device-local',
usedSuffix
});
}
if (canonical) {
attempts.push({
kind: 'login',
username,
secret: canonical,
secretSource: 'canonical',
usedSuffix
});
}
if (deviceLocal && deviceLocal !== canonical) {
attempts.push({
kind: 'login',
username,
secret: deviceLocal,
secretSource: 'device-local',
usedSuffix
});
}
});
return attempts;
}
/**
* A login that succeeded with the legacy device-local secret leaves the
* account unreachable from the user's other devices until its password is
* moved to the canonical secret.
*/
export function shouldAdoptCanonicalSecret(
attempt: Pick<ProvisionAttempt, 'kind' | 'secretSource'>,
canonicalSecret: string | null
): canonicalSecret is string {
return attempt.kind === 'login'
&& attempt.secretSource === 'device-local'
&& !!canonicalSecret;
}
@@ -1,8 +1,10 @@
export * from './application/services/authentication.service';
export * from './application/services/auth-token-store.service';
export * from './application/services/user-logout.service';
export * from './application/services/home-provision-secret.service';
export * from './application/services/signal-server-auth.service';
export * from './application/services/signal-server-authorize.service';
export * from './application/services/signal-server-auth-recovery.service';
export * from './application/services/signal-server-credential-store.service';
export * from './application/services/signal-server-provisioner.service';
export * from './application/services/signal-server-provision-notice.service';
+11
View File
@@ -17,6 +17,7 @@ chat/
│ ├── message-integrity.rules.ts headHash, inventory refresh, revision merge predicates
│ ├── message-revision.builder.rules.ts buildMessageRevision, materializeMessageFromRevision
│ ├── message-sync.rules.ts Inventory-based sync: chunkArray, findMissingIds, limits
│ ├── message-sync-round.rules.ts Inventory round evidence: createInventoryRound, recordInventoryReply, isInventoryRoundClean
│ └── auto-scroll.rules.ts resolveAutoScrollBehavior (instant on channel switch, smooth for live msgs) + isStuckToBottom predicate
├── feature/
@@ -128,6 +129,16 @@ sequenceDiagram
`findMissingIds` compares each remote item's timestamp and reaction/attachment counts against the local map. Any item that is missing, newer, or has different counts is requested.
### Polling cadence: only evidence buys the slow poll
`store/messages/messages-sync.effects.ts` polls every connected peer for its inventory and alternates between `SYNC_POLL_FAST_MS` (10 s) and `SYNC_POLL_SLOW_MS` (15 min). Which one it picks is decided by the round model in `message-sync-round.rules.ts`, not by elapsed time:
- The poll records who it actually reached. `sendToPeer` returns whether the payload entered an open data channel, so a peer listed as connected whose channel is closed counts as **undelivered**, not asked.
- Each reply dispatches `peerInventoryCompared` with the number of ids that comparison showed we lack.
- A round is **clean** only when every asked peer replied and no reply reported missing ids. A timed-out round, a partially answered round, and a round with an undelivered request are all dirty, and dirty keeps the fast poll.
- Every scored round re-arms the poll timer, so the delay always comes from the verdict of the round that just closed - not from the previous round's verdict, which is how a client could end up waiting out 15 minutes right after discovering it was behind.
- A reply that lands after its round closed (a peer answering the reconnect or room-activation kickoff) re-arms the fast cadence when it reports missing ids, so convergence never waits out a slow poll.
## GIF integration
`KlipyService` checks availability on the active server, then proxies search requests through the server API. Rendered remote images now attempt a direct load first and only fall back to the image proxy after the browser reports a load failure, which is the practical approximation of a CORS or mixed-content fallback path in the renderer.
@@ -0,0 +1,109 @@
import {
describe,
it,
expect
} from 'vitest';
import {
createInventoryRound,
isInventoryRoundClean,
recordInventoryReply
} from './message-sync-round.rules';
describe('message-sync-round.rules', () => {
it('is clean when every asked peer replied with nothing missing', () => {
let round = createInventoryRound({
roomId: 'room-1',
askedPeerIds: ['peer-a', 'peer-b']
});
round = recordInventoryReply(round, { roomId: 'room-1', peerId: 'peer-a', missingIdCount: 0 });
expect(isInventoryRoundClean(round)).toBe(false);
round = recordInventoryReply(round, { roomId: 'room-1', peerId: 'peer-b', missingIdCount: 0 });
expect(isInventoryRoundClean(round)).toBe(true);
});
it('is dirty while a peer we asked has not replied', () => {
// A timed-out round is not a clean round: silence is not evidence of
// convergence, and treating it as clean drops the poll to the slow
// cadence while the peer still holds messages we never received.
const round = createInventoryRound({
roomId: 'room-1',
askedPeerIds: ['peer-a', 'peer-b']
});
expect(isInventoryRoundClean(
recordInventoryReply(round, { roomId: 'room-1', peerId: 'peer-a', missingIdCount: 0 })
)).toBe(false);
});
it('is dirty when a request could not be delivered', () => {
const round = createInventoryRound({
roomId: 'room-1',
askedPeerIds: ['peer-a'],
undeliveredPeerIds: ['peer-b']
});
expect(isInventoryRoundClean(
recordInventoryReply(round, { roomId: 'room-1', peerId: 'peer-a', missingIdCount: 0 })
)).toBe(false);
});
it('is dirty when any reply reported missing ids', () => {
let round = createInventoryRound({
roomId: 'room-1',
askedPeerIds: ['peer-a', 'peer-b']
});
round = recordInventoryReply(round, { roomId: 'room-1', peerId: 'peer-a', missingIdCount: 3 });
round = recordInventoryReply(round, { roomId: 'room-1', peerId: 'peer-b', missingIdCount: 0 });
expect(isInventoryRoundClean(round)).toBe(false);
});
it('is dirty when nobody was asked', () => {
expect(isInventoryRoundClean(createInventoryRound({ roomId: 'room-1', askedPeerIds: [] }))).toBe(false);
expect(isInventoryRoundClean(null)).toBe(false);
});
it('counts a reply from a peer outside the round, but not toward completeness', () => {
// A peer answering a room-activation kickoff still proves we are behind.
let round = createInventoryRound({
roomId: 'room-1',
askedPeerIds: ['peer-a']
});
round = recordInventoryReply(round, { roomId: 'room-1', peerId: 'peer-c', missingIdCount: 2 });
expect(isInventoryRoundClean(round)).toBe(false);
expect(round?.repliedPeerIds).toEqual([]);
round = recordInventoryReply(round, { roomId: 'room-1', peerId: 'peer-a', missingIdCount: 0 });
expect(isInventoryRoundClean(round)).toBe(false);
});
it('ignores replies for another room and never double-counts a peer', () => {
let round = createInventoryRound({
roomId: 'room-1',
askedPeerIds: ['peer-a']
});
round = recordInventoryReply(round, { roomId: 'room-2', peerId: 'peer-a', missingIdCount: 5 });
expect(round?.repliedPeerIds).toEqual([]);
expect(round?.missingIdCount).toBe(0);
round = recordInventoryReply(round, { roomId: 'room-1', peerId: 'peer-a', missingIdCount: 0 });
round = recordInventoryReply(round, { roomId: 'room-1', peerId: 'peer-a', missingIdCount: 0 });
expect(round?.repliedPeerIds).toEqual(['peer-a']);
expect(isInventoryRoundClean(round)).toBe(true);
});
it('leaves a closed round closed', () => {
expect(recordInventoryReply(null, { roomId: 'room-1', peerId: 'peer-a', missingIdCount: 0 })).toBeNull();
});
});
@@ -0,0 +1,75 @@
/**
* Evidence model for one message-inventory reconciliation round.
*
* The sync poll asks every reachable peer for its room inventory and then
* decides how soon to ask again. That decision must rest on replies, not on
* elapsed time: a round that timed out, only partly answered, or could not be
* delivered proves nothing about convergence, so it must not buy the slow
* polling cadence.
*/
/** Peers asked, peers heard from, and what they reported, for one room. */
export interface InventoryRound {
readonly roomId: string;
/** Peers the inventory request was actually handed to the transport for. */
readonly askedPeerIds: readonly string[];
/** Subset of `askedPeerIds` that answered with an inventory. */
readonly repliedPeerIds: readonly string[];
/** Peers whose data channel refused the request. */
readonly undeliveredPeerIds: readonly string[];
/** Total ids any reply showed we are missing or hold at a stale revision. */
readonly missingIdCount: number;
}
/** Starts a round from the peers a poll reached and the peers it could not. */
export function createInventoryRound(input: {
roomId: string;
askedPeerIds: readonly string[];
undeliveredPeerIds?: readonly string[];
}): InventoryRound {
return {
roomId: input.roomId,
askedPeerIds: [...input.askedPeerIds],
repliedPeerIds: [],
undeliveredPeerIds: [...(input.undeliveredPeerIds ?? [])],
missingIdCount: 0
};
}
/**
* Records one peer's inventory comparison.
*
* Replies for another room are ignored. A reply from a peer outside the round
* (it answered an earlier kickoff) still counts its missing ids - we are
* demonstrably behind - but cannot complete the round on another peer's behalf.
*/
export function recordInventoryReply(
round: InventoryRound | null,
reply: { roomId: string; peerId: string; missingIdCount: number }
): InventoryRound | null {
if (!round || round.roomId !== reply.roomId)
return round;
const isAsked = round.askedPeerIds.includes(reply.peerId);
const alreadyReplied = round.repliedPeerIds.includes(reply.peerId);
return {
...round,
repliedPeerIds:
isAsked && !alreadyReplied
? [...round.repliedPeerIds, reply.peerId]
: round.repliedPeerIds,
missingIdCount: round.missingIdCount + Math.max(0, reply.missingIdCount)
};
}
/** A round is clean only when every asked peer replied and nothing was missing. */
export function isInventoryRoundClean(round: InventoryRound | null): boolean {
if (!round || round.askedPeerIds.length === 0)
return false;
if (round.undeliveredPeerIds.length > 0 || round.missingIdCount > 0)
return false;
return round.askedPeerIds.every((peerId) => round.repliedPeerIds.includes(peerId));
}
@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
HostListener,
@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import {
@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { CommonModule } from '@angular/common';
import {
Component,
@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { CommonModule } from '@angular/common';
import {
AfterViewChecked,
@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
AfterViewInit,
Component,
@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering, */
import {
Component,
computed,
@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
inject,
@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core';
import { createEffect } from '@ngrx/effects';
import { Store } from '@ngrx/store';

Some files were not shown because too many files have changed in this diff Show More