* chore(workflows): raise subagent effort tiers (sonnet/haiku to xhigh, prove opus to high)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(voice): 6 defect(s) (OC-0098, OC-0004, OC-0005, OC-0006, OC-0007, OC-0020)
* fix(db): 1 defect(s) (OC-0096)
* fix(admin): 1 defect(s) (OC-0097)
* fix(auth): 2 defect(s) (OC-0099, OC-0021)
* fix(voice): 1 defect(s) (OC-0018)
* fix(admin): 1 defect(s) (OC-0045)
* fix(api): 1 defect(s) (OC-0103)
* fix(client): 1 defect(s) (OC-0105)
* fix(client): 1 defect(s) (OC-0107)
* fix(api): 1 defect(s) (OC-0109)
* fix(api): 1 defect(s) (OC-0112)
* test(admin): compare restore bytes with bytes.Equal
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(voice): 2 defect(s) (OC-0095, OC-0014)
OC-0095: createRoom never called setE2EEEnabled(true), so the full ECDH/HKDF/AES-GCM key exchange completed but frames still reached the SFU in plaintext.
OC-0014: token refresh timer was 23h while the server mints LiveKit tokens with a 5-minute TTL, so any reconnect after minute 5 presented an expired token.
* fix(profile): 2 defect(s) (OC-0100, OC-0102)
* fix(service): 1 defect(s) (OC-0022)
Archived channels were only read-only for SendMessage/DeleteMessage. Edit, reaction, pin and purge sinks bypassed the check. Route every write sink through a shared requireChannelWritable gate.
* fix(api): 1 defect(s) (OC-0048)
* chore(workflows): correct stale model labels in bughunt-fix phase details
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(client): 1 defect(s) (OC-0015)
* fix(voice): 1 defect(s) (OC-0002)
* test: fix two CI-only failures in the batch-4 test suite
The delete-account broadcast test now observes member_ban on a second
client's socket: the hub broadcasts and then force-disconnects the target,
so on a slow runner the close could beat the target's own copy of the
frame. The observer is also the party the event exists for.
The voice e2e mock now echoes the real joined channel id on voice_leave
(it hardcoded channel_id 0, which the dispatcher's channel-matched
self-leave teardown correctly ignores), and the rejoin test waits for the
mock's delayed echoes to settle before clicking the row again — clicking
inside the echo window toggled a leave instead of a join.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The client held the Klipy key in VITE_KLIPY_API_KEY, which Vite inlines into
the shipped bundle by design — a build variable can never hold a secret. Move
the integration behind the server:
- New authenticated GET /api/v1/gif/search and /api/v1/gif/trending. The key
comes from the new `gif.api_key` config section (koanf,
OWNCORD_GIF_API_KEY) and never leaves the server.
- Default-off: with no key, both endpoints return 503 GIF_DISABLED so clients
can hide the picker instead of showing a broken one. Auth is checked first,
so anonymous callers cannot probe whether a key is configured.
- Outbound call reuses the existing SSRF-guarded dialer (exported as
plugin.GuardedDialContext) rather than a bare http.Get: resolve once,
reject private/loopback/link-local/CGN, dial only vetted IPs. Redirects are
not followed and the response body is size-capped.
- Only id/title/media_formats.{tinygif,gif}.url are forwarded — decoding into
the narrow struct is the allowlist, so an upstream that echoed the key
could not leak it. Upstream errors become a generic 502 and the key is
redacted from anything that reaches the logs.
- Dedicated `gif:` rate-limit bucket (30/min per IP) so debounced search
traffic cannot exhaust the shared bucket used by password/TOTP endpoints.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>