Compare commits
16
Commits
20d7f22fd2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9429dacac5 | ||
|
|
1ce72e7ac3 | ||
|
|
718f4a99f0 | ||
|
|
e45e165a6f | ||
|
|
2a88d62ddf | ||
|
|
7e2cbcfe6c | ||
|
|
3266581d3c | ||
|
|
92c2f578e2 | ||
|
|
a83f5aa750 | ||
|
|
f9e8538c80 | ||
|
|
e49b3ec112 | ||
|
|
d71e3a98da | ||
|
|
d658ab0827 | ||
|
|
34c32ba64a | ||
|
|
cfe10907be | ||
|
|
cdc32db30f |
@@ -0,0 +1,37 @@
|
||||
---
|
||||
description: Short-chat handoff to cut token burn; agents cannot open new chats
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Handoff (token control)
|
||||
|
||||
Agents **cannot** create a new Cursor chat. When a handoff is needed, **overwrite** `agents-docs/HANDOFF.md` and tell the user to start a **new chat** with `@agents-docs/HANDOFF.md`.
|
||||
|
||||
## File size rule (mandatory)
|
||||
|
||||
- **Never append** to `HANDOFF.md`. Always replace the entire file.
|
||||
- One active handoff only — no history stack in this file.
|
||||
- Keep sections short (bullets, paths, not log dumps).
|
||||
|
||||
## When to hand off
|
||||
|
||||
- User says "handoff", "new chat", or "wrap up this session"
|
||||
- Thread is long (many tool rounds, large pastes, repeated failed approaches) and more major work remains
|
||||
- Switching to a clearly separate objective
|
||||
|
||||
## What to do (new handoff)
|
||||
|
||||
1. **Overwrite** `agents-docs/HANDOFF.md` completely — `Status: active`; fill Goal, Completed, Changed files, Decisions, Failed approaches, Current issue, Next steps, Commands (brief).
|
||||
2. Stop major new work in this chat after writing the handoff (unless the user says continue here).
|
||||
3. Tell the user the one-liner for the new chat.
|
||||
|
||||
## When the task is finished
|
||||
|
||||
After the user-approved work is done (or they abandon the handoff objective):
|
||||
|
||||
1. **Clear** `agents-docs/HANDOFF.md`: set `Status: none` and empty all section bodies (restore the short template — do not leave old Completed/Next steps lying around).
|
||||
2. Do this in the same turn you claim done, so the next chat does not reload stale handoff context.
|
||||
|
||||
## New-chat bootstrap
|
||||
|
||||
If `Status: active`, read the handoff first and continue Next steps. Do not reload the whole monorepo or redo Completed work. When that work is finished → **clear** the file as above.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
description: Interview the user before implementing fixes; no guessing
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Interview before implement
|
||||
|
||||
Before writing or changing product code for a bug/feature ask:
|
||||
|
||||
1. Read the ask (and the one Obsidian bug note if named). Do **not** start a large codebase rewrite yet.
|
||||
2. Reply with a **short interview** (bullets only):
|
||||
- What you think the bug/goal is (1–2 sentences)
|
||||
- What’s missing / unclear
|
||||
- Choices (A/B/C) with a **recommended** default
|
||||
- Proposed scope (files/areas you will touch; what you will not)
|
||||
- How you will prove done
|
||||
3. **Stop and wait** for the user’s answers. Do not implement until they approve or choose.
|
||||
4. Then implement exactly what they chose — no silent extra scope.
|
||||
|
||||
Skip the interview only when the user says e.g. “just fix it”, “no interview”, or the handoff already records approved Next steps/decisions and they said continue.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Reduce indexing / accidental bulk reads. Scope policy lives in AGENTS.md.
|
||||
# Do NOT list electron/, server/, e2e/, website/, or docs-site/ here —
|
||||
# those stay reachable when the user explicitly expands scope.
|
||||
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
dist/
|
||||
dist-electron/
|
||||
dist-server/
|
||||
.angular/
|
||||
coverage/
|
||||
test-results/
|
||||
e2e/playwright-report/
|
||||
docs-site/.docusaurus/
|
||||
docs-site/build/
|
||||
*.sqlite
|
||||
package-lock.json
|
||||
|
||||
# Media / binary bulk
|
||||
images/
|
||||
**/*.png
|
||||
**/*.jpg
|
||||
**/*.jpeg
|
||||
**/*.webp
|
||||
**/*.gif
|
||||
**/*.mp4
|
||||
**/*.wasm
|
||||
@@ -249,8 +249,8 @@ jobs:
|
||||
run: |
|
||||
$projectRoot = $PWD.ProviderPath
|
||||
$electronBuilderWorkspace = Join-Path $env:TEMP ([guid]::NewGuid().ToString('N'))
|
||||
$electronBuilderCache = Join-Path $electronBuilderWorkspace 'electron-builder-cache'
|
||||
$electronCache = Join-Path $electronBuilderWorkspace 'electron-cache'
|
||||
$electronBuilderCache = Join-Path $env:LOCALAPPDATA 'electron-builder\Cache'
|
||||
$electronCache = Join-Path $env:LOCALAPPDATA 'electron\Cache'
|
||||
$locationPushed = $false
|
||||
|
||||
function Invoke-RoboCopy {
|
||||
@@ -266,21 +266,68 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
function Initialize-WinCodeSignCache {
|
||||
param(
|
||||
[string]$CacheRoot,
|
||||
[string]$SevenZip
|
||||
)
|
||||
|
||||
# electron-builder downloads winCodeSign for rcedit (icon and version stamping)
|
||||
# even when nothing is signed. Its two darwin symlinks cannot be recreated
|
||||
# without SeCreateSymbolicLinkPrivilege, so seed the cache without them.
|
||||
# The version must match the one app-builder resolves, otherwise it downloads
|
||||
# its own copy and fails on the symlinks again.
|
||||
$version = 'winCodeSign-2.6.0'
|
||||
$target = Join-Path $CacheRoot "winCodeSign\$version"
|
||||
|
||||
if (Test-Path $target) {
|
||||
return
|
||||
}
|
||||
|
||||
$staging = "$target.incomplete"
|
||||
$archive = Join-Path $env:TEMP "$version.7z"
|
||||
$url = "https://github.com/electron-userland/electron-builder-binaries/releases/download/$version/$version.7z"
|
||||
|
||||
Remove-Item $staging -Recurse -Force -ErrorAction SilentlyContinue
|
||||
|
||||
try {
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
Invoke-WebRequest -Uri $url -OutFile $archive -UseBasicParsing
|
||||
& $SevenZip x -bd -y "-o$staging" '-x!darwin' $archive | Out-Null
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "failed to extract $version with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path (Split-Path -Parent $target) -Force | Out-Null
|
||||
Move-Item $staging $target
|
||||
} finally {
|
||||
Remove-Item $archive -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item $staging -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
# Stage the packaging inputs into a real short-path directory.
|
||||
# electron-builder rejects junction-backed files during asar creation
|
||||
# because their resolved path sits outside the package root.
|
||||
New-Item -ItemType Directory -Path $electronBuilderWorkspace | Out-Null
|
||||
New-Item -ItemType Directory -Path $electronBuilderCache | Out-Null
|
||||
New-Item -ItemType Directory -Path $electronCache | Out-Null
|
||||
New-Item -ItemType Directory -Path $electronBuilderCache -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path $electronCache -Force | Out-Null
|
||||
$env:ELECTRON_BUILDER_CACHE = $electronBuilderCache
|
||||
$env:ELECTRON_CACHE = $electronCache
|
||||
|
||||
try {
|
||||
Initialize-WinCodeSignCache `
|
||||
-CacheRoot $electronBuilderCache `
|
||||
-SevenZip (Join-Path $projectRoot 'node_modules\7zip-bin\win\x64\7za.exe')
|
||||
|
||||
Copy-Item -Path (Join-Path $projectRoot 'package.json') -Destination (Join-Path $electronBuilderWorkspace 'package.json') -Force
|
||||
Copy-Item -Path (Join-Path $projectRoot 'package-lock.json') -Destination (Join-Path $electronBuilderWorkspace 'package-lock.json') -Force
|
||||
Invoke-RoboCopy (Join-Path $projectRoot 'dist') (Join-Path $electronBuilderWorkspace 'dist')
|
||||
Invoke-RoboCopy (Join-Path $projectRoot 'docs-site/build') (Join-Path $electronBuilderWorkspace 'docs-site/build')
|
||||
Invoke-RoboCopy (Join-Path $projectRoot 'images') (Join-Path $electronBuilderWorkspace 'images')
|
||||
Invoke-RoboCopy (Join-Path $projectRoot 'tools') (Join-Path $electronBuilderWorkspace 'tools')
|
||||
Invoke-RoboCopy (Join-Path $projectRoot 'node_modules') (Join-Path $electronBuilderWorkspace 'node_modules')
|
||||
|
||||
Push-Location $electronBuilderWorkspace
|
||||
|
||||
@@ -58,6 +58,7 @@ Thumbs.db
|
||||
# Environment & certs
|
||||
.env
|
||||
.certs/
|
||||
.dev-userdata/
|
||||
/server/data/variables.json
|
||||
/server/data/metoyou.sqlite
|
||||
dist-server/*
|
||||
|
||||
@@ -1,103 +1,118 @@
|
||||
# AGENTS.md
|
||||
|
||||
Read these files at the start of every session before doing any work:
|
||||
Keep this file small. Detail lives in linked docs — load those **only when the task needs them**.
|
||||
|
||||
1. `agents-docs/AGENT_WORKFLOW.md` — workflow and operating rules
|
||||
2. `agents-docs/LESSONS.md` — durable rules learned from past corrections; apply any that match this session's work
|
||||
3. `agents-docs/AGENTS_FEATURES.md` — when and how to update feature docs
|
||||
4. `agents-docs/FEATURES.md` — feature index
|
||||
5. `agents-docs/ENGINEERING.md` — engineering standards
|
||||
6. `agents-docs/CONTEXT-MAP.md` — index of bounded contexts in this repo
|
||||
## Session handoff (new chats)
|
||||
|
||||
Reference on-demand (when the workflow triggers them — see `agents-docs/AGENT_WORKFLOW.md` §§ 4–5):
|
||||
Agents **cannot** open a new Cursor chat. To reset context:
|
||||
|
||||
- `agents-docs/AGENTS_CONTEXT.md` — contract for updating `CONTEXT.md` / `CONTEXT-MAP.md`
|
||||
- `agents-docs/AGENTS_ADRS.md` — contract for writing architecture decision records
|
||||
- `agents-docs/BUG_TRACKER.md` — Obsidian bug inbox location, allowed vault edits, and triage workflow
|
||||
1. **Overwrite** `agents-docs/HANDOFF.md` entirely (`Status: active`) — never append. See `.cursor/rules/handoff.mdc`.
|
||||
2. Ask the user to start a **new chat** and attach `@agents-docs/HANDOFF.md`.
|
||||
|
||||
When working in a subdomain, also read its `CONTEXT.md` first:
|
||||
**At session start:** if `Status: active`, read it first and continue Next steps. Do not redo Completed work.
|
||||
|
||||
- Product client (Angular 21): `toju-app/CONTEXT.md`
|
||||
- Desktop shell (Electron main + preload): `electron/CONTEXT.md`
|
||||
- Signaling server (Express + WebSocket): `server/CONTEXT.md`
|
||||
- End-to-end tests (Playwright): `e2e/CONTEXT.md`
|
||||
- Marketing site (Angular 19): `website/CONTEXT.md`
|
||||
- Application documentation (Docusaurus): `docs-site/CONTEXT.md`
|
||||
**When the task is finished:** clear the handoff — set `Status: none` and empty all sections (same short template). Never leave a growing archive in `HANDOFF.md`.
|
||||
|
||||
## Named Obsidian bugs
|
||||
|
||||
When the user says `fix bug "…"`, follow `agents-docs/BUG_TRACKER.md` § Named fix: open **that one** vault note, **interview before implementing**, then fix in default scope and set `status: Resolved`. Do not scan the whole bug inbox.
|
||||
|
||||
## Interview before implement (user in control)
|
||||
|
||||
Before changing product code for a bug or feature:
|
||||
|
||||
1. Read the ask / named bug note (cheap orientation only — not a monorepo dig).
|
||||
2. Post a **short interview**: your understanding, gaps, A/B/C choices with a recommended default, proposed scope, how you’ll prove done.
|
||||
3. **Wait** for the user’s choices. Do not guess past ambiguity.
|
||||
4. Implement only what they approved.
|
||||
|
||||
Skip only if they say “just fix it” / “no interview”, or an active handoff already has approved decisions and they said continue. See `.cursor/rules/interview-before-fix.mdc`.
|
||||
|
||||
## Default work scope (token fence)
|
||||
|
||||
Unless the user **explicitly** expands scope, stay inside:
|
||||
|
||||
- `toju-app/` (primary)
|
||||
- `electron/` — **targeted only** (desktop shell is coupled to the client; see below)
|
||||
- CI: `.gitea/workflows/`
|
||||
- Agent/docs as needed: this file, `agents-docs/` (index-first / handoff), `toju-app/CONTEXT.md`; `electron/CONTEXT.md` only when touching Electron
|
||||
|
||||
### Electron — relevant files only
|
||||
|
||||
`electron/` is in default scope because the renderer talks to it via preload/IPC/local DB. Do **not** browse the whole tree.
|
||||
|
||||
When a `toju-app` change needs the desktop bridge:
|
||||
|
||||
1. Start from the renderer call site (`window.api` / Electron bridge usage).
|
||||
2. Open only the matching surface: usually `electron/preload.ts`, then the specific handler under `electron/ipc/`, `electron/cqrs*`, or the one module/entity/migration involved.
|
||||
3. Prefer ripgrep with path `electron/` + a concrete symbol over listing directories.
|
||||
4. Skip unrelated areas (`electron/api/` docs server, `game-detection/`, `update/`, other migrations, etc.) unless the bug points there.
|
||||
|
||||
**Still out of scope by default** (do not search/read/edit unless the user names them):
|
||||
|
||||
- `server/`, `e2e/`, `website/`, `docs-site/`
|
||||
- Root noise: `dist*/`, `node_modules/`, `images/`, `project-files/`, `test-results/`
|
||||
|
||||
If the root cause looks like `server/` or e2e-only, **ask once** instead of exploring those trees.
|
||||
|
||||
Search with path filters. Prefer `toju-app/src/app/domains/<name>/` over repo-wide greps.
|
||||
|
||||
## Session start (cheap bootstrap)
|
||||
|
||||
1. Skim this file.
|
||||
2. If handoff `Status: active` → read `agents-docs/HANDOFF.md`.
|
||||
3. Open `agents-docs/LESSONS-INDEX.md` only — match tags; open matching bodies in `LESSONS.md`.
|
||||
4. Read `toju-app/CONTEXT.md` for client work; `electron/CONTEXT.md` only if this task touches Electron.
|
||||
5. Other docs **on demand** only.
|
||||
|
||||
**Models:** use the latest problem-solving model the user selected. Save tokens with **scope, handoffs, and short chats** — not by silently downgrading model quality.
|
||||
|
||||
**Do not auto-read:** `ENGINEERING.md`, `AGENTS_FEATURES.md`, `FEATURES.md`, `CONTEXT-MAP.md`, full `AGENT_WORKFLOW.md`, feature docs, ADRs — unless needed.
|
||||
|
||||
On-demand: `agents-docs/AGENT_WORKFLOW.md`, `AGENTS_FEATURES.md`, `FEATURES.md`, `ENGINEERING.md`, `AGENTS_CONTEXT.md`, `AGENTS_ADRS.md`, `BUG_TRACKER.md`.
|
||||
|
||||
---
|
||||
|
||||
MetoYou (also called Toju) is a desktop-first, P2P Discord-style chat application managed as an npm-workspaces monorepo. It bundles an Angular 21 product client, an Electron 39 desktop shell with TypeORM + sql.js for local persistence, a small Node/TypeScript Express signaling server with WebSocket-based realtime, a Playwright end-to-end suite, an Angular 19 marketing site, and a Docusaurus app/plugin documentation site that ships inside the Electron build. Voice and screen-share are WebRTC, with RNNoise denoising via a WASM audio worklet.
|
||||
MetoYou / Toju: desktop-first P2P chat. Default surface: Angular client (`toju-app/`) + targeted Electron bridge + CI.
|
||||
|
||||
## CRITICAL — Non-negotiable rules for all agents
|
||||
## CRITICAL — Done means the asked behavior works
|
||||
|
||||
### Test-Driven Development (MANDATORY)
|
||||
**Write tests before implementation code.**
|
||||
**Unit/spec green is support, not done.**
|
||||
|
||||
When creating or changing anything:
|
||||
1. STOP — do not write implementation first
|
||||
2. Write failing tests (RED)
|
||||
3. Run tests and confirm failure (`npm run test` for the product client; `npm run test:e2e` for end-to-end; place spec files colocated with source, suffix `.spec.ts`)
|
||||
4. Write minimal code to pass tests (GREEN)
|
||||
5. Refactor while keeping tests green
|
||||
1. Restate acceptance in one sentence.
|
||||
2. Prove the behavior (user-visible path, focused test at the right level, or explicit manual check).
|
||||
3. Prefer a regression that fails if the asked behavior regresses.
|
||||
|
||||
This applies to all code — Angular components and services, NgRx effects/reducers, Electron IPC handlers, server CQRS handlers, websocket message handlers, plugin runtime, and domain logic. If the code lives in a package without a configured test runner (server, website, docs-site), surface that gap before adding logic there.
|
||||
### Test-backed development (balanced)
|
||||
|
||||
### Lint correctness (MANDATORY)
|
||||
Before completing any task:
|
||||
1. Run `npm run lint` from the repo root (ESLint 9 flat config in `eslint.config.js` covers every package)
|
||||
2. Fix all errors
|
||||
3. Do not consider work complete until it exits with code 0
|
||||
For domain/logic: failing behavior-level test → minimal fix → green.
|
||||
Skip full red-green for docs/copy/agent text, formatting, trivial wiring already covered higher up.
|
||||
|
||||
### Type / build correctness (MANDATORY)
|
||||
Type checks live in build scripts:
|
||||
**Do not:** ship implementation-shaped mocks as the feature; stop at unit-green for product asks; run full monorepo / full e2e on every tiny change — targeted specs first.
|
||||
|
||||
- Product client (`toju-app/`): `npm run build` (Angular CLI runs `tsc` with strict settings)
|
||||
- Electron (`electron/`): `npm run build:electron` (invokes `tsc -p tsconfig.electron.json`)
|
||||
- Server (`server/`): `cd server && npm run build` (invokes `tsc`)
|
||||
### Lint / type correctness (scoped)
|
||||
|
||||
If your change touches one of these packages, run the corresponding build and ensure it exits 0 before marking work complete.
|
||||
1. Targeted Vitest under `toju-app/` (and colocated Electron specs only if you changed those files).
|
||||
2. Auto-fix style first: `npm run lint:fix` from repo root (`format` + `sort:props` + `eslint . --fix`). Do **not** hand-edit formatting/import-sort/eslint-fixable issues — re-run `lint:fix`. Then confirm clean with `npm run lint` only if you need a no-write check; prefer trusting `lint:fix` exit 0.
|
||||
3. Do not paste entire lint logs into the chat — fix via `lint:fix` / minimal code changes for non-auto issues.
|
||||
4. `npm run build` when client types/templates could break; `npm run build:electron` only if you changed Electron sources.
|
||||
|
||||
## Most important rule
|
||||
Do **not** run `cd server && npm run build` or `npm run test:e2e` unless scope expanded or the bug is proven there.
|
||||
|
||||
After any change that affects API contracts, schemas, invariants, workflows, or major behavior: update the relevant `agents-docs/features/<slug>.md` as part of the same task — not as a follow-up. New feature area → create `agents-docs/features/<slug>.md` and add an entry to `agents-docs/FEATURES.md` (alphabetical).
|
||||
### Feature docs
|
||||
|
||||
The product client already maintains per-domain READMEs under `toju-app/src/app/domains/<name>/README.md`. When the change is fully internal to one of those bounded contexts and its surface stays the same, the domain README is the right place to update; cross-context contracts (websocket envelopes, IPC channels, server routes, plugin manifests) belong in `agents-docs/features/`.
|
||||
|
||||
## Structure of further instructions
|
||||
|
||||
- **Agent workflow & operating rules:** `agents-docs/AGENT_WORKFLOW.md`
|
||||
- **Agent lessons (durable cross-session rules):** `agents-docs/LESSONS.md`
|
||||
- **Engineering standards:** `agents-docs/ENGINEERING.md`
|
||||
- **Feature documentation contract:** `agents-docs/AGENTS_FEATURES.md`
|
||||
- **CONTEXT documentation contract:** `agents-docs/AGENTS_CONTEXT.md`
|
||||
- **ADR contract:** `agents-docs/AGENTS_ADRS.md`
|
||||
- **Feature index:** `agents-docs/FEATURES.md`
|
||||
- **Feature docs:** `agents-docs/features/`
|
||||
- **Architecture decisions:** `agents-docs/adr/`
|
||||
- **Context map:** `agents-docs/CONTEXT-MAP.md`
|
||||
- **Obsidian bug tracker:** `agents-docs/BUG_TRACKER.md`
|
||||
- **Product-client domain:** `toju-app/CONTEXT.md`
|
||||
- **Desktop-shell domain:** `electron/CONTEXT.md`
|
||||
- **Server domain:** `server/CONTEXT.md`
|
||||
- **E2E suite domain:** `e2e/CONTEXT.md`
|
||||
- **Marketing-site domain:** `website/CONTEXT.md`
|
||||
- **App-docs domain:** `docs-site/CONTEXT.md`
|
||||
|
||||
Keep this file minimal. Do not duplicate detailed rules here.
|
||||
Internal domain changes → `toju-app/src/app/domains/<name>/README.md`.
|
||||
IPC/preload/WS contract changes → `agents-docs/features/<slug>.md` when that contract actually changed.
|
||||
|
||||
## Completion checklist
|
||||
|
||||
Before marking work complete:
|
||||
|
||||
- [ ] Tests written before implementation
|
||||
- [ ] All tests passing (`npm run test`, plus `npm run test:e2e` if behavior is user-visible)
|
||||
- [ ] `npm run lint` passes
|
||||
- [ ] Affected package builds: `npm run build` / `npm run build:electron` / `cd server && npm run build`
|
||||
- [ ] Naming conventions followed (kebab-case files; domain `*.rules.ts` / `*.model.ts` / `*.component.ts` suffixes)
|
||||
- [ ] Errors handled
|
||||
- [ ] Feature docs updated if contract/schema/invariant changed (see `agents-docs/AGENTS_FEATURES.md`)
|
||||
- [ ] `CONTEXT.md` updated if a domain term was resolved or introduced (see `agents-docs/AGENTS_CONTEXT.md`)
|
||||
- [ ] ADR written if a hard-to-reverse decision was made (see `agents-docs/AGENTS_ADRS.md`)
|
||||
- [ ] Lesson recorded in `agents-docs/LESSONS.md` if this session produced a correction, revert, or hidden constraint (see triggers in `agents-docs/AGENT_WORKFLOW.md`)
|
||||
- [ ] PR opened with summary and linked issues (`Fixes #<n>` / `Relates to #<n>`)
|
||||
- [ ] Gitea Workflows checks passing
|
||||
- [ ] Interview completed (or user opted out); implemented only approved choices
|
||||
- [ ] Asked behavior proven (not only unit tests green)
|
||||
- [ ] Stayed in scope (`toju-app` + targeted `electron` + CI) unless user expanded it
|
||||
- [ ] Appropriate targeted tests for logic changes
|
||||
- [ ] Lint via `npm run lint:fix` (not hand-fixed style); build only touched packages
|
||||
- [ ] Docs only if contracts changed
|
||||
- [ ] Lesson + index entry if corrected this session
|
||||
- [ ] If the thread is long and work remains: overwrite `HANDOFF.md` and ask user for a new chat
|
||||
- [ ] If work from an active handoff is finished: clear `HANDOFF.md` to `Status: none` (empty sections)
|
||||
- [ ] PR when requesting merge (`Fixes #<n>` / `Relates to #<n>`)
|
||||
|
||||
@@ -1,110 +1,83 @@
|
||||
# Agent Workflow & Operating Instructions
|
||||
|
||||
These rules apply to **all AI agents** working on this project, regardless of platform or model.
|
||||
These rules apply to **all AI agents** working on this project.
|
||||
|
||||
Read this file at the start of every session.
|
||||
**Token budget (mandatory):**
|
||||
|
||||
- Default scope: **`toju-app/` + targeted `electron/` + `.gitea/workflows/`** — see `/AGENTS.md`. No `server/`, `e2e/`, `website/`, `docs-site/` unless the user expands scope.
|
||||
- Electron: follow the renderer → preload → one handler path; never dump the whole `electron/` tree.
|
||||
- Prefer path-scoped search; one focused agent; no Bugbot / security / best-of-N unless asked.
|
||||
- **Handoff > fat chats:** agents cannot open new chats. Write `agents-docs/HANDOFF.md` and ask the user to start a new chat with that file attached.
|
||||
- **Named bugs:** `fix bug "…"` → one Obsidian note (`BUG_TRACKER.md` § Named fix), then default repo scope — not the whole vault or monorepo.
|
||||
- **Models:** keep the user’s latest problem-solving model. Cut cost with scope + handoffs, not weaker models.
|
||||
|
||||
Do **not** re-read this whole file every turn after the first skim.
|
||||
|
||||
---
|
||||
|
||||
## Workflow Orchestration
|
||||
|
||||
### 1. Plan Mode Default
|
||||
### 1. Interview before implement (default)
|
||||
|
||||
- Enter plan mode for ANY non-trivial task (3+ steps or architectural decisions)
|
||||
- If something goes sideways, STOP and re-plan immediately — don't keep pushing
|
||||
- Use plan mode for verification steps, not just building
|
||||
- Write detailed specs upfront to reduce ambiguity
|
||||
- For bugs/features: short interview first (understanding, gaps, choices + recommended default, scope, proof) — then wait. See `/AGENTS.md` and `.cursor/rules/interview-before-fix.mdc`.
|
||||
- Do not guess past ambiguity; the user controls the implementation choices.
|
||||
- Skip only when the user opts out (“just fix it”) or an approved handoff already decided.
|
||||
|
||||
### 2. Subagent Strategy
|
||||
### 2. Plan mode
|
||||
|
||||
- Use subagents liberally to keep the main context window clean
|
||||
- Offload research, exploration, and parallel analysis to subagents
|
||||
- For complex problems, throw more compute at it via subagents
|
||||
- One task per subagent for focused execution
|
||||
- Use plan mode when architecture is unclear or the user asks — the interview often replaces a heavy plan for normal bug fixes.
|
||||
- Skip long planning essays; prefer bullet choices.
|
||||
|
||||
### 3. Self-Improvement Loop
|
||||
### 3. Subagents sparingly
|
||||
|
||||
The goal is a small, sharp file of project-specific rules in `agents-docs/LESSONS.md` that future sessions read and apply. The format of a lesson is defined at the top of `agents-docs/LESSONS.md` — read it before writing one.
|
||||
- Default: one agent.
|
||||
- Subagents only for true parallel search inside allowed paths.
|
||||
- Never spawn extra review agents unless the user asks.
|
||||
|
||||
**Read at session start.** Open `agents-docs/LESSONS.md` and apply any rules that match the work you're about to do. This is non-optional; the file exists so the same mistake isn't made twice.
|
||||
### 4. Handoff / short sessions
|
||||
|
||||
**Triggers — record a lesson when any of these happen.** Don't wait for a formal request; these are the signals:
|
||||
Triggers: user says handoff / new chat; thread is long with more major work left; switching objectives.
|
||||
|
||||
- User says "no", "actually", "don't", "stop", "that's wrong", or "instead do X"
|
||||
- User reverts, rewrites, or asks you to redo your edit
|
||||
- User re-prompts you with the same or similar instruction (signal that the first attempt missed something)
|
||||
- User points out a hidden constraint, past incident, or convention you didn't know
|
||||
- Code review (human or `/review`) surfaces an issue caused by your approach
|
||||
- You catch yourself about to do the same thing the project has been corrected on before
|
||||
Action: **overwrite** (never append) `agents-docs/HANDOFF.md` with `Status: active` and short sections. Then stop major work and ask the user to open a new chat.
|
||||
|
||||
If unsure whether it's worth recording: write it. Sharper is better than missing, and grooming the file is cheap.
|
||||
New chat: if handoff is active, read it first; continue Next steps; do not redo Completed work. If Next steps still need choices, re-interview — don’t invent them.
|
||||
|
||||
**Write before reporting done.** A session that produced a correction must produce a lesson — record it in the same turn the work is completed, not "later". The `AGENTS.md` completion checklist has a line for this; don't tick the box without it.
|
||||
**When finished:** clear `HANDOFF.md` to `Status: none` with empty sections so the file stays tiny for the next session.
|
||||
|
||||
**Groom periodically.** When `agents-docs/LESSONS.md` passes ~20 entries, propose consolidations to the user — merge duplicates, delete rules that no longer apply, shorten anything vague.
|
||||
### 5. Self-Improvement Loop
|
||||
|
||||
### 4. CONTEXT.md upkeep
|
||||
**At session start:** `LESSONS-INDEX.md` only; open matching lesson bodies by tag.
|
||||
|
||||
Read `CONTEXT.md` (or `agents-docs/CONTEXT-MAP.md` → per-subdomain `CONTEXT.md`) when working in a subdomain. Use its vocabulary verbatim **where defined** in code, tests, issues, and commits. If a needed term isn't in the glossary, treat it as a trigger (see below) rather than silently inventing a synonym; the full contract lives in `agents-docs/AGENTS_CONTEXT.md`.
|
||||
Record a lesson + index line when corrected. Prefer fewer sharp rules (~20).
|
||||
|
||||
**Triggers — capture vocabulary in the moment:**
|
||||
### 6. CONTEXT.md upkeep
|
||||
|
||||
- A previously-ambiguous domain term gets a clear resolution → add it (one-sentence definition, aliases to avoid).
|
||||
- User corrects your terminology → record the correct term; mark the wrong one as an alias to avoid.
|
||||
- A new feature introduces a concept absent from the glossary → add it before claiming the feature done.
|
||||
- You catch yourself inventing a synonym because the right term isn't there → flag the gap; don't silently coin a new term.
|
||||
Default: `toju-app/CONTEXT.md`. Read `electron/CONTEXT.md` only when touching Electron. Other packages only when in scope.
|
||||
|
||||
**Write before reporting done.** Update the relevant `CONTEXT.md` in the same turn the trigger fires. Append-only — add new entries, don't reshuffle existing ones. The format is documented at the top of each `CONTEXT.md`. See `agents-docs/AGENTS_CONTEXT.md` for the full contract.
|
||||
### 7. ADR upkeep
|
||||
|
||||
### 5. ADR upkeep
|
||||
Only when hard-to-reverse + surprising + real trade-offs. Contract: `agents-docs/AGENTS_ADRS.md`.
|
||||
|
||||
Read `agents-docs/adr/` when about to change anything that crosses an existing decision boundary. If your work would contradict an ADR, surface it explicitly — never silently override.
|
||||
### 8. Verification Before Done (behavior first)
|
||||
|
||||
**Triggers — write an ADR only when all three apply:**
|
||||
Done = asked functionality works **as the user confirmed in the interview**. Unit green ≠ done for product asks.
|
||||
|
||||
- **Hard to reverse** (schema migration, framework swap, integration redesign).
|
||||
- **Surprising without context** (future engineers will question the approach).
|
||||
- **Result of genuine trade-offs** (real alternatives existed and you chose deliberately).
|
||||
### 9. Demand Elegance (Balanced)
|
||||
|
||||
If all three apply: write the ADR in the same turn as the decision. Next number (4-digit zero-padded), kebab-case slug, Nygard short form — see `agents-docs/adr/0001-record-architectural-decisions.md` for the canonical example and `agents-docs/AGENTS_ADRS.md` for the contract. If any of the three is missing: don't write one.
|
||||
One pause for non-trivial design; skip for obvious fixes once the user has chosen a direction.
|
||||
|
||||
**Supersede, don't delete.** Overturned decisions get a new ADR; the old one stays with a `Superseded by ADR-NNNN` note.
|
||||
### 10. Bug fixing (after interview)
|
||||
|
||||
### 6. Verification Before Done
|
||||
|
||||
- Never mark a task complete without proving it works
|
||||
- Diff behavior between main and your changes when relevant
|
||||
- Ask yourself: "Would a staff engineer approve this?"
|
||||
- Run tests, check logs, demonstrate correctness
|
||||
|
||||
### 7. Demand Elegance (Balanced)
|
||||
|
||||
- For non-trivial changes: pause and ask "is there a more elegant way?"
|
||||
- If a fix feels hacky: "Knowing everything I know now, implement the elegant solution"
|
||||
- Skip this for simple, obvious fixes — don't over-engineer
|
||||
- Challenge your own work before presenting it
|
||||
|
||||
### 8. Autonomous Bug Fixing
|
||||
|
||||
- When given a bug report: just fix it. Don't ask for hand-holding
|
||||
- Point at logs, errors, failing tests — then resolve them
|
||||
- Zero context switching required from the user
|
||||
Implement the approved plan with evidence in default scope. If root cause is clearly outside scope, say so and ask to expand — don’t silently crawl.
|
||||
|
||||
---
|
||||
|
||||
## Pull Requests
|
||||
|
||||
This project hosts at Gitea (`git.azaaxin.com/myxelium/Toju`). Gitea PRs and issues use GitHub-style syntax.
|
||||
|
||||
- Create a feature branch for every change: `<type>/<short-description>` (e.g. `feat/add-retry-logic`, `fix/null-pointer-webhook`) — `<type>` should match the Conventional Commits prefix (`feat`, `fix`, `chore`, `docs`, `perf`, `refactor`, `test`)
|
||||
- Open the PR via the Gitea web UI (or `tea pulls create` if `tea` CLI is installed) — include a summary and a test plan
|
||||
- Link issues in the PR body with `Fixes #<number>` for auto-close or `Relates to #<number>` for reference (Gitea honors the same keywords as GitHub)
|
||||
- After merge, delete the feature branch
|
||||
Gitea: `git.azaaxin.com/myxelium/Toju`. Branch `<type>/<short-description>`; PR with summary + test plan; `Fixes #<n>` / `Relates to #<n>`.
|
||||
|
||||
---
|
||||
|
||||
## Core Principles
|
||||
|
||||
- **Simplicity First:** Make every change as simple as possible. Impact minimal code.
|
||||
- **No Laziness:** Find root causes. No temporary fixes. Senior developer standards.
|
||||
- **Minimal Impact:** Changes should only touch what's necessary. Avoid introducing bugs.
|
||||
- **Simplicity First** · **No Laziness** · **Minimal Impact** · **Cheap Context** · **Default Scope Fence** · **Handoff Beats Fat Context** · **Interview Before Guessing**
|
||||
|
||||
+25
-55
@@ -1,49 +1,49 @@
|
||||
# Obsidian Bug Tracker — Agent Contract
|
||||
|
||||
User-maintained bug reports live outside the repo. Read this file when asked to triage, investigate, or work from the bug backlog.
|
||||
User-maintained bug reports live outside the repo. Use this when the user names a bug or asks to triage the backlog.
|
||||
|
||||
**Overrides** `agents-docs/AGENT_WORKFLOW.md` §8 (Autonomous Bug Fixing) unless the user explicitly asks you to fix a bug in code.
|
||||
**Inbox:** `/home/ludde/Nextcloud/Obsidian Vault/Log/Bugs/`
|
||||
**Attachments:** `…/Bugs/attachments/<Bug title>/`
|
||||
**Dashboard / template:** `…/Log/Create bug.md`, `…/Log/Templates/Bug Report.md`
|
||||
|
||||
---
|
||||
|
||||
## Location
|
||||
## Named fix (default — cheap path)
|
||||
|
||||
| Item | Path |
|
||||
|------|------|
|
||||
| Bug inbox | `/home/ludde/Nextcloud/Obsidian Vault/Log/Bugs/` |
|
||||
| Attachments | `…/Bugs/attachments/<Bug title>/` |
|
||||
| Dashboard | `/home/ludde/Nextcloud/Obsidian Vault/Log/Create bug.md` |
|
||||
| Template | `/home/ludde/Nextcloud/Obsidian Vault/Log/Templates/Bug Report.md` |
|
||||
When the user says e.g. `fix bug "Images and files in chat doesn't load"`:
|
||||
|
||||
1. **Resolve one note** under `Log/Bugs/` whose title matches (usually `Bug - <title>.md`). Do not list or read the whole inbox.
|
||||
2. **Read that note** (Description, Steps, Expected/Actual, Logs). Read attachments only under that bug’s attachment folder if referenced.
|
||||
3. **Interview before implement** (see `/AGENTS.md` and `.cursor/rules/interview-before-fix.mdc`): restate the bug, list gaps, present choices with a recommended default, propose scope and proof of done — then **wait** for the user. Do not start coding until they answer (unless they said “just fix it”).
|
||||
4. Acceptance criteria = the note’s Expected Result **plus** the user’s interview answers.
|
||||
5. **Fix in default repo scope** (`toju-app/` + targeted `electron/` + CI) per `AGENTS.md`. Do not crawl `server/` / `e2e/` / other packages unless the note or user clearly requires it — then ask once.
|
||||
6. Prove the asked behavior (not only unit-green). Prefer a regression that encodes the note’s failure mode.
|
||||
7. When done: set that note’s frontmatter `status` to `Resolved` (or `Closed` if the user prefers). Do not rewrite Description / Investigation / Resolution unless asked.
|
||||
8. Long thread + more work left → write `agents-docs/HANDOFF.md` and ask for a new chat.
|
||||
|
||||
Do **not** re-read `BUG_TRACKER.md` every turn after the first use. Do **not** load the stale “open bugs” snapshot as truth — the vault files are source of truth.
|
||||
|
||||
---
|
||||
|
||||
## Allowed actions on vault files
|
||||
## Backlog triage only
|
||||
|
||||
Unless the user explicitly asks for more:
|
||||
|
||||
1. **Change `status`** in a bug note's YAML frontmatter (`Open` → `Resolved` or `Closed`).
|
||||
2. **Move files** (e.g. reorganize notes or attachments when instructed).
|
||||
|
||||
Do **not** edit other vault fields or sections (`Investigation`, `Resolution`, description, etc.) unless the user asks.
|
||||
If the user asks to list/triage open bugs (not a named fix): `ls` / glob `Log/Bugs/*.md`, filter `status: Open`, summarize titles — still don’t open every body until they pick one.
|
||||
|
||||
---
|
||||
|
||||
## Allowed reads (unrestricted)
|
||||
## Vault edit policy
|
||||
|
||||
To understand and solve bugs you may read freely:
|
||||
Unless the user asks for more:
|
||||
|
||||
- All bug notes and attachments under `Log/Bugs/`
|
||||
- The full MetoYou repo (code, tests, logs, docs)
|
||||
- Runtime output, test results, and debug artifacts
|
||||
- **Allowed write:** frontmatter `status` (`Open` → `Resolved` / `Closed`); move files if they specify a convention.
|
||||
- **Do not edit:** other frontmatter fields, Description, Steps, Investigation, Resolution, etc.
|
||||
|
||||
Investigation findings belong in chat or in repo changes — not in the vault — unless the user asks you to update the note.
|
||||
Investigation findings go in chat or the repo — not the vault — unless asked.
|
||||
|
||||
---
|
||||
|
||||
## Bug note format
|
||||
|
||||
Each note is Markdown with YAML frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Bug - …
|
||||
@@ -57,34 +57,4 @@ tags: [bug]
|
||||
---
|
||||
```
|
||||
|
||||
Body sections: **Description**, **Steps to Reproduce**, **Expected Result**, **Actual Result**, **Logs / Screenshots**, **Investigation**, **Resolution**.
|
||||
|
||||
The dashboard (`Create bug.md`) uses Dataview; keep `type: bug` and `status` accurate so counts stay correct.
|
||||
|
||||
---
|
||||
|
||||
## Workflow
|
||||
|
||||
1. List open bugs: `Glob` or `ls` on `…/Log/Bugs/*.md`, filter `status: Open`.
|
||||
2. Read the note and any linked attachments.
|
||||
3. Investigate in the repo (read-only toward the vault).
|
||||
4. Report findings to the user.
|
||||
5. Only when told to fix: implement in repo (TDD, lint, build per `AGENTS.md`).
|
||||
6. When a bug is done: update vault `status` to `Resolved` or `Closed` (and move files if the user specifies a convention).
|
||||
|
||||
---
|
||||
|
||||
## Open bugs (snapshot 2026-06-10)
|
||||
|
||||
| Title | Priority | Environment |
|
||||
|-------|----------|-------------|
|
||||
| Attachments gets syncronized corrupt | Critical | All major clients |
|
||||
| Chats doesn't sync for multi client users | High | All |
|
||||
| No android app icon | High | Android |
|
||||
| No login screen mobile phone on startup | High | Android, Android Browser |
|
||||
| Fresh users have the server list in dashboard completely empty until anything searched | High | — |
|
||||
| Video attachment on android gets sent in the message bubble above with no preview image | High | Android |
|
||||
| Local files should be remembered by client | High | — |
|
||||
| Emojis should be user bound not client bound | Medium | All |
|
||||
|
||||
Re-scan the folder at session start; this table is not auto-updated.
|
||||
Body: **Description**, **Steps to Reproduce**, **Expected Result**, **Actual Result**, **Logs / Screenshots**, **Investigation**, **Resolution**.
|
||||
|
||||
+15
-10
@@ -45,9 +45,14 @@ The server package does not currently have a test runner script — there is one
|
||||
|
||||
E2E tests exercise the real Electron app against the real signaling server. The `.agents/skills/playwright-e2e/SKILL.md` describes the convention this repo uses for E2E test design — read it before adding new tests.
|
||||
|
||||
### TDD discipline
|
||||
### Test-backed development (balanced)
|
||||
|
||||
Write the failing test first. Run it, watch it fail, then write the smallest code that makes it pass. This rule is non-negotiable (see `/AGENTS.md` § CRITICAL).
|
||||
See `/AGENTS.md` § CRITICAL for the full policy. Summary:
|
||||
|
||||
- **Done** = the asked behavior works, proven at the right level. Green unit tests alone are not done when the ask was product behavior.
|
||||
- Prefer red-green for domain/logic and bug invariants; encode the **user-facing or cross-boundary** outcome, not only implementation details.
|
||||
- Skip full red-green for docs/copy/agent-instruction text, pure formatting, and trivial wiring already covered by a higher-level test you will run.
|
||||
- Run **targeted** specs while iterating (`cd toju-app && npx vitest run <path>`); broaden to full suite / e2e when risk is cross-cutting or before merge.
|
||||
|
||||
Integration / cross-package work that needs a real database can rely on Electron's TypeORM + sql.js setup (in-memory by default) — no Testcontainers required.
|
||||
|
||||
@@ -191,8 +196,8 @@ cd server && npm run build # server tsc
|
||||
npm run build:all # all of the above
|
||||
|
||||
# --- lint / format ---
|
||||
npm run lint # eslint .
|
||||
npm run lint:fix # format + sort:props + eslint --fix
|
||||
npm run lint # eslint . (check only)
|
||||
npm run lint:fix # preferred: format + sort:props + eslint --fix
|
||||
npm run format # prettier on Angular HTML templates only
|
||||
npm run format:check # prettier --check on HTML templates
|
||||
|
||||
@@ -209,16 +214,16 @@ npm run migration:revert # roll back last
|
||||
|
||||
Before marking work complete:
|
||||
|
||||
- [ ] Tests written before implementation
|
||||
- [ ] All tests passing (`npm run test`, plus `npm run test:e2e` if behavior is user-visible)
|
||||
- [ ] `npm run lint` passes
|
||||
- [ ] Affected package builds: `npm run build` / `npm run build:electron` / `cd server && npm run build`
|
||||
- [ ] Tests written before implementation (balanced — see `/AGENTS.md`)
|
||||
- [ ] Asked behavior proven; targeted tests pass (`npm run test:e2e` only if in scope / user-visible and needed)
|
||||
- [ ] `npm run lint:fix` passes (do not hand-fix auto-fixable lint/format)
|
||||
- [ ] Affected package builds: `npm run build` / `npm run build:electron` when those packages were touched
|
||||
- [ ] Naming conventions followed
|
||||
- [ ] Errors handled
|
||||
- [ ] Security considered (no secrets in code, no plaintext token logging, no IPC handler accepting arbitrary file paths)
|
||||
- [ ] Feature docs updated if contract/schema/invariant changed (see `agents-docs/AGENTS_FEATURES.md`)
|
||||
- [ ] `CONTEXT.md` updated if a domain term was resolved or introduced (see `agents-docs/AGENTS_CONTEXT.md`)
|
||||
- [ ] ADR written if a hard-to-reverse decision was made (see `agents-docs/AGENTS_ADRS.md`)
|
||||
- [ ] Lesson recorded in `agents-docs/LESSONS.md` if this session produced a correction, revert, or hidden constraint (see triggers in `agents-docs/AGENT_WORKFLOW.md`)
|
||||
- [ ] PR opened with summary and linked issues
|
||||
- [ ] Lesson recorded in `agents-docs/LESSONS.md` + `LESSONS-INDEX.md` if this session produced a correction
|
||||
- [ ] PR opened with summary and linked issues when requesting merge
|
||||
- [ ] Gitea Workflows checks passing
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Session Handoff
|
||||
|
||||
> **New chat:** attach `@agents-docs/HANDOFF.md`. Say: continue from this handoff; do not redo completed work.
|
||||
|
||||
**Status:** none
|
||||
|
||||
## Goal
|
||||
|
||||
## Completed
|
||||
|
||||
## Changed files
|
||||
|
||||
## Decisions
|
||||
|
||||
## Failed approaches
|
||||
|
||||
## Current issue
|
||||
|
||||
## Next steps
|
||||
|
||||
## Commands
|
||||
@@ -0,0 +1,82 @@
|
||||
# Agent Lessons — Index
|
||||
|
||||
**Session start:** read this file only. Match tags to the task. Open the matching lesson body in `agents-docs/LESSONS.md` — do **not** load every lesson.
|
||||
|
||||
When adding a lesson: append the full entry near the top of `LESSONS.md` (under `## Lessons`) **and** add one bullet here.
|
||||
|
||||
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]`
|
||||
- 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]`
|
||||
- Don't strand signed-out mobile users on a logged-out dashboard — `[auth] [mobile] [routing]`
|
||||
- "Shared from your device" must gate on local bytes, not uploader user id — `[attachments] [multi-device]`
|
||||
- Generate Android brand icons from the source mark; guard against stock Capacitor placeholders — `[mobile] [android] [assets]`
|
||||
- Bind chat attachments to a pre-allocated message id, never by matching content — `[attachments] [chat] [mobile]`
|
||||
- Attachment file persistence must be platform-agnostic, not Electron-only — `[attachments] [persistence] [mobile]`
|
||||
- Never count duplicate chunks toward transfer progress, and never finalize on byte counters — `[attachments] [webrtc]`
|
||||
- Don't bump E2E timeouts for sync flakes - gate on presence and read server logs — `[testing] [realtime]`
|
||||
- When renaming an Angular route, sweep every navigate/url-match/doc reference — `[routing]`
|
||||
- Server discovery must fan out across all endpoints and self-heal on 404 — never hardcode a host capability blocklist — `[server-directory]`
|
||||
- Server registration needs `ownerPublicKey: oderId || id`, and must not be fire-and-forget — `[server-directory] [rooms]`
|
||||
- Identify must fall back to the legacy session token, not only the new credential store — `[realtime] [authentication]`
|
||||
- Keep the per-signal-URL identify credential resolvable from the store — `[realtime] [authentication]`
|
||||
- Store clientInstanceId in sessionStorage not localStorage — `[realtime] [multi-device]`
|
||||
- Revalidate IndexedDB scope without reinitializing on every read — `[persistence] [performance]`
|
||||
- Restore local user scope before protected writes — `[authentication] [persistence]`
|
||||
- Persisted local user state still requires a session token — `[authentication] [signaling]`
|
||||
- Declare MODIFY_AUDIO_SETTINGS for Android WebRTC mic capture — `[mobile] [android]`
|
||||
- Do not override Tailwind with box-sizing inherit — `[mobile] [css]`
|
||||
- Use the app-shell servers rail for mobile discovery pages — `[mobile] [layout]`
|
||||
- Defer attachment blob hydration on Electron startup — `[attachments] [electron]`
|
||||
- Lazy-load Capacitor modules on Electron/desktop — `[mobile] [electron]`
|
||||
- Use the upgrade transaction during IndexedDB schema migrations — `[persistence] [browser]`
|
||||
- Wait for authenticateUser storage prep before post-login navigation — `[authentication] [browser]`
|
||||
- Use dense arrays for chunked transfer buffers — `[custom-emoji] [webrtc]`
|
||||
- Route custom emoji right-click through the native context menu — `[custom-emoji] [ux]`
|
||||
- Separate known emoji assets from saved library — `[custom-emoji] [ux]`
|
||||
- Chunk custom emoji assets over data channels — `[custom-emoji] [webrtc]`
|
||||
- Re-clear visible notification channels after recompute — `[notifications] [startup]`
|
||||
- Disambiguate nested chat cards — `[chat] [ui]`
|
||||
- Use terminal Vitest when the test tool hangs — `[testing]`
|
||||
- Do not add fake chrome around screenshots — `[website] [design]`
|
||||
- Verify lint exits 0 before claiming done — `[verification]`
|
||||
- Prefer `npm run lint:fix` over hand-fixing lint/format — `[verification] [lint] [tokens]`
|
||||
- Use blob URLs for inline attachment previews — `[attachments] [electron]`
|
||||
- Resolve Electron drag-and-drop file paths with webUtils — `[attachments] [electron]`
|
||||
- Preserve uploader local attachment paths across sync — `[attachments] [persistence]`
|
||||
- Interview before coding; don’t guess the fix — `[workflow] [bugs] [tokens]`
|
||||
- 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]`
|
||||
|
||||
+212
-9
@@ -1,12 +1,12 @@
|
||||
# Agent Lessons
|
||||
|
||||
Durable rules for AI agents working on this project. Read this file at session start. Append to it when this session produces a correction worth remembering.
|
||||
Durable rules for AI agents working on this project.
|
||||
|
||||
## How to use this file
|
||||
|
||||
**At session start:** scan the rules below. If any match the work you're about to do, apply them.
|
||||
**At session start:** read `agents-docs/LESSONS-INDEX.md` only. Open lesson bodies here **only** for tags that match the task. Do not load this entire file into context by default.
|
||||
|
||||
**During the session:** if the user corrects you, reverts your edit, or re-prompts with the same instruction — that is a signal to record a lesson before closing the task. See the trigger list in `agents-docs/AGENT_WORKFLOW.md`.
|
||||
**During the session:** if the user corrects you, reverts your edit, or re-prompts with the same instruction — record a lesson here **and** add a one-line entry to `LESSONS-INDEX.md` before closing the task. See triggers in `agents-docs/AGENT_WORKFLOW.md`.
|
||||
|
||||
**Format of a lesson:** every entry uses the four-slot template below. Brevity matters — if you can't state the rule in one sentence, the lesson isn't sharp enough yet.
|
||||
|
||||
@@ -25,6 +25,195 @@ Durable rules for AI agents working on this project. Read this file at session s
|
||||
|
||||
## 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; don’t 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.
|
||||
- **Rule:** send a short interview (understanding, gaps, A/B/C + recommended default, proposed scope, proof of done), wait for the user’s choices, then implement only that — skip only if they said “just fix it” / “no interview.”
|
||||
- **Why:** unprompted guesses cause wrong fixes and expensive back-and-forth; one clarifying turn costs less than a wrong implementation thread.
|
||||
- **Example:** `fix bug "Images and files in chat doesn't load"` → read the note → ask whether the failure is channel-switch blank vs cold reload vs both before touching attachment services.
|
||||
|
||||
### Default to `toju-app/` + targeted `electron/` + CI; do not crawl the monorepo [workflow] [tokens] [scope]
|
||||
|
||||
- **Trigger:** about to browse all of `electron/`, or to `grep`/`Read` under `server/`, `e2e/`, `website/`, or `docs-site/` on a normal product bug without the user naming those packages.
|
||||
- **Rule:** stay in `toju-app/`, `.gitea/workflows/`, and **only the Electron files on the renderer→preload→handler path**; if the fix looks like `server/`/e2e, ask once instead of exploring those trees.
|
||||
- **Why:** monorepo-wide (and whole-`electron/`) exploration multiplies context on expensive problem-solving models without fixing the asked client bug.
|
||||
- **Example:** attachment disk restore → `toju-app` persistence service + `electron/preload.ts` + the one IPC/file helper involved — not every file under `electron/migrations/` or `electron/api/`.
|
||||
|
||||
### Write HANDOFF.md and ask the user for a new chat — agents cannot open chats [workflow] [tokens] [handoff]
|
||||
|
||||
- **Trigger:** the thread is long, the user says "handoff"/"new chat", or a new major objective starts while more work remains.
|
||||
- **Rule:** **overwrite** (never append) `agents-docs/HANDOFF.md` with `Status: active` and short sections, ask the user to start a new chat with that file; when the handoff task is finished, **clear** the file to `Status: none` with empty sections.
|
||||
- **Why:** fat chat history dominates token burn; an appending handoff file becomes a second fat archive that every new chat reloads.
|
||||
- **Example:** user: "handoff" → replace HANDOFF → reply: "Start a new chat and attach `@agents-docs/HANDOFF.md`." Later when done → reset HANDOFF to empty `Status: none`.
|
||||
|
||||
### Prove the asked behavior; unit-green is not done [verification] [testing] [workflow]
|
||||
|
||||
- **Trigger:** about to report a task finished because colocated Vitest specs (or a narrow mocked unit) are green, while the user’s ask was a product behavior, UI flow, or bug they can still reproduce.
|
||||
- **Rule:** treat acceptance as “the asked functionality works” — prove it with a user-visible path, focused e2e, or an explicit manual check; keep unit tests as support, never as the sole done signal.
|
||||
- **Why:** agents optimized for TDD often stop at implementation-shaped tests that pass while the real feature/bug remains broken, which wastes follow-up turns and burns tokens on false completion.
|
||||
- **Example:** for “DM reply doesn’t show for the caller,” a passing `DirectMessageService` mock test is insufficient until the cross-signal conversation identity path is exercised (e2e or a behavior-level regression that fails on the old fork-thread bug).
|
||||
|
||||
### Keep `NgOptimizedImage` off runtime blob and data URLs [angular] [images]
|
||||
|
||||
- **Trigger:** Angular template lint suggests replacing `[src]` with `ngSrc` for a user-uploaded image rendered from `blob:` or `data:`.
|
||||
@@ -34,10 +223,10 @@ Durable rules for AI agents working on this project. Read this file at session s
|
||||
|
||||
### Read the exact Obsidian bug note before diagnosing a named ticket [workflow] [bugs]
|
||||
|
||||
- **Trigger:** The user names a `Bug - …` ticket, but the worktree already contains plausible changes or a similarly named resolved ticket.
|
||||
- **Rule:** Resolve the exact note under `Log/Bugs/`, read every reported variant and reproduction step, and only then decide which code changes and status update belong to that ticket.
|
||||
- **Why:** attachment reload-host changes looked related to “Images and files in chat doesn't load” but came from a separate resolved ticket and did not cover the reported channel-switch state regression.
|
||||
- **Example:** read `/home/ludde/Nextcloud/Obsidian Vault/Log/Bugs/Bug - Images and files in chat doesn't load.md` before implementing or committing its fix.
|
||||
- **Trigger:** The user says `fix bug "…"`, names a `Bug - …` ticket, or the worktree already contains plausible changes / a similarly named resolved ticket.
|
||||
- **Rule:** Resolve and read **only** that note under `Log/Bugs/` (and its attachment folder if needed); use Expected Result as acceptance; fix in default scope; do not list the whole inbox or treat `BUG_TRACKER.md`'s snapshot table as live.
|
||||
- **Why:** attachment reload-host changes looked related to “Images and files in chat doesn't load” but came from a separate resolved ticket and did not cover the reported channel-switch state regression; inbox-wide reads also burn tokens for no gain.
|
||||
- **Example:** `fix bug "Images and files in chat doesn't load"` → read `/home/ludde/Nextcloud/Obsidian Vault/Log/Bugs/Bug - Images and files in chat doesn't load.md` only, then implement against its Steps/Expected.
|
||||
|
||||
### Run `npm run i18n:sync` after editing any `public/i18n/catalog/*.json` file [i18n] [testing]
|
||||
|
||||
@@ -53,6 +242,13 @@ Durable rules for AI agents working on this project. Read this file at session s
|
||||
- **Why:** the failure only reproduces when caller and callee have different home signal servers, which no same-server e2e covers; and when one identity-alias bug is fixed in a domain, grep for the same `=== currentUserId` pattern in sibling domains that share the transport — the direct-call domain reused `PeerDeliveryService` but kept the naive check for another month.
|
||||
- **Example:** `direct-call-participant-identity.rules.ts#directCallPayloadIncludesAnyId` / `normalizeDirectCallPayloadSelfAliases`; regression e2e `e2e/tests/voice/dm-header-call-ring.spec.ts` registers Bob on a secondary signal server, meets in a primary-signal room, and asserts the DM-header call rings Bob's incoming-call modal (fails on old code, passes after).
|
||||
|
||||
### Resolve outbound direct-call recipient ids to the peer's connected signal identity [direct-call] [identity] [signaling]
|
||||
|
||||
- **Trigger:** cross-signal direct calls still failed after the inbound alias fix — the caller joined voice and showed "In voice" while the callee never rang. `PeerDeliveryService.resolveSignalingPeerId` returned null when the stored peer id was a home id but presence/route was registered under the provisioned actor id, so `sendRawMessage` was never called; even when attempted, the server relays only when `targetUserId` exactly matches the callee's connected `oderId`.
|
||||
- **Rule:** outbound DM/call delivery must collect every recipient alias (`peer-delivery-identity.rules.ts#collectRecipientDeliveryCandidateIds`), pick the routable id with `pickRoutableRecipientId`, always attempt signaling send (broadcast fallback when no single route works), and surface `call.errors.recipientUnreachable` to the caller when delivery cannot succeed — never leave the caller in a silent "In voice" state.
|
||||
- **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).
|
||||
|
||||
### 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 10–50 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.
|
||||
@@ -291,12 +487,19 @@ Durable rules for AI agents working on this project. Read this file at session s
|
||||
- **Why:** duplicated chrome makes CTA/product previews look broken, and bottom-aligned large headings can cover accompanying text on the marketing site.
|
||||
- **Example:** `website/src/app/pages/home/home.component.html` should render the screenshot directly; `host-section` should use top-aligned heading and `.host-section-copy` columns.
|
||||
|
||||
### Prefer `npm run lint:fix` over hand-fixing lint/format [verification] [lint] [tokens]
|
||||
|
||||
- **Trigger:** about to manually re-indent, reorder imports, or tweak Prettier/ESLint-fixable style after seeing lint failures.
|
||||
- **Rule:** from repo root run `npm run lint:fix` (`format` + `sort:props` + `eslint . --fix`); only hand-edit remaining non-fixable errors.
|
||||
- **Why:** manual style fixes burn turns and tokens and often miss what the project script already auto-corrects.
|
||||
- **Example:** after code changes → `npm run lint:fix` → if exit 0, do not also rewrite imports by hand.
|
||||
|
||||
### Verify lint exits 0 before claiming done [verification]
|
||||
|
||||
- **Trigger:** about to report a task as complete after running tests but skipping ESLint.
|
||||
- **Rule:** run `npm run lint` from the repo root and confirm exit code 0 before any "done" claim.
|
||||
- **Rule:** run `npm run lint:fix` from the repo root (or `npm run lint` after fixes) and confirm exit code 0 before any "done" claim.
|
||||
- **Why:** `npm run test` only runs the toju-app Vitest suite — it doesn't cover the server, Electron, or website packages. ESLint (flat config in `eslint.config.js`) is the universal check across every package; type-style violations slip through tests and break Gitea Workflows for the next agent.
|
||||
- **Example:** `npm run lint && echo OK` — only claim done after seeing `OK`. For Electron type errors specifically, also confirm `npm run build:electron` succeeds (it invokes `tsc -p tsconfig.electron.json`).
|
||||
- **Example:** `npm run lint:fix && echo OK` — only claim done after seeing `OK`. For Electron type errors specifically, also confirm `npm run build:electron` succeeds (it invokes `tsc -p tsconfig.electron.json`).
|
||||
|
||||
### Use blob URLs for inline attachment previews [attachments] [electron]
|
||||
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
# User Story: Silent cross–signal-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 credential’s 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. Login’s 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
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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)}`
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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') {
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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, '&').replace(/</g, '<');
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface AuthUserPayload {
|
||||
displayName: string;
|
||||
createdAt: number;
|
||||
signingPublicKey?: string | null;
|
||||
provisionSecret?: string | null;
|
||||
}
|
||||
|
||||
export type ServerChannelType = 'text' | 'voice';
|
||||
|
||||
@@ -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"');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +181,8 @@
|
||||
"microphone": "Microphone",
|
||||
"speaker": "Speaker",
|
||||
"microphoneFallback": "Microphone {{index}}",
|
||||
"speakerFallback": "Speaker {{index}}"
|
||||
"speakerFallback": "Speaker {{index}}",
|
||||
"systemDefault": "System default"
|
||||
},
|
||||
"volume": {
|
||||
"title": "Volume",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
+99
@@ -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'
|
||||
});
|
||||
});
|
||||
});
|
||||
+106
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-8
@@ -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('');
|
||||
}
|
||||
|
||||
+51
@@ -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(/\/+$/, '');
|
||||
}
|
||||
}
|
||||
+152
@@ -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'
|
||||
});
|
||||
});
|
||||
});
|
||||
+45
-24
@@ -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(/\/+$/, '');
|
||||
}
|
||||
|
||||
+3
-5
@@ -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 () => {
|
||||
|
||||
+4
-24
@@ -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),
|
||||
|
||||
+112
-76
@@ -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();
|
||||
|
||||
+79
-23
@@ -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,
|
||||
|
||||
-44
@@ -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';
|
||||
}
|
||||
+61
-1
@@ -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']);
|
||||
|
||||
|
||||
+101
@@ -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';
|
||||
|
||||
@@ -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
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
|
||||
-1
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
|
||||
-1
@@ -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';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Injectable,
|
||||
computed,
|
||||
|
||||
-1
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
|
||||
@@ -5,7 +5,7 @@ Direct calls coordinate private voice sessions started from people cards, direct
|
||||
## Flow
|
||||
|
||||
1. `DirectCallService.startCall()` creates or reuses the direct-message conversation for a peer, while `startConversationCall()` starts from an existing one-to-one or group conversation. Both paths reuse a live call for the same peer or group before creating a new session.
|
||||
2. The caller joins a call-scoped voice session and sends a `direct-call` ring event through `PeerDeliveryService`. Joining a direct call first leaves any other joined call or server voice channel.
|
||||
2. The caller rings first, then joins a call-scoped voice session; joining a direct call first leaves any other joined call or server voice channel. `ringParticipants` reports the result of every `direct-call` ring sent through `PeerDeliveryService`: when no recipient could be reached over a peer data channel or a signaling route, `deliveryError` is set (`call.errors.ringUndelivered`) and the private-call view shows it next to join errors. A call that reached nobody must not sit in "calling" as if it were live.
|
||||
3. The caller and recipient both record a direct-message `call-started` system entry for the call's conversation, so the chat history shows who started the call without creating a normal text message.
|
||||
4. The recipient stores the incoming session, loops `assets/audio/call.wav`, shows an in-app answer/decline modal, and shows a desktop notification when permission allows. If the recipient is set to Do Not Disturb (`status: "busy"`), the session is stored silently without call audio, the in-app modal, or a desktop notification. Ring events received before the current user identity is hydrated are queued and replayed once identity is available. The ring stops when the recipient joins, declines, leaves, or the call ends; stale duplicate ring events for a locally ended call are ignored.
|
||||
5. Opening `/call/:callId` shows the private call surface with portraits, voice indicators, media controls (mute, deafen, camera, screen share), screen/camera tiles, add-user control, and a narrow DM chat panel. Deafen mutes incoming audio and also mutes the local mic, matching voice-channel behavior.
|
||||
|
||||
@@ -527,6 +527,47 @@ describe('DirectCallService', () => {
|
||||
expect(context.service.sessionById(session.callId)?.participants.alice.joined).toBe(true);
|
||||
});
|
||||
|
||||
it('rings the peer before joining local voice', async () => {
|
||||
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
|
||||
const joinCall = vi.fn(async () => undefined);
|
||||
|
||||
context.service.joinCall = joinCall;
|
||||
await context.service.startCall(bob);
|
||||
|
||||
expect(context.delivery.sendCallEvent).toHaveBeenCalled();
|
||||
expect(context.delivery.sendCallEvent.mock.invocationCallOrder[0])
|
||||
.toBeLessThan(joinCall.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it('surfaces an undelivered ring instead of looking like a live call', async () => {
|
||||
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
|
||||
|
||||
context.service.joinCall = vi.fn(async () => undefined);
|
||||
context.delivery.sendCallEvent.mockReturnValue(false);
|
||||
|
||||
await context.service.startCall(bob);
|
||||
|
||||
expect(context.service.deliveryError()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('clears the delivery error once a ring reaches the peer', async () => {
|
||||
const context = createServiceContext({ currentUser: alice, allUsers: [
|
||||
alice,
|
||||
bob,
|
||||
charlie
|
||||
] });
|
||||
|
||||
context.service.joinCall = vi.fn(async () => undefined);
|
||||
context.delivery.sendCallEvent.mockReturnValueOnce(false);
|
||||
|
||||
await context.service.startCall(bob);
|
||||
expect(context.service.deliveryError()).not.toBeNull();
|
||||
|
||||
await context.service.startCall(charlie);
|
||||
|
||||
expect(context.service.deliveryError()).toBeNull();
|
||||
});
|
||||
|
||||
it('starts group calls by keeping the rail-visible call session and ringing every other participant', async () => {
|
||||
const context = createServiceContext({ currentUser: alice, allUsers: [
|
||||
alice,
|
||||
|
||||
@@ -21,7 +21,14 @@ import {
|
||||
VoiceConnectionFacade,
|
||||
VoicePlaybackService
|
||||
} from '../../../voice-connection';
|
||||
import { VoiceSessionFacade, isVoiceOnAnotherClient } from '../../../voice-session';
|
||||
import {
|
||||
SYSTEM_DEFAULT_AUDIO_DEVICE_ID,
|
||||
VoiceSessionFacade,
|
||||
buildMicrophoneConstraints,
|
||||
isDeviceUnavailableError,
|
||||
isVoiceOnAnotherClient,
|
||||
loadVoiceSettingsFromStorage
|
||||
} from '../../../voice-session';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import { SignalServerCredentialStoreService } from '../../../authentication/application/services/signal-server-credential-store.service';
|
||||
import { DirectMessageService, PeerDeliveryService } from '../../../direct-message';
|
||||
@@ -93,6 +100,8 @@ export class DirectCallService {
|
||||
readonly currentSession = signal<DirectCallSession | null>(null);
|
||||
/** User-facing reason the last joinCall attempt failed; null after a successful join. */
|
||||
readonly joinError = signal<string | null>(null);
|
||||
/** User-facing reason the last outgoing ring reached nobody; null once a ring is delivered. */
|
||||
readonly deliveryError = signal<string | null>(null);
|
||||
readonly hasActiveCall = computed(() => this.visibleActiveSessions().length > 0);
|
||||
readonly mobileOverlaySession = computed(() => {
|
||||
const callId = this.mobileOverlayCallId();
|
||||
@@ -230,8 +239,8 @@ export class DirectCallService {
|
||||
session.createdAt
|
||||
);
|
||||
|
||||
this.ringParticipants(session, [peerParticipant.userId]);
|
||||
await this.joinCall(session.callId, false);
|
||||
this.sendCallEvent(peerParticipant.userId, 'ring', session);
|
||||
await this.openCallView(session.callId);
|
||||
return session;
|
||||
}
|
||||
@@ -367,12 +376,7 @@ export class DirectCallService {
|
||||
let stream: MediaStream;
|
||||
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: false
|
||||
}
|
||||
});
|
||||
stream = await this.captureCallMicrophone();
|
||||
} catch {
|
||||
this.joinError.set(this.i18n.instant('call.errors.microphoneUnavailable'));
|
||||
return;
|
||||
@@ -419,6 +423,38 @@ export class DirectCallService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the mic for a call, honouring the saved device.
|
||||
*
|
||||
* The device is requested exactly, so a saved id that is gone or already
|
||||
* claimed rejects instead of quietly opening a different mic. That must not
|
||||
* cost the user the call, so retry once on the system default.
|
||||
*/
|
||||
private async captureCallMicrophone(): Promise<MediaStream> {
|
||||
const voiceSettings = loadVoiceSettingsFromStorage();
|
||||
const browserNoiseSuppression = !voiceSettings.noiseReduction;
|
||||
|
||||
try {
|
||||
return await navigator.mediaDevices.getUserMedia(
|
||||
buildMicrophoneConstraints({
|
||||
browserNoiseSuppression,
|
||||
deviceId: voiceSettings.inputDevice
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
if (!voiceSettings.inputDevice || !isDeviceUnavailableError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return await navigator.mediaDevices.getUserMedia(
|
||||
buildMicrophoneConstraints({
|
||||
browserNoiseSuppression,
|
||||
deviceId: SYSTEM_DEFAULT_AUDIO_DEVICE_ID
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private leaveJoinedSession(session: DirectCallSession, endForEveryone = false): void {
|
||||
const action = endForEveryone ? 'end' : 'leave';
|
||||
const nextSession = this.markCurrentUserLeft(session, endForEveryone);
|
||||
@@ -451,7 +487,7 @@ export class DirectCallService {
|
||||
this.upsertSession(convertedSession);
|
||||
this.currentSession.set(convertedSession);
|
||||
this.broadcastCallEvent('update', convertedSession, [participant.userId]);
|
||||
this.sendCallEvent(participant.userId, 'ring', convertedSession);
|
||||
this.ringParticipants(convertedSession, [participant.userId]);
|
||||
}
|
||||
|
||||
remoteParticipantIds(session: DirectCallSession): string[] {
|
||||
@@ -611,8 +647,8 @@ export class DirectCallService {
|
||||
|
||||
this.upsertSession(session);
|
||||
this.currentSession.set(session);
|
||||
this.ringParticipants(session, this.remoteParticipantIds(session));
|
||||
await this.joinCall(session.callId, false);
|
||||
this.broadcastCallEvent('ring', this.sessionById(session.callId) ?? session);
|
||||
await this.router.navigate(['/call', session.callId]);
|
||||
return this.sessionById(session.callId) ?? session;
|
||||
}
|
||||
@@ -756,10 +792,25 @@ export class DirectCallService {
|
||||
return session;
|
||||
}
|
||||
|
||||
private sendCallEvent(recipientId: string, action: DirectCallEventPayload['action'], session: DirectCallSession): void {
|
||||
/**
|
||||
* Ring every recipient and report the outcome. A call whose ring reached
|
||||
* nobody must not look live, so an undelivered ring surfaces as an error
|
||||
* instead of a session that waits forever.
|
||||
*/
|
||||
private ringParticipants(session: DirectCallSession, recipientIds: readonly string[]): void {
|
||||
const delivered = recipientIds
|
||||
.map((recipientId) => this.sendCallEvent(recipientId, 'ring', session))
|
||||
.filter((wasDelivered) => wasDelivered);
|
||||
|
||||
this.deliveryError.set(delivered.length > 0
|
||||
? null
|
||||
: this.i18n.instant('call.errors.ringUndelivered'));
|
||||
}
|
||||
|
||||
private sendCallEvent(recipientId: string, action: DirectCallEventPayload['action'], session: DirectCallSession): boolean {
|
||||
const me = this.requireCurrentUser();
|
||||
|
||||
this.delivery.sendCallEvent(recipientId, {
|
||||
return this.delivery.sendCallEvent(recipientId, {
|
||||
type: 'direct-call',
|
||||
directCall: {
|
||||
action,
|
||||
|
||||
@@ -32,6 +32,14 @@ Unread counts are idempotent by message id: re-receiving or syncing a message th
|
||||
|
||||
Incoming PM and group-chat events are ignored unless the current user is declared in the message recipients, participant profiles, or existing local conversation. Sync requests are only answered for conversation participants, so a stray peer route cannot create unread state or expose private history.
|
||||
|
||||
## One human, one thread
|
||||
|
||||
A one-to-one conversation id is derived from participant ids, so an id must never be built from an actor alias. `direct-message-identity.rules.ts` owns the canonicalization: `buildDirectParticipantAliasIndex` maps every alias of a human (home id, entity id, peer id, and each provisioned signal-server actor id from `SignalServerCredentialStoreService`) to the id their threads are stored under, and `getCanonicalDirectConversationId` / `canonicalizeDirectConversationId` resolve outbound and inbound ids through it. Group ids and any id that is not a plain participant pair pass through untouched.
|
||||
|
||||
A peer who met the local user on a foreign signal server addresses them by the provisioned actor id, so the inbound `conversationId` differs from the one the local user builds from their home identity. `DirectMessageService.collapseAliasConversations` therefore merges every stored thread that resolves to the same canonical id on first touch (create, inbound message, inbound sync, call-started record) with `mergeAliasDirectConversations`, deletes the alias copies, and re-points the current selection. Message `senderId`, `recipientId`, and `recipientIds` are canonicalized as well, so acknowledgements and read receipts route to one identity. Without this the recipient kept two threads for one human — one holding the peer's messages, one empty — and clicking the peer opened the empty one (`e2e/tests/chat/cross-signal-dm-identity.spec.ts`).
|
||||
|
||||
The index can only collapse identities the client knows: all of the local user's own aliases, plus the alias ids carried on stored user entities. Two roster entries for one remote human on different signal servers still read as two people.
|
||||
|
||||
Status transitions are monotonic, so a stale `SENT` event cannot overwrite `DELIVERED` or `ACKNOWLEDGED`.
|
||||
|
||||
## Chat View
|
||||
|
||||
+158
-39
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
|
||||
import {
|
||||
Injectable,
|
||||
computed,
|
||||
@@ -24,12 +24,20 @@ import {
|
||||
directMessageConversationIncludesUser,
|
||||
directMessageEventIncludesUser,
|
||||
directMessageSyncIncludesUser,
|
||||
getDirectConversationId,
|
||||
isGroupDirectConversation,
|
||||
updateMessageStatusInConversation,
|
||||
upsertDirectMessage
|
||||
} from '../../domain/logic/direct-message.logic';
|
||||
import { collectDirectMessageSelfUserIds, isSelfDirectMessageSender } from '../../domain/logic/direct-message-identity.rules';
|
||||
import {
|
||||
buildDirectParticipantAliasIndex,
|
||||
canonicalizeDirectConversationId,
|
||||
canonicalizeDirectParticipantId,
|
||||
collectDirectMessageSelfUserIds,
|
||||
getCanonicalDirectConversationId,
|
||||
isSelfDirectMessageSender,
|
||||
mergeAliasDirectConversations,
|
||||
type DirectParticipantAliasIndex
|
||||
} from '../../domain/logic/direct-message-identity.rules';
|
||||
import {
|
||||
DirectMessage,
|
||||
DirectMessageConversation,
|
||||
@@ -48,7 +56,7 @@ import type {
|
||||
Reaction,
|
||||
User
|
||||
} from '../../../../shared-kernel';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
|
||||
const DIRECT_MESSAGE_SYNC_LIMIT = 1000;
|
||||
const DIRECT_MESSAGE_SYNC_REQUEST_COOLDOWN_MS = 5000;
|
||||
@@ -75,6 +83,7 @@ export class DirectMessageService {
|
||||
private readonly router = inject(Router);
|
||||
private readonly notifications = inject(NotificationsFacade);
|
||||
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
private readonly users = this.store.selectSignal(selectAllUsers);
|
||||
private readonly conversationsSignal = signal<DirectMessageConversation[]>([]);
|
||||
private readonly selectedConversationIdSignal = signal<string | null>(null);
|
||||
private readonly typingEntriesSignal = signal<DirectMessageTypingEntry[]>([]);
|
||||
@@ -128,10 +137,11 @@ export class DirectMessageService {
|
||||
|
||||
await this.loadForOwner(ownerId);
|
||||
|
||||
const currentParticipant = toDirectMessageParticipant(currentUser);
|
||||
const peerParticipant = toDirectMessageParticipant(user);
|
||||
const conversationId = getDirectConversationId(currentParticipant.userId, peerParticipant.userId);
|
||||
const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === conversationId);
|
||||
const aliasIndex = this.participantAliasIndex();
|
||||
const currentParticipant = this.canonicalParticipant(aliasIndex, toDirectMessageParticipant(currentUser));
|
||||
const peerParticipant = this.canonicalParticipant(aliasIndex, toDirectMessageParticipant(user));
|
||||
const conversationId = getCanonicalDirectConversationId(aliasIndex, currentParticipant.userId, peerParticipant.userId);
|
||||
const existingConversation = await this.collapseAliasConversations(ownerId, conversationId);
|
||||
|
||||
if (existingConversation) {
|
||||
this.selectedConversationIdSignal.set(existingConversation.id);
|
||||
@@ -258,30 +268,32 @@ export class DirectMessageService {
|
||||
}
|
||||
|
||||
async recordCallStarted(
|
||||
conversationId: string,
|
||||
rawConversationId: string,
|
||||
caller: DirectMessageParticipant,
|
||||
participants: DirectMessageParticipant[],
|
||||
timestamp = Date.now()
|
||||
): Promise<void> {
|
||||
const ownerId = this.getCurrentUserIdOrThrow();
|
||||
const currentUser = this.requireCurrentUser();
|
||||
const currentParticipant = toDirectMessageParticipant(currentUser);
|
||||
const aliasIndex = this.participantAliasIndex();
|
||||
const conversationId = canonicalizeDirectConversationId(aliasIndex, rawConversationId);
|
||||
const currentParticipant = this.canonicalParticipant(aliasIndex, toDirectMessageParticipant(currentUser));
|
||||
const canonicalCaller = this.canonicalParticipant(aliasIndex, caller);
|
||||
const allParticipants = this.uniqueParticipants([
|
||||
currentParticipant,
|
||||
caller,
|
||||
...participants
|
||||
canonicalCaller,
|
||||
...participants.map((participant) => this.canonicalParticipant(aliasIndex, participant))
|
||||
]);
|
||||
|
||||
await this.loadForOwner(ownerId);
|
||||
|
||||
const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === conversationId)
|
||||
?? await this.repository.getConversation(ownerId, conversationId)
|
||||
?? this.createConversationForSystemEvent(conversationId, currentParticipant, caller, allParticipants, timestamp);
|
||||
const existingConversation = await this.collapseAliasConversations(ownerId, conversationId)
|
||||
?? this.createConversationForSystemEvent(conversationId, currentParticipant, canonicalCaller, allParticipants, timestamp);
|
||||
const conversation = this.mergeConversationParticipants(existingConversation, allParticipants);
|
||||
const message = createDirectCallStartedMessage(
|
||||
conversation.id,
|
||||
caller,
|
||||
conversation.participants.filter((participantId) => participantId !== caller.userId),
|
||||
canonicalCaller,
|
||||
conversation.participants.filter((participantId) => participantId !== canonicalCaller.userId),
|
||||
timestamp
|
||||
);
|
||||
|
||||
@@ -517,23 +529,32 @@ export class DirectMessageService {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentParticipant = toDirectMessageParticipant(currentUser);
|
||||
const sender = payload.sender;
|
||||
const aliasIndex = this.participantAliasIndex();
|
||||
const currentParticipant = this.canonicalParticipant(aliasIndex, toDirectMessageParticipant(currentUser));
|
||||
const sender = this.canonicalParticipant(aliasIndex, payload.sender);
|
||||
const conversationId = payload.message.conversationId
|
||||
|| getDirectConversationId(currentParticipant.userId, sender.userId);
|
||||
? canonicalizeDirectConversationId(aliasIndex, payload.message.conversationId)
|
||||
: getCanonicalDirectConversationId(aliasIndex, currentParticipant.userId, sender.userId);
|
||||
const participants = this.uniqueParticipants([
|
||||
currentParticipant,
|
||||
sender,
|
||||
...(payload.participants ?? [])
|
||||
...(payload.participants ?? []).map((participant) => this.canonicalParticipant(aliasIndex, participant))
|
||||
]);
|
||||
const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === conversationId)
|
||||
const existingConversation = await this.collapseAliasConversations(ownerId, conversationId)
|
||||
?? (payload.conversationKind === 'group' || participants.length > 2
|
||||
? createGroupConversation(conversationId, participants, payload.message.timestamp, payload.conversationTitle)
|
||||
: createDirectConversation(currentParticipant, sender, payload.message.timestamp));
|
||||
: {
|
||||
...createDirectConversation(currentParticipant, sender, payload.message.timestamp),
|
||||
id: conversationId
|
||||
});
|
||||
const conversationWithParticipants = this.mergeConversationParticipants(existingConversation, participants);
|
||||
const incomingMessage: DirectMessage = {
|
||||
...payload.message,
|
||||
conversationId,
|
||||
senderId: canonicalizeDirectParticipantId(aliasIndex, payload.message.senderId),
|
||||
recipientId: canonicalizeDirectParticipantId(aliasIndex, payload.message.recipientId),
|
||||
recipientIds: payload.message.recipientIds?.map((recipientId) =>
|
||||
canonicalizeDirectParticipantId(aliasIndex, recipientId)),
|
||||
status: advanceDirectMessageStatus(payload.message.status, 'DELIVERED')
|
||||
};
|
||||
const shouldIncrementUnread = !this.isConversationVisible(conversationId);
|
||||
@@ -590,7 +611,10 @@ export class DirectMessageService {
|
||||
|
||||
private async handleIncomingMutation(payload: DirectMessageMutationEventPayload): Promise<void> {
|
||||
const ownerId = this.getCurrentUserIdOrThrow();
|
||||
const conversation = await this.findConversation(ownerId, payload.conversationId);
|
||||
const conversation = await this.findConversation(
|
||||
ownerId,
|
||||
canonicalizeDirectConversationId(this.participantAliasIndex(), payload.conversationId)
|
||||
);
|
||||
const selfUserIds = this.getSelfUserIds();
|
||||
|
||||
if (!conversation || !directMessageConversationIncludesUser(conversation, selfUserIds)) {
|
||||
@@ -607,25 +631,28 @@ export class DirectMessageService {
|
||||
return;
|
||||
}
|
||||
|
||||
const conversation = this.conversationsSignal().find((entry) => entry.id === payload.conversationId);
|
||||
const aliasIndex = this.participantAliasIndex();
|
||||
const conversationId = canonicalizeDirectConversationId(aliasIndex, payload.conversationId);
|
||||
const senderId = canonicalizeDirectParticipantId(aliasIndex, payload.sender.userId);
|
||||
const conversation = this.conversationsSignal().find((entry) => entry.id === conversationId);
|
||||
|
||||
if (!conversation
|
||||
|| !directMessageConversationIncludesUser(conversation, selfUserIds)
|
||||
|| !directMessageConversationIncludesUser(conversation, payload.sender.userId)) {
|
||||
|| !directMessageConversationIncludesUser(conversation, senderId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!payload.isTyping) {
|
||||
this.typingEntriesSignal.update((entries) => entries.filter((entry) =>
|
||||
!(entry.conversationId === payload.conversationId && entry.userId === payload.sender.userId)
|
||||
!(entry.conversationId === conversationId && entry.userId === senderId)
|
||||
));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const nextEntry: DirectMessageTypingEntry = {
|
||||
conversationId: payload.conversationId,
|
||||
userId: payload.sender.userId,
|
||||
conversationId,
|
||||
userId: senderId,
|
||||
displayName: payload.sender.displayName,
|
||||
expiresAt: Date.now() + DIRECT_MESSAGE_TYPING_TTL_MS
|
||||
};
|
||||
@@ -641,7 +668,10 @@ export class DirectMessageService {
|
||||
private async handleIncomingSyncRequest(payload: DirectMessageSyncRequestEventPayload): Promise<void> {
|
||||
const ownerId = this.getCurrentUserIdOrThrow();
|
||||
const currentUser = this.requireCurrentUser();
|
||||
const conversation = await this.findConversation(ownerId, payload.conversationId);
|
||||
const conversation = await this.findConversation(
|
||||
ownerId,
|
||||
canonicalizeDirectConversationId(this.participantAliasIndex(), payload.conversationId)
|
||||
);
|
||||
const selfUserIds = this.getSelfUserIds();
|
||||
|
||||
if (!conversation
|
||||
@@ -668,7 +698,9 @@ export class DirectMessageService {
|
||||
private async handleIncomingSync(payload: DirectMessageSyncEventPayload): Promise<void> {
|
||||
const ownerId = this.getCurrentUserIdOrThrow();
|
||||
const currentUser = this.requireCurrentUser();
|
||||
const currentParticipant = toDirectMessageParticipant(currentUser);
|
||||
const aliasIndex = this.participantAliasIndex();
|
||||
const currentParticipant = this.canonicalParticipant(aliasIndex, toDirectMessageParticipant(currentUser));
|
||||
const sender = this.canonicalParticipant(aliasIndex, payload.sender);
|
||||
const selfUserIds = this.getSelfUserIds();
|
||||
|
||||
if (selfUserIds.has(payload.sender.userId)) {
|
||||
@@ -679,14 +711,18 @@ export class DirectMessageService {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === payload.conversationId)
|
||||
?? await this.repository.getConversation(ownerId, payload.conversationId)
|
||||
const conversationId = canonicalizeDirectConversationId(aliasIndex, payload.conversationId);
|
||||
const syncParticipants = payload.participants.map((participant) => this.canonicalParticipant(aliasIndex, participant));
|
||||
const existingConversation = await this.collapseAliasConversations(ownerId, conversationId)
|
||||
?? (payload.conversationKind === 'group' || payload.participants.length > 2
|
||||
? createGroupConversation(payload.conversationId, [currentParticipant, ...payload.participants], payload.syncedAt, payload.conversationTitle)
|
||||
: createDirectConversation(currentParticipant, payload.sender, payload.syncedAt));
|
||||
? createGroupConversation(conversationId, [currentParticipant, ...syncParticipants], payload.syncedAt, payload.conversationTitle)
|
||||
: {
|
||||
...createDirectConversation(currentParticipant, sender, payload.syncedAt),
|
||||
id: conversationId
|
||||
});
|
||||
const participantProfiles = {
|
||||
...existingConversation.participantProfiles,
|
||||
...Object.fromEntries(payload.participants.map((participant) => [participant.userId, participant])),
|
||||
...Object.fromEntries(syncParticipants.map((participant) => [participant.userId, participant])),
|
||||
[currentParticipant.userId]: currentParticipant
|
||||
};
|
||||
const syncBaseConversation: DirectMessageConversation = {
|
||||
@@ -697,14 +733,20 @@ export class DirectMessageService {
|
||||
participantProfiles
|
||||
};
|
||||
const mergedConversation = payload.messages.reduce<DirectMessageConversation>(
|
||||
(conversation, message) => upsertDirectMessage(conversation, message, false),
|
||||
(conversation, message) => upsertDirectMessage(conversation, {
|
||||
...message,
|
||||
conversationId,
|
||||
senderId: canonicalizeDirectParticipantId(aliasIndex, message.senderId),
|
||||
recipientId: canonicalizeDirectParticipantId(aliasIndex, message.recipientId),
|
||||
recipientIds: message.recipientIds?.map((recipientId) => canonicalizeDirectParticipantId(aliasIndex, recipientId))
|
||||
}, false),
|
||||
syncBaseConversation
|
||||
);
|
||||
|
||||
await this.persistConversation(ownerId, mergedConversation);
|
||||
|
||||
if (this.selectedConversationIdSignal() === payload.conversationId) {
|
||||
await this.markRead(payload.conversationId);
|
||||
if (this.selectedConversationIdSignal() === conversationId) {
|
||||
await this.markRead(conversationId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -912,6 +954,83 @@ export class DirectMessageService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One human can be addressed by several ids: their home id and one
|
||||
* provisioned actor id per foreign signal server. Threads are stored under
|
||||
* the home id, so every inbound id is resolved through this index first.
|
||||
*/
|
||||
private participantAliasIndex(): DirectParticipantAliasIndex {
|
||||
const currentUser = this.currentUser();
|
||||
const peerGroups = this.users().map((user) => ({
|
||||
canonicalId: user.oderId || user.id,
|
||||
aliasIds: [
|
||||
user.id,
|
||||
user.oderId,
|
||||
user.peerId
|
||||
].filter((aliasId): aliasId is string => !!aliasId)
|
||||
}));
|
||||
const selfGroups = currentUser
|
||||
? [
|
||||
{
|
||||
canonicalId: currentUser.oderId || currentUser.id,
|
||||
aliasIds: [...this.getSelfUserIds()]
|
||||
}
|
||||
]
|
||||
: [];
|
||||
|
||||
return buildDirectParticipantAliasIndex([...peerGroups, ...selfGroups]);
|
||||
}
|
||||
|
||||
private canonicalParticipant(
|
||||
aliasIndex: DirectParticipantAliasIndex,
|
||||
participant: DirectMessageParticipant
|
||||
): DirectMessageParticipant {
|
||||
const canonicalId = canonicalizeDirectParticipantId(aliasIndex, participant.userId);
|
||||
|
||||
return canonicalId === participant.userId ? participant : { ...participant, userId: canonicalId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold any thread that resolves to `canonicalId` into a single stored
|
||||
* conversation. Returns null when this human pair has no thread yet.
|
||||
*/
|
||||
private async collapseAliasConversations(
|
||||
ownerId: string,
|
||||
canonicalId: string
|
||||
): Promise<DirectMessageConversation | null> {
|
||||
await this.loadForOwner(ownerId);
|
||||
|
||||
const aliasIndex = this.participantAliasIndex();
|
||||
const matching = this.conversationsSignal().filter((conversation) =>
|
||||
canonicalizeDirectConversationId(aliasIndex, conversation.id) === canonicalId);
|
||||
|
||||
if (matching.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (matching.length === 1 && matching[0].id === canonicalId) {
|
||||
return matching[0];
|
||||
}
|
||||
|
||||
const merged = mergeAliasDirectConversations(aliasIndex, matching);
|
||||
const staleIds = matching.map((conversation) => conversation.id).filter((id) => id !== merged.id);
|
||||
|
||||
await this.persistConversation(ownerId, merged);
|
||||
|
||||
for (const staleId of staleIds) {
|
||||
await this.repository.deleteConversation(ownerId, staleId);
|
||||
}
|
||||
|
||||
this.conversationsSignal.update((conversations) => conversations.filter((conversation) =>
|
||||
!staleIds.includes(conversation.id)));
|
||||
|
||||
if (staleIds.includes(this.selectedConversationIdSignal() ?? '')) {
|
||||
this.selectedConversationIdSignal.set(merged.id);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
private mergeConversationParticipants(
|
||||
conversation: DirectMessageConversation,
|
||||
participants: DirectMessageParticipant[]
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Injectable,
|
||||
computed,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import {
|
||||
|
||||
+111
-2
@@ -1,10 +1,21 @@
|
||||
import {
|
||||
buildDirectParticipantAliasIndex,
|
||||
canonicalizeDirectConversation,
|
||||
canonicalizeDirectConversationId,
|
||||
canonicalizeDirectParticipantId,
|
||||
collectDirectMessageSelfUserIds,
|
||||
directMessageConversationIncludesAnyUser,
|
||||
directMessageEventIncludesAnyUser,
|
||||
isSelfDirectMessageSender
|
||||
getCanonicalDirectConversationId,
|
||||
isSelfDirectMessageSender,
|
||||
mergeAliasDirectConversations
|
||||
} from './direct-message-identity.rules';
|
||||
import type { DirectMessageConversation, DirectMessageParticipant } from '../models/direct-message.model';
|
||||
import { getDirectConversationId } from './direct-message.logic';
|
||||
import type {
|
||||
DirectMessage,
|
||||
DirectMessageConversation,
|
||||
DirectMessageParticipant
|
||||
} from '../models/direct-message.model';
|
||||
|
||||
const aliceHome: DirectMessageParticipant = {
|
||||
userId: 'alice-home',
|
||||
@@ -112,3 +123,101 @@ describe('direct-message-identity.rules', () => {
|
||||
)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('direct conversation canonicalization', () => {
|
||||
const bobAliases = { canonicalId: 'bob-home', aliasIds: ['bob-home', 'bob-foreign'] };
|
||||
const aliceAliases = { canonicalId: 'alice-home', aliasIds: ['alice-home', 'alice-foreign'] };
|
||||
const aliasIndex = buildDirectParticipantAliasIndex([bobAliases, aliceAliases]);
|
||||
|
||||
it('resolves every alias of one human to the same participant id', () => {
|
||||
expect(canonicalizeDirectParticipantId(aliasIndex, 'alice-foreign')).toBe('alice-home');
|
||||
expect(canonicalizeDirectParticipantId(aliasIndex, 'alice-home')).toBe('alice-home');
|
||||
expect(canonicalizeDirectParticipantId(aliasIndex, 'charlie')).toBe('charlie');
|
||||
});
|
||||
|
||||
it('gives two devices of the same human one conversation id', () => {
|
||||
const fromHomeDevice = getCanonicalDirectConversationId(aliasIndex, 'bob-home', 'alice-home');
|
||||
const fromForeignDevice = getCanonicalDirectConversationId(aliasIndex, 'bob-foreign', 'alice-foreign');
|
||||
|
||||
expect(fromForeignDevice).toBe(fromHomeDevice);
|
||||
expect(fromHomeDevice).toBe(getDirectConversationId('bob-home', 'alice-home'));
|
||||
});
|
||||
|
||||
it('rewrites an inbound conversation id built from actor aliases', () => {
|
||||
const inboundId = getDirectConversationId('alice-foreign', 'bob-foreign');
|
||||
|
||||
expect(canonicalizeDirectConversationId(aliasIndex, inboundId))
|
||||
.toBe(getDirectConversationId('alice-home', 'bob-home'));
|
||||
});
|
||||
|
||||
it('leaves group and unparseable conversation ids untouched', () => {
|
||||
expect(canonicalizeDirectConversationId(aliasIndex, 'dm-group-1234')).toBe('dm-group-1234');
|
||||
expect(canonicalizeDirectConversationId(aliasIndex, 'call-42')).toBe('call-42');
|
||||
});
|
||||
|
||||
it('rewrites conversation participants and message ids onto canonical identities', () => {
|
||||
const canonical = canonicalizeDirectConversation(aliasIndex, aliasConversation());
|
||||
|
||||
expect(canonical.id).toBe(getDirectConversationId('alice-home', 'bob-home'));
|
||||
expect(canonical.participants).toEqual(['alice-home', 'bob-home']);
|
||||
expect(Object.keys(canonical.participantProfiles).sort()).toEqual(['alice-home', 'bob-home']);
|
||||
expect(canonical.messages[0].senderId).toBe('alice-home');
|
||||
expect(canonical.messages[0].recipientId).toBe('bob-home');
|
||||
expect(canonical.messages[0].recipientIds).toEqual(['bob-home']);
|
||||
expect(canonical.messages[0].conversationId).toBe(canonical.id);
|
||||
});
|
||||
|
||||
it('merges an alias thread into the canonical thread without losing messages or unread count', () => {
|
||||
const merged = mergeAliasDirectConversations(aliasIndex, [canonicalConversation(), aliasConversation()]);
|
||||
|
||||
expect(merged.id).toBe(getDirectConversationId('alice-home', 'bob-home'));
|
||||
expect(merged.messages.map((message) => message.id)).toEqual(['message-home', 'message-foreign']);
|
||||
expect(merged.messages.every((message) => message.conversationId === merged.id)).toBe(true);
|
||||
expect(merged.unreadCount).toBe(3);
|
||||
expect(merged.lastMessageAt).toBe(20);
|
||||
expect(merged.participants).toEqual(['alice-home', 'bob-home']);
|
||||
});
|
||||
});
|
||||
|
||||
function canonicalConversation(): DirectMessageConversation {
|
||||
return {
|
||||
id: getDirectConversationId('alice-home', 'bob-home'),
|
||||
kind: 'direct',
|
||||
participants: ['alice-home', 'bob-home'],
|
||||
participantProfiles: {
|
||||
'alice-home': aliceHome,
|
||||
'bob-home': bobHome
|
||||
},
|
||||
messages: [createMessage('message-home', 'alice-home', 'bob-home', 10)],
|
||||
lastMessageAt: 10,
|
||||
unreadCount: 1
|
||||
};
|
||||
}
|
||||
|
||||
function aliasConversation(): DirectMessageConversation {
|
||||
return {
|
||||
id: getDirectConversationId('alice-foreign', 'bob-foreign'),
|
||||
kind: 'direct',
|
||||
participants: ['alice-foreign', 'bob-foreign'],
|
||||
participantProfiles: {
|
||||
'alice-foreign': { ...aliceHome, userId: 'alice-foreign' },
|
||||
'bob-foreign': { ...bobHome, userId: 'bob-foreign' }
|
||||
},
|
||||
messages: [createMessage('message-foreign', 'alice-foreign', 'bob-foreign', 20)],
|
||||
lastMessageAt: 20,
|
||||
unreadCount: 2
|
||||
};
|
||||
}
|
||||
|
||||
function createMessage(id: string, senderId: string, recipientId: string, timestamp: number): DirectMessage {
|
||||
return {
|
||||
id,
|
||||
conversationId: getDirectConversationId(senderId, recipientId),
|
||||
senderId,
|
||||
recipientId,
|
||||
recipientIds: [recipientId],
|
||||
content: 'hello',
|
||||
timestamp,
|
||||
status: 'DELIVERED'
|
||||
};
|
||||
}
|
||||
|
||||
+192
-2
@@ -1,9 +1,29 @@
|
||||
import type { User } from '../../../../shared-kernel';
|
||||
import { directMessageConversationIncludesUser, directMessageEventIncludesUser } from './direct-message.logic';
|
||||
import type { DirectMessageConversation, DirectMessageEventPayload } from '../models/direct-message.model';
|
||||
import {
|
||||
directMessageConversationIncludesUser,
|
||||
directMessageEventIncludesUser,
|
||||
getDirectConversationId,
|
||||
upsertDirectMessage
|
||||
} from './direct-message.logic';
|
||||
import type {
|
||||
DirectMessage,
|
||||
DirectMessageConversation,
|
||||
DirectMessageEventPayload
|
||||
} from '../models/direct-message.model';
|
||||
|
||||
type UserIdentityFields = Pick<User, 'id' | 'oderId' | 'peerId'>;
|
||||
|
||||
const DIRECT_CONVERSATION_ID_PREFIX = 'dm-';
|
||||
const DIRECT_CONVERSATION_ID_SEPARATOR = '--';
|
||||
|
||||
export interface DirectParticipantAliasGroup {
|
||||
canonicalId: string;
|
||||
aliasIds: readonly string[];
|
||||
}
|
||||
|
||||
/** Maps every known alias id of a human to the one id their threads are stored under. */
|
||||
export type DirectParticipantAliasIndex = ReadonlyMap<string, string>;
|
||||
|
||||
/** Collect every id that can represent the local user in direct-message traffic. */
|
||||
export function collectDirectMessageSelfUserIds(
|
||||
user: UserIdentityFields,
|
||||
@@ -47,3 +67,173 @@ export function directMessageConversationIncludesAnyUser(
|
||||
): boolean {
|
||||
return directMessageConversationIncludesUser(conversation, userIds);
|
||||
}
|
||||
|
||||
export function buildDirectParticipantAliasIndex(
|
||||
groups: readonly DirectParticipantAliasGroup[]
|
||||
): DirectParticipantAliasIndex {
|
||||
const index = new Map<string, string>();
|
||||
|
||||
for (const group of groups) {
|
||||
const canonicalId = group.canonicalId.trim();
|
||||
|
||||
if (!canonicalId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const aliasId of group.aliasIds) {
|
||||
const alias = aliasId?.trim();
|
||||
|
||||
if (alias) {
|
||||
index.set(alias, canonicalId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
export function canonicalizeDirectParticipantId(
|
||||
aliasIndex: DirectParticipantAliasIndex,
|
||||
participantId: string
|
||||
): string {
|
||||
const normalized = participantId?.trim();
|
||||
|
||||
if (!normalized) {
|
||||
return participantId;
|
||||
}
|
||||
|
||||
return aliasIndex.get(normalized) ?? normalized;
|
||||
}
|
||||
|
||||
export function getCanonicalDirectConversationId(
|
||||
aliasIndex: DirectParticipantAliasIndex,
|
||||
firstParticipantId: string,
|
||||
secondParticipantId: string
|
||||
): string {
|
||||
return getDirectConversationId(
|
||||
canonicalizeDirectParticipantId(aliasIndex, firstParticipantId),
|
||||
canonicalizeDirectParticipantId(aliasIndex, secondParticipantId)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a two-party conversation id whose ids are actor aliases onto the
|
||||
* canonical pair id. Ids that are not a plain participant pair (group threads,
|
||||
* ids carrying the separator inside a participant id) are returned unchanged.
|
||||
*/
|
||||
export function canonicalizeDirectConversationId(
|
||||
aliasIndex: DirectParticipantAliasIndex,
|
||||
conversationId: string
|
||||
): string {
|
||||
const participantIds = parseDirectConversationParticipantIds(conversationId);
|
||||
|
||||
if (!participantIds) {
|
||||
return conversationId;
|
||||
}
|
||||
|
||||
return getCanonicalDirectConversationId(aliasIndex, participantIds[0], participantIds[1]);
|
||||
}
|
||||
|
||||
export function canonicalizeDirectConversation(
|
||||
aliasIndex: DirectParticipantAliasIndex,
|
||||
conversation: DirectMessageConversation
|
||||
): DirectMessageConversation {
|
||||
const canonicalId = canonicalizeDirectConversationId(aliasIndex, conversation.id);
|
||||
const participantProfiles = Object.fromEntries(
|
||||
Object.entries(conversation.participantProfiles).map(([participantId, profile]) => {
|
||||
const canonicalParticipantId = canonicalizeDirectParticipantId(aliasIndex, participantId);
|
||||
|
||||
return [
|
||||
canonicalParticipantId,
|
||||
{
|
||||
...profile,
|
||||
userId: canonicalizeDirectParticipantId(aliasIndex, profile.userId)
|
||||
}
|
||||
];
|
||||
})
|
||||
);
|
||||
const canonicalParticipantIds = conversation.participants.map((participantId) =>
|
||||
canonicalizeDirectParticipantId(aliasIndex, participantId));
|
||||
const participants = [...new Set([...canonicalParticipantIds, ...Object.keys(participantProfiles)])].sort();
|
||||
|
||||
return {
|
||||
...conversation,
|
||||
id: canonicalId,
|
||||
participants,
|
||||
participantProfiles,
|
||||
messages: conversation.messages.map((message) => canonicalizeDirectMessage(aliasIndex, message, canonicalId))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold alias threads of the same two humans into one canonical thread. Unread
|
||||
* counts add up because each thread held messages the user has not seen yet.
|
||||
*/
|
||||
export function mergeAliasDirectConversations(
|
||||
aliasIndex: DirectParticipantAliasIndex,
|
||||
conversations: readonly DirectMessageConversation[]
|
||||
): DirectMessageConversation {
|
||||
const canonicalConversations = conversations.map((conversation) =>
|
||||
canonicalizeDirectConversation(aliasIndex, conversation));
|
||||
const [first, ...rest] = canonicalConversations;
|
||||
|
||||
if (!first) {
|
||||
throw new Error('Cannot merge an empty list of direct conversations.');
|
||||
}
|
||||
|
||||
return rest.reduce((merged, conversation) => {
|
||||
const withParticipants: DirectMessageConversation = {
|
||||
...merged,
|
||||
kind: merged.kind === 'group' || conversation.kind === 'group' ? 'group' : merged.kind,
|
||||
title: merged.title ?? conversation.title,
|
||||
participants: [...new Set([...merged.participants, ...conversation.participants])].sort(),
|
||||
participantProfiles: {
|
||||
...conversation.participantProfiles,
|
||||
...merged.participantProfiles
|
||||
},
|
||||
lastMessageAt: Math.max(merged.lastMessageAt, conversation.lastMessageAt),
|
||||
unreadCount: merged.unreadCount + conversation.unreadCount
|
||||
};
|
||||
|
||||
return conversation.messages.reduce(
|
||||
(target, message) => upsertDirectMessage(target, message, false),
|
||||
withParticipants
|
||||
);
|
||||
}, first);
|
||||
}
|
||||
|
||||
function canonicalizeDirectMessage(
|
||||
aliasIndex: DirectParticipantAliasIndex,
|
||||
message: DirectMessage,
|
||||
conversationId: string
|
||||
): DirectMessage {
|
||||
return {
|
||||
...message,
|
||||
conversationId,
|
||||
senderId: canonicalizeDirectParticipantId(aliasIndex, message.senderId),
|
||||
recipientId: canonicalizeDirectParticipantId(aliasIndex, message.recipientId),
|
||||
recipientIds: message.recipientIds
|
||||
? [...new Set(message.recipientIds.map((recipientId) => canonicalizeDirectParticipantId(aliasIndex, recipientId)))]
|
||||
: message.recipientIds
|
||||
};
|
||||
}
|
||||
|
||||
function parseDirectConversationParticipantIds(conversationId: string): [string, string] | null {
|
||||
if (!conversationId?.startsWith(DIRECT_CONVERSATION_ID_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const segments = conversationId
|
||||
.slice(DIRECT_CONVERSATION_ID_PREFIX.length)
|
||||
.split(DIRECT_CONVERSATION_ID_SEPARATOR);
|
||||
|
||||
if (segments.length !== 2 || !segments[0] || !segments[1]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return [decodeURIComponent(segments[0]), decodeURIComponent(segments[1])];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
|
||||
-1
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user