Audit writes stay best-effort — a LogAudit failure must never fail or abort
the request — but a failed write must no longer be silently discarded. Add
db.WriteAudit(auditor, actor, action, targetType, targetID, detail), which
logs a failed write with actor/action/target context (never the detail
string, which may be sensitive) and never propagates the error.
The Auditor interface is satisfied structurally by both *db.DB and the
service-layer Store, so api/admin/ws/service all reach the helper without an
import cycle. Converts all ~26 call sites from `_ = LogAudit(...)` (and the
two backup handlers' inline `if err` blocks) to db.WriteAudit. Pinned by
db/audit_test.go: failure logged and not propagated, success logs nothing,
detail never leaks.
Resolves the repo-wide LogAudit policy question flagged by the D8 note.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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
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
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
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
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>
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>
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>
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>
Phase B + C review pass: critical security and correctness fixes.
Security
- S1: plugin admin endpoints now require admin.RequireAdminAuth in addition
to AdminIPRestrict. Previously a LAN attacker on the allowed CIDR could
list/enable/disable/uninstall plugins without a session.
- S2: rewrite plugin HTTPDo allowlist with proper net/url parsing. Empty
entries are ignored, suffix matches require a dot boundary, and a custom
Dialer rejects loopback / RFC1918 / link-local addresses to close the
DNS-rebinding TOCTOU window. Redirects re-validated.
- S3 + #9: manifest Name pinned to ^[a-z0-9][a-z0-9_-]{0,63}$, Entrypoint
and UI tab assets validated against absolute / "..", NUL byte, backslash
and non-canonical paths. Asset handler hardened with filepath.Rel check
for symlink and prefix-without-separator escapes.
- S5: pluginBridge postMessage handler ignores the pluginId in the message
body and uses an e.source -> contentWindow lookup instead, defeating
spoofed messages from same-origin scripts.
- S8: HTTPDo body capped at 5 MiB via io.LimitReader, redirects bounded
to 5 hops.
Correctness
- Critical seq alignment: PersistEvent now takes the hub-assigned seq as a
required parameter so the events table row seq always matches the wrapped
payload seq. Hub seeds its in-memory atomic counter from MAX(events.seq)
on startup. Drops in the persister queue no longer mis-align row vs
payload seq.
- #1: live plugin.Registry constructed in main.go BEFORE NewRouter and
threaded through; admin handler is no longer wired with nil.
- #3: EventPersister.Stop is now safe to call without a prior Start by
tracking a started flag — previously deadlocked waiting on done.
Wiring
- NewRouter signature gains *plugin.Registry; two test callers updated.
- admin.RequireAdminAuth exported as a thin wrapper over the existing
package-private adminAuthMiddleware.
- sqlc query templates updated for the new PersistEvent + GetMaxEventSeq
contracts (sqlite + postgres).
https://claude.ai/code/session_01UsBsQW2YiA2usk9pnJjAWk
Critical fixes:
- Move svc creation in router.go above MountInviteRoutes/MountChannelRoutes
(was used before definition — compile error)
- Restore hasChannelPermREST in channel_handler.go for upload_handler.go
(was removed but still referenced — compile error)
Permission cache invalidation:
- Add PermissionInvalidator interface to admin package
- Wire through NewHandler → NewAdminAPI → handlePatchUser
- Call InvalidateUser(userID) after role changes in admin panel
- Update all admin test files to pass nil as new parameter
Also clarifies WithTx documentation for SQLite single-writer semantics.
https://claude.ai/code/session_01CBFF3r84ywkJRWwuqw8zD8
- release.yml: integrate signing/manifest/changelog steps with new
multi-platform artifact layout (windows/ + linux/ dirs)
- updater.go: combine Linux tar.gz support with existing signature
verification; merge platform-aware asset matching into switch
- updater_test.go: keep PR Linux tests + dev signing/manifest tests
Soundboard was never implemented — remove USE_SOUNDBOARD permission bit,
rate limiter, protocol entry, admin mockup reference, TODOS entry, and
all related test assertions.
Security audit across all 11 sections (AUTH-001 through DATA-001) found
0 critical, 1 high, 7 medium, 15 low issues. This commit addresses:
- Add json:"-" to User.PasswordHash, User.TOTPSecret, Session.TokenHash
to prevent accidental serialization of sensitive fields (M7)
- Add X-Content-Type-Options: nosniff to file serve responses (M5)
- Apply owner-only guard to backup list endpoint for consistency (M6)
- Persist rate-limit lockouts to SQLite so they survive restarts (M2)
- Normalize DM non-participant responses to 404 to prevent oracle (L3)
- Add explicit per-entry expiry check in partial auth Lookup/Consume (L1)
- Truncate unknown WS message type to 64 chars before echo (L6)
- Rate-limit ping handler to 2/sec per user (L7)
- Replace raw error strings in update handlers with generic messages (L15)
- Update 4 tests to match new 404 behavior for DM non-participant
Replace separate UserCount() + CreateUser() with atomic
CreateOwnerIfEmpty() that checks and inserts in a single SQLite
transaction. Concurrent race test validates exactly 1 owner under
20 parallel requests.
The first-run setup POST was vulnerable to cross-site request forgery
because it had no Origin validation. Added isSetupOriginAllowed check
that validates the Origin header against configured allowed_origins.
Requests with a mismatched Origin are rejected with 403. Requests
without an Origin header (same-origin or curl) are allowed through.
The restore handler was overwriting the live SQLite database while the
old *sql.DB handle remained open. Now: broadcasts server_restart to
clients, checkpoints WAL, closes the DB connection, then copies the
backup file over the closed database. Server must restart after restore.
- Add syncutil package with deadlock-detecting mutex (build-tag switchable)
- Add main_test.go TestMain scaffolding across all server packages
- Harden concurrency in ws, admin, auth, and updater packages
- Update CI workflow, go.mod/sum, Cargo.lock, and root changelogen tooling
Wrap navigateTo() and renderContent() in try/catch blocks. Show visible
error message with "Back to Dashboard" recovery button on failure.
Add null guard on content element and stale-navigation guard on async paths.
- C-3: inject setupLimiter into NewAdminAPI instead of package-level global
- H-5: generateRandomKey returns error instead of panicking
- H-6: replace init() bcrypt with sync.Once lazy initialization
- M-2: remove unsafe-inline from admin CSP
- M-3: sanitize upload filenames (strip control chars, truncate to 255)
- M-5: truncate User-Agent to 512 bytes before storing as device
- M-10: MaxBodySizeUnless uses prefix matching instead of exact path
- M-12: wrap seedExistingDatabase in a single transaction
- M-13: use errors.Is for EOF check in upload handler
- M-14: log writeJSON encoding errors instead of discarding
- M-16: standardize error codes to INTERNAL_ERROR across all handlers
- C-3: inject setupLimiter into NewAdminAPI instead of package-level global
- H-5: generateRandomKey returns error instead of panicking
- H-6: replace init() bcrypt with sync.Once lazy initialization
- M-2: remove unsafe-inline from admin CSP script-src and style-src
- M-3: sanitize upload filenames (strip control chars, truncate to 255)
- M-5: truncate User-Agent to 512 bytes before storing as device
- M-10: MaxBodySizeUnless uses prefix matching instead of exact path
- M-12: wrap seedExistingDatabase in a single transaction
- M-13: use errors.Is for EOF check in upload handler
- M-14: log writeJSON encoding errors instead of discarding
- M-16: standardize error codes to INTERNAL_ERROR across all handlers
CSP nonce policy blocked all onclick handlers, preventing navigation.
Switched to 'unsafe-inline' (admin panel is IP-restricted). Also fixed
boolean settings display — toggles now accept '1' from the database.
Admin panel redesign:
- Rebuild frontend from mockup with Discord-style dark theme
- Stat cards, section cards, role badges, modal system, toast notifications
- All 7 sections: Dashboard, Users, Channels, Audit Log, Settings, Backups, Updates
- Modals replace confirm()/prompt() for all destructive actions
Live server logs (new):
- RingBuffer + MultiHandler tees slog to stdout AND in-memory buffer
- SSE endpoint at /admin/api/logs/stream streams logs in real-time
- Log viewer with level filters (DEBUG/INFO/WARN/ERROR), search,
auto-scroll, pause/resume, copy all, clear
- Color-coded lines by level, source categorization from file paths
Audit log improvements:
- Search filter (actor, action, target, detail)
- Action type dropdown filter
- Copy All and Export CSV buttons
- Instant client-side re-filtering
Console output:
- Switch from JSON to human-readable text format (slog.TextHandler)
- Move startup banner before init logs so it appears first
- NEW-1: Add rows.Err() check in ListMembers to catch cursor errors
- NEW-2: Add minVal parameter to queryInt so offset=0 is not rejected
- NEW-3: Fix copyFile double-close by removing defer, using explicit
close on both success and error paths
- NEW-4: Add GetAllChannelPermissionsForRole batch query, eliminating
N+1 GetChannelPermissions calls in channel list and search handlers
- NEW-5: Cap fetchBody with io.LimitReader(1 MiB) to prevent memory
exhaustion from malformed release assets
Server:
- Enforce category-type validation: text/announcement only under text categories,
voice only under voice categories (400 on mismatch)
- Admin panel category field changed to dropdown with auto-filtered type options
- Default setup creates both Text Channels and Voice Channels categories
Client:
- Add create/edit/delete channel modals (admin/owner only)
- "+" button on category headers to create channels with pre-filled category
- Right-click context menu on channels for edit/delete
- Mouse-based drag-and-drop reordering within categories
- Admin API methods: adminCreateChannel, adminUpdateChannel, adminDeleteChannel
- Immediate local store update on reorder for instant feedback
Tests: 7 server integration tests, 31 client unit tests (create/edit/delete modals)
- #16: Fix golangci-lint issues (unchecked Close(), unused funcs, naming)
- #17: Add KeybindsTab and LogsTab unit tests (19 tests, 81%+ coverage)
- #18: Add rate limiting to chat_edit and chat_delete handlers
- #19: Fix cert mismatch handling via event listener instead of string match
- #20: Validate SHA-256 colon-hex fingerprint format in Rust ws_proxy
- #21: Optimize session+ban check with single JOIN query
- #22: Sort channels by position when redirecting after deletion
- #23: Rename admin test files for clarity
Also fixes ban expiry regression (H-1 from code review) by using
auth.IsEffectivelyBanned() to properly respect temporary ban expiry.
Fix 70+ errcheck violations by adding explicit error discards (`_ =`)
for unchecked return values across test helpers and deferred Close()
calls. Remove unused `senderID` field from broadcastMsg and unused
`defaultCleanupMaxWindow` const. Apply De Morgan's law, remove empty
branch, and simplify redundant type declaration per staticcheck.
- Fix double-close panic in Hub.Stop/GracefulStop using sync.Once (#3)
- Bump golangci-lint action to v9 with v2.11.3 for Go 1.25 support (#4)
- Add input validation guards to SearchMessages (#5)
- Handle promise rejections in InviteManager with error toasts (#6)
- Add missing reply_to and edited_at columns to admin test schema (#7)
- Add ClientCount to HubBroadcaster interface and wire into stats endpoint (#8)
Server:
- Add message search and pinned messages support
- Add admin hub integration and live connection stats
- Update admin test mocks for hub interface
Client:
- Add data-testid attributes to components for E2E testing
- Add window management capabilities (position, size, maximize)
- Add prod E2E test config and script
- Fix CSS imports (use vite bundling instead of HTML link tags)
- Add inline styles to InviteManager overlay for reliability
- Update CHATSERVER.md references from WPF to Tauri
Docs:
- Update quick-start guide
- Split Server/admin/api.go (788→281 lines) into handlers_users.go,
handlers_channels.go, handlers_settings.go, handlers_backup.go
- Split Client SettingsOverlay.ts (~685→173 lines) into 7 per-tab
modules under components/settings/
- Add queueMicrotask-based notification batching to createStore with
flush() for synchronous test assertions
- Update 8 test files with flush() calls for batched store updates
Addresses TODOS.md #9 (split oversized files) for 2 of 3 targets.