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>
Register and Unregister travelled on two separate channels, and Run's
select picks randomly when both are ready: a fast connect/disconnect could
process the unregister first (a silent no-op for a not-yet-known client)
and then the register — admitting an already-dead connection as a ghost
client that held presence and swallowed broadcasts until the stale sweep
reaped it minutes later. One tagged event channel preserves each
connection's Register→Unregister submission order, making the inversion
structurally impossible. Found via TestHub_ConcurrentRegisterUnregister
failing the P1 gate under -race on windows-latest (2 ghosts after churn);
that test now settles in milliseconds instead of polling out its deadline.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
admin's fileSHA256 duplicated VerifyChecksum's hashing body. One exported
helper now serves both the TOCTOU snapshot in handleApplyUpdate and
VerifyChecksum itself.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
requireChannelBroadcastAccess went through RequireChannelAccess, whose DM
branch checks only participant membership — a blocked user's plugin
broadcast could reach the person who blocked them — and it issued a raw
GetRoleByID per broadcast, bypassing the permission cache. The gate now
delegates to MessageService.CanPost (extracted over checkSendPermission),
so DM blocks, channel permissions, and future posting policy apply from
exactly one place; fails closed when no service is wired. First brick of
the permission-path unification. MemStore.GetDMRecipient gets an honest
implementation so the block path is testable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
After validating every resolved IP, the guarded dial connected only to
ips[0] — an allowlisted dual-stack or round-robin host whose first record
was down hard-failed despite reachable vetted alternatives. The dial now
tries each vetted address in order (all records still validated before
any dial: one poisoned private record refuses the whole request).
Also removes the redundant rejectPrivateAddrs pre-resolves (initial
request + redirect hop): the guarded dial is the authoritative check and
every path flows through it, so the pre-resolve only cost an extra DNS
round trip while re-opening the rebinding TOCTOU it was meant to close.
Folds the W3-2-adjacent double-resolve cleanup from the plan.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With trusted_proxies covering client networks (e.g. 10.0.0.0/8 over LAN
clients), the right-to-left XFF walk skipped every entry, exhausted, and
fell back to the proxy's RemoteAddr — collapsing all clients into one
rate-limit/lockout bucket, so one user's failed logins locked out
everyone. On exhaustion the walk now returns the leftmost valid entry
(furthest-upstream hop), the best distinct per-client key such a config
allows. An untrusted RemoteAddr still never gets its headers honoured.
Also (W3-3): the CIDR list parses once per request instead of once per
XFF candidate, config load warns about invalid CIDR entries at startup
(a silently skipped entry silently un-trusts the proxy), and the sample
config documents that trusted_proxies must list only proxy hops.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
installFromDisk replaced r.plugins/r.byName with a fresh *Instance but
left r.commands keyed to the old pointer and the old module running:
re-installing an enabled plugin blocked its own command re-registration
(RegisterCommand compared ownership by pointer) and kept dispatch routing
into the orphaned module until restart. Reinstall now deactivates the old
instance and clears its bindings, and RegisterCommand compares ownership
by plugin identity (manifest name) — the same plugin re-binds freely, a
different plugin still cannot hijack an owned command.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Revocation failure: no error, audit still written, RevokeFailed set,
password committed. Transient failure: absorbed by exactly one retry.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
UpdateUserPassword commits first; when DeleteOtherSessions then errored the
handler returned 500 and skipped the audit row — telling the user the
change failed while the new password was already live, walking them into
retrying with a dead password and tripping the confirm lockout. The
committed change now always audits and reports success; revocation gets
one bounded compensating retry, and a persistent failure surfaces as a
200 + warning (sessions_revoked count) the client can show.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The empty-prefix middleware shared per-IP buckets with verify-totp,
password change, and the sensitive endpoints, so a client's 30/min
auto-poll could 429 its own user's 2FA or password change. Dedicated
"client_update:" prefix, mirroring "livekit_proxy:".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simulates an 8-participant call: two back-to-back full rotations (7 offers
each) must pass the limiter, while same-target spam still trips it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A rotation is a burst of one offer per peer (join/leave and the periodic
re-key), but the limiter was keyed per sender at 5/sec — in calls with 6+
participants the 6th+ peer's offer was silently rate-limited, that peer
never received the rotated key, and their audio never decrypted again.
Keying per (sender, target) admits any rotation burst regardless of
channel size while still capping repeated offers at a single victim,
which is the abuse the limit exists for (an offer can force the target to
re-key or disconnect).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Service level: BAN_MEMBERS refusal (Forbidden even for nonexistent targets
— no id enumeration), equal-rank and owner-target hierarchy refusals,
authorized ban/unban round-trip, self-ban rejection. Admin API level:
equal-rank owner ban 403s, a lower-positioned ADMINISTRATOR cannot ban the
owner, downward bans still work. All existing NewAdminAPI/NewHandler test
callsites now inject a real ModerationService so the production
authorization runs in every PATCH-user test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
requireBanAuthority (BAN_MEMBERS + role hierarchy) was wired only into
ModerationService.BanUser/UnbanUser — which had zero production callers.
The live path, handlePatchUser, ran a raw UPDATE with no hierarchy check,
so any admin-panel actor could ban an equal- or higher-ranked user,
including the owner. The ban/unban branch now calls the service (dead code
becomes THE code — ban path 1 of 3 consolidated), which also audits as
user_ban/user_unban, keeping the historical audit vocabulary.
Authorization now runs in permission → existence → hierarchy order: an
actor without ban authority sees Forbidden, never NotFound, so the ban
path cannot enumerate user ids. The role+ban transaction is gone — the
ban leg lives in the service, runs first, and a refusal returns before
the role change executes, so a rejected ban never half-applies a PATCH.
MemStore gains honest BanUser/UnbanUser so the matrix is testable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mechanical signature updates for LinkAttachmentsToMessage callsites, plus:
db-level OwnershipGuard test (owned links, foreign never links, legacy
NULL-uploader claimable, nonexistent skipped) and a service-level
SendMessage test proving skip semantics end-to-end including the
already-linked retry path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-attachment GetAttachmentByID pre-check loop was a check-then-link
TOCTOU (the same race pattern this branch fixes elsewhere), an N+1 on the
hot send path, and a hard ErrForbidden for legit retries naming an
already-linked attachment. Ownership now lives in the one UPDATE that
links: `AND message_id IS NULL AND (uploader_id = ? OR uploader_id IS
NULL)` — a foreign attachment can never be claimed, legacy NULL-uploader
rows stay claimable, and skipped rows (foreign/linked/missing) are logged
but never fail the send, so retries can't hard-fail. Subsumes W2-4; the
MemStore (nil,nil) GetAttachmentByID contortion is replaced by a real
map-backed attachment store so the guard is testable (W3-5).
Companion commit updates test callsites and adds ownership coverage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Hand-assembled wasm fixture whose command_dispatch spins forever only for
payloads over 100 bytes: baseline dispatch succeeds, an over-budget dispatch
surfaces the budget error, and the next dispatch on the same plugin succeeds
again via lazy re-instantiation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WithCloseOnContextDone(true) closes the module when the per-call budget
deadline fires, and nothing ever re-instantiated it — one over-budget
command bricked the plugin for every user until an admin disable/enable
cycle or a server restart. Now any guest-call failure that closed the
module (deadline, trap, parent-context cancellation) releases inst.module,
and the next dispatch lazily re-activates the same instance; a concurrent-
activation guard keeps double dispatches from leaking modules. Re-
instantiation resets guest in-memory state — documented at the budget site.
Host-call time exclusion from the budget is documented as a requirement but
not implemented: no host imports are wired into the runtime yet, so there
is no host-call time to exclude today.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PostgresStore was 86% stubs behind a build tag nothing enables, pgdbgen
carried hand-added build tags that fought sqlc-verify, and the runtime never
threaded store.Store through the handler boundary. Single-engine reality
shrinks the W1-3 attachment-ownership fix and ends the pgdbgen churn.
Removed: store/postgres.go, db/pgdbgen/, db/queries/postgres/,
migrations/postgres/, the sqlc postgres block, pgx from go.mod, the
startup-refusal branch, and the dead Postgres config surface.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New key ID 1D1E33AC50B11BC2 replaces ABB078FD8EBFF5FA, whose private half had
no recoverable backup. Rotation is free at this exact moment: the v1.0.0
fleet predates signature verification entirely and no verifying (alpha.1+)
install exists yet. Both SERVER_UPDATE_SIGNING_* secrets updated in lockstep;
private key + password backed up locally for the maintainer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Covers the release pipeline's actual .sig format (base64-wrapped minisign),
the raw minisign format, garbage base64 rejection, and tamper rejection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`tauri signer sign` emits .sig files that are base64-wrapped minisign
documents — the same wrapping already handled for the pinned public key —
but verifySignatureReader fed the wrapped text straight to
minisign.Signature.UnmarshalText, so every real release signature failed to
parse ("minisign: invalid signature"). Unwrap base64 when the text is not a
raw minisign document; raw documents (and the test fixtures) pass through
unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three latent bugs in the never-exercised publish path:
- `tauri signer sign -k` loads the key from a *string*; we passed the mktemp
path, so the CLI base64-decoded "/tmp/tmp.XXXX" and died with "Invalid
symbol 46, offset 8" (the dot). Use `-f` (key from file). The stored secret
was never read and never at fault.
- checksums.sha256 lines carried "windows/"/"linux/" path prefixes; the
v1.0.0 updater's ParseChecksumFile exact-matches the last field against
"chatserver.exe", so every deployed 1.0.0 server would have failed the
checksum lookup. Emit bare asset filenames (current updater accepts both).
- No end-to-end proof the signed assets verify against the pinned public key
that ships inside the server binary. Add a fail-closed minisign verify step
before any release is created; it catches key/pubkey mismatch, signature
format drift, and signer flag regressions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Forward-only versioning for the alpha reset: 1.1.0-alpha.N ascends
past the superseded v1.0.0 for every installed client, and the alpha
series stays below 1.1.0-beta.N and the final 1.1.0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ubuntu-22.04-arm runner image does not preinstall xdg-utils, so
AppImage bundling fails with 'xdg-open binary not found'. Add it to
every Linux system-dependency list in ci.yml and release.yml (the
release ARM job shares the same list and would have failed identically
at tag time).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
'Network' is not a tauri-bundler AppCategory, so every bundling run
failed with 'invalid category' after compilation succeeded.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bump crossbeam-epoch 0.9.20, quinn-proto 0.11.16, rustls-webpki
0.103.13 in Cargo.lock (semver-compatible; RUSTSEC-2026-0204, -0185,
-0098, -0099, -0104 and the second quick-xml instance resolved).
quick-xml 0.37 remains pinned by tauri-winrt-notification 0.7 (via
tauri-plugin-notification) with no compatible route to the fixed 0.41;
it only parses toast XML the library itself builds, never
attacker-controlled input, so RUSTSEC-2026-0194/-0195 are ignored in
.cargo/audit.toml with removal criteria documented inline.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
readPump teardown always ran handleVoiceLeave, trusting the joined_at
guard in LeaveVoiceChannelIfMatch to protect replacement sessions. On
reconnect the voice session TRANSFERS to the replacement client with
the same joined_at, so the guard cannot tell the two apart: whenever
teardown snapshotted voiceChID before the transfer zeroed it, the old
connection deleted the replacement's voice_state row (flaked on the
Windows CI runner as TestServeWS_Reconnect_PreservesVoiceState).
Gate voice cleanup on !replaced — the same condition the
presence-offline broadcast four lines down already uses. A genuinely
final disconnect behaves exactly as before, and a stale row from a
crashed replacement is still swept by the fresh-connect cleanup.
Also deflake TestHub_ConcurrentRegisterUnregister: poll for quiescence
with a deadline instead of a fixed 50ms sleep that loses to the -race
scheduler on slow runners.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- cfg-gate open_devtools entirely behind the devtools feature (its
registration in lib.rs already was), removing the dead-code warning
in non-devtools builds
- drop let-bindings of unit-returning store.set in the three cert
rollback paths (let_unit_value)
- remove a needless borrow on the startup-error dialog description
(only compiled on non-Linux, hence Windows-only finding)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
golangci-lint had been failing invisibly behind the earlier CI gate
failures. Default-build lint is now clean:
- Delete the unused pre-topic-limiter rate-limit constants, the unused
bluemonday sanitizer, and the dead broadcast variants superseded by
their Low/High counterparts (broadcastExclude,
broadcastToDMParticipants(+Exclude), sendSequencedToUsers,
PubSub.debugDump). Test references were comments only; updated to
name the live variants.
- Separate 'Phase X Step Y' file headers from the package clause with a
blank line so staticcheck ST1000 no longer reads them as malformed
package comments (proper package docs exist in hub.go/manifest.go).
- Add .gitattributes normalizing line endings to LF on checkout —
the Windows CI runner materialized CRLF, which made every
prettier-formatted file fail the format gate.
Known remainder (pre-existing, out of P0 scope): golangci-lint with
-tags wazero reports 3 gosec + 2 staticcheck and -tags otel 1+1; CI
lints the default build. Tracked for the P1 plugin pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mechanical prettier --write; 297 files had drifted while the CI
format gate was dead. No functional changes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Remove the unused 'type Event' named import from the committed
generated events.ts and teach the CI patch step to strip it on future
regenerations — the previous line-anchored patch deliberately skipped
imports, so ESLint failed on every run.
- Split vitest into its own client-tests job so the known-red suite
(P2 triage pending) is exactly one visible failing check instead of
masking the audit/lint/typecheck/prettier gates, which are now green.
- Drop the three stale roadmap files (PHASE_BC_LOCAL_TODO.md,
phase-b-acceleration.md, phase-c-differentiation.md) — referenced
nowhere since the CHANGELOG cleanup; already deleted on the
security-hardening branch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>