- livekit-session: assert joining→securing→connected on join, idle on leave,
connected after auto-reconnect
- voice-widget: assert each status label + secured badge visibility, and controls
disabled with reason while the socket is down, re-enabled on reconnect
- voice-callbacks: assert join/leave/disconnect do not send over a down socket
- voice.store: assert join seeds joining, leave resets idle, setter writes status
- thread voiceStatus through existing full-state fixtures; keep an active-call
socket live in widget fixtures so control-click tests still operate enabled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a store-backed voice.store.voiceStatus (idle | joining | securing |
connected | reconnecting), written as the single source of truth from
livekitSession at each lifecycle transition: joining at connectAndSetup start,
securing when ECDH key exchange begins, connected on the connected transition
(initial join and auto-reconnect), reconnecting when the room drops, idle on
leaveVoice. joinVoiceChannel seeds joining optimistically on click.
VoiceWidget renders the phase in its header: 'Connecting…' / 'Securing…' (amber)
and a persistent '🔒 Secured' badge once the room key is ready, replacing the
log-line-only E2EE feedback. While ui.store.connectionStatus is not 'connected',
the widget disables its controls with a 'Reconnecting…' / 'Not connected' reason,
and the VoiceCallbacks join/leave paths refuse to send over a down socket.
LiveKit's own reconnection machinery is untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes from an adversarial review of the previous commit:
- MainPage banner: sync the banner with the current store status at mount.
The selector subscription baselines on the current value and only fires
on change, so a MainPage mounted mid-outage (status already
"reconnecting") would never show the banner — the whole retry cycle maps
to the same 3-state value. The status→banner dispatch is extracted to
ServerBanner.applyConnectionStatus and unit-tested.
- History-fetch failure is no longer silent when the channel already has
rows (live broadcasts / optimistic sends): the inline error region only
renders in an empty channel, so loadMessages now also raises a toast in
that case.
- Composer disable reason distinguishes "Reconnecting…" from
"Not connected" per the spec §3 table (it previously showed
"Reconnecting…" while disconnected, contradicting the banner).
- The single-writer wiring is extracted to
dispatcher.wireConnectionStatus(ws) and pinned by a test (it was
previously an untestable main.ts module-scope line — deleting it would
have failed zero tests).
- Docs honesty: messaging.md's transport-drop diagram arm now shows both
codes (channel full → NETWORK, closed/not-open → OFFLINE) instead of
claiming NETWORK for both; README §3's callout now explicitly lists the
voice column ("frozen" during reconnect) as a remaining gap instead of
implying the section is fully closed; the composer table documents both
offline reasons.
- New pinning tests: SidebarArea passes ws to UserBar (the production-bug
fix was previously unasserted), ServerBanner.showDisconnected,
applyConnectionStatus mapping, ChannelController onRetryLoad /
onRetry-resend / onDeleteDraft, composer reason per status, and the
history-failure toast fallback.
Verified: tsc + full client unit suite (3234 tests) + oxlint/eslint +
prettier all green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implements the next four gaps from the client UX spec (docs/architecture/ux).
Connection status as single source of truth (spec §3):
- main.ts registers the one writer: ws.onStateChange → toConnectionStatus
(new 5→3 state mapper exported from ws.ts) → ui.store.connectionStatus.
- Consumers now subscribe to the store instead of ad-hoc ws wirings: the
MainPage reconnect banner (which also gains a "Disconnected" state via
ServerBanner.showDisconnected instead of going stale), ChannelController
composer gating + per-click send guard, and the UserBar presence picker.
- Fixes a latent production bug: SidebarArea never passed ws to UserBar, so
the status picker was permanently disabled and its presence_update path
dead. It now gates on the store and receives the ws send path.
- The one-shot connected-overlay wiring stays on ws.onStateChange by design
(it needs the exact internal transition); LiveKit voice reconnection stays
independent ("retrying underneath").
Transport backpressure surfaced (spec §5):
- ws.ts sendRaw no longer drops local send failures silently: send() passes
the envelope id, and failures notify a new onSendFailure(id, code)
listener — channel full → NETWORK, closed/not-open → OFFLINE (deferred a
microtask on the not-open path so the optimistic row registers first).
- The dispatcher fails the matching pending row via markSendFailed, exactly
like a server error reply; id-less sends (heartbeat) and fire-and-forget
sends (typing, presence) stay silent by design. MessageList renders the
new NETWORK reason ("Connection problem — message not sent").
uploadFile honors global 401 handling (spec §5):
- api.uploadFile now calls onUnauthorized and throws ApiClientError(401)
like every other REST call; main.ts sets the "Your session expired — sign
in again." transient error so the connect page shows the reason.
History fetch loading/error states (messaging.md §1):
- messages.store gains per-channel historyLoadState (loading/error, absent
= idle) with setChannelLoading/setChannelLoadError; setMessages and
clearChannelMessages clear it.
- MessageController.loadMessages sets loading synchronously before the
fetch and marks error inline instead of a toast; MessageList renders an
in-region spinner placeholder or an inline error + Retry (onRetryLoad
re-invokes loadMessages via ChannelController).
Also fixes two pre-existing eslint errors in api.ts (redundant assertions).
Docs: the corresponding gap callouts in docs/architecture/ux are updated
(README §3/§5, messaging.md §1/§3/§6, channels-members-dms.md block-gating
note no longer claims the composer lacks a read-only mode).
Verified: tsc + full client unit suite (3225 tests) + oxlint/eslint +
prettier all green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implements the two highest-impact gaps from the client UX spec.
Optimistic send:
- messages.store gains addOptimisticMessage / markSendFailed /
removeOptimistic, and confirmSend now stamps the real id + "sent" on
the ack. addMessage reconciles the broadcast by real id (idempotent,
replay-safe) with a defensive author match, so an echo never
duplicates. Message gains status/correlationId/errorCode.
- ChannelController.performSend renders a pending row immediately and
supports retry / delete-draft (retry preserves attachments).
- MessageList renders pending (dimmed) and failed (reason + Retry /
Delete) rows; the hover action bar is limited to confirmed rows.
- Failures are precise: the server echoes the request id on error
replies (buildErrorMsgWithID), so the dispatcher maps SLOW_MODE /
FORBIDDEN / RATE_LIMITED / BAD_REQUEST to the exact row instead of
dropping the code. An offline send is shown failed, not silently lost.
Composer permission + connection gating:
- The server computes an authoritative per-channel can_send in the ready
payload (channelCanSend mirrors MessageService.checkSendPermission:
READ|SEND, MANAGE_MESSAGES for announcement, admin bypass, channel
overrides). channels.store carries it as Channel.canSend.
- MessageInput gains a disabled-with-reason mode; ChannelController
derives the reason from can_send + channel type + connection status and
disables the composer reactively (announcement read-only, no-permission,
reconnecting) rather than accepting a click and failing. Older servers
that omit can_send default permissive.
Docs: the corresponding "Current gap" callouts in docs/architecture/ux
are updated to reflect the implementation.
Verified: full server suite + client tsc + 3204 unit tests + lint + gofmt.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
Adds docs/architecture/ux/ — a prescriptive (to-be) behavior spec for the
Tauri client, complementing the as-built module map in
docs/architecture/client.md. Covers every view and how it should react to
server events, permission state, and failure:
- README.md — view-state vocabulary, feedback primitives,
connection-status contract, the global
event->reaction map, and the error/permission
reaction matrix
- connection-and-auth — boot, profiles/health, login, TOTP,
register-by-invite, connected handshake,
reconnect, cert-TOFU trust
- messaging — composer permission/connection gating, optimistic
send lifecycle, edit/delete, reactions,
attachments, pins, search, read/unread, slow-mode
- channels-members-dms — channel list/switch/categories, member list +
presence + typing, DM open/close, blocking
- voice-and-e2ee — join/leave, mute/deafen/camera/screenshare, PTT,
active-speaker, and the E2EE securing/secured
indicators
- settings-and-admin — settings tabs, profile/password/2FA/delete,
theming, inline admin (ban/kick/roles, channel
CRUD, invites), updater
Each flow carries dated "Current gap" callouts where today's code diverges
from the target (grounded in file:line references), so the set doubles as a
UX improvement backlog. Notable gaps captured: non-optimistic send with a
dead pending-send path, no composer read-only/permission gating (incl.
announcement channels), silently-dropped WS error codes, no E2EE "securing"
indicator, no updater download progress, client-local logout that never
revokes the server session, and a duplicated role store.
All 12 Mermaid diagrams validated; intra-repo links checked. Indexed from
docs/architecture/README.md and the top-level Docs Index.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
- CLAUDE.md (root + Server + client) documenting commands, generated-code
rules, and the known-red client unit suite policy
- .claude/skills: ci-check (local CI mirror), protocol-change, db-change
workflows for the sqlc/protocol codegen invariants CI enforces
- .githooks pre-commit/pre-push mirroring CI's fast gates (gofmt, go vet,
oxlint, prettier, tsc, tag-variant builds, sqlc/protocol staleness),
enabled via 'npm run hooks:install'
- .claude SessionStart hook installing client npm deps and warming the Go
module cache so remote sessions can run tests/linters immediately
- .mcp.json with Playwright and Context7 MCP servers
- .gitignore: commit shared Claude config; keep local-only overrides ignored
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BDcVJqGjHcJVLoV4x1HFjW
Deletes Server/store (SQLiteStore, MemStore, the composed Store
interface) and collapses to a single sqlc-backed db package, executing
the prior audit's P4 "single data layer" direction (finding #6).
SQLiteStore was a pure pass-through to *db.DB, so consumers now depend
on narrow interfaces that *db.DB satisfies directly:
- service.Store (service/datastore.go, renamed from store/store.go)
- ws.EventStore (ws/eventstore.go)
- plugin.PluginStore (plugin/pluginstore.go)
The event- and plugin-KV methods that lived in the store's SQLite
implementation move into the db package (db/event_queries.go,
db/plugin_queries.go), keeping their raw-SQL form.
Tests: the MemStore-based unit tests now run against a real in-memory
SQLite db opened per-test with migrations applied, via package-local
seed helpers. Fault-injection tests embed a real *db.DB and override the
single method under test, preserving error-path coverage. Full server
suite and sqlc-verify are green.
Docs: audit finding #6 and A-2026-07-06 marked resolved; decisions D3
updated; architecture server.md / data-model.md diagrams and prose
updated to the api -> service -> db layering.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
Make 'announcement' a real channel type, resolving the contradiction where
it was documented and offered by the admin API but hard-rejected by the
migration-013 DB triggers.
Model: announcement channels are readable like text channels (same
READ_MESSAGES visibility), but posting is restricted to users with
MANAGE_MESSAGES — no new permission bit, migration, or client permission
plumbing needed.
Server:
- migrations/016: recreate the channel-type triggers to allow
text/voice/announcement/dm.
- service/message.go: checkSendPermission now takes the channel type and
rejects posts to announcement channels from users lacking MANAGE_MESSAGES
(SendMessage + CanPost paths). Added a service test.
- Unread counts: ready-payload builder (ws/serve.go) and
GetChannelUnreadCounts (db) now include announcement channels alongside
text, so they track unread/last-message like text channels.
Client:
- ChannelSidebar renders announcement channels with a megaphone icon
(added to the icon set) instead of the '#' text prefix; they otherwise
behave like text channels (already typed in ChannelType).
Specs + trackers (api.md, protocol.md, schema.md incl. migration 016,
architecture/data-model.md, audit A-2026-07-01, decisions D1) updated.
Verified: go build ./...; go test ./service ./db ./ws ./api ./admin;
sqlc-verify; client tsc + oxlint + prettier clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
messages/reactions: CreateMessage, GetMessage (messageFromGen mapper),
EditMessage (EditMessageContent), DeleteMessage (SoftDeleteMessage),
AddReaction, RemoveReaction, GetReactions (GetReactionCounts),
SetMessagePinned, UpdateReadState. Retired the obsolete scanMessage.
Kept raw by design (no clean sqlc mapping): FTS search, cursor-paginated
GetMessages/GetMessagesForAPI/GetPinnedMessages, getReactionsBatch,
GetChannelUnreadCounts, GetLatestMessageID (interface{} MAX result).
D2 status: 97 db.DB methods now delegate to dbgen across every domain;
43 raw d.sqlDB calls remain by design (db.go passthroughs, migrate.go,
variable-length IN(), FTS, multi-statement transactions, PRAGMA/VACUUM).
sqlc is no longer dead code — audit A-2026-07-05 resolved. Full rationale
+ the kept-raw list in docs/plans/sqlc-adoption.md.
Verified: go build ./...; go test ./db ./service ./ws ./auth; sqlc-verify;
gofmt + go vet clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
Convert the auth_queries.go user + session reads/writes and
profile_queries.go to the sqlc-generated layer, adding shared
userFromGen/sessionFromGen mappers (db/mappers.go) for the
int64/*string -> int/bool/string domain-model narrowing.
Delegated: GetUserByID, GetUserByUsername, UpdateUserStatus,
UpdateUserTOTPSecret, ResetAllUserStatuses, BanUser, UnbanUser,
ListMembers, CreateSession (EvictOldestSessions + InsertSession),
GetSessionByTokenHash, GetSessionWithBanStatus, DeleteSession,
DeleteOtherSessions, DeleteExpiredSessions, TouchSession,
UpdateUserProfile, UpdateUserPassword, ListUserSessions,
DeleteSessionByID (query changed to :execresult so the RowsAffected
ErrNotFound check is preserved). Behavior and public signatures unchanged.
Verified: go build ./...; go test ./db ./service ./auth; sqlc-verify.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
Wire the sqlc-generated dbgen package into db.DB so it stops being dead
code (audit A-2026-07-05) and becomes the real, CI-verified query layer.
db.DB now holds a *dbgen.Queries (initialized in Open via dbgen.New).
Query method bodies delegate to it; sqlc owns the SQL text and parameter
binding (make sqlc-verify), while db keeps its stable public API and
domain model types so no caller in api/admin/ws/service changes. The
migration is incremental — a method either delegates to d.q.* or still
runs raw SQL — so both layers are correct during the transition.
Converted domains (now load-bearing through sqlc):
- blocks: BlockUser, UnblockUser, IsBlocked, IsEitherBlocked,
ListBlockedUsers (added the query to blocks.sql + regenerated).
Empty ListBlockedUsers now returns []int64{} instead of nil, matching
the MemStore backend — a latent inconsistency fixed, not a regression.
- lockouts: UpsertLockout, LoadActiveLockouts, CleanupExpiredLockouts,
DeleteLockout (RFC3339 time formatting/parsing kept in the wrappers).
- roles: GetRoleByID, ListRoles, GetRoleForUser via a shared roleFromGen
mapper (int64 position/is_default -> int/bool). GetUserWithRole stays
raw for now.
Remaining domains stay on raw SQL and are tracked in
docs/plans/sqlc-adoption.md; store/ event+plugin SQL is intentionally
excluded (that layer is removed in D3). Decisions doc + audit closure
updated (A-2026-07-05 -> in progress).
Verified: go build ./...; go test -race ./db ./service ./auth ./ws (api
green non-race, race run matches CI's -timeout 20m); make sqlc-verify and
protocol-verify pass with the regenerated output committed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
The REST path previously used tauri-plugin-http with
danger.acceptInvalidCerts, so it accepted ANY certificate while the WS
and LiveKit paths were TOFU-pinned in Rust — and the bearer token rides
every REST request. This routes REST through a new Rust loopback
TCP->TLS proxy that pins the server certificate to the same
trust-on-first-use fingerprint as the WS proxy.
Rust (src-tauri):
- New http_proxy.rs: per-host loopback tunnels (HttpProxyState map);
per-connection TOFU via CaptureVerifier + tofu_check, sharing
ws_proxy's cert store (cert_store_key) and emitting the same
cert-tofu events (first-use banner / mismatch modal). First request's
Host is rewritten and Connection: close injected so one request rides
each connection. Mismatch returns a clean 502 to the loopback fetch.
- Register HttpProxyState + start_http_proxy/stop_http_proxy in lib.rs.
- Drop the dangerous-settings feature from tauri-plugin-http.
TypeScript (src):
- New lib/httpProxy.ts: ensureHttpProxy(host) (per-host cache +
concurrent-start dedup) / stopHttpProxy(host).
- api.ts, profiles.ts (health), attachments.ts (image + download) resolve
server URLs to http://127.0.0.1:{port}; remove the allowSelfSigned
config field and every acceptInvalidCerts block. External hosts (CDNs,
OG previews, YouTube) keep normal TLS validation.
- main.ts constructs the API client without allowSelfSigned.
- capabilities/default.json: allow http://127.0.0.1:* fetch scope.
Tests:
- New tests/unit/http-proxy.test.ts (cache, dedup, stop/restart).
- api.test.ts and attachments-render.test.ts: mock httpProxy, replace the
acceptInvalidCerts assertions with proxy-origin assertions.
Verified: tsc --noEmit clean; new + affected vitest suites green
(176 tests); the http_proxy pure logic (host validation, header rewrite)
passes as standalone Rust unit tests; oxlint/eslint counts unchanged
from HEAD; prettier clean. The full Tauri build (cargo) requires GUI
system libs not present in this environment and runs on CI/real runners.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
Pins the implementation approach for closing audit finding A-2026-07-02:
a loopback TCP-to-TLS tunnel reusing the livekit_proxy pattern and the
shared per-host fingerprint store, with ws_proxy-equivalent TOFU
first-trust/mismatch flows (required because the first TLS contact with
a server is the login HTTP request), per-host tunnel lifecycle for
multi-profile health polling, and removal of the acceptInvalidCerts
path plus the dangerous-settings feature flag as the ratchet.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
The Solid.js migration was abandoned (per CHANGELOG); the 154-LOC
beachhead and its scaffolding remained in-tree, leaving two UI paradigms
for contributors. Removed:
- src/components/solid/ (Badge, ChannelListItem, PluginContainer — none
imported by production code)
- src/lib/solidMount.ts and src/lib/solidAdapter.ts
- tests/setup-solid.ts and tests/setup-solid.test.tsx
- vite-plugin-solid from vite.config.ts and vitest.config.ts (and the
now-unneeded tsx test include + setupFiles)
- jsx/jsxImportSource from tsconfig.json
- solid-js, @solidjs/testing-library, vite-plugin-solid from package.json
docs/client-architecture.md (which described the SolidJS design) is
retired to a pointer at docs/architecture/client.md; README links
updated. Audit A-2026-07-12 and decision D6 marked closed.
Verified: tsc --noEmit clean (previous 3 test-file errors were caused by
the Solid jsx config and are gone); oxlint/eslint error counts identical
to HEAD (pre-existing); vitest runner healthy on a sample suite.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
One-PR spec refresh per decision D7, using the 2026-07-19 audit's
conformance matrix as the checklist:
api.md
- Remove the deleted version field from /health and /api/v1/info
(anti-fingerprinting C-2) and document the removal.
- Document the profile surface (PATCH /users/me, PUT /users/me/password,
GET/DELETE /users/me/sessions), the user-blocks surface
(GET/PUT/DELETE /api/v1/blocks), and the plugin admin surface
(/api/v1/admin/plugins).
- Correct GET /api/v1/files/{id}: auth is required and caching is
'private, no-cache' (was documented as public/immutable, unauthenticated).
- Add search (30/min) and upload (10/min) rate limits; note announcement
channel type as planned-only.
protocol.md
- Document auth_ok.replay_source and the 3-tier reconnect replay
(ring buffer -> events table -> full resync) with the visibility
watermark.
- Add the Voice End-to-End Encryption section (voice_e2ee_announce/offer
in both directions, key-holder semantics, rate limits) and
voice_token.is_key_holder.
- Document user_update; mark voice_speakers and member_leave as defined
but not currently emitted; extend voice_config fields.
- Update the reference tables (19 client->server / 30 server->client)
and point them at protocol-schema.json as the generated inventory.
schema.md
- Correct the migration history to the real 001-015 numbering.
- Document the previously missing tables: login_attempts, settings,
emoji, sounds (dead schema), rate_lockouts, user_blocks, events,
plugins, plugin_kv; add attachments.uploader_id and new indexes.
- Fix the channel-type list to text/voice/dm (013 triggers) and correct
the permission formula to (base & ~deny) | allow to match
permissions.EffectivePerms.
- Note the sqlc/dbgen layer and link the architecture data-model doc.
Also correct the audit_log_v6 claim (transient rename inside migration
003, not a coexisting table) in the audit and data-model blueprint, and
update the audit/decisions trackers (A-2026-07-03 closed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
Protocol codegen (decision D4, audit A-2026-07-08):
- Add docs/protocol-schema.json as the real single source of truth for
WS message-type constants, making the long-standing 'generated from'
comment in both constant files true.
- Add Server/scripts/genprotocol, a generator emitting both
Server/ws/message_types.go and Client .../lib/protocolTypes.ts
(constants byte-for-byte value-identical to before; only headers,
ordering alignment, and provenance comments changed).
- Add make protocol-generate / protocol-verify and wire protocol-verify
into CI next to sqlc-verify.
Quick wins (decision D8):
- admin: log LogAudit write failures in the backup handlers instead of
discarding them (prior audit #10).
- api: fix self-contradictory upload Cache-Control to 'private,
no-cache' per remediation plan W3-4; drop the now-unused
fileCacheMaxAgeSeconds constant; update test.
- ws: route the hub settings cache through db.GetSetting instead of
inline SQL.
- ws: fix a latent data race — main.go wires SetEventPersister and
SetEventStore after NewRouter has already started the hub Run loop,
which reads those fields on the broadcast/replay paths. They (and
pluginSink, which one test sets post-Run) are now atomic pointers;
the remaining pre-Run-only setters reject late calls with an error
log instead of racing silently.
Update the audit closure table and decisions doc statuses accordingly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
Add docs/plans/audit-2026-07-19-decisions.md capturing the eight decisions
taken on the audit's open questions (announcement channels: implement;
data layer: adopt sqlc + remove store/; protocol constants: real codegen;
client HTTP TOFU pinning: next security work; Solid beachhead: delete;
specs: one refresh PR after codegen) with sequencing, and mark the greenlit
items (protocol codegen + quick-wins batch).
Update the closure table in docs/audit-2026-07-19.md from OPEN to DECIDED
for the seven findings these decisions cover.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
Add docs/architecture/ — a curated blueprint set with 10 Mermaid diagrams
covering system context, deployment topology, server package map, REST
request lifecycle, WebSocket auth/replay/dispatch, the full data model
(migrations 001-015), voice/E2EE flow, and the client module map.
Add docs/audit-2026-07-19.md — successor to audit-2026-04-07.md:
re-verifies carried-over findings, catalogues spec-vs-code drift in
api.md/protocol.md/schema.md (incl. the announcement channel-type
contradiction and the undocumented voice-E2EE protocol surface), records
server/client/CI findings with file:line evidence, and closes with a
12-item prioritized improvement backlog.
Link both from the README docs index.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
Three review findings on the #93 feature:
- RefreshChannelVisibility targeted clients by their connect-time role
snapshot; a user whose role changed mid-session was evaluated against the
stale role. Resolve the current role from the DB per client (fail closed).
- Visibility updates are targeted, unsequenced messages, so a client that
disconnected before an override change and later resumed via replay never
converged (stale sidebar until a fresh connect). Track a visibility-change
sequence watermark and force resumes from at/before it onto the
full-ready path.
- The admin SPA interpolated channel/user names into single-quoted JS
strings inside onclick attributes with HTML-escaping only; a name
containing a quote broke out of the string literal (XSS in the admin
panel, reachable by any user allowed to create channels). Add a jsq()
helper (JS-escape then HTML-escape) and use it for every onclick name
interpolation.
Follow-up to #93.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwtnpHAoSFr1ZibQgQkNQK
A zero-byte or whitespace-only livekit.yaml (truncated write, touch(1)
placeholder) has no auto-generated marker and was permanently treated as a
user-managed config, wedging LiveKit startup with an empty config file.
Follow-up to #111.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwtnpHAoSFr1ZibQgQkNQK
createLocalScreenTracks injects a default 1080p30 resolution when none is
set and mutates the passed options object, so (a) a 'source' share was
captured at 30 fps regardless of the FPS setting, with only a best-effort
applyConstraints afterwards, and (b) the shared 'source' preset object was
permanently mutated after the first share. Capture options are now always
copies; 'source' with an explicit 60/120 override passes a zero-size
resolution sentinel (uncapped in livekit's constraint translation) with the
frame rate in the raw video constraints, so the fps applies at
getDisplayMedia time.
Follow-up to #115.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwtnpHAoSFr1ZibQgQkNQK
A minimized window reports placeholder coordinates (-32000 on Windows); the
move event fired by minimize was persisting them, so quitting while
minimized silently discarded the remembered position (the new off-screen
validation then falls back to centered). Skip the save while minimized so
the last real geometry survives.
Follow-up to #124.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwtnpHAoSFr1ZibQgQkNQK
The read side of channel visibility was already complete — channel_overrides
rows deny ReadMessages and every list/focus/send/voice path enforces them —
but nothing could write those rows. Add the missing write path and UI:
- db: UpsertChannelOverride / DeleteChannelOverride / ListChannelRoleOverrides
(roles LEFT JOIN overrides so the UI gets everything in one call)
- admin API: GET/PUT/DELETE /admin/api/channels/{id}/permissions[/{roleId}]
with unknown permission bits masked via the new permissions.AllPerms,
audit logging, and immediate permission-cache invalidation
- ws: Hub.RefreshChannelVisibility sends targeted channel_create /
channel_delete to connected clients after an override change, unsubscribes
hidden clients from the channel topic, and clears their focus. Sent outside
the sequenced replay path on purpose: a replayed channel_delete would be
filtered by the post-change allowed-channel set, inverting its audience.
- admin panel: per-channel Access modal (lock icon) with per-role
"Can access" checkboxes; unchecking writes deny = ReadMessages|ConnectVoice
Known limits (follow-ups): users offline during a revoke keep a stale
sidebar entry until their next fresh connect (server still denies access),
and users already in a voice channel are not kicked when it goes private.
Closes#93
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwtnpHAoSFr1ZibQgQkNQK
Servers reachable via both a LAN IP and a public IP could only serve voice
on one of them: config.yaml accepts a single voice.node_ip and OwnCord
regenerates data/livekit.yaml on every start, discarding manual edits.
LiveKit has no multi-IP list, but it does support advertising internal host
candidates alongside the external mapping.
- New voice.advertise_internal_ip (OWNCORD_VOICE_ADVERTISE_INTERNAL_IP):
emits rtc.advertise_internal_ip: true so LAN clients get a reachable
candidate while remote clients keep using node_ip.
- livekit.yaml escape hatch: if the file exists without the auto-generated
marker header, OwnCord leaves it untouched, giving operators access to
every LiveKit option (ips.includes, interfaces, stun_servers, ...). The
generated header documents how to take ownership.
Closes#111
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwtnpHAoSFr1ZibQgQkNQK
Screen share frame rate was hardcoded per quality (5/15/30). Add a
"Screen Share FPS" setting (30 default / 60 / 120) next to Stream Quality:
- 30 keeps the existing per-quality caps unchanged
- 60/120 override the capture constraints and publish maxFramerate for all
qualities, with bitrate scaled 1.5x/2x to keep the image sharp
- "source" quality (no fixed resolution) applies the fps to the live
capture track via applyConstraints, best-effort
Actual delivered fps still depends on what the capture source and display
can sustain.
Closes#115
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwtnpHAoSFr1ZibQgQkNQK
Three defects made the screenshare tile's volume slider ineffective:
- The 0-200 slider mapped to element volume /200 clamped to [0,1], while
the element attached at 1.0 — dragging the upper half did nothing. The
screenshare slider is now 0-100 with 100 = 1.0 (HTMLAudioElement.volume
cannot exceed 1.0; mic tiles keep the 0-200 boost range via LiveKit's
GainNode-backed setVolume).
- Setting a volume before the screenshare audio track attached was silently
dropped. The per-user volume now persists independently of the element
map and is applied on attach.
- Changing the master output volume overwrote per-user screenshare volumes
with just the master multiplier; they now scale together.
The slider and mute button also initialize from persisted state when a tile
is rebuilt, and unmuting via the button re-applies the restored volume.
Fixes#121
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwtnpHAoSFr1ZibQgQkNQK
WebView2/Edge renders its own password-reveal eye inside password inputs,
stacking with the app's custom toggle on the login form. Hide the native
::-ms-reveal / ::-ms-clear controls globally.
Fixes#123
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwtnpHAoSFr1ZibQgQkNQK
On Wayland sessions (notably GNOME + NVIDIA), WebKitGTK's DMABUF renderer
can crash or render a blank window, so the client failed to start. Set
WEBKIT_DISABLE_DMABUF_RENDERER=1 on Wayland unless the user has already set
it themselves.
device_query's global key state needs an X11/XWayland display and panicked
per poll on pure-Wayland setups. Use DeviceState::checked_new() so push-to-
talk degrades to inactive with a single warning instead.
Fixes#96
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwtnpHAoSFr1ZibQgQkNQK
Restoring a stale window position (e.g. from a disconnected monitor) placed
the window off-screen with no way to see it. Before applying the saved
position, check that the rect is reachable on some monitor reported by
availableMonitors(): at least 100px of horizontal overlap and a grabbable
title bar row. If not, keep the default centered placement. Also reject
non-finite or non-positive saved dimensions. If monitors cannot be queried,
restore proceeds unchanged.
Fixes#124
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwtnpHAoSFr1ZibQgQkNQK
- Replace static badge block with CI, release, status, Go, Tauri,
platforms, and license badges (release badge tracks the public
OwnCord-releases repo)
- Update platform support table for v1.1.0-alpha.2 assets (Linux x64
server, Linux x64/ARM64 client)
- Stamp build examples with the current version
- Point contributing docs at main now that dev is pruned
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
contextcheck (CI lint) flagged the admin handler calling BanUser without
the request context — the service opened its telemetry span from
context.Background(), detaching the ban from its request trace. Both
moderation entrypoints now take ctx; the span joins the caller's trace.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>