Commit Graph
13 Commits
Author SHA1 Message Date
J3vbandClaude Opus 4.8 58005c9c6f feat(auth): revocable API tokens, introspect MCP server, and a Go 1.26 idiom pass (#1266)
* feat(auth): add revocable API tokens (bot/service auth)

Add long-lived, revocable API tokens so headless clients (the introspection
MCP tool, bots, CI) can authenticate without a password. Presented as
"Authorization: Bearer <token>", a token authenticates as a specific user,
inheriting that user's role and permissions.

- migration 018 + dedicated api_tokens table (kept separate from sessions so
  bulk logout and the per-user session cap never touch these); only the
  SHA-256 hash is stored, raw token shown once at creation
- auth.ResolveTokenHash: one shared bearer resolver that both AuthMiddleware
  and adminAuthMiddleware now call. Sessions are matched first so existing
  login behavior is unchanged; API tokens are a fallback only on session miss.
  A DB outage is returned wrapped, never mistaken for a bad token.
- `server token create|list|revoke` CLI: mints directly against the DB with no
  HTTP and no login — the password-free bootstrap path
- tests: resolver (8 cases incl. outage-not-fallthrough), db queries (6),
  api middleware integration (valid + revoked token)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(tools): add owncord-introspect MCP server

A local MCP dev tool that lets Claude Code introspect a running OwnCord
instance: read its logs, query any REST endpoint, and tail the desktop
client's log file. It is a thin wrapper over the existing API plus the
client log — no new product surface.

- tools/mcp-introspect/index.mjs (Node/ESM, one dep: @modelcontextprotocol/sdk)
  exposes api_request (full read-write passthrough), server_logs (admin SSE
  ring-buffer stream), client_logs (reads the desktop log file)
- authenticates with an API token (OWNCORD_API_TOKEN); pins the self-signed
  cert and skips hostname checks (the cert has no SAN)
- registered in .mcp.json (secret-free ${OWNCORD_API_TOKEN})
- un-ignore tools/mcp-introspect/ so this shared dev tool is committed, while
  tools/livekit-server.exe and node_modules stay ignored
- docs/mcp-introspect.md: how it works, tool reference, setup, troubleshooting

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(dependencies): update and add various crate versions in Cargo.lock

* feat(admin): manage API tokens from the admin panel

Add Owner-gated HTTP endpoints and a UI card to create, list, and revoke
API tokens from the web admin panel. Previously only the `server token`
CLI could manage them, which requires shell access to the host.

- POST|GET|DELETE /admin/api/tokens in admin/handlers_tokens.go, wired in
  admin/api.go. All three are Owner-only (ownerOnlyMiddleware, like
  backups/updates): an HTTP token-mint endpoint is a network-reachable
  credential-minting surface, and API tokens deliberately survive password
  change + bulk logout, so a hijacked admin session must not mint one.
- Reuses the same db.*APIToken calls as the CLI; create sources the actor
  from request context (audits who clicked, not the bound user); the raw
  token is returned once in the 201 body, never stored.
- Add json tags to db.APITokenListItem for snake_case wire consistency.
- Admin panel: "API Tokens" nav item + create modal, show-once reveal,
  revoke confirm in admin/static/index.html.
- Tests: 7 in admin/api_test.go (+api_tokens table in the in-memory schema).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: modernize to Go 1.26 idioms + enable modernize linter

Apply `golangci-lint modernize` autofixes across the server and enable the
linter in .golangci.yml so these stop re-accumulating (they built up only
because modernize was never in the config).

Production code: slices.Contains for hand-rolled membership loops (api
router, ws origin, db/account, plugin manifest); strings.SplitSeq for
allocation-free line/segment iteration (db/migrate, updater, livekit_proxy);
strings.Cut (config); fmt.Appendf (dm_handler); min() (event_pruner);
any (ws client). Tests: range-over-int, t.Context(), WaitGroup.Go,
slices.Sort, maps.Copy, new(expr), interface{}->any.

- plugin/manifest.go parent-traversal check applied by hand: modernize
  skipped it (two conflicting rewrites); used the slices.Contains form.
- Removed the now-dead ptr() test helper after newexpr inlined its callers.
- Dropped dangling sort imports left by the sort.Slice->slices.Sort rewrite.

No behavior change. All four tag variants build, full test suite is green,
and golangci-lint (with modernize enabled) reports 0 issues.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 13:25:46 +02:00
J3vbandClaude Opus 5 17b17eb1b3 fix(security): close all 13 findings from the 2026-07-28 server scan, plus dependabot rollup (#1264)
* fix(admin): reject banned users in admin auth (F1)

adminAuthMiddleware accepted a Bearer token on session validity plus the
ADMINISTRATOR bit alone and never consulted ban state, so a ban never
revoked admin-panel access. Adds the auth.IsEffectivelyBanned guard that
api.AuthMiddleware already uses, at both admin credential-resolution points.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ws): gate the voice-channel text subscription on READ_MESSAGES (F2)

registerNow subscribed any client with voice state to that channel's
text-message topic regardless of READ_MESSAGES. The handshake's
already-computed readable-channel set is now passed into registerNow and the
subscription only happens when the voice channel is in it, preserving
authorized reconnect delivery.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(service): require READ_MESSAGES to delete messages (F4)

The non-DM delete gate checked MANAGE_MESSAGES without READ_MESSAGES, so a
role locked out of a private channel could still delete every message in it.
Requires ReadMessages alongside ManageMessages (and alongside SendMessages on
the author path) and derives the mod flag from that same gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(service): require READ_MESSAGES alongside MANAGE_MESSAGES in SetMessagePinned (F8)

Pin/unpin checked only MANAGE_MESSAGES, so a role denied READ on a private
channel could still pin and unpin its messages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(service): enforce the DM block at every DM interaction sink (F5)

The DM block was only checked on send, leaving edit, reactions, pins and
typing as bypasses. One shared requireDMNotBlocked is now called from all of
them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ws): re-check CONNECT_VOICE when minting a refreshed LiveKit token (F6)

voice_token_refresh re-minted a LiveKit token without re-checking
CONNECT_VOICE, so a revoked permission kept working for the life of the
session. The permission is now re-checked where the token is minted, and a
60s sweep evicts participants whose permission was revoked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ws): rate-limit voice_e2ee_offer after validation, keyed on server state (F7)

The limiter key was built from unvalidated client input, letting an attacker
grow the limiter map without bound. The limiter now runs after validation and
keys on (sender, voiceChannelID), never on client input.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ws): deliver voice_state/voice_leave only to roles that may read the channel (F9)

Voice state of private channels was broadcast to every connected client,
leaking channel membership. All 11 emit sites now route through one
READ-filtered fan-out, channel-tagged so replay filters too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(api): redact the LiveKit access token from proxy dial-failure logs (F10)

A dial failure wrote the LiveKit access-token JWT into the server log via the
URL in the error. redactKey now runs on the error before it reaches slog.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(auth): reserve the [deleted-N] username namespace (F11, F12)

The tombstone username namespace used by account deletion was freely
registrable, letting a user impersonate a deleted account. The namespace is
now reserved at validation, and DeleteAccount retries with a random suffix on
collision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(api): strip Unicode format characters from upload filenames (F13)

The attachment filename sanitizer stripped control characters but not
unicode.Cf, allowing bidi-override extension spoofing. Cf is now stripped
alongside controls and foreign path separators are cut.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(api): reserve the login attempt before the bcrypt compare (F3)

The per-username lockout was a read-only IsLockedOut check followed by a
failure recorded only after the ~250ms bcrypt compare, so N concurrent
requests all passed the stale check before any of them recorded a failure.
The per-username cap is the only cross-IP brute-force defence (the middleware
limits per IP), so a distributed burst landed N guesses per 15-minute window
instead of 10.

Both counters are now reserved atomically with limiter.Allow before the
compare, and the lockout decision moves to the read-only limiter.Check so the
reservation is not double-counted. The limits are sized at threshold+1, which
leaves the sequential accepted-input set byte-identical to the previous
behaviour: failures 1-10 still land, the 10th still trips the lockout, and the
account owner's correct password on attempt 10 still returns 200. Sizing at
threshold instead would make 9 cheap wrong guesses convert the victim's own
correct password into a 15-minute lockout - the regression that got two
earlier attempts at this fix rejected, now pinned by a boundary test.

Deliberately scoped to handleLogin. The report also suggested widening to the
password-confirmation endpoints, but those are authenticated, share a single
pw_confirm_fail key across the TOTP endpoints, and widening there is what got
the first attempt rejected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(deps): bump five Rust dependencies in /Client/tauri-client/src-tauri

Rolls up dependabot #1259, #1260, #1261, #1262 and #1263:

  tauri-build        2.5.6 -> 2.6.3
  tauri-plugin-fs    2.4.5 -> 2.5.1
  tauri-plugin-http  2.5.7 -> 2.5.9
  tauri-plugin-store 2.4.2 -> 2.4.4
  webpki-roots       1.0.6 -> 1.0.9

All five are lockfile-only; the manifest constraints already permitted the
new versions. The five PRs each rewrote overlapping regions of the same
Cargo.lock and so could not be merged independently, so the lockfile was
regenerated with cargo update --precise for each crate instead. The combined
result is smaller than the sum of the five diffs because they share
transitive updates.

Verified with cargo check --locked --all-targets (exit 0).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(deps): bump typescript-eslint from 8.58.0 to 8.65.0 in /Client/tauri-client

Dependabot #1258.

8.65.0 improves @typescript-eslint/no-unnecessary-type-assertion, which
surfaces four assertions that were already redundant and now fail the lint
gate. They are removed here rather than in a follow-up so no commit in this
branch leaves `npm run lint` red:

  UserBar.ts / members.store.ts  "online" as UserStatus -> "online"
                                 (the receiver already accepts the literal)
  media.ts                       drops `as RequestInit` on a literal that is
                                 already assignable
  LoginForm.ts                   drops `as { message: unknown }` made
                                 redundant by the `"message" in err` narrowing

All four are the rule's own autofix. Verified: npm run typecheck, npm run
lint, npm run format:check all clean, and the unit suite is 3572/3572 green
across 129 files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:48:47 +02:00
J3vbandClaude Fable 5 fbcbd39a9c chore(deps): migrate nhooyr.io/websocket to github.com/coder/websocket
nhooyr.io/websocket now resolves to github.com/nhooyr/websocket-old and its
README is a one-line deprecation pointing at coder/websocket. Its last three
releases (v1.8.15-17) all shipped on 2024-08-10 as the redirect; the fork has
shipped through 2026-06-15.

The version number decreases (v1.8.17 -> v1.8.15) because both paths tagged in
the same space, but the coder release is ~2 years newer. Import path only; the
9 API symbols used are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 16:54:19 +02:00
J3vbandClaude Opus 4.8 7b178ff30b fix(security): harden server against verified code-review findings
Applies fixes for 20 adversarially-verified findings from a whole-codebase
security review (server side). All Go build-tag variants build, `go vet` is
clean, and the suite passes (the sole failing test, ws TestEmitEvents, is a
pre-existing nil-harness failure unrelated to these changes).

High severity:
- auth: close TOCTOU in TOTP verify rate-limit by recording each attempt
  atomically up-front (was Check-then-Allow), restoring the per-user
  brute-force cap.
- plugin: enforce the CPU/time budget on every WASM guest call via a
  WithTimeout context (WithCloseOnContextDone interrupts runaways); the
  configured budget was previously parsed but never applied.
- api/waf: inspect request bodies for chunked (ContentLength==-1) requests
  so the SQLi/XSS/RCE body rules can no longer be bypassed.
- ws: rate-limit voice_join/voice_leave and voice_e2ee announce/offer, which
  fan out to every participant and could force mass disconnects.

Medium severity:
- api: run bcrypt on the unknown-user login path (no || short-circuit) to
  remove the timing-based username-enumeration oracle.
- ws: verify LiveKit webhooks via the SDK receiver so the signature is bound
  to the body hash (kills forgery/replay).
- authz: require READ_MESSAGES for reactions and for plugin-command
  broadcasts; route the latter through RequireChannelAccess.
- api: cache the client-update signature fetch and rate-limit the endpoint.
- service: propagate DeleteOtherSessions failure from ChangePassword instead
  of silently reporting success.
- api: trust the rightmost non-proxy X-Forwarded-For entry, not the
  client-controllable leftmost one.
- plugin: route auto-registered commands through the conflict-checked
  RegisterCommand; pin the DNS-validated IP for host_http dials
  (DNS-rebinding TOCTOU).
- api: mark access-controlled downloads private/no-cache + Vary: Origin.

Low severity:
- auth: fail closed when a fully-shaped TOTP ciphertext fails GCM auth
  (was returning the ciphertext as plaintext).
- api: apply the livekit-proxy path allowlist to WebSocket upgrades too.
- service: verify attachment ownership before linking (IDOR).
- admin: bound the bootstrap setup invite (5 uses / 24h); re-verify the
  update binary hash immediately before rename+spawn (TOCTOU).
- service: require BanMembers + role hierarchy for moderation ban/unban.

chore: stop tracking the stray Server/owncord-server.exe build artifact.

Test infra: add uploader_id to the hand-rolled ws test attachment schemas and
make MemStore.GetAttachmentByID a no-op lookup, matching production/DB behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 21:08:54 +02:00
Claude 1673c37b9c fix: comprehensive security hardening from full codebase audit
Addresses 14 findings from the security audit across all severity levels:

CRITICAL:
- C-1: Add user blocking system (migration, DB queries, REST API, WS DM
  send check) to prevent harassment via unconsented DMs
- C-2: Remove server version from unauthenticated /health and /info endpoints
  to prevent fingerprinting

HIGH:
- H-1: Remove dangerous-settings feature from tauri-plugin-http
- H-3: Default allowSelfSigned to false in API client (was hardcoded true)
- H-4: Cap invite expiration to 30 days (720 hours)
- H-5: Add 256KB message size limit to LiveKit WS proxy (prevents OOM)
- H-6: Cap concurrent sessions to 25 per user (evicts oldest on overflow)
- H-8: Restrict /diagnostics/connectivity to ADMINISTRATOR role

MEDIUM:
- M-2: Deny access to legacy NULL-uploader unlinked attachments
- M-4: Log warnings on TOTP plaintext decryption fallback paths
- M-8: Remove acceptInvalidCerts from OG preview fetches
- M-10: Expand file upload blocklist (Java .class, OLE2, WASM, .lnk)
- M-12: Add LIMIT to ListInvites (200) and ListMembers (1000)
- M-14: Add CHECK constraint trigger on channels.type (text/voice/dm)

https://claude.ai/code/session_01KKo3RwjdmcNzkgXNfUkgNT
2026-04-04 16:48:57 +00:00
jevb 447a4543e7 chore: remaining server changes (code quality, go mod tidy)
Go mod tidy, minor server-side adjustments from security verification
and code quality cleanup pass.
2026-04-01 11:38:33 +02:00
jevb 4c4526e539 fix: security hardening — 45 issues from full-project Copilot audit
Critical (6):
- C1: SQL injection in VACUUM INTO backup path — strict character allowlist
- C2: Unlimited binary download in updater — 500MB LimitReader
- C3: JSON injection in SSE log stream — json.Marshal instead of concat
- C4: CSS injection via custom themes — reject () and {} in values
- C5: Silent DM message loss — error response on participant lookup failure
- C6: LiveKit URL credential leak — strip creds from diagnostics endpoint

High (11):
- H1: DB errors no longer trigger login rate-limit lockout
- H2: Permission fetch failure returns 500, not empty channel list
- H3: TOCTOU race on duplicate WS — atomic check-and-register in hub
- H5: LiveKit webhook verifies voice channel match (already implemented)
- H7: Server host address validated before storage (hostname regex)
- H8: WS message deduplication on reconnect replay (1000-entry Set)
- H9: Admin setup endpoint rate limited (5/min/IP)
- H10: Backup responses return filename only, not full path
- H11: Update binary recovery failure now alerts admin

Medium (17):
- M1: MIME type from magic bytes, not client header
- M3: Nil guard on DM broadcast recipient
- M5: LiveKit process run-done channel race fixed
- M6: Backup restore calls fsync before close
- M7: Partial download file cleaned up on error
- M8: Admin CSP uses nonce instead of unsafe-inline
- M9: Client rate limiter enforced for presence_update
- M10: Voice joinedAt not reset on double-join
- M11: Unread count skips increment during reconnect replay
- M13: Category type uses exact match, not substring
- M14: Storage LimitReader off-by-one fixed
- M15: GitHub token only sent to GitHub hosts
- M16: Content-parser ReDoS regex replaced with split approach
- M17: Audio device switch error handling added

Low (11):
- L1: CORS uses configured origins instead of wildcard
- L2: HSTS header added when TLS enabled
- L3: Consistent JSON error responses across all endpoints
- L4: File modtime from stat, not time.Now()
- L5: Malformed invite JSON returns 400
- L6: TouchSession failure logged at warn
- L8: MessageInput timers cleared on destroy
- L9: Log persistence flush errors caught
- L10: Credential save failure surfaced to user
- L11: Case-insensitive asset name matching in updater

Found by GitHub Copilot full-project review (claude-sonnet-4.6 + claude-haiku-4.5).
2026-03-29 12:35:04 +02:00
jevb b8879fe237 fix: resolve all 11 open bugs, add account deletion, harden security
- BUG-046: wrap switchActiveDevice in isolated try-catch with fallback
- BUG-047: track pending uploads, block send until complete
- BUG-048: add 100MB size limit and MIME allowlist on paste
- BUG-049: replace requestAnimationFrame with setTimeout for VAD
- BUG-050: clear stale audio elements before auto-reconnect
- BUG-051: add origin check + segment-based path deny-list to proxy
- BUG-052: replace 6 swallowed .catch(() => {}) with logging
- BUG-053: already fixed (TOFU pinning in livekit_proxy.rs)
- BUG-054: account deletion endpoint + UI with password confirmation,
  per-user progressive lockout, and anonymization (not hard delete)
- BUG-055: remove 4 stale vitest coverage exclusions
- BUG-056: fix proxy URL test with proper Tauri invoke mock
- Fix pre-existing themes.test.ts accent color key mismatch
- Harden isOriginAllowed to default-deny when no origins configured
- Return 204 No Content on account deletion (consistency)
2026-03-28 13:21:07 +01:00
jevb 76cb9b9630 fix: security hardening, DM auth, LiveKit stability, and voice call timer
Security fixes (from multi-reviewer code review):
- Add DM participant auth checks to channel_focus, typing, and REST
  message endpoints — prevents unauthorized access to DM channels
- Fix TOCTOU race in GetOrCreateDMChannel using IMMEDIATE transaction
- Validate YAML credentials before LiveKit config interpolation
- Add CSS variable injection prevention in custom theme loader
- Validate localStorage JSON before unsafe type casts

LiveKit stability:
- Track remote mic audio elements for cleanup on abnormal disconnect
- Remove duplicate token refresh timer scheduling
- Add .catch() to all floating applyMicMuteState promises
- Clear reconnectAc after async post-connect work completes
- Fix double cmd.Wait() race in LiveKit process Stop()
- Reorder voice_join guards: validate channel before livekit==nil check
- Add startup warning for external LiveKit webhook CIDR mismatch

DM system fixes:
- Emit dm_channel_close WebSocket event from REST close handler
- Re-open DM for caller when channel already exists
- Fix unread count incrementing for own messages and active DMs
- Reset channelBeforeDm after Back navigation (stale state bug)

New feature:
- Voice call duration timer in VoiceWidget (MM:SS / HH:MM:SS elapsed)
- Accent color restored on app startup (was only applied in settings)

Test infrastructure:
- Add DM tables to all test schemas (hubTestSchema)
- Inject test LiveKit client in voice handler tests (fixes 28 failures)
2026-03-27 16:14:54 +01:00
jevb a87824f2a5 fix: resolve CI failures — lint errors and coverage threshold
Server: suppress errcheck on deferred Close() calls, discard
resp.Body.Close error, remove unused voiceSpeakersPayload type
and buildVoiceSpeakers func.

Client: add unit tests for os-motion, livekitSession, and
safe-render to bring coverage from 74.16% to 76.06% (threshold 75%).
2026-03-22 20:06:59 +01:00
jevb 7978ec40e8 fix: security hardening, LiveKit class refactor, and eng review fixes
Server:
- Fix YAML injection in LiveKit config generation (quote values)
- Revert token TTL to 4h (no server-side JWT revocation)
- Derive LiveKit publish permissions from user role (prevent SFU bypass)
- Add CAS guard for webhook/voice_leave race condition
- Add voice_leave broadcast to rollbackVoiceJoin (prevent ghost state)
- Limit webhook body to 64KB (prevent memory abuse)
- Add rate limit to voice_token_refresh handler (1/60s)
- Add LiveKit health check endpoint (GET /api/v1/livekit/health, 503 on degraded)
- Add voice_token_refresh WS handler for client-initiated token refresh
- Consolidate voice quality constants (single source of truth)
- Fix video limit TOCTOU race (count from DB instead of LiveKit API)
- Raise default voice_max_video from 10 to 25 (Discord parity)
- Add CountActiveCameras DB query
- Non-blocking broadcast send, circuit breaker, exponential backoff
- Close send channel before context cancel in serve.go
- Guard voice mute/deafen for active channel
- Delete orphaned message on attachment link failure
- Redact query string from proxy logs (prevent token leak)
- Use instance-level HTTP client for health checks (no redirect following)
- Set cmd.WaitDelay to prevent goroutine leak on Windows
- Log buildJSON marshal errors

Client:
- Refactor livekitSession.ts from singleton module to LiveKitSession class
- Share single AudioContext for all analysers (was 1 per participant)
- Extract createRoom() helper (DRY)
- Add token refresh timer (3.5h interval, re-arms on failure)
- Skip setSpeakers if unchanged (sort in-place, no allocations)
- Distinguish user-initiated leave from connection error in retry
- Add YouTube videoId validation (prevent iframe src injection)
- Add try/finally to disableCamera
- Wrap store subscription callbacks in try/catch
- Track and cancel initial scroll RAF on cleanup
- Add 5s timeout + encodeURIComponent to YouTube oEmbed fetch
- Clean raw mic stream on RNNoise suppressor failure
- Full voice cleanup on logout via cleanupAll()

Tests:
- Add 7 new server tests (webhook parsing, voice guards, quality fallback)
- Fix 2 pre-existing test failures (mute/deafen invalid payload)
2026-03-21 11:59:14 +01:00
jevb 9a853fd078 refactor: UI architecture improvements + GIF auto-pause
Architecture:
- Add subscribeSelector to store.ts for selective state subscriptions
- Create reconcileList utility for DOM list patching without rebuild
- Create shared createContextMenu utility (dedup 3 files)
- Convert all 20 subscribe() calls to subscribeSelector across 11 files
- Split renderers.ts (1131L) into 7 focused files by concern
- Split ConnectPage.ts (838L) into ServerPanel + LoginForm + shell
- Fix ineffective (s) => s selector in ChannelSidebar

GIF visibility:
- Add media-visibility.ts with IntersectionObserver + canvas snapshots
- GIFs auto-pause after 10s, play/pause button overlay on hover
- Freeze GIFs on scroll-away, window blur, and minimize
- Wire into media.ts, attachments.ts, embeds.ts renderers

Tests: 46 new tests (1073 total), all passing
Net: -1166 lines across client codebase
2026-03-20 15:21:56 +01:00
jevb bf3fada16b fix: proxy LiveKit through HTTPS to fix mixed-content block
- Add reverse proxy at /livekit/* that forwards to LiveKit server
- Server sends relative URL "/livekit" in voice_token; client
  resolves to wss://server:port/livekit using known server host
- Fix API secret minimum length (32 chars required by LiveKit)
- Pass TLS config to LiveKit process manager for TURN certs
2026-03-20 06:11:09 +01:00