Commit Graph
268 Commits
Author SHA1 Message Date
Claude 071426c0d8 feat(server,client): announcement channels (D1, closes A-2026-07-01)
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
2026-07-19 16:00:18 +00:00
Claude f66d6c5274 refactor(server/db): delegate messages + reactions to dbgen; finalize D2
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
2026-07-19 15:48:39 +00:00
Claude c7e6702c8c refactor(server/db): delegate voice, dm, channels, admin to dbgen (D2)
voice: all reads (GetVoiceState, GetChannelVoiceStates, GetAllVoiceStates)
and writes (join/leave/mute/deafen/camera/screenshare/clear, capacity +
camera-limit atomic guards); CountChannelVoiceUsers stays raw (no query).
dm: OpenDM, CloseDM, IsDMParticipant, GetDMParticipantIDs; GetOrCreateDMChannel
(serializable tx), GetUserDMChannels (aggregate), GetDMRecipient stay raw.
channels: List/Get (shared channelFromFields mapper), Create/Update/Delete,
slow-mode/max-users setters, permission overrides get/list-for-role/upsert/
delete; ListChannelRoleOverrides + GetChannelTypes (variable IN) stay raw.
admin: UserCount, GetServerStats counts (PRAGMA stays raw), ListAllUsers,
UpdateUserRole, ForceLogoutUser, GetUserSessions, AdminUpdateChannel,
AdminDeleteChannel, LogAudit, GetAuditLog, GetSetting, SetSetting,
GetAllSettings, CountUsersWithoutTOTP; AdminCreateChannel + Backup stay raw.

Added b2i64 and strToNullPtr mapper helpers; retired the obsolete
scanChannel/nullableString. Behavior and public signatures unchanged.

Verified: go build ./...; go test ./db ./service ./permissions ./admin;
sqlc-verify.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
2026-07-19 15:43:46 +00:00
Claude d769b8bdb6 refactor(server/db): delegate invites + attachments to dbgen (D2)
invites: CreateInvite, GetInvite, UseInviteAtomic, RevokeInvite,
ListInvites. attachments: CreateAttachment, GetAttachmentByID,
GetAttachmentWithChannel, DeleteOrphanedAttachments. Added ptrI64toI /
ptrItoI64 helpers for the *int64<->*int narrowing (invite max_uses,
attachment width/height). LinkAttachmentsToMessage and
GetAttachmentsByMessageIDs keep raw SQL (variable-length IN() lists sqlc
can't express). Behavior and signatures unchanged.

Verified: go build ./...; go test ./db (Invite, Attachment).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
2026-07-19 15:36:59 +00:00
Claude e46bd0e015 refactor(server/db): delegate users, sessions, profile to dbgen (D2)
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
2026-07-19 15:34:27 +00:00
Claude 44323373e3 refactor(server/db): adopt sqlc as the query layer — phase 1 (D2)
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
2026-07-19 15:01:54 +00:00
Claude 2e7a80171b feat(server,client): protocol codegen + audit quick-wins batch
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
2026-07-19 13:32:58 +00:00
Claude 8c590c0b6d fix(server): harden private-channel visibility propagation
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
2026-07-19 11:20:15 +00:00
Claude ccb8c54dd2 fix(server): regenerate empty livekit.yaml instead of treating it as user-managed
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
2026-07-19 11:20:14 +00:00
Claude 3cb8dc34d5 chore(server): drop unnecessary int64 conversion in perms test
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwtnpHAoSFr1ZibQgQkNQK
2026-07-19 11:03:59 +00:00
Claude 9e6ff47194 feat(server,admin): private channels via per-role permission overrides
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
2026-07-19 10:50:11 +00:00
Claude b9180bdbeb feat(server): support dual-homed voice hosts and user-managed livekit.yaml
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
2026-07-19 10:50:11 +00:00
J3vbandClaude Fable 5 963bea9a64 test(service): update moderation callsites for context parameter
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 10:18:40 +02:00
J3vbandClaude Fable 5 bc7d65ab29 fix(service): thread request context through BanUser/UnbanUser
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>
2026-07-19 10:18:40 +02:00
J3vbandClaude Fable 5 4c2fecbf02 test(ws): update emit-test hub literal for the single event channel
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 09:33:33 +02:00
J3vbandClaude Fable 5 0baccb58ee fix(ws): process register/unregister on one ordered channel
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>
2026-07-19 09:33:33 +02:00
J3vbandClaude Fable 5 2eec831d6a refactor(updater): export FileSHA256 and reuse it for the update snapshot (W3-2)
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>
2026-07-19 09:04:28 +02:00
J3vbandClaude Fable 5 9d44942b04 test(service): lock CanPost DM-block and permission refusals (W2-7)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 09:03:03 +02:00
J3vbandClaude Fable 5 b329a61a7c fix(ws): route plugin broadcasts through the service-layer send check (W2-7)
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>
2026-07-19 09:03:03 +02:00
J3vbandClaude Fable 5 7fbfbef581 test(plugin): lock vetted-IP dial fallback and fail-closed vetting (W2-6)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 08:58:57 +02:00
J3vbandClaude Fable 5 9145f893d1 fix(plugin): restore multi-address fallback in the SSRF-guarded dialer (W2-6)
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>
2026-07-19 08:58:57 +02:00
J3vbandClaude Fable 5 68e24d32d4 test(api): lock distinct client keys under broad trusted CIDRs (W2-5)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 08:56:34 +02:00
J3vbandClaude Fable 5 a762dc712d fix(api): keep per-client keys distinct under broad trusted_proxies (W2-5)
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>
2026-07-19 08:56:34 +02:00
J3vbandClaude Fable 5 92abe7c173 test(plugin): lock reinstall command rebinding + hijack refusal (W2-3)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 08:52:31 +02:00
J3vbandClaude Fable 5 2a4b2e1628 fix(plugin): make in-place plugin upgrades rebind commands (W2-3)
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>
2026-07-19 08:52:31 +02:00
J3vbandClaude Fable 5 47663e2be3 test(service): cover password-change partial-success contract (W2-2)
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>
2026-07-19 08:50:35 +02:00
J3vbandClaude Fable 5 e5491c20aa fix(service): report password change as partial success when revocation fails (W2-2)
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>
2026-07-19 08:50:35 +02:00
J3vbandClaude Fable 5 f3005572b4 test(api): lock client-update bucket isolation (W2-1)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 08:50:34 +02:00
J3vbandClaude Fable 5 26a367a7a1 fix(api): give client-update polling its own rate-limit bucket (W2-1)
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>
2026-07-19 08:50:34 +02:00
J3vbandClaude Fable 5 5d6257d848 test(ws): lock rotation-burst admission for E2EE offers (W1-2)
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>
2026-07-19 08:46:28 +02:00
J3vbandClaude Fable 5 5df452299b fix(ws): key E2EE offer rate limit per sender+target (W1-2)
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>
2026-07-19 08:46:28 +02:00
J3vbandClaude Fable 5 94d8c2f827 test(admin): cover ban authorization matrix (W1-4)
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>
2026-07-19 08:41:12 +02:00
J3vbandClaude Fable 5 a3459e5f80 fix(admin): route admin-panel bans through ModerationService (W1-4)
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>
2026-07-19 08:41:12 +02:00
J3vbandClaude Fable 5 95f85e213f test(service): cover atomic attachment-ownership link semantics (W1-3)
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>
2026-07-19 08:29:26 +02:00
J3vbandClaude Fable 5 4b37c8024e fix(service): enforce attachment ownership atomically in the link UPDATE (W1-3)
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>
2026-07-19 08:29:26 +02:00
J3vbandClaude Fable 5 a3f7d63f7d test(plugin): lock CPU-budget overrun recovery (W1-1)
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>
2026-07-19 08:21:55 +02:00
J3vbandClaude Fable 5 b13adf23aa fix(plugin): re-instantiate wazero module after CPU-budget overrun (W1-1)
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>
2026-07-19 08:21:54 +02:00
J3vbandClaude Fable 5 0c093e8403 chore(server): delete unfinished Postgres scaffolding
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>
2026-07-19 08:15:58 +02:00
J3vb d0da33f1da Merge remote-tracking branch 'origin/main' into fix/security-hardening-review 2026-07-19 08:09:56 +02:00
J3vbandClaude Fable 5 183ef09f7c chore(release): rotate server update signing key
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>
2026-07-19 08:07:30 +02:00
J3vbandClaude Fable 5 01ec825e51 test(updater): lock tauri base64-wrapped signature round-trip
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>
2026-07-19 08:07:30 +02:00
J3vbandClaude Fable 5 668e1bde83 fix(updater): accept tauri base64-wrapped minisign signatures
`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>
2026-07-19 08:07:30 +02:00
J3vbandClaude Fable 5 20e9e2f6a5 fix(ws): don't clean voice state when a replacement connection exists
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>
2026-07-18 13:50:38 +02:00
J3vbandClaude Fable 5 1fbedb404c fix(lint): delete dead ws broadcast cluster; demote phase-header comments
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>
2026-07-18 12:55:22 +02:00
J3vbandClaude Fable 5 77ec4f1466 chore(db): commit stale sqlc output; make sqlc-verify honest
db/queries/sqlite/events.sql was changed (typed CAST for
GetMaxEventSeq) without re-running generation; commit the regenerated
dbgen (interface{} -> int64, no callers depend on the old signature).

Scope sqlc-verify's diff to db/dbgen: regeneration strips the
hand-added //go:build postgres tags in db/pgdbgen, so verifying that
tree can never pass; pgdbgen is scheduled for removal with the
Postgres scaffolding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 11:45:30 +02:00
J3vbandClaude Fable 5 522003ebe3 fix(ws): wire topicLimiter into hand-rolled test hubs
newEmitTestHub and NewHubForTest built raw Hub literals without the
topic rate limiter, so deliverBroadcast panicked on a nil receiver and
TestEmitEvents_ChannelEvent_CallsBroadcastToChannel could never pass.
CI never surfaced it because the pipeline died at govulncheck first.
Wire the limiter exactly as NewHub does; no production nil-guard, since
a nil limiter in production would silently disable rate limiting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 11:45:30 +02:00
J3vbandClaude Fable 5 c7b49ee1fc fix(deps): patch reachable vulnerabilities blocking CI
Server: golang.org/x/net 0.52.0->0.55.0 (GO-2026-5026, reachable via
autocert.HostWhitelist) and go-jose/v3 3.0.4->3.0.5 (GO-2026-4945,
reachable via the LiveKit SDK). govulncheck now passes clean; full
test suite and the otel/wazero build-tag matrix verified locally.

Client: npm audit fix (semver-compatible only, 44 packages) — npm audit
--audit-level=high now exits clean. vitest moved 3.2.4->3.2.7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 11:45:30 +02:00
J3vbandClaude Fable 5 e25018d6d2 feat(release): decouple distribution from the source repo
Publish every release to the public OwnCord-releases repo with a full
source snapshot (AGPL section 6 for binary recipients), and make the
updater's repo coordinates configurable (github.owner/github.repo),
defaulting to OwnCord-releases. Both the server self-update and the
/client-update chain follow the new default, so deployed servers keep
updating after the source repo goes private.

The publish step fails closed: a private source repo with no
RELEASES_REPO_TOKEN aborts the release instead of silently shipping
binaries with no public source or update feed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 11:17:29 +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
J3vb 65002d0d7f h 2026-04-07 10:13:47 +02:00