Files
OwnCord/docs/architecture/ux/connection-and-auth.md
T
J3vbandClaude Fable 5 80903a4ccb fix(client): revoke server session on user-initiated logout
api.logout() (POST /auth/logout) existed but was never called, leaving the
bearer token valid server-side after a client-local logout. Add a small
logout() helper that fires the revocation best-effort — fire-and-forget with
its rejection swallowed — then runs clearAuth() synchronously, so a slow,
offline, or rejecting server can never block or delay the local logout. Wire
it into the settings Log Out button. Tests pin both paths: logout is called,
and local logout still completes when the request rejects or never settles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 08:39:12 +02:00

11 KiB

Connection & Authentication — target UX

Verified against: commit da4acc5, 2026-07-19 Part of the Client UX Specification. Shared vocabulary, feedback primitives, and the error matrix live in the README and are not repeated here.

Covers: app boot → server-profile selection → health → login / TOTP / register-by-invite → the connected handshake → reconnect → cert-TOFU trust.


1. Boot & page model

The app is a two-page state machine (lib/router.ts: connect | main). The router only tracks the page; main.ts:renderPage mounts/destroys the page DOM.

stateDiagram-v2
    [*] --> Connect: launch
    Connect --> Authenticating: submit login (valid)
    Authenticating --> TotpChallenge: requires_2fa
    TotpChallenge --> Authenticating: code accepted
    Authenticating --> Connecting: token obtained → WS connect
    Connecting --> ConnectedOverlay: ws "connected"
    ConnectedOverlay --> Main: "ready" received
    Main --> Connect: logout / 401 / banned / cert-reject
    Connecting --> Connect: auth_error / connect fail
    Authenticating --> Connect: login error (stay on form)

Target rule: the transition Connect → Main is gated by the connected overlay, which resolves only on the ready event — never navigate to Main on a bare socket-open. (Already the case: main.ts:270-286.) This guarantees Main never renders against empty stores.


2. Connect page

Three regions: server panel (profiles + health), login form, and a status area. Settings are reachable unauthenticated (for appearance/advanced).

2.1 Server profiles & health

State Trigger Target reaction
loading Profile list resolving from the Rust store (owncord:profiles) Skeleton rows; no flash of "no servers"
ready Profiles loaded List with per-profile health dot
empty No saved profiles "Add a server to get started" with an inline add affordance
health: reachable GET /api/v1/health ok within 3 s Green dot + server name/MOTD preview
health: unreachable timeout/opaque error Amber "unreachable" dot; do not block selecting it (user may still try)

Health polls every 15 s (profiles.ts); auto-connect, if enabled for the active profile, drives the login form's auto-connecting state.

2.2 Login form — state machine

The form is an explicit FSM: idle | loading | totp | connecting | error | auto-connecting (LoginForm.ts:12). This is the model other views should follow.

State Presentation Exit
idle Enabled fields; Login/Register toggle submit → validate
loading Submit shows spinner, fields disabled (LoginForm.ts:232-235,443-446) auth.login resolves
totp 6-digit overlay, Verify/Cancel code → verifyTotp
connecting "Connecting…" while WS handshakes ws connected
auto-connecting Dedicated spinner card for saved-profile auto-login any key/click cancels to idle
error Shake-animated banner, server message capped 200 chars (LoginForm.ts:590-606) user edits → idle

Client-side validation before any request (LoginForm.ts:536-560): host, username, password required; password ≥ 8; register mode also requires the invite code. Validation failures never hit the network.

2.3 Login sequence

sequenceDiagram
    autonumber
    participant U as User
    participant F as LoginForm
    participant API as api.ts (via HTTP TOFU proxy)
    participant WS as ws.ts
    U->>F: enter host + credentials, submit
    F->>F: validate (host/user/pass≥8)
    F->>API: POST /auth/login
    alt requires_2fa
        API-->>F: 200 {partial_token, requires_2fa}
        F->>U: show TOTP overlay
        U->>F: 6-digit code
        F->>API: POST /auth/verify-totp (Bearer partial_token)
        API-->>F: 200 {token, user}
    else banned
        API-->>F: 403 "account suspended"
        F->>U: error banner (stay on form)
    else success
        API-->>F: 200 {token, user}
    end
    F->>WS: connect(wss://host/api/v1/ws) with token
    WS->>WS: auth handshake → auth_ok → ready
    WS-->>U: connected overlay → Main

Auth branches → reaction (server auth_handler.go):

Server result Target reaction
200 {token, user} Proceed to WS connect
200 {partial_token, requires_2fa} TOTP overlay; on cancel, clear the partial token (already cleared in finally, main.ts:377-380)
403 banned/suspended Error banner with the server message; remain on the form
403 require-2FA-but-none-set Error banner directing the user to set up 2FA on the web panel
400 invalid input Inline field error
429 rate-limited "Too many attempts — wait a moment." Keep entered username; re-enable after cooldown

2.4 Register-by-invite

Same form, register mode reveals the invite field. POST /auth/register returns a token directly → straight to WS connect (no separate login round-trip). Closed registration / require-2FA policy → 403 shown as an error banner.

Note — first-run owner setup is not in this client. POST /admin/api/setup is server-web-panel only; the Tauri client has no owner-setup UI (admin/setup_handler.go). If the target is to support standing up a server from the desktop app, that is a new flow (detect GET /admin/api/setup/status = no users → offer an owner-creation form) — currently out of scope, flagged here so the omission is a decision.


3. The connected handshake

sequenceDiagram
    autonumber
    participant WS as ws.ts
    participant OVL as ConnectedOverlay
    participant ST as stores
    WS->>WS: ws-state "open" → send auth{token,last_seq}
    WS->>WS: auth_ok → state=connected, start heartbeat(30s)
    WS-->>OVL: onStateChange("connected") → show overlay
    WS->>ST: ready → setChannels/roles/members/voice/dm
    ST-->>OVL: ready handled → markReady()
    OVL->>OVL: onReady → router.navigate("main")

Target rule: the ready overlay is the only full-screen blocker in the app. It exists specifically so Main never renders mid-populate. Everything else (message load, member load) uses in-region loading, not a global block.


4. Reconnect UX

The WS client auto-reconnects with exponential backoff (base 1 s, cap 30 s, no jitter/cap; ws.ts:123-126), preserving last_seq for replay. The user-facing contract:

stateDiagram-v2
    Connected --> Reconnecting: socket closed (unintentional)
    Reconnecting --> Reconnecting: backoff retry (1,2,4,…,30s)
    Reconnecting --> Resyncing: socket open → auth{last_seq}
    Resyncing --> Connected: replay (dedup) or full ready
    Reconnecting --> Connect: auth_error (fatal) → transient-error
    Connected --> Restarting: server_restart{delay}
    Restarting --> Reconnecting: server drops us
Phase Target reaction
reconnecting ServerBanner.showReconnecting() (already MainPage.ts:199-211); live-only controls disable via connection status (§3 of README); drafted input preserved
replay resync Silent when the ring buffer covers last_seq; deduped so no double-render (ws.ts:212-231); unread suppressed during replay (dispatcher.ts:195)
full resync If last_seq predates buffer coverage, server replays from the events table or forces a full ready; the UI simply re-populates — no user action
server_restart ServerBanner.showRestart(delay_seconds) with a live countdown (ServerBanner.ts:28-43)
fatal (auth_error) intentionalClose, transient-error store → connect page

Target rule: reconnection is invisible on the happy path and honest on the sad path. The user should never wonder whether the app is live — the banner and the disabled live-controls answer it. This is where consolidating connection status onto ui.store (README §3) pays off: the composer, voice controls, and presence picker all disable reactively while reconnecting, instead of accepting a click and failing.


5. Cert trust (TOFU) prompts

The Rust proxies pin the server cert on first use and emit cert-tofu events. The HTTP proxy usually establishes the pin first (login precedes WS).

Event Target reaction Current
trusted_first_use 8 s informational banner "Trusting this server's certificate" Implemented ad hoc in main.ts:105-129
trusted No UI (silent, expected)
mismatch Blocking CertMismatchModal: explain the fingerprint changed; Accept re-pins (accept_cert_fingerprint) + reconnects; Reject disconnects, clearAuth(), → connect page Implemented main.ts:133-164; reconnect blocked until resolved (certMismatchBlock)
sequenceDiagram
    autonumber
    participant P as Rust proxy
    participant M as main.ts
    participant U as User
    P-->>M: cert-tofu {status: mismatch, fingerprint}
    M->>M: certMismatchBlock = true (reconnect halted)
    M->>U: CertMismatchModal (blocking)
    alt Accept
        U->>M: Accept
        M->>P: accept_cert_fingerprint(host, fp)
        M->>M: clear block → reconnect
    else Reject
        U->>M: Reject
        M->>M: disconnect + clearAuth → connect page
    end

Target rule: a cert mismatch is the one moment the client must stop and ask — never auto-accept, never silently reconnect. This is correct today; the spec locks it.


6. Logout & session lifecycle

Trigger Target behavior
User logout best-effort POST /auth/logout (fire-and-forget) → clearAuth() → leave voice, disconnect WS, delete stored credential for the host, → connect page
401 anywhere Same as logout, with "Your session expired — sign in again."
WS BANNED Transient-error → connect page, no reconnect
Cert reject Disconnect → connect page

✓ Resolved 2026-07-20 — server session revoked on logout. User-initiated logout now calls api.logout() (POST /auth/logout) via the logout() helper (src/lib/logout.ts), wired into the settings Log Out button (MainPage.tslogout(api)). The revocation is strictly best-effort: fire-and-forget with its rejection swallowed, so a slow/offline/rejecting server never blocks or delays the local teardown — clearAuth() always runs synchronously. The credential is still deleted locally (main.ts), and the server token is now invalidated too.


Source of truth

src/lib/router.ts, src/main.ts, src/pages/ConnectPage.ts, src/pages/connect-page/LoginForm.ts, src/lib/ws.ts, src/lib/api.ts, src/lib/httpProxy.ts, src/components/ConnectedOverlay.ts, src/components/ServerBanner.ts, src/components/CertMismatchModal.ts, src-tauri/src/ws_proxy.rs, src-tauri/src/http_proxy.rs; server Server/api/auth_handler.go, Server/api/totp_handler.go.