Skip to content

Production audit – Batch 1 (Security, Backend & API fixes) - #2

Merged
arxdeployments merged 31 commits into
mainfrom
full-app-audit
Aug 5, 2026
Merged

arxdeployments merged 31 commits into
mainfrom
full-app-audit

Conversation

@arxdeployments

@arxdeployments arxdeployments commented Aug 5, 2026 •

Copy link
Copy Markdown
Owner

Summary

This PR is the first batch of a production-quality audit of the Hive application.

Included

  • Backend security fixes
  • Authentication improvements
  • API correctness fixes
  • Realtime stability improvements
  • Contract validation fixes
  • Performance improvements where safe

Validation

  • All tests passing
  • Linters passing
  • Build checks passing

This is Batch 1 of a larger production audit. Additional batches will address the remaining findings.

Summary by CodeRabbit

  • New Features

    • More reliable calls with offline ringing, reconnect recovery, active-call restoration, network indicators, camera switching, and group invitations.
    • Added pre-send media editing, including cropping, rotation, annotations, quality selection, and video processing.
    • Improved large-file uploads, media previews, image zoom, video playback, and message metadata.
  • Improvements

    • Simplified organization administration and clarified administrator restrictions.
    • Improved session-expiration messaging and password-change security.
    • Streamlined messaging access and group management.
  • Documentation

    • Added call troubleshooting, architecture, and iOS-to-web parity guidance.

arxdeployments and others added 24 commits July 31, 2026 16:05
Two rules, each reachable by more than one route, which is what most of this
change is about.

An admin creates MEMBERS, never admins. POST /org-admin/users offered
role=admin and honoured it. That is not just a policy preference: an admin with
no admin_departments rows is ORGANIZATION-WIDE by definition (managed_dept_ids
returns None for them), so a single-department admin could mint an admin
account with reach over the whole org and sign in as it, since they choose its
password. Department scoping was therefore decorative — one POST away from
being bypassed. Granting the admin role stays a superadmin action, where the
actor is already org-wide.

Blocking the create alone would have been a one-line detour, so PUT
/users/{id} with role=admin is refused too. Demotion is deliberately still
allowed: removing reach is not escalation, and an admin who has lost their post
needs to be demotable by whoever is on shift. The check is on the CHANGE, not
the requested value — the edit drawer sends every field on save, so keying on
role=admin alone would have made renaming an existing admin impossible.

An admin places people only in departments they administer. POST /users
validated the department against the ORG but never against the admin's scope,
so a Ward-only admin could create staff in Pharmacy. PUT /users/{id} had the
same hole and a worse consequence: moving an account to a department you do not
manage is a one-way trip, since the target then fails _load_org_user and the
admin cannot undo their own edit. Both now resolve through one helper, so a new
route inherits the check rather than having to remember it.

Out of scope answers 404, not 403, matching how this module already treats
another tenant's objects. 403 would let an admin enumerate the departments they
do not manage by watching which ids answer differently.

Scoping the writes alone would have left the console lying. GET /departments IS
the department dropdown in both forms, so it is scoped now — otherwise the
restriction only shows up as a 404 after the admin has filled the form in. The
dashboard counts are scoped for the same reason: "8 departments" beside a
listing of two reads as a broken listing, not as scope. total_conversations
stays org-wide because a conversation has participants, not a department, and a
per-department count would double-count cross-department threads.

The activity feed is filtered too, and that one is a confidentiality fix rather
than a cosmetic one: an audit row's `target` is the affected person's EMAIL, so
an unscoped feed handed a department-scoped admin the addresses of the very
people their user list was hiding. Filtered on actor, since audit_logs stores
who acted but not who was acted upon as an id. Own rows always survive the
filter — an admin must be able to see what they themselves did.

A scoped admin also cannot create departments. Not a security boundary but a
coherence one: the new department would fall outside their scope the instant it
existed, so it would vanish from their own listing and they could neither staff
it nor rename it. Auto-granting it to them would be worse — self-service scope
expansion.

Console: /auth/me and /auth/login now carry managed_departments for org admins,
so the UI can hide what the API would refuse instead of letting someone fill in
a form and collect an error. Attached to LOGIN as well as /me because
AuthContext sets the user straight from the login response, so a scoped admin
would otherwise be shown org-wide controls until their next page load. An empty
list means org-wide; the key is absent entirely for members and superadmins, so
nobody reads "no departments" as "manages nothing".

The create form drops its role picker and says what it does instead. The edit
drawer disables the admin option unless the target already is one, which is
what keeps demotion reachable. The users page names the departments it is
scoped to, so a short list reads as scope rather than as missing people.

Every org_admin in production today has no admin_departments rows and is
therefore org-wide, so nothing about their reach changes; only the role
restriction applies to them. That path is pinned by its own test.

12 new tests. 164 pass, and verified against the running stack with a real
scoped admin: all ten allow/deny cases behave, including the two-step move and
the save-unchanged no-op.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The iOS admin screen cannot create users — there is no POST /org-admin/users in
RxHiveAPI — but it can promote and it can move someone between departments, the
two update routes the previous commit locked down. The server refuses both now,
so this is not an enforcement gap; the buttons just still looked live and would
have failed with an unexplained error toast.

The Admin segment is disabled unless the target already is one, matching the
web drawer, and for the same reason: demotion is still allowed, so the current
role needs a selected state to demote FROM.

The department menu needed no change. It is fed by GET /org-admin/departments,
which is now scoped server-side, so a department-scoped admin no longer sees
departments the API would 404 on.

Also corrects the caption. "Admins can see this screen and manage everyone in
the organisation" stopped being true when a super admin could scope an admin to
named departments.

Verified by `swiftc -parse` only. The iOS target still cannot be built here —
xcodebuild wants `sudo xcodebuild -license accept`, which needs a password I do
not have — so this is unverified beyond syntax, and it sits in its own commit
rather than alongside the backend and web work, which was tested end to end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Everyone in an organisation may message everyone else in it, and may send any
format at any size up to 2 GB. The super admin's Access Control page is gone,
along with the engine behind it.

Reachability. services/access.py and its deny-by-default rule evaluation are
deleted. What replaces them is one line at conversations.get_or_create_direct:
same organisation. That check is restated there rather than inherited, because
it used to be the FIRST CLAUSE of can_converse — deleting the rules engine
without it would have quietly opened direct conversations between tenants, which
is a different feature (cross-org groups) with its own membership model. The
per-send re-check in messaging.py goes entirely: membership of a conversation is
now the permission, and the org check already sitting above it is what keeps
tenants apart. Contacts and search lose their reachability filters and fall back
to what their queries already said — everyone active in my org.

Send policy. The per-category toggles and the document whitelist are gone from
the claim path and from forwarding. GET /users/me/send-policy is gone; nothing
resolves a policy any more.

File types. The upload path had a SECOND, older restriction that had nothing to
do with the access-control feature: a hard-coded extension allow-list in
storage.py. Removing only the super admin page would have left .psd, .dwg, .rar
and .exe rejected with "File type not supported", so classify() no longer
rejects anything — the four extension sets are now purely a rendering hint
deciding which bubble a file lands in, and anything unrecognised is a document.

What that makes load-bearing is the content type. Media is served from a
SAME-ORIGIN path with Content-Disposition: inline, so a stored type the browser
renders is stored XSS on the app's own origin: an uploaded .html or .svg would
execute as us. MIME_BY_EXT already defaulted to application/octet-stream, which
makes the browser download instead, and that default is now the thing standing
between "any format" and a scripting vector. There is a test pinning it, and a
comment saying not to replace it with mimetypes.guess_type.

Size. One ceiling of 2 GB instead of 16/200/100 MB per category. This could not
just be a bigger number: the route accumulated the whole upload into a bytearray,
so a 2 GB file meant 2 GB resident in the API process — for every concurrent
upload, taking the box down for everyone. Starlette has already spooled the body
to a temp file on disk past 1 MB, so the handler now hands that file object
straight to S3 via a new storage.put_stream and never holds the payload. Reading
bytes into memory survives in exactly one place, thumbnailing, which genuinely
needs a buffer; past 64 MB the upload succeeds without a preview rather than
risking the process. The honest remaining cost is disk, not memory: a 2 GB upload
occupies the instance's temp space for the length of the request.

Schema. chat_access_rules, send_policies and admin_departments are dropped, with
the access_party_type enum. The migration reports the row counts it destroys, and
downgrade() recreates the schema EMPTY — the rules themselves are not recoverable.

Dropping admin_departments changes behaviour rather than merely removing an
unused table, and the choice was made explicitly: managed_dept_ids read "no rows"
as ORGANISATION-WIDE, so every org admin goes back to managing their whole
organisation. Department scoping is no longer expressible. All the scope filters
added with it — user list, department list, dashboard counts, activity feed —
come back out.

Kept, because it never depended on departments: an admin creates members, not
admins, and cannot promote one either.

Extended, because of a review finding that survives the removal: _load_org_user
now refuses any target who is not a member. Without it "admins cannot create
admins" was decorative — reset-password returns the new password in its own
response body, so an admin who could not MINT an admin could simply TAKE one, and
one call against a peer handed over a working login. Blocking creation while
leaving seizure open protects nothing. Peer admins stay VISIBLE in the user list
(you need to know who to escalate to) and an admin can still manage their own
row; only mutating someone else's admin account is refused, which now includes
demotion. That is deliberate: removing an admin joins granting one as a
superadmin action.

iOS needed no commit. Its send-policy awareness was the uncommitted Phase 7 work,
so removing it left five files byte-identical to HEAD and SendPolicyStore.swift,
which was never tracked, simply gone.

Verified: 135 backend tests pass. The migration was run against a real Postgres
(2 rules destroyed) and round-tripped down and up. Against the running stack:
two users hold a conversation with zero rules in the database, .exe/.psd/.dwg
upload and .html stores as octet-stream, a 12 MB file goes through the streaming
path, creating an admin is still refused, and both removed endpoints 404. The
built bundle contains no Access Control nav entry, route, send-policy fetch or
access_changed handler, and the composer offers all three attach options with a
document picker that accepts anything. iOS type-checks with zero errors across
51 files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Parity items 17-20, 22, 23. The timestamp and ticks used to be one shared block
emitted below every kind of content, which is why a one-word message was two
rows tall, why every photo carried a strip of empty bubble under it, and why a
260px audio card and a 280px document card were each followed by a full-width
band of green.

MessageFooter is extracted so it can be handed to four different places:

  beside    text — a sibling in a bottom-aligned row, so "hi  10:24 AM ✓" is one
            line. Deliberately not flex-1: trailing alignment already comes from
            the row's justify-end, and stretching it is what pushed every bubble
            to the full column width on iOS.
  overlaid  photo/video — absolutely positioned in the media's bottom-right
            inside a black/45 scrim. The scrim is not decoration: a photo can be
            white exactly where the timestamp lands.
  inline    a CAPTIONED photo/video, and the unavailable case — there is nothing
            to sit beside or on top of, so it keeps its own row.
  passed    audio/document — handed to the card as a prop and rendered on a row
            the card already had.

Two things fell out of doing this that are worth naming separately.

ImageBubble's overlay wrapper carries a min-height. An overlaid footer is
positioned against that box, so a picture that renders with NO height — a 404, a
decode failure, a degenerate 1x1 — let the scrim escape the bubble and land on
the message below it. Caught in the first screenshot, because the test fixture
was a 1x1 PNG; a broken image in production would have done the same.

VideoBubble is new, and covers three items at once. It opens
FullscreenVideoViewer, which already existed and was already wired to the
gallery and to starred messages — the chat bubble was the one caller that never
reached it, so a video in the thread was the only medium with no filename, no
download and no jump-to-message (item 15). It generates a poster frame at 0.5s
client-side, because the upload service only thumbnails images and PDFs, so
`poster` was always undefined and the browser painted frame zero, which on a
phone recording is very often black (item 12). And it stamps a duration badge,
bottom-LEFT with a play glyph so "0:09" is not read as the send time (item 13).
The poster work is deferred to first intersection and cached by URL, so a thread
scrolled back through fifty clips does not open fifty decoders.

AudioPlayer's leading slot now swaps. Before first play it shows the sender's
initial with a mic badge; after, the speed pill. Offering "2x" on a note nobody
has heard is a control for a decision the listener has not had the chance to
make (item 5). hasPlayed is latched in the play handler and deliberately NOT
derived from position > 0, because reaching the end rewinds to zero and would
flip a fully-listened note back to looking unplayed.

Media that is missing now says so instead of rendering empty (item 22).
Previously ImageBubble returned null leaving a bubble containing only a
timestamp, audio got <AudioPlayer src="">, video got <video src=""> and the
document card rendered a dead download link named "Document".

Run spacing carries the grouping (item 23). showSenderName IS the run boundary —
already computed, already driving the avatar and the name — it just never
reached the vertical rhythm. Applied to DMs too, where the avatar and name are
suppressed entirely and spacing is the only grouping signal there is.

Verified with five geometry assertions in tests/bubble-layout.spec.js, under
Playwright rather than the embedded browser: the message list is react-virtuoso
and a virtualiser renders NOTHING in a viewport it cannot measure, which is what
a zero-height headless pane gives it. The assertions are comparisons of bounding
boxes — footer to the right of the text and sharing its baseline, footer inside
the image's box, footer inside the card's box — because "beside, not below" is a
geometry claim and nothing weaker actually tests it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… usable

Parity items 21 and 24.

The composer used to freeze for the whole of an upload. `disabled={uploading}`
sat on the textarea and the send button, so a large file on a slow connection
wedged the chat with no way out but reloading the page — and there was no cancel
anywhere: the tray's clear button and the per-tile remove were both disabled
too, and nothing passed an AbortSignal to axios.

Progress was one scalar shared by the whole batch, so the bar reset to zero for
each file and nothing said WHICH file was going. The status line read
"Sending 5 files…" for the entire batch even when four were already done,
because stagedFiles was not trimmed until the loop finished.

Now: the tray hands its batch off and closes immediately, the files become one
row each ABOVE a live composer, and the user keeps typing and sending while they
upload. Each row carries its own filename, its own determinate percentage and an
X that aborts the request through an AbortController — not one that merely hides
the row while the bytes keep going.

The determinate bar is deliberately kept. iOS shows an indeterminate one only
because APIClient.upload exposes no progress callback; axios does, so the web
has real byte progress and regressing to a spinner to "match" would be backwards.

Controllers live in a ref rather than on the job objects. The first version read
one back through a setUploadJobs updater, which does not run at call time —
React invokes it during the render pass — so the send loop would have got
`undefined` for every file and cancel would have silently done nothing. Caught
before it shipped; noting it because the code reads plausibly either way.

A cancel is not a failure: it skips the toast and does not re-stage the file. A
genuine failure still goes back into the tray so it can be retried, which is the
existing recovery path and worth keeping.

No "Compressing…" phase. iOS shows one because it re-encodes before uploading;
the web does not, so inventing the status would be a lie about what is happening.

Item 24: whatever is typed when a file is attached becomes the caption and the
box is cleared. Typing a sentence and then attaching used to strand it — the
user either lost it or sent it as a second message. Only seeded when the caption
is still empty, so a second batch cannot clobber a caption already written.

Verified: 5 bubble-layout and 3 messaging e2e tests pass against the running
stack. (An intermediate run failed at superadmin login with the seeding helper
throwing — that was the 10-per-window login limiter exhausted by my own test
volume, not a regression; it passes once the bucket recovers.)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tures

The remaining iOS-parity items. Grouped by area.

AUDIO (items 4-8)

PeaksWaveform now draws a sent note's REAL envelope. It used to refuse anything
that was not a blob: URL, on two grounds. One was sound and is kept: a
FABRICATED envelope is a lie, and invented amplitude on a clinical recording
invites scrubbing to a peak that is not there. Decoding the actual file is the
opposite of that — it is the honest version, and it is what makes the seek
surface aimable rather than a flat row you can drag but not aim.

The other ground was cost, and that is answered rather than ignored: decoding is
deferred to first PLAY (a thread scrolled past fifty notes decodes none), cached
by URL, deduplicated per URL in flight, and by the time it runs the player is
fetching the same bytes anyway.

Peaks are NORMALISED to the clip's own loudest bar rather than scaled by a fixed
1.6x gain. Fixed gain renders a quietly-recorded note as a near-flat line, which
is the uninformative row this was meant to replace — and quiet is the common
case, since a phone held at arm's length in a noisy ward records low.

The paused preview is now honest when it cannot play. This is NOT Safari-only,
despite how it is usually described: pickAudioFormat puts audio/mp4 first
because the backend classifies .webm as video, an MP4's moov atom is written on
STOP, and current Chrome records MP4 too — so both browsers hand back a partial
blob no player can open. Only Firefox, falling through to Ogg/Opus, previews
mid-recording. AudioPlayer had no error handling at all, so the documented
"degrades to cannot-preview" behaviour never happened; the user tapped play and
nothing occurred. It now says review is available once the recording is
finished, and Resume and Send still work.

A minimum-duration guard (0.6s, matching iOS) plus a reason for every silent
drop. Three paths in the hook returned to idle with no message, one of which —
a sub-second pause — left an empty player beside a live Send button.

Track `ended`/`mute` handling. A browser has no phone-call interruption, but it
has the two events that matter: device unplugged or permission revoked, and
input seized by another app. Without them the wall-clock tick keeps counting
against a recorder that has stopped capturing and the user sends silence.
PAUSES rather than cancels — discarding a half-finished message without asking
is worse than handing it back.

IMAGES (items 9, 10, 14)

utils/mediaQuality: Standard/HD tiers with a real canvas re-encode, mirroring
MediaQuality.swift including the three rules that make it safe — never upscale,
keep the original when the re-encode comes out larger (an optimised JPEG
routinely inflates), and rename to .jpg because the server derives MIME from the
extension. EXIF rotation is baked in via imageOrientation: 'from-image', without
which a portrait phone photo arrives on its side.

HEIC is the one case a browser cannot match: Chrome and Firefox cannot decode
it, so the original bytes go up untouched. A wasm decoder is not available — the
CSP is script-src 'self' with no 'wasm-unsafe-eval', the same constraint that
ruled out pdf.js.

The size shown in the tray is MEASURED by running the real transcode, not
estimated, because a number beside the send button that is not the number that
arrives is worse than no number. Videos count at original size and say
"original", exactly as iOS does.

The avatar upload is routed through the same transcode. The brief flagged it and
it was the worse of the two raw paths: no size check at all, so a 40 MB photo
was pushed at the server and only bounced by the backend limit.

Video duration is measured on send. It is CLIENT-supplied on message create —
the server never probes the file — and the web only ever passed it for voice
notes, so every video ever sent from the web has duration null forever and the
badge added in the previous commit could never render for them.

useZoomPan: wheel and two-pointer pinch, anchored at the cursor rather than the
image centre, because zooming to the middle while the user points at a corner is
what makes a homemade zoom feel broken. Panning is enabled only while zoomed, so
click-to-close and swipe-to-page keep working untouched and the gesture
arbitration iOS needs a single DragGesture for is avoided by not competing.

SESSION (items 25-29)

Replays are marked. A follower of a single-flight refresh replayed through
client(originalRequest) with _retry unset, so a follower whose replay 401d again
re-entered as a WINNER and fired a second refresh — a redundant rotation racing
the first, against a backend that treats refresh-token reuse as a stolen cookie
and burns the whole session family.

A refresh generation counter. A request already on the wire when someone else
refreshed comes back 401 against a cookie that merely predates the rotation;
that is now replayed rather than triggering a second rotation.

Sign-out says why. Both teardowns were bare navigations carrying nothing, and
the one piece of expiry copy in the codebase (helpers.js) is dead — grep shows
handleApiError is never called anywhere, so no session-expiry message could ever
reach a user. The reason travels in sessionStorage because the teardown is a
document navigation that destroys in-memory state and any toast on screen.
Login also shows the server's own 401 detail now, since "Account is deactivated"
is not a typo the user can fix by retyping their password.

Jittered reconnect backoff. Every tab connected when the API restarts otherwise
wakes on the same 1s/2s/4s schedule and the herd hits a server still coming up —
and the web has the most sockets per user, one per tab.

Realtime frames are dropped when the socket is down, not queued. The finding
named typing and read receipts; the queue also carried every call-signalling
frame, so a queued call:initiate would RING the callee after reconnect for a
call already abandoned. Nothing here needs replay: chat already refuses to queue
because the HTTP fallback owns it.

TOUCH (items 1-3)

useHoldToTalk: hold-to-talk, swipe-up-to-lock, slide-left-to-cancel — attached
on COARSE POINTERS ONLY. The click toggle is kept for a mouse rather than
replaced, because a mouse-hold is a poor primary interaction and on desktop the
click flow already behaves like iOS's locked state.

Three adaptations a browser forces. The first hold triggers getUserMedia, whose
prompt steals the pointer and swallows pointerup, so the gesture arms only once
the recorder is genuinely running. MediaRecorder start-up latency means a
sub-300ms hold captures a zero-byte blob, so a tap is discarded with "Hold to
record, release to send" rather than sent. And mobile Safari raises the
selection callout on long press, so the surface sets touch-action: none.

Lock is checked before cancel, so a diagonal drag locks rather than reading as a
half-hearted cancel — the same ordering as iOS micGesture.

NOT DONE, deliberately: item 11 (video re-encode), which the brief itself
concludes is not portable and which the CSP independently blocks; item 16
(camera), which section 4 rules out as a mobile-app decision; and items 30-33,
which the brief records so that nobody ports them.

Verified: 135 backend tests and all 17 Playwright e2e tests pass, including the
calling, chat-ui and render-check suites that exercise the rewritten components.
Running the full e2e suite needs RXHIVE_RATE_LIMIT_LOGIN raised locally — the
default 10/minute is far below what seeding 17 tests costs from one IP, and the
limiter is documented as tunable for exactly this. Unset it when running pytest,
or test_login_rate_limiter_fires correctly fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A pre-send editor on both clients, opened from the confirmation step: crop /
rotate / flip, freehand drawing with four pen widths and a colour strip, and
text boxes with independently controlled text and plate colour, type size and
wrap width.

The whole thing rests on one rule: a staged item keeps its ORIGINAL bytes for as
long as it is staged and carries an edit model beside them, and the uploaded
bytes are re-derived from original + model on every save. So re-opening the
editor resumes where the user left off, "revert to the original" is the empty
model rather than an inverse transform, and cropping twice never compounds JPEG
generations.

Annotations are stored against the ORIGINAL image, not the visible frame, so
crop / rotate / flip carry them along and a tighter crop genuinely cuts a stroke
in half. The pipeline order is fixed at crop -> flip -> rotate, and the preview
and the exporter go through the same transform so what is on screen is what is
sent. Text is the one exception: it stays glued to its point but is
counter-rotated so it never renders sideways or mirrored.

Web
  utils/mediaEdit.js      the model, the geometry and the canvas compositor
  utils/videoEdit.js      video crop, MP4-only on purpose (see below)
  chat/editor/            the editor shell, crop stage, annotate stage, controls
  MessageComposer.jsx     a pencil per staged tile, an Edited badge, the wiring
  StagedFilePreview.jsx   the same affordance at full size

iOS
  Media/Editor/           model, renderers, editor shell and the two stages
  MediaQuality.swift      video crop folded into the existing tier export, one
                          pass, and every failure path now refuses instead of
                          silently sending the uncropped original
  MediaSendSheet.swift    PendingMedia keeps its original and is mutated in place
  RxHiveTests/            9 tests pinning the transform against the point
                          converters, and text staying upright under every
                          rotation and flip

Deliberate limits, all documented at the call site: web video crop is MP4-only
and hidden where MP4 is unavailable, because AVFoundation cannot decode VP8/VP9
and a crop done in Firefox would be unplayable for every iPhone user; it is a
real-time encode, capped at three minutes, and needs the tab visible. Drawing
and text are photo-only. No free-angle straighten, because an arbitrary angle
stops the crop being expressible as a rectangle. Editing a GIF flattens it to a
still, and the editor says so before saving.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nce tests

Work in progress that was sitting uncommitted in the tree, committed to this
branch at the author's request so the attachment-editor PR could be untangled
from it. Not my change — recorded here so the history says where it came from.

The centrepiece is infra/livekit.yaml. `use_external_ip: true` makes the SFU
STUN out and advertise the host's PUBLIC address in ICE candidates, which is
right for a deployed server behind NAT and wrong for anything on a LAN: a phone
on the same Wi-Fi cannot reach its own network's public IP, because home routers
rarely hairpin. Signalling succeeds, the call shows as connected, and no media
ever arrives — and nothing in any log says so, which is exactly the shape of the
mobile-to-web failure under investigation. LIVEKIT_NODE_IP now selects between
the two, substituted at start-up because livekit-server does not expand env vars
inside its config.

Also in here: app/services/call_deadlines.py, a CallConnectivityWatcher on the
web, group-call joining (AddParticipantsModal, OngoingGroupCallBar), docs/CALLS.md,
and new coverage in test_call_resilience.py, CallResilienceTests.swift and
group-calling.spec.js.

Two lint fixes of mine on top: ruff I001 import ordering in app/api/media.py and
app/services/conversations.py.

Deliberately NOT included, because they belong to the attachment-editor branch
and including them is what made that PR unreviewable in the first place: the
editor components and utils on both platforms, and the five shared files
(MessageComposer.jsx, StagedFilePreview.jsx, MessageComposer.swift,
MediaQuality.swift, MediaSendSheet.swift) whose working-tree contents are
byte-identical to that branch. Also excluded: ios/build-device (884 MB of build
output), infra/.env.bak.1785822698, and frontend/tests/_audit-a.spec.js.

169 backend tests pass; ruff clean on app and tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RESTORES ios/.../Editor/MediaEditHistory.swift, which I deleted. It existed only
in the working tree, never committed and not on this branch; my pre-checkout
backup enumerated the files that were ON THE BRANCH rather than the ones in the
directory, so a locally-new file was never copied, and the rm -rf that followed
took it. git had never seen it, there were no APFS snapshots, and build-device
held only object code and an index record.

Rebuilt from what did survive: MediaEditHistoryTests, which specifies every
behaviour, and the index record, which gave the exact original surface —
present, past, limit, canUndo, init(), init(_:), set(_:), apply(_:), commit(),
undo(), reset(to:) — down to the stdlib calls it made (popLast, contains(where:),
removeFirst(_:)). All ten of its tests pass, including the ones that pin the
behaviour nobody would guess: a no-op commit must not eat an Undo press, a
25-sample drag is one undo step, the stack is bounded and drops OLDEST first,
and a saved edit is the floor Undo cannot reach past.

The only deviation from the original is that `present` and `past` are
private(set): nothing outside assigns them — the view writes through set(_:) —
and the tests only read.

CodeRabbit, the four findings that touch this PR's diff:

videoEdit.js — the `seeked` wait had no timeout and no abort path, so an engine
that never fires it left renderCroppedVideo unsettled, the editor stuck in its
saving state, and Cancel inert (the abort listener is registered further down).
Now bounded and abortable. The timeout RESOLVES rather than rejecting: a seek
that never reported completion has usually still landed, and rendering from
wherever the playhead is beats refusing to render.

CropStageView, AnnotateStageView, MediaEditorView — `.task` in a view body
inherits the view's main actor, so a device-resolution composite and a
full-resolution decode were running on the run loop. Moved to
Task.detached(.userInitiated), with previewOutput still computed on the main
actor because it reads UIScreen.main.

MessageComposer.swift — same shape: MediaTranscoder.image is a synchronous
ImageIO decode plus JPEG encode inside a @mainactor task, so it froze the
composer for every send. Only the raster work moves; the uploads and toast
mutations stay on the main actor.

EditorControls / MediaSendSheet — one @mainactor EditorInsets.window instead of
two byte-identical copies. Two cached `static let`s are also two caches, which
can disagree after a scene change. The annotation is not enforced under Swift 5
language mode; it is there so Swift 6 is a no-op rather than an error in two
files.

The other 14 CodeRabbit threads are on base-branch files — client.js,
websocket.js, DocumentBubble.jsx, MessageBubble.jsx, CallStore.swift, calls.py,
Info.plist and the rest. They are real, but fixing them here would put
non-editor changes back into a diff that was just cleaned of them, so they
belong on a PR against access-control-and-chat-fixes.

Also ignores ios/build-device (884 MB of Xcode output) and infra/.env.bak.*,
both of which were sitting untracked and one `git add -A` away from the history.

Verified: 33 iOS tests pass (10 MediaEditHistory, 9 MediaEditGeometry, 14
others); web lint 0 errors; web build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI runs `ruff check` AND `ruff format --check`; only the first was being run
locally, so 19 files had drifted out of format and the backend job was already
red on this branch before the attachment-editor PR existed. That PR touches no
backend file at all, so it inherited a red check it could not have caused and
could not fix from its own diff — the fix belongs here.

Pure formatting: no logic changed, and the 19 files include several nobody has
edited recently (admin.py, messages.py, test_auth.py), which is what confirms
this is accumulated drift rather than anything introduced by recent work.

169 backend tests pass; ruff check and ruff format --check both clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The backend job ran postgres and redis but no S3, while the suite has always
contained tests that upload for real — test_media_upload_and_authenticated_serving
and test_voice_note_duration_round_trip both predate any of the recent work.
They could only ever fail there.

Nobody noticed because the `ruff format --check` step failed first and the job
never reached Tests. Fixing the formatting in the previous commit is what
exposed this; it is not new breakage, it is the next layer down. CI has not
passed on main in at least the last twenty runs.

Same image, credentials and health check as the e2e job, so there is one way to
stand up MinIO in this file rather than two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`docker pull bitnami/minio:latest` returns "manifest unknown" — Bitnami
withdrew their public Docker Hub catalogue. The job died at container start,
before any step ran, so the previous commit's MinIO service could never have
worked.

Both jobs are switched, not just the backend one. The e2e job carried the same
reference and would have failed identically the moment it ran; it is currently
SKIPPED on this PR, which is the only reason it has not.

minio/minio:edge-cicd is MinIO's own image for CI. It matters that its default
command is `minio server /data`: a GitHub Actions service container cannot
supply a command, which rules out the plain minio/minio tag. Same
MINIO_ROOT_USER / MINIO_ROOT_PASSWORD variables, so nothing else changes.

Verified by pulling the image and inspecting its entrypoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ifespan

`app.main` calls `ensure_bucket()` from its lifespan, but httpx's ASGITransport
does not run lifespan events, so under pytest the bucket was never created. The
four tests that really upload therefore failed with NoSuchBucket the moment CI
finally had a MinIO to talk to.

They passed locally for a reason worth naming: the dev container's app had
already created that bucket in the shared MinIO, so the suite was silently
depending on a side effect of something else having run. That is the same class
of problem as the stale-bytecode failures earlier in this session — a green local
run that proves less than it appears to.

Placed beside the schema fixture because it is the same job for the object store
that the schema fixture does for Postgres: the suite establishes its own
preconditions instead of inheriting whatever happens to be running.

Deliberately tolerant of no object store: most of the suite never touches one,
and the few that do now fail with their own clear S3 error rather than being
masked by a fixture failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Web + iOS: crop, draw and caption an attachment before sending
…tomic

Three concurrency defects found auditing the realtime layer.

registry.add() ran outside the try/finally that owns teardown. The guard did
not open until the receive loop, ~30 lines later, and three of the awaits in
between raise in ordinary operation: presence.mark_online talks to Redis, the
last_seen_at block talks to Postgres, and the "connected" send_text fails
outright for a tab that navigated away mid-handshake. Any of them escaping left
the entry in LocalRegistry.connections and the user's Redis channel subscribed
for the life of the process, because nothing else prunes them. A Redis blip
during a reconnect storm therefore leaked a socket per failed handshake, left
_reader fanning every subsequent event at a dead connection, drifted the
ws_local_sockets gauge upward permanently, and skipped handle_user_link_down so
a call the user was in never got its grace window.

presence.mark_online issued SADD, EXPIRE and SCARD as separate round trips
while the caller used the result as an edge trigger. Two clients connecting
within the same few milliseconds both landed their SADD before either SCARD, so
both read count == 2, both returned False, and no "online" event was ever
published — the user stayed grey to every conversation partner until something
unrelated forced a refetch. Both mark_online and mark_offline now run their
commands in one MULTI. Uncontended behaviour is unchanged.

get_or_create_direct was a check-then-act with nothing in the schema
constraining one direct conversation per pair. Three paths reach it — the
direct route, forward-to-contacts and call initiation — so Alice tapping call
on Bob as Bob opens a chat with her is enough to create two conversations with
the same person, each holding half the history, with no way for either client
to merge them. Serialized with a transaction-scoped advisory lock per pair
rather than a unique index, since the index would need a migration over data
that may already contain duplicates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…gating

Password hashing ran on the event loop. bcrypt at cost 12 is ~170ms of
uninterruptible CPU measured locally, spent inside async handlers, so a burst of
sign-ins stalled every unrelated request behind it — WebSocket pings and the
health probe included. hash_password/verify_password are now async and run in
the default thread pool, where the GIL is released for the C hash.

Passwords were unbounded above. bcrypt hashes only the first 72 bytes and
discards the rest silently, so a 100-character password and its own 72-byte
prefix authenticated identically. Verified against bcrypt 4.2.1. Capped at the
boundary rather than pre-hashing, which would invalidate every stored hash. This
rejects set-time input that was previously accepted and truncated; existing
passwords are unaffected, since the policy only runs when a password is set.

/api/auth/change-password had no rate limit. password_limiter existed and was
wired to the org-admin reset but not to the self-service route, which verifies
current_password and is therefore an online guessing surface for anyone sitting
on a hijacked session.

Changing your password revoked your own session. The comment said "every other
session" and the test was named test_change_password_revokes_other_sessions,
but the assertion required every token revoked, the caller's included — so the
test locked in the bug its name described avoiding. The effect was a silent
sign-out of the tab you had just used, up to access_token_minutes later with no
visible cause, and the refresh that discovered it presented a revoked token with
no successor, which rotation cannot distinguish from a replay: every password
change also logged a "revoking that client's session family" theft warning
against an account nobody had attacked. The caller's token is now preserved and
the test asserts the contract its name claims.

Deactivating a conversation did not revoke reading it. Setting is_active = False
is the only lever a superadmin has to cut a tenant off from a cross-org group —
both the delete and archive routes leave the participant rows in place — and no
read path checked the flag. The group vanished from the conversation list while
its full message history, search, starred, pinned and media tabs stayed readable
to anyone holding the id.

The login lookup could not use its index. users.email is CITEXT with a UNIQUE
btree index, so equality is already case-insensitive, but the query wrapped it
in lower(), which that index cannot serve. EXPLAIN ANALYZE over 50k rows: 6.62ms
sequential scan discarding 49,999 rows, against 0.044ms index scan — 150x, on
every sign-in including every failed one. Fixed at all four call sites.

Push-endpoint validation blocked the event loop. The SSRF check resolves a
fully caller-chosen hostname with socket.getaddrinfo, the blocking libc resolver,
which takes no timeout of its own: any authenticated user could point it at a
black-holing nameserver and freeze the worker for the tens of seconds
resolv.conf allows. Moved to a worker thread under a 2s deadline.

Also adds the negative tests for require_superadmin and require_org_admin. The
suite had none: every admin-portal test signs in as a superadmin, so it proved
the 31 admin routes work and never that they are shut — a guard that returned
its argument unconditionally would have passed the whole suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…p real

mark-seen swallowed calls that had not happened yet. The UPDATE had no status
filter, and the CallParticipant row is written at initiation, so it already
exists while the phone is ringing: opening the Calls tab mid-ring stamped
seen_at on the in-flight call, and when the ring then timed out into `missed`
the badge stayed at zero. The one call the user most needed flagged was the one
silently marked read. In-flight calls are now excluded. The added test fails
against the previous code and passes against this one.

Over-long names returned 500 instead of 400. Group name, scheduled-call title,
org-admin user rename, department rename and org rename all wrote straight into
String(200)/String(300) columns with no input bound, so anything longer reached
Postgres and came back as a value-too-long DataError. Note the asymmetry this
exposed: the create schemas already capped these fields and the update schemas
did not, so the identical value was a clean 422 on create and a 500 on rename.
Bounded at the column widths, which converts only the crashing input.

RXHIVE_MAX_UPLOAD_BYTES did nothing. config.py carried a max_upload_bytes
setting advertising 100 MB that nothing read, while the enforced ceiling was a
hardcoded 2 GB literal in storage.py — so an operator who set that variable, or
who read it to learn the limit, was wrong by 20x in the permissive direction.
storage.py now reads the setting and the default stays 2 GB, so the effective
ceiling is unchanged; the deliberate rationale for that number moves to
config.py with it.

test_upload_still_refuses_past_the_ceiling only ever asserted that the constant
equalled 2 GB, so deleting the size check from the route entirely would not have
failed it. It now lowers the ceiling for the duration and exercises the real
rejection branch, both sides of it, without a test moving two gigabytes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 5, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@arxdeployments, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 2 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a52e3415-577b-41b9-916b-5e550c2c519d

📥 Commits

Reviewing files that changed from the base of the PR and between abf8eaa and 3be77dc.

📒 Files selected for processing (2)
  • frontend/eslint.config.js
  • frontend/src/utils/generatePassword.js
📝 Walkthrough

Walkthrough

This PR adds durable cross-platform call recovery, group-call invitations, media editing and upload workflows, access-control removal, password and upload limits, CI object storage setup, platform networking configuration, and expanded backend, web, and iOS validation.

Changes

Application foundations

Layer / File(s) Summary
Repository and infrastructure foundations
.coderabbit.yaml, .github/workflows/ci.yml, infra/*, scripts/*, docs/*
Review configuration, MinIO-backed CI, LiveKit network templating, operational scripts, and call documentation are added.
Backend security and storage
backend/app/api/*, backend/app/core/*, backend/app/services/*, backend/alembic/*
Password hashing, authorization boundaries, inactive-conversation handling, upload streaming, upload limits, presence transactions, and access-control removal are updated.
Durable backend call lifecycle
backend/app/services/calls.py, backend/app/services/call_deadlines.py, backend/app/api/calls.py, backend/app/realtime/hub.py
Calls support durable ring and grace deadlines, offline ringing, device identities, group invitations, active-call recovery, link states, and reconnect grace periods.
Web call recovery and group calling
frontend/src/services/*, frontend/src/stores/callStore.js, frontend/src/components/calls/*, frontend/public/sw.js
The web client restores calls after signaling or media interruptions, supports group invitations, displays pending invitees, and reports connectivity state.
Web media workflow
frontend/src/components/chat/*, frontend/src/components/chat/editor/*, frontend/src/utils/*, frontend/src/hooks/*
The composer adds staged editing, quality tiers, background uploads, cancellation, hold-to-talk, media fallbacks, waveform decoding, video posters, and zoom/pan controls.
iOS call and media workflow
ios/RxHive/Features/Calls/*, ios/RxHive/Features/Media/*, ios/RxHive/Realtime/*, ios/RxHive/Models/*
The iOS client adds call reconciliation, reconnect handling, group invitations, device identities, media editing, quality processing, and attachment metadata layouts.
Validation
backend/tests/*, frontend/tests/*, ios/RxHiveTests/*
Tests cover security restrictions, call resilience, group calling, media uploads, message layouts, media editing, geometry, and undo history.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • arxdeployments/hive#1: The current change extends attachment-editor work across the same media editing, composer, quality, and rendering components.

Suggested reviewers: claude

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 51.76% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the production-audit scope and accurately reflects the security, backend, and API changes in the pull request.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch full-app-audit

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
ios/RxHive/Features/Chat/GroupInfoView.swift (1)

992-1009: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Selections made under an earlier search query are silently dropped.

onAdd now receives candidates.filter { selected.contains($0.id) }. candidates derives from contacts, and contacts is replaced on every debounced query by load(force: true). selected is not cleared when the query changes.

So this sequence loses a selection:

  1. The user types "an" and selects Anna.
  2. The user types "bo". contacts now holds only Bob, so Anna is no longer in candidates.
  3. The user selects Bob. The button reads "Add 2".
  4. onAdd receives Bob only. Anna is never added, and nothing tells the user.

The previous code passed the raw selected id set, which did not have this failure. Hold the selected contacts by id so a selection survives a query change.

🐛 Proposed fix to retain selections across queries
-    `@State` private var selected: Set<String> = []
+    /// Keyed by contact id so a selection survives the roster being replaced by the
+    /// next debounced query. A plain id set loses the contact itself, and a filter
+    /// over the current page then drops anyone picked under an earlier query.
+    `@State` private var selected: [String: Contact] = [:]
                                 Button {
-                                    if selected.contains(contact.id) {
-                                        selected.remove(contact.id)
-                                    } else {
-                                        selected.insert(contact.id)
-                                    }
+                                    if selected[contact.id] != nil {
+                                        selected[contact.id] = nil
+                                    } else {
+                                        selected[contact.id] = contact
+                                    }
                                 } label: {
-                            await onAdd(candidates.filter { selected.contains($0.id) })
+                            await onAdd(Array(selected.values))

contactRow and the toolbar label read the same state:

-            Image(systemName: selected.contains(contact.id) ? "checkmark.circle.fill" : "circle")
+            Image(systemName: selected[contact.id] != nil ? "checkmark.circle.fill" : "circle")
                 .font(.system(size: 20))
-                .foregroundStyle(selected.contains(contact.id) ? Theme.Color.primary : Theme.Color.border2)
+                .foregroundStyle(selected[contact.id] != nil ? Theme.Color.primary : Theme.Color.border2)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ios/RxHive/Features/Chat/GroupInfoView.swift` around lines 992 - 1009, Update
the selection flow in GroupInfoView so selected contacts are retained by ID
across debounced query changes instead of deriving the add list solely from the
current candidates. Preserve selections from prior queries when handling the
toolbar action and ensure contactRow and the toolbar label use the same
persistent selection state, so onAdd receives every selected contact.
backend/app/realtime/hub.py (1)

56-92: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Close the remaining subscribe/unsubscribe race in add.

add reads str(user_id) not in self.connections outside self._lock, awaits subscribe, and only then takes the lock to register the socket. The re-check added in remove does not cover every interleaving.

Consider this order on one event loop:

  1. add sees no existing bucket and awaits subscribe, which completes.
  2. remove takes the lock, pops the last connection, releases the lock, and awaits unsubscribe, which completes.
  3. remove takes the lock for its re-check. add has not registered yet, so the bucket is still empty and remove does not resubscribe.
  4. add takes the lock and registers the socket.

The user now holds a live socket on an unsubscribed channel. _reader never fans out to it, so the client appears online and receives nothing until it reconnects — the exact failure the remove re-check was added to prevent. Mid-call reconnects are the common trigger.

Perform the membership check and the registration under one lock hold, and subscribe while still holding it.

🐛 Proposed fix to serialize the check, the subscribe, and the registration
     async def add(self, user_id: uuid.UUID, conn_id: str, ws: WebSocket) -> None:
-        # SUBSCRIBE FIRST, then register the socket.
+        # SUBSCRIBE FIRST, then register the socket — both under one lock hold.
         #
         # The other order leaves a window in which this worker holds a live socket it
         # has not yet subscribed a channel for, so anything published to that user in
         # the meantime is dropped by the broker with no subscriber. It is a small
         # window, but a reconnecting client's first act is to be sent its resumed call
         # state, and losing that frame is precisely the failure this whole path exists
         # to prevent. Subscribing to a channel with no sockets yet is harmless: the
         # reader simply finds an empty bucket and moves on.
-        if self._pubsub is not None and str(user_id) not in self.connections:
-            with contextlib.suppress(Exception):
-                await self._pubsub.subscribe(user_channel(user_id))
-        async with self._lock:
-            self.connections.setdefault(str(user_id), {})[conn_id] = ws
+        #
+        # The lock is held across the subscribe as well, so a concurrent `remove`
+        # cannot unsubscribe and re-check between our subscribe and our registration.
+        async with self._lock:
+            if self._pubsub is not None and str(user_id) not in self.connections:
+                with contextlib.suppress(Exception):
+                    await self._pubsub.subscribe(user_channel(user_id))
+            self.connections.setdefault(str(user_id), {})[conn_id] = ws

remove must then also hold the lock across its unsubscribe, so the two operations cannot interleave at all:

     async def remove(self, user_id: uuid.UUID, conn_id: str) -> None:
         async with self._lock:
             bucket = self.connections.get(str(user_id), {})
             bucket.pop(conn_id, None)
             last_for_user = not bucket
             if last_for_user:
                 self.connections.pop(str(user_id), None)
-        if last_for_user and self._pubsub is not None:
-            with contextlib.suppress(Exception):
-                await self._pubsub.unsubscribe(user_channel(user_id))
+            if last_for_user and self._pubsub is not None:
+                with contextlib.suppress(Exception):
+                    await self._pubsub.unsubscribe(user_channel(user_id))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/realtime/hub.py` around lines 56 - 92, Update Hub.add so checking
whether the user has connections, subscribing via _pubsub.subscribe, and
registering the socket occur under one continuous self._lock hold; do not await
subscribe before acquiring the lock or perform the membership check outside it.
Update Hub.remove to retain the same lock through _pubsub.unsubscribe,
preserving the existing last-connection logic so add and remove cannot
interleave between subscription and registration.
ios/RxHive/Realtime/RealtimeClient.swift (1)

272-292: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

A pending backoff task is not cancelled when a call forces a reconnect, so a duplicate socket is opened.

Two problems combine here:

  1. hasLiveCall() is read once, at schedule time. A backoff scheduled before a call started keeps the 30-second chat ceiling for its whole wait, even though a call is now live.
  2. CallStore.accept() (Line 698 of ios/RxHive/Features/Calls/CallStore.swift) calls auth.realtime.connect() when the socket is down. During backoff the state is .reconnecting, so connect() passes its guard and calls openSocket() — but neither connect() nor openSocket() cancels reconnectTask. The pending task then fires later and opens a second socket on top of the live one.

That is the same orphan-socket condition the comment at Lines 359-364 describes for the foreground path, reached through the newly added accept flow.

🐛 Proposed fix
     private func openSocket() {
+        // Any pending backoff is now moot: it would open a second socket on top of
+        // this one, and the orphan lingers server-side until the heartbeat timeout.
+        reconnectTask?.cancel(); reconnectTask = nil
         state = .connecting

Re-read the ceiling at wake-up so a call that starts mid-wait shortens the remaining delay:

         reconnectTask = Task { [weak self] in
             try? await Task.sleep(for: .seconds(delay))
             guard let self, !Task.isCancelled, !self.intentionallyClosed else { return }
             self.openSocket()
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ios/RxHive/Realtime/RealtimeClient.swift` around lines 272 - 292, Update
scheduleReconnect so the reconnect task re-evaluates hasLiveCall() after waking
and applies the two-second ceiling when a call starts during the wait. Ensure
the manual reconnect path used by CallStore.accept, through connect/openSocket,
cancels any pending reconnectTask before opening a socket, preventing the
delayed task from creating a duplicate connection.
🟡 Minor comments (30)
README.md-157-157 (1)

157-157: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add blank lines around the troubleshooting table.

Markdownlint reports MD058 because the table is adjacent to list content. Add a blank line before and after the table. Indent the table under item 2 if it must remain part of that list item.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` at line 157, Add blank lines immediately before and after the
troubleshooting table in README.md, and indent the table consistently under list
item 2 if it remains part of that item, resolving the MD058 markdownlint
violation.

Source: Linters/SAST tools

docs/IOS_TO_WEB_PARITY.md-381-381 (1)

381-381: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Escape the literal <img> element.

Wrap <img> in backticks. Markdownlint currently parses it as an image element without alternate text.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/IOS_TO_WEB_PARITY.md` at line 381, Update the documentation text in the
“What the web does today” section so the literal img element is enclosed in
Markdown inline-code backticks, including the existing angle brackets,
preventing Markdownlint from treating it as an image.

Source: Linters/SAST tools

docs/IOS_TO_WEB_PARITY.md-320-320 (1)

320-320: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Replace workstation-specific absolute paths.

Use repository-relative paths such as frontend/src/components/chat/ProfileDrawer.jsx. The current /Users/carinrowena/... paths do not work for other contributors and disclose local workstation details.

Also applies to: 720-720, 822-822

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/IOS_TO_WEB_PARITY.md` at line 320, Replace the workstation-specific
absolute paths in the referenced documentation findings with repository-relative
paths, including frontend/src/components/chat/ProfileDrawer.jsx and the entries
at the other referenced locations. Preserve the cited file and line information
while removing the local /Users/carinrowena/... prefix.
scripts/inspect-call.py-72-83 (1)

72-83: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Track duplicate participants per room.

problems is process-wide. If an earlier room has duplicate identities, Line 83 suppresses the acoustic-echo diagnosis for every later room.

Use a room_has_duplicates flag for the current room. Keep problems only for the final exit status.

Proposed fix
         for room in rooms:
+            room_has_duplicates = False
             print(f"\nroom {room.name}  ({room.num_participants} participants)")
@@
                 if len(identities) > 1:
                     problems += 1
+                    room_has_duplicates = True
@@
-            if not problems and len(speakers) > 1:
+            if not room_has_duplicates and len(speakers) > 1:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/inspect-call.py` around lines 72 - 83, Update the room-processing
loop around by_user and the speakers check to track duplicate identities with a
per-room room_has_duplicates flag, resetting it for each room and using it to
gate the acoustic-echo diagnosis. Continue incrementing problems for duplicate
rooms so it remains the aggregate final exit-status counter.
docs/CALLS.md-5-6 (1)

5-6: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the Android link fragment.

[Android](#android) does not match the ## 5. Android heading. Use #5-android so the link resolves.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/CALLS.md` around lines 5 - 6, Update the Android link in the wire
contract documentation to use the fragment `#5-android`, matching the “## 5.
Android” heading and preserving the link text.

Source: Linters/SAST tools

backend/tests/test_call_resilience.py-26-39 (1)

26-39: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the captured_events docstring.

The fixture patches only calls_service.publish_to_users. The tests call calls_service directly, so update the docstring to describe the single patched binding instead of claiming that the bus and API bindings are also patched.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/test_call_resilience.py` around lines 26 - 39, Update the
captured_events fixture docstring to state that it patches only
calls_service.publish_to_users and that tests invoke calls_service directly;
remove the inaccurate claim about patching the bus and API import bindings.
backend/tests/test_calls_and_media.py-245-260 (1)

245-260: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Security Misconfiguration (CWE-79): Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')

Reachability: External · Exploitability: Theoretical

Assert the final object response headers. /api/media/* returns a 307; the API’s nosniff header does not protect the subsequent MinIO/S3 response. Because uploads are presigned with Content-Disposition: inline, test the final response and enforce X-Content-Type-Options: nosniff and Content-Disposition: attachment for unknown types. The current assertion covers only stored metadata.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/test_calls_and_media.py` around lines 245 - 260, Extend
test_unknown_types_are_stored_as_octet_stream to request each uploaded file
through its /api/media/* endpoint, follow the redirect to the final object
response, and assert the final response includes X-Content-Type-Options: nosniff
and Content-Disposition: attachment. Retain the existing
application/octet-stream metadata assertions while validating headers on the
final response rather than the initial 307.
frontend/tests/calling.spec.js-357-405 (1)

357-405: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Set an explicit timeout, and re-check the "unavailable" assertion after the ring screen appears.

Two points on this test:

  1. It has no test.setTimeout. Its own waits already allow 10s for the offline poll, 10s for the cancel button, 30s for the ringer, and 30s for audio, plus two UI logins. That exceeds the 60s default, so this test can fail on the timeout rather than on the behavior. Every other test added in this file sets an explicit budget.
  2. Line 384 runs immediately after the click, so it passes before the server can answer at all. A call:unavailable toast that arrives 500ms later is not caught. Move the count check to after the ring screen is confirmed on line 385.
♻️ Proposed change
 test('a ring placed while the callee is offline arrives when they come back', async ({
   browser,
 }) => {
+  test.setTimeout(180000);
   const aliceCtx = await browser.newContext({ permissions: ['camera', 'microphone'] });
@@
   await aPage.getByTestId('header-voice-call-btn').click();
 
-  // The caller must NOT be told the callee is unavailable — the ring is live, and
-  // the ringing screen (not a dead-call toast) is what proves it.
-  await expect(aPage.getByText(/User is unavailable/i)).toHaveCount(0);
   await expect(aPage.getByTestId('call-cancel-btn')).toBeVisible({ timeout: 10000 });
+  // The caller must NOT be told the callee is unavailable — checked after the ring
+  // screen is up, so a late toast is still caught.
+  await expect(aPage.getByText(/User is unavailable/i)).toHaveCount(0);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/tests/calling.spec.js` around lines 357 - 405, Update the test case
around “a ring placed while the callee is offline arrives when they come back”
to set an explicit timeout sufficient for its offline, ringing, and audio waits
plus login setup. Move the “User is unavailable” count assertion until after the
ringing screen is confirmed via the call-cancel button or ring heading, so
delayed unavailable notifications are detected.
backend/app/realtime/hub.py-304-331 (1)

304-331: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Reconcile the resume ordering with the service docstring.

resume_calls_for is called at Line 331, after _broadcast_presence(user, "online") at Line 312. The docstring of resume_calls_for in backend/app/services/calls.py states it is called "before presence is published". Either move the resume call above the presence broadcast, or correct the docstring so it describes the real order. A reader debugging frame ordering will otherwise trust the wrong sequence.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/realtime/hub.py` around lines 304 - 331, Align the call in the
connection flow with the documented ordering of resume_calls_for: either invoke
resume_calls_for before _broadcast_presence(user, "online"), or update
resume_calls_for’s docstring to accurately describe its post-presence
invocation. Keep the implementation and documentation consistent so frame-order
debugging reflects the actual sequence.
frontend/src/components/chat/editor/MediaEditor.jsx-375-380 (1)

375-380: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Do not carry item.editedDuration when handing back the original file.

When hasEdits(model) is false, save returns the untouched original bytes but still reports duration: item.editedDuration ?? null. editedDuration was measured from a previous rendered output, not from original. So this pairs original bytes with a duration taken from different bytes.

The path is reachable: open a previously cropped video, press Revert, then press Done. The composer receives the original clip together with the stale duration from the earlier render.

Report null here and let the consumer read the duration from the original file.

🐛 Proposed fix
     // Nothing to bake. Hand back the original so a save with no edits cannot
     // silently re-encode a photo — and so the item stops being marked as edited.
+    // `duration` is null rather than `item.editedDuration`: that value was measured
+    // from a previous render's output, and these are the ORIGINAL bytes.
     if (!hasEdits(model)) {
-      onSave({ file: original, edit: emptyEdit(), duration: item.editedDuration ?? null });
+      onSave({ file: original, edit: emptyEdit(), duration: null });
       return;
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/chat/editor/MediaEditor.jsx` around lines 375 - 380,
Update the no-edits branch in save so the onSave payload for the original file
reports duration as null instead of using item.editedDuration. Keep the existing
original-file and empty-edit behavior unchanged, allowing the consumer to derive
duration from the original bytes.
ios/RxHive/Features/Media/Editor/EditorControls.swift-274-300 (1)

274-300: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the duplicated EditorInsets documentation.

Keep one documentation block. MediaSendSheet.windowInsets already delegates to EditorInsets.window, so retain that ownership statement.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ios/RxHive/Features/Media/Editor/EditorControls.swift` around lines 274 -
300, Remove the duplicated documentation block associated with
EditorInsets.window in EditorControls.swift, keeping a single concise
documentation block for the shared implementation. Preserve the ownership
statement that MediaSendSheet.windowInsets delegates to EditorInsets.window.
frontend/src/components/chat/editor/MediaEditor.jsx-275-279 (1)

275-279: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Release the previous source when original changes, not only on unmount.

The release effect has an empty dependency array, so sourceRef.current?.release?.() runs only at unmount. The load effect at Line 211 depends on [original, isVideo] and sets cleanup = null after ownership transfers to source.release. If original changes while a source is already set, the load effect teardown releases nothing and the previous source is never released. For the video branch that leaks a detached video element and an object URL; for the image branch it leaks the decoded bitmap.

The comment at Line 272 states that original is stable for the lifetime of the editor. That holds only if the parent remounts the editor for each item. Releasing the previous source in the load effect makes the code correct regardless of how the parent mounts it.

🐛 Proposed fix to release the previous source on every reload
   useEffect(() => {
     let cancelled = false;
     let cleanup = null;
+    // Whatever was decoded for the PREVIOUS `original` is dead the moment this
+    // effect re-runs. Releasing it here rather than only at unmount means a
+    // parent that reuses this component for a different item does not leak a
+    // video element, an object URL, or a decoded bitmap.
+    const previous = sourceRef.current;
+    if (previous) {
+      previous.release?.();
+      setSource(null);
+    }
 
     const load = async () => {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/chat/editor/MediaEditor.jsx` around lines 275 - 279,
Update the source cleanup logic near sourceRef and the load effect so the
previously owned source is released whenever original or isVideo triggers a
reload, not only during unmount. Reuse the existing sourceRef ownership and
ensure cleanup occurs before replacing it, while preserving final unmount
cleanup and avoiding release of the newly loaded source.
backend/app/api/calls.py-280-295 (1)

280-295: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Sensitive Data Exposure (CWE-525): Use of Web Browser Cache Containing Sensitive Information

Reachability: External · Exploitability: Moderate

Reachability path
● Entry
  backend/app/main.py:37
  _call_deadline_sweeper: Fire ring timeouts and reconnect-grace expiries whoever scheduled them. The safety net that makes call timers survive a worker restart. P…
│
▼
● Hop
  backend/app/services/calls.py:567
  invite_to_call
│
▼
● Sink
  backend/app/api/calls.py

Prevent caching of /active responses.

The response contains identity-specific call state and has no cache directive. Set Cache-Control: no-store for this endpoint or all authenticated API responses.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/api/calls.py` around lines 280 - 295, The active_call endpoint
currently returns identity-specific state without preventing intermediary or
browser caching. Update active_call to include a Cache-Control: no-store
response header, using the project’s established response/header pattern if
available; keep the existing response payload and service call unchanged.
ios/RxHive/Features/Chat/MessageBubble.swift-205-213 (1)

205-213: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Match image/video footer placement with the web client.

ios/RxHive/Features/Chat/MessageBubble.swift:210 always overlays the footer for images and videos. The web client places it below content when media has a caption or is unavailable. Add an iOS placement for those cases. Audio and file messages with attachments already place the footer inside the card.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ios/RxHive/Features/Chat/MessageBubble.swift` around lines 205 - 213, Update
the footerPlacement computed property to match web behavior for .image and
.video messages: return the below-content placement when the media has a caption
or is unavailable, and retain .overlaid otherwise. Keep the existing audio/file
and text/system/unknown placement logic unchanged.

Source: Path instructions

ios/RxHive/Features/Media/Editor/MediaEdit.swift-477-488 (1)

477-488: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

output does not re-derive the scale from the rounded size, so an edge-flush export can keep a dark hairline.

displayOutput at lines 498-504 deliberately errs large and its comment explains why: erring small "leaves a transparent hairline down one edge." output does not apply the same correction. It rounds w and h but returns the unrounded scale.

When round() rounds up, the surface is up to half a pixel wider than the pixels drawn into it. Everywhere except a crop flush with the source's right or bottom edge there is more image to clip, so nothing shows. Flush against that edge there is not, and the uncovered column keeps the opaque black that MediaEditRenderer.compose fills at lines 247-248 — a dark hairline down the edge of the sent photo.

The web already fixed exactly this. outputPixelSize in frontend/src/utils/mediaEdit.js lines 409-413 re-derives the scale from the rounded canvas against the unrounded crop and takes the larger axis. Line 43-45 of this file states the two sides must be changed together.

🐛 Proposed fix
     static func output(_ source: CGSize, _ edit: MediaEdit, maxEdge: CGFloat) -> Output {
         let cropped = croppedPixelSize(source, edit)
-        let scale = min(1, maxEdge / max(cropped.width, cropped.height))
-        let w = max(1, (cropped.width * scale).rounded())
-        let h = max(1, (cropped.height * scale).rounded())
+        let fit = min(1, maxEdge / max(cropped.width, cropped.height))
+        let w = max(1, (cropped.width * fit).rounded())
+        let h = max(1, (cropped.height * fit).rounded())
+        // Re-derived FROM the rounded surface against the UNROUNDED crop, and the
+        // larger axis wins — the same correction `displayOutput` makes, and for the
+        // same reason. Erring large costs under a pixel of overscan, which is clipped.
+        let c = crop(edit)
+        let exactWidth = max(1e-6, c.width * source.width)
+        let exactHeight = max(1e-6, c.height * source.height)
+        let scale = max(w / exactWidth, h / exactHeight)
         let unrotated = CGSize(width: w, height: h)

As per path instructions: "This app must stay behaviorally aligned with frontend/ — see docs/IOS_TO_WEB_PARITY.md. If a change alters shared behavior on only one platform, say so."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ios/RxHive/Features/Media/Editor/MediaEdit.swift` around lines 477 - 488,
Update MediaEdit.output to re-derive the returned scale from the rounded
dimensions and unrounded cropped size, using the larger width- and height-based
scale as frontend outputPixelSize does. Keep the rounded output dimensions and
quarter-turn size handling intact, and preserve behavioral parity with the web
implementation.

Source: Path instructions

ios/RxHive/Features/Media/Editor/CropStageView.swift-291-298 (1)

291-298: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

A folded rect keeps the pre-fold anchor, so a locked ratio re-anchors to the wrong edge.

Lines 293-294 fold the rect when the user drags a handle past the opposite edge, but anchor is passed to clampFrameRect unchanged at line 297. After a west-handle drag folds, the user is effectively holding the east edge, yet clampFrameRect still evaluates anchor.holdsWest and pins x = rect.maxX - w (see ios/RxHive/Features/Media/Editor/MediaEdit.swift line 621). The rect then jumps away from the finger.

Free crop is unaffected, because the re-anchor block only runs when ratio is non-nil. The defect is visible only with a locked aspect preset.

Mirror the anchor when the span folds.

🐛 Proposed fix
                 var next = start
+                var effective = anchor
                 if anchor.holdsWest { next.origin.x = start.minX + dx; next.size.width = start.width - dx }
                 if anchor.holdsEast { next.size.width = start.width + dx }
                 if anchor.holdsNorth { next.origin.y = start.minY + dy; next.size.height = start.height - dy }
                 if anchor.holdsSouth { next.size.height = start.height + dy }
                 // Dragged past the opposite edge: fold the rect rather than letting a
                 // negative span through, which would render inside-out.
-                if next.size.width < 0 { next.origin.x += next.size.width; next.size.width = -next.size.width }
-                if next.size.height < 0 { next.origin.y += next.size.height; next.size.height = -next.size.height }
+                if next.size.width < 0 {
+                    next.origin.x += next.size.width
+                    next.size.width = -next.size.width
+                    effective = effective.mirroredHorizontally
+                }
+                if next.size.height < 0 {
+                    next.origin.y += next.size.height
+                    next.size.height = -next.size.height
+                    effective = effective.mirroredVertically
+                }
 
                 write(MediaEditGeometry.clampFrameRect(
-                    next, frame: frame, ratio: aspect.ratio, anchor: anchor
+                    next, frame: frame, ratio: aspect.ratio, anchor: effective
                 ))

Add the two mirrors to CropAnchor in ios/RxHive/Features/Media/Editor/MediaEdit.swift:

extension CropAnchor {
    var mirroredHorizontally: CropAnchor {
        switch self {
        case .nw: return .ne
        case .ne: return .nw
        case .sw: return .se
        case .se: return .sw
        case .w: return .e
        case .e: return .w
        case .n, .s: return self
        }
    }

    var mirroredVertically: CropAnchor {
        switch self {
        case .nw: return .sw
        case .sw: return .nw
        case .ne: return .se
        case .se: return .ne
        case .n: return .s
        case .s: return .n
        case .w, .e: return self
        }
    }
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ios/RxHive/Features/Media/Editor/CropStageView.swift` around lines 291 - 298,
Update the fold handling in the crop drag logic to mirror the anchor whenever
the corresponding width or height span becomes negative before calling
clampFrameRect. Add mirroredHorizontally and mirroredVertically to CropAnchor,
preserving corner and edge mappings while leaving unaffected axes unchanged,
then use the updated anchor for ratio-locked clamping.
ios/RxHive/Features/Media/MediaAttachmentViews.swift-881-885 (1)

881-885: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Align the PDF preview to the top

AuthenticatedImage uses .scaledToFill(), so the centered frame can hide the title of a portrait page. Set the frame alignment to .top before clipping.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ios/RxHive/Features/Media/MediaAttachmentViews.swift` around lines 881 - 885,
Update the PDF preview view around AuthenticatedImage so its fixed-size frame
uses top alignment, preserving the existing previewSize dimensions and clipping
behavior. Apply the alignment directly to the frame before .clipped() so
portrait pages display their top content.
frontend/src/services/livekitClient.js-612-614 (1)

612-614: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The stale-participant filter misses screen-share-only participants.

The filter keeps any entry whose stream is null. _syncParticipant excludes the screen-share source from stream, so a remote who publishes only a screen share has stream === null and screenStream set. If that person leaves while this client is disconnected, their tile is treated as a signalled-only placeholder and stays on the grid for the rest of the call.

Test both media fields.

🐛 Proposed fix
     store.remoteParticipants
-      .filter((p) => p.stream && !present.has(p.id))
+      .filter((p) => (p.stream || p.screenStream) && !present.has(p.id))
       .forEach((p) => store.removeRemoteParticipant(p.id));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/services/livekitClient.js` around lines 612 - 614, Update the
stale-participant filter in the remote-participant cleanup flow to retain
entries only when they have either a regular stream or a screenStream, while
still excluding IDs present in present. This ensures screen-share-only
participants are removed when no longer signalled.
frontend/src/services/livekitClient.js-698-724 (1)

698-724: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Derive camera state from the publication after a queued failure.

previous can be stale when queued toggles reject in sequence. Read getTrackPublication(Track.Source.Camera) in catch, set isCameraOn to publication ? !publication.isMuted : false, and return that value.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/services/livekitClient.js` around lines 698 - 724, Update the
catch block in setCameraEnabled to derive the rollback state from
room.localParticipant.getTrackPublication(Track.Source.Camera) instead of the
stale previous value; set isCameraOn to publication ? !publication.isMuted :
false, keep localStream refreshed, and return the derived state.
frontend/src/components/chat/AudioRecorderBar.jsx-132-141 (1)

132-141: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Correct the message when the finished recording fails to load.

This branch shows one string for two different states. If paused is true, "Review is available once you finish recording." is correct. If previewBroken becomes true after the recording is finished, the recording IS finished, and the text instructs the user to do what they already did.

previewBroken is reachable in the finished state: the .ogg/.weba outputs from pickAudioFormat do not load in every engine, as the comment at Line 46 states. Send still works, so the impact is limited to wrong copy.

✏️ Proposed fix to branch the copy
             <AlertTriangle size={14} className="text-[`#F59E0B`] flex-shrink-0" />
             <span className="text-xs text-[`#A3A3A3`] leading-tight">
-              Review is available once you finish recording.
+              {paused
+                ? 'Review is available once you finish recording.'
+                : 'This recording cannot be played back here. You can still send it.'}
             </span>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/chat/AudioRecorderBar.jsx` around lines 132 - 141,
Update the unavailable-preview message in the previewBroken/paused branch of
AudioRecorderBar so paused recordings retain “Review is available once you
finish recording,” while a finished recording with previewBroken displays copy
indicating the recording preview failed to load. Keep the existing layout and
conditions unchanged.
frontend/src/components/chat/VideoBubble.jsx-73-83 (1)

73-83: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

A keyboard press on the nested retry control also opens the viewer.

The container carries role="button", tabIndex={0}, and an onKeyDown handler. The footer is rendered inside that container at Line 118. For a failed own send, MessageFooter renders a <button data-testid="message-retry"> inside the footer.

MessageFooter stops click propagation, so a mouse click on retry is contained. It does not stop keydown. A keyboard user who focuses the retry control and presses Enter or Space triggers the retry and the container handler, so the fullscreen viewer opens on top of the retry. A nested interactive control inside role="button" is also invalid ARIA.

Restrict the container key handler to keys that target the container itself.

🐛 Proposed fix to scope the key handler
           onKeyDown={(e) => {
+            // The footer's retry control is a nested button; its keydown bubbles here.
+            if (e.target !== e.currentTarget) return;
             if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setViewerOpen(true); }
           }}

Also applies to: 116-119

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/chat/VideoBubble.jsx` around lines 73 - 83, Update
the VideoBubble container’s onKeyDown handler to open the viewer only when the
event target is the container itself, preventing Enter or Space on the nested
MessageFooter retry button from opening the viewer. Preserve the existing
keyboard activation behavior when the container is the focused target.
frontend/src/components/chat/FullscreenImageViewer.jsx-164-170 (1)

164-170: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Backdrop click no longer closes the viewer.

The gesture container spans the whole flex-1 region and calls e.stopPropagation() on every click. The overlay root at Line 121 holds onClick={onClose}. A click on the dark area beside the photo therefore no longer closes the viewer. Before this change only the image itself stopped propagation.

Close on a click that lands on the container itself, and keep propagation stopped for the image.

🐛 Proposed fix to restore backdrop close
         <div
           ref={zoom.containerRef}
           className="flex-1 flex items-center justify-center px-16 py-4 overflow-hidden touch-none select-none"
           style={{ cursor: zoom.isZoomed ? 'grab' : 'default' }}
-          onClick={(e) => e.stopPropagation()}
+          // A click on the padding around the photo still closes the viewer;
+          // a click on the photo (below) does not.
+          onClick={(e) => { if (e.target === e.currentTarget && !zoom.isZoomed) onClose(); }}
           {...zoom.handlers}
         >
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/chat/FullscreenImageViewer.jsx` around lines 164 -
170, Update the gesture container around zoom.containerRef so it closes the
viewer when the click target is the container itself, while preserving
propagation prevention for clicks on the image. Keep the overlay root’s
onClick={onClose} behavior and ensure image interactions do not trigger backdrop
closing.
frontend/src/components/chat/ProfileDrawer.jsx-66-73 (1)

66-73: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The transcode does not guarantee a bounded upload size.

The comment states that the raw path "had no size check at all" and implies the transcode replaces one. transcodeImage returns the original file unchanged in four cases: the file is not an image, the type is image/gif, the canvas pipeline throws (for example an undecodable HEIC), or the re-encoded blob is not smaller than the source. A 40 MB animated GIF or an undecodable HEIC is therefore still pushed to the server in full.

The catch at Line 82 also discards err, so a rejected oversize upload and a network failure both show "Failed to upload avatar".

Add an explicit size check after the transcode, and surface the server message.

🛡️ Proposed fix to bound the upload and report the reason
       const outgoing = await transcodeImage(file, 'standard');
+      // transcodeImage returns the original for GIFs, undecodable files, and
+      // any re-encode that is not smaller — so the cap has to be checked here.
+      const MAX_AVATAR_BYTES = 5 * 1024 * 1024;
+      if (outgoing.size > MAX_AVATAR_BYTES) {
+        toast.error('Image is too large. Choose a file under 5 MB.');
+        return;
+      }
       const formData = new FormData();
       formData.append('file', outgoing);
-    } catch (err) { toast.error('Failed to upload avatar'); }
+    } catch (err) { toast.error(err.response?.data?.detail || 'Failed to upload avatar'); }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/chat/ProfileDrawer.jsx` around lines 66 - 73, After
transcodeImage in the avatar upload flow, explicitly reject outgoing files
exceeding the existing avatar upload size limit before constructing or
submitting FormData, including unchanged GIFs and unsupported images. Update the
catch block to retain the error and display or log the server-provided message
when available, while preserving a fallback for errors without a message.
frontend/src/components/chat/MessageComposer.jsx-636-641 (1)

636-641: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

uploading still blocks the composer it was meant to free.

The comment at Lines 627-629 states the composer stays usable during background uploads. Two things contradict that while uploading is true for the whole batch:

  1. Line 1222 renders <Loader2> on the main composer button instead of <Send> or <Mic>. The onClick still calls handleSend, so text sending works, but the button shows a spinner for the entire batch and the user cannot see which action it performs.
  2. sendingFilesRef blocks a second handleConfirmSend for the whole batch, so the user cannot stage and send a further batch while the first uploads.

The uploadJobs rows already carry the in-flight state. Consider scoping uploading to the tray only, and reading uploadJobs.length where a global indicator is needed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/chat/MessageComposer.jsx` around lines 636 - 641,
Update the upload state handling in MessageComposer so uploading does not
disable or obscure the main composer during background transfers. Scope
uploading to the upload tray, use uploadJobs.length for any global in-flight
indicator, and remove the whole-batch blocking behavior from sendingFilesRef so
handleConfirmSend can stage and send additional batches while prior jobs remain
active; preserve the uploadJobs row state for tracking each transfer.
frontend/src/utils/videoPoster.js-103-112 (1)

103-112: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not cache a total failure permanently.

The finally block caches result unconditionally. A failed extraction stores { posterUrl: null, duration: null }, and the guard at Line 45 then returns that stored failure for every later call with the same URL.

The comment at Lines 28-30 states that mobile Safari refuses to load video data while the tab is backgrounded. That produces exactly this outcome: the 8-second timeout fires, the failure is cached, and the clip shows a grey box for the rest of the session even after the tab returns to the foreground.

Cache only when at least one of posterUrl or duration was obtained.

🐛 Proposed fix
       if (objectUrl) URL.revokeObjectURL(objectUrl);
       if (key) {
-        posterCache.set(key, result);
         inflight.delete(key);
+        // A total failure is not cached: a backgrounded tab or a transient
+        // network error would otherwise pin a grey tile for the session.
+        if (result.posterUrl || result.duration != null) posterCache.set(key, result);
       }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/utils/videoPoster.js` around lines 103 - 112, Update the finally
block in the video extraction function so posterCache.set only runs when
result.posterUrl or result.duration is available; always remove the inflight
entry for keyed requests, but leave complete failures uncached so later calls
can retry.
frontend/src/components/chat/MessageComposer.jsx-333-334 (1)

333-334: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Pass signal to the message-create request as well.

sendMediaFile forwards signal only to uploadFile. The POST /api/conversations/{id}/messages call at Line 343 ignores it. If the user cancels after the upload completes but before the message is created, the optimistic bubble at Line 340 still appears and the message is still persisted. The cancel then has no visible effect.

🐛 Proposed fix
       const { data } = await client.post(`/api/conversations/${conversationId}/messages`, {
         content,
         type: msgType,
         temp_id: tempId,
         media_url: uploadResult.file_url,
         reply_to: replyId,
         duration,
-      });
+      }, { signal });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/chat/MessageComposer.jsx` around lines 333 - 334,
Update sendMediaFile and its message-create request so the provided signal is
passed through to the POST /api/conversations/{id}/messages call, preserving
cancellation between upload completion and message persistence and preventing
the optimistic bubble from being committed after cancellation.
frontend/src/components/chat/MessageComposer.jsx-714-721 (1)

714-721: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

A fully cancelled batch reports success.

stillFailed receives only genuine failures. The abort branch at Line 699 uses continue, so cancelled items are counted in neither stillFailed nor any other tally. If the user cancels every upload in the batch, stillFailed.length is 0 and sent equals batch.length. The code then shows "N files sent" for files that were never sent.

Track cancellations and exclude them from sent.

🐛 Proposed fix
     const stillFailed = [];
+    let cancelledCount = 0;
     try {
       for (let i = 0; i < batch.length; i += 1) {
         const item = batch[i];
         const controller = uploadControllers.current.get(item.id);
-        if (!controller) { URL.revokeObjectURL(item.url); continue; }
+        if (!controller) { URL.revokeObjectURL(item.url); cancelledCount += 1; continue; }
           const aborted = err?.name === 'CanceledError' || err?.code === 'ERR_CANCELED'
             || controller.signal.aborted;
-          if (aborted) { URL.revokeObjectURL(item.url); continue; }
+          if (aborted) { URL.revokeObjectURL(item.url); cancelledCount += 1; continue; }
-    const sent = batch.length - stillFailed.length;
+    const sent = batch.length - stillFailed.length - cancelledCount;
     if (stillFailed.length > 0) {
       setStagedFiles((prev) => [...prev, ...stillFailed]);
       if (sent > 0) toast.error(`${stillFailed.length} of ${batch.length} failed — still attached`);
-    } else {
+    } else if (sent > 0) {
       toast.success(sent > 1 ? `${sent} files sent` : 'File sent');
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/chat/MessageComposer.jsx` around lines 714 - 721,
Update the batch accounting around the upload loop and the `sent` calculation so
cancelled items are tracked separately from genuine failures. Exclude cancelled
items when computing `sent`, and ensure a batch where every item is cancelled
does not show a success toast; preserve reattachment and failure-toast behavior
for items in `stillFailed`.
frontend/src/utils/mediaQuality.js-68-105 (1)

68-105: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Close the bitmap in a finally.

bitmap.close?.() runs at Line 87, after ctx.drawImage. If drawImage throws, control jumps to the catch at Line 101 and the bitmap is never closed. An ImageBitmap for a 12 MP photo holds tens of megabytes until garbage collection, and close() is the deterministic release.

drawImage throwing is most likely under memory pressure, which is exactly when the retained bitmap matters.

🐛 Proposed fix
   const tier = TIERS[tierKey] || TIERS.standard;
 
+  let bitmap = null;
   try {
     // imageOrientation: 'from-image' bakes the EXIF rotation into the pixels,
     // which is what kCGImageSourceCreateThumbnailWithTransform does on iOS.
     // Without it a portrait photo from a phone arrives on its side, because the
     // orientation tag is lost the moment it is drawn to a canvas.
-    const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' });
+    bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' });
 
     const longEdge = Math.max(bitmap.width, bitmap.height);
     // Never upscale.
     const scale = Math.min(1, tier.maxEdge / longEdge);
     const width = Math.max(1, Math.round(bitmap.width * scale));
     const height = Math.max(1, Math.round(bitmap.height * scale));
 
     const canvas = typeof OffscreenCanvas !== 'undefined'
       ? new OffscreenCanvas(width, height)
       : Object.assign(document.createElement('canvas'), { width, height });
     const ctx = canvas.getContext('2d');
-    if (!ctx) { bitmap.close?.(); return file; }
+    if (!ctx) return file;
     ctx.drawImage(bitmap, 0, 0, width, height);
-    bitmap.close?.();
 
     const blob = canvas.convertToBlob
       ? await canvas.convertToBlob({ type: 'image/jpeg', quality: tier.jpegQuality })
       : await new Promise((r) => canvas.toBlob(r, 'image/jpeg', tier.jpegQuality));
     if (!blob) return file;
 
     // Keep whichever is smaller. A JPEG that is already well compressed usually
     // grows when re-encoded, and sending the larger of the two would make the
     // whole feature counterproductive.
     if (blob.size >= file.size) return file;
 
     const base = (file.name || 'image').replace(/\.[^.]+$/, '');
     return new File([blob], `${base}.jpg`, { type: 'image/jpeg', lastModified: Date.now() });
   } catch {
     // HEIC in a browser that cannot decode it, a corrupt file, a tainted canvas.
     // Upload what the user picked.
     return file;
+  } finally {
+    bitmap?.close?.();
   }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/utils/mediaQuality.js` around lines 68 - 105, Update the bitmap
lifecycle in the image-processing try block so the ImageBitmap created by
createImageBitmap is always closed via a finally path, including when drawImage
or later canvas operations throw. Remove reliance on the current inline
bitmap.close call while preserving the existing fallback return behavior and
successful image conversion flow.
frontend/src/hooks/useAudioRecorder.js-123-133 (1)

123-133: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Debounce transient MediaStreamTrack mute events

Browser implementations can emit transient mute/unmute pairs during device or Bluetooth route changes. This callback immediately pauses the recording and reports device_lost, even when the track resumes. Delay the pause until the track remains muted, and cancel the pending loss on unmute.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/hooks/useAudioRecorder.js` around lines 123 - 133, Update the
watchTrack callback to debounce transient mute events: start a delayed loss
notification when the track emits mute, cancel that pending notification on
unmute, and preserve immediate handling for ended. Ensure cleanup in watchTrack
clears the timer and removes all listeners.
frontend/src/components/chat/DocumentBubble.jsx-107-112 (1)

107-112: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Keep the retry control outside the download anchor.

MessageFooter renders a retry <button> for failed own messages, including file uploads. This branch nests that button inside <a download>. The click handler only stops propagation, so the anchor's download action can still run.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/chat/DocumentBubble.jsx` around lines 107 - 112,
Update the download-link layout around the Download icon and trailingMeta so the
retry button rendered by MessageFooter remains outside the <a download> element.
Keep the download anchor limited to the file-download control while preserving
the existing footer alignment and metadata behavior.

Comment thread ios/RxHive/Features/Calls/LiveKitSession.swift
arxdeployments and others added 3 commits August 5, 2026 12:10
CodeRabbit review finding, verified against the code.

The rejoin loop cleared `rejoinTask` at two of its five exits. The three that
did not — the `callID == nil` guard, the mid-loop cancellation check, and the
post-loop guard — left the handle pointing at a task that had already finished.
`handleRoomGone` opens with `guard rejoinTask == nil else { return }`, so from
that moment every room loss was ignored and `onRoomLost` never fired again.

The reachable path is a hang-up during a rejoin. `leave()` deliberately skips
`rejoinTask?.cancel()` while `isRejoining` — cancelling there would abort the
very `room.connect` it is waiting on — and clears `callID`, so the next pass
through the loop returns at the `callID` guard without touching the handle.
Because CallStore holds one long-lived `LiveKitSession` for the app's lifetime,
the dead handle outlived the call that stranded it: SFU restarts, captive
portals and sleep/wake stopped being recoverable for the rest of the process,
silently, on every subsequent call.

Clearing it in a `defer` puts the handle's lifetime in one place. The defer is
generation-guarded rather than unconditional: `leave()` can cancel a sleeping
task and a new attempt can claim the handle before the cancelled one resumes,
and an unconditional `defer` would then have the old task clear the new task's
handle — orphaning a running loop and letting a second start beside it. The
generation makes a task clear the handle only while it still owns it.

Verified: xcodebuild test, 54 tests pass, no new warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`no-unused-vars` could not see JSX. eslint.config.js registered
eslint-plugin-react-hooks but not eslint-plugin-react, so react/jsx-uses-vars
never ran and every identifier used only in markup read as unused. A
`varsIgnorePattern: '^[A-Z_]'` had been added to paper over it, but that only
covers Capitalized names — lowercase `motion` still warned in 41 files, and a
destructured `icon: Icon` is an argument, so it warned too.

44 of the project's 77 warnings were therefore false, and dangerous: deleting
one of those imports type-checks, lints and builds clean, then throws
ReferenceError in the browser the moment the element renders. That is why all 77
were ignored, and why the ~30 real ones sat unread in the noise.

Registering the plugin and dropping the pattern escape hatch takes the count to
8 warnings and 0 errors:
  - 41 false `motion` warnings gone
  - 77 dead `import React` removed (React 19's automatic JSX transform does not
    need them; verified no file retains a `React` code reference — the ones that
    still contain the word have it in a comment or a menu label)
  - 23 unused catch bindings folded into optional catch binding
  - ~15 dead icon/component imports and one duplicate `createdPw` state removed
  - genuinely unused Capitalized imports now surface instead of being exempted

react/jsx-no-undef is added as an ERROR, which is the guard that was missing.
Both fast gates pass an undefined JSX component today — `vite build` does not
resolve identifiers, core `no-undef` does not descend into JSX — so the only
thing standing between a bad merge and a blank pane behind the error boundary is
the Playwright suite: 3.6 minutes in, and only on pages a test happens to open.
Verified the rule catches exactly that case before committing.

The 8 remaining warnings are left deliberately. None are dead code; each is a
feature wired up but never finished — the `loading` and `jumpLoading` spinners
that are computed and never rendered, `onBack` and `isScreenSharing` props
accepted and ignored, `setCollapsed` never called. Deleting them would erase the
evidence; they want finishing, not removing.

Also drops frontend/dump.rdb, a Redis snapshot committed by accident holding one
stale local rate-limit counter, and gitignores dump.rdb so it cannot return.

Verified: npm run lint (0 errors), npm run build, and the full Playwright suite —
31/31 passing against a live API, covering chat, group calls, the media editor
and the admin portal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
password randomness, and security headers nginx was discarding

Four defects, each verified against running software rather than by reading.

Both notification toggles in Settings were inert. Settings.jsx assigns a boolean
to localStorage, so the stored value is the string "true" or "false" — but both
readers in websocket.js compared against 'off', which nothing writes. Turning
"Notification sound" or "Desktop notifications" off therefore changed nothing:
the tone kept playing and a browser notification kept appearing for every
message. Four readers had grown three dialects between them — callSounds
compared against 'false', IncomingCallOverlay accepted either, websocket.js
accepted neither — so the call sounds honoured the setting and the message ones
did not. All four now go through utils/notificationPrefs.js, which accepts 'off'
as well so a profile written by an older build keeps working.

Message tones leaked an AudioContext each. _playNotificationSound constructed a
new AudioContext per inbound message and never closed one. Browsers cap
concurrent contexts — Chrome at six — so the constructor began throwing on the
seventh message, the throw was swallowed by that method's own catch, and
notification sounds silently stopped for the rest of the session while every
abandoned context kept its audio thread. callSounds already owned exactly the
right thing: one long-lived context, correctly gated. websocket.js now delegates
to it. Its oscillator list is now pruned on `ended` as well, which it did not
need when only call events drove it but does now that a message can.

Generated passwords came from Math.random(). Two copies — generatePassword in
admin/Users.jsx and genPassword in OrgAdmin/OrgAdminUsers.jsx — and this is the
credential a new user is handed to sign in with. Math.random is a seeded PRNG
whose internal state is recoverable from a modest run of outputs, so an admin who
sees a few generated passwords can predict the ones minted next, including for
accounts in organisations that are not theirs. Both now call one shared helper
over crypto.getRandomValues. Bytes >= 201 are rejected rather than folded in with
%, and 201 is exactly 67 x 3, so every character of the alphabet has exactly
three byte values and the draw is uniform by construction. Checked empirically:
2.4M characters, chi-square 54.1 on 66 df against a 96.6 critical value, 20000
of 20000 sampled passwords distinct, alphabet and length unchanged.

nginx was discarding every security header. add_header is inherited into a
location only while that location declares no add_header of its own, and all
three locations set Cache-Control — so the four headers in the server block
reached nothing. Confirmed against nginx:alpine before touching it: `curl -I` on
/, /index.html, /sw.js and /assets/* each returned Cache-Control and nothing
else. The SPA document itself shipped with no CSP and no X-Frame-Options, so the
app was framable and script-unrestricted while the config sat there looking
correct. Moved to a snippet included by every block that sets a header, which is
the only mechanism nginx offers. Re-verified from the actual built image: all
four present on every path, Cache-Control intact, nginx -t clean. form-action
'self' added while restoring the CSP — without it the policy does not stop an
injected form posting credentials off-origin, which is the gap that matters most
on a login page.

Verified: npm run lint (0 errors), npm run build, docker build, and the full
Playwright suite — 31/31 against a live API.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@frontend/eslint.config.js`:
- Around line 16-19: Upgrade eslint-plugin-react-hooks from 5.2.0 to 6.1.0 or
newer in both dependency manifests and regenerate the lockfile so its resolved
version is updated consistently, while preserving the existing reactHooks
configuration in eslint.config.js.

In `@frontend/src/utils/generatePassword.js`:
- Around line 22-31: Update generatePassword to reserve and add at least one
letter and one digit before filling the remaining positions from the existing
character set. Then shuffle the completed out array using crypto.getRandomValues
so the required characters are not positionally predictable, while preserving
the requested length behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fe37c14a-c773-4194-b938-6dcbcff9516a

📥 Commits

Reviewing files that changed from the base of the PR and between 0a53fcb and 712b813.

⛔ Files ignored due to path filters (1)
  • frontend/package-lock.json is excluded by !**/package-lock.json, !**/package-lock.json
📒 Files selected for processing (91)
  • .claude/launch.json
  • .gitignore
  • frontend/Dockerfile
  • frontend/dump.rdb
  • frontend/eslint.config.js
  • frontend/nginx-security-headers.conf
  • frontend/nginx.conf
  • frontend/package.json
  • frontend/src/App.jsx
  • frontend/src/components/calls/ActiveCallView.jsx
  • frontend/src/components/calls/AddParticipantsModal.jsx
  • frontend/src/components/calls/CallAudioSink.jsx
  • frontend/src/components/calls/CallsTab.jsx
  • frontend/src/components/calls/IncomingCallOverlay.jsx
  • frontend/src/components/calls/MinimizedCallBanner.jsx
  • frontend/src/components/calls/OngoingGroupCallBar.jsx
  • frontend/src/components/calls/OutgoingCallScreen.jsx
  • frontend/src/components/calls/StreamVideo.jsx
  • frontend/src/components/calls/VideoGrid.jsx
  • frontend/src/components/chat/AudioPlayer.jsx
  • frontend/src/components/chat/AudioRecorderBar.jsx
  • frontend/src/components/chat/CallButtonDropdown.jsx
  • frontend/src/components/chat/ChatHeaderMenu.jsx
  • frontend/src/components/chat/ChatPanel.jsx
  • frontend/src/components/chat/ChatSidebar.jsx
  • frontend/src/components/chat/ContactInfoPanel.jsx
  • frontend/src/components/chat/ConversationItem.jsx
  • frontend/src/components/chat/ConversationSearch.jsx
  • frontend/src/components/chat/CreateGroupModal.jsx
  • frontend/src/components/chat/DateSeparator.jsx
  • frontend/src/components/chat/DocumentBubble.jsx
  • frontend/src/components/chat/EmptyChat.jsx
  • frontend/src/components/chat/ForwardModal.jsx
  • frontend/src/components/chat/FullscreenImageViewer.jsx
  • frontend/src/components/chat/FullscreenVideoViewer.jsx
  • frontend/src/components/chat/GlobalSearchResults.jsx
  • frontend/src/components/chat/GroupInfoPanel.jsx
  • frontend/src/components/chat/ImageBubble.jsx
  • frontend/src/components/chat/MessageBubble.jsx
  • frontend/src/components/chat/MessageComposer.jsx
  • frontend/src/components/chat/MessageContextMenu.jsx
  • frontend/src/components/chat/MessageFooter.jsx
  • frontend/src/components/chat/MessageInfoModal.jsx
  • frontend/src/components/chat/NewChatModal.jsx
  • frontend/src/components/chat/PdfViewer.jsx
  • frontend/src/components/chat/ProfileDrawer.jsx
  • frontend/src/components/chat/ReactionPicker.jsx
  • frontend/src/components/chat/ReplyPreview.jsx
  • frontend/src/components/chat/StagedFilePreview.jsx
  • frontend/src/components/chat/TypingIndicator.jsx
  • frontend/src/components/chat/VideoBubble.jsx
  • frontend/src/components/chat/Waveform.jsx
  • frontend/src/components/chat/editor/AnnotateStage.jsx
  • frontend/src/components/chat/editor/CropStage.jsx
  • frontend/src/components/chat/editor/MediaEditor.jsx
  • frontend/src/components/chat/editor/editorKit.jsx
  • frontend/src/components/chat/info/EncryptionSection.jsx
  • frontend/src/components/chat/info/InfoPanelPrimitives.jsx
  • frontend/src/components/chat/info/InfoPanelShell.jsx
  • frontend/src/components/chat/info/MediaLinksDocsSection.jsx
  • frontend/src/components/chat/info/StarredSection.jsx
  • frontend/src/components/chat/menuKit.jsx
  • frontend/src/components/common/FloatingInput.jsx
  • frontend/src/components/common/PageTransition.jsx
  • frontend/src/components/layout/AdminLayout.jsx
  • frontend/src/components/layout/Sidebar.jsx
  • frontend/src/components/layout/TopBar.jsx
  • frontend/src/components/org-admin/OrgAdminLayout.jsx
  • frontend/src/components/shared/OfflineBanner.jsx
  • frontend/src/components/shared/WorkspaceSwitcher.jsx
  • frontend/src/contexts/AuthContext.jsx
  • frontend/src/pages/Chat.jsx
  • frontend/src/pages/Login.jsx
  • frontend/src/pages/NotFound.jsx
  • frontend/src/pages/OrgAdmin/OrgAdminDashboard.jsx
  • frontend/src/pages/OrgAdmin/OrgAdminDepartments.jsx
  • frontend/src/pages/OrgAdmin/OrgAdminSettings.jsx
  • frontend/src/pages/OrgAdmin/OrgAdminUsers.jsx
  • frontend/src/pages/Settings.jsx
  • frontend/src/pages/admin/CrossOrgGroups.jsx
  • frontend/src/pages/admin/Dashboard.jsx
  • frontend/src/pages/admin/Departments.jsx
  • frontend/src/pages/admin/Organizations.jsx
  • frontend/src/pages/admin/Settings.jsx
  • frontend/src/pages/admin/Users.jsx
  • frontend/src/services/callSounds.js
  • frontend/src/services/livekitClient.js
  • frontend/src/services/websocket.js
  • frontend/src/utils/generatePassword.js
  • frontend/src/utils/notificationPrefs.js
  • ios/RxHive/Features/Calls/LiveKitSession.swift
💤 Files with no reviewable changes (13)
  • frontend/src/components/chat/EmptyChat.jsx
  • frontend/src/components/layout/TopBar.jsx
  • frontend/src/components/chat/info/InfoPanelPrimitives.jsx
  • frontend/src/pages/NotFound.jsx
  • frontend/src/pages/admin/Settings.jsx
  • frontend/src/components/shared/WorkspaceSwitcher.jsx
  • frontend/src/components/common/PageTransition.jsx
  • frontend/src/components/chat/TypingIndicator.jsx
  • frontend/src/components/chat/info/EncryptionSection.jsx
  • frontend/src/components/chat/DateSeparator.jsx
  • frontend/src/components/layout/Sidebar.jsx
  • frontend/src/components/chat/MessageFooter.jsx
  • frontend/src/components/calls/OngoingGroupCallBar.jsx
🚧 Files skipped from review as they are similar to previous changes (28)
  • frontend/src/components/chat/DocumentBubble.jsx
  • frontend/src/contexts/AuthContext.jsx
  • frontend/src/components/chat/ImageBubble.jsx
  • frontend/src/components/calls/MinimizedCallBanner.jsx
  • frontend/src/components/chat/ProfileDrawer.jsx
  • frontend/src/pages/OrgAdmin/OrgAdminDepartments.jsx
  • frontend/src/components/chat/AudioRecorderBar.jsx
  • frontend/src/components/calls/VideoGrid.jsx
  • frontend/src/components/chat/VideoBubble.jsx
  • frontend/src/components/chat/Waveform.jsx
  • frontend/src/components/chat/MessageBubble.jsx
  • frontend/src/components/calls/AddParticipantsModal.jsx
  • frontend/src/components/chat/StagedFilePreview.jsx
  • frontend/src/App.jsx
  • frontend/src/components/chat/editor/CropStage.jsx
  • frontend/src/components/chat/ChatPanel.jsx
  • frontend/src/components/chat/editor/AnnotateStage.jsx
  • frontend/src/components/calls/OutgoingCallScreen.jsx
  • frontend/src/components/chat/editor/editorKit.jsx
  • frontend/src/components/chat/editor/MediaEditor.jsx
  • frontend/src/services/livekitClient.js
  • frontend/src/components/chat/AudioPlayer.jsx
  • ios/RxHive/Features/Calls/LiveKitSession.swift
  • frontend/src/components/chat/MessageComposer.jsx
  • frontend/src/services/websocket.js
  • frontend/src/pages/Login.jsx
  • frontend/src/components/calls/ActiveCallView.jsx
  • frontend/src/components/chat/FullscreenImageViewer.jsx

Comment thread frontend/eslint.config.js Outdated
Comment thread frontend/src/utils/generatePassword.js
arxdeployments and others added 2 commits August 5, 2026 13:13
Clears the remaining 8 `no-unused-vars` warnings so `npm run lint` is
clean, without suppressions, rule changes, or behaviour changes.

- CallsTab: drop `missedCallCount` from the `useCallStore()` destructure;
  only `setMissedCallCount` is read here. Calling the hook without a
  selector already subscribes to the whole store, so the subscription is
  unchanged — the badge that does render the count reads it separately in
  ChatSidebar.
- VideoGrid: remove the `isScreenSharing` prop. It went unread when the
  tile switched to the real `localScreenStream` instead of aliasing the
  camera as a share, and ActiveCallView's now-dead pass-through goes with
  it (the flag is still used for the share button and camera-cover logic
  there).
- ChatPanel: `jumpLoading` was never rendered — keep the setter only,
  `const [, setJumpLoading]`. The two setter calls still run, so the
  render behaviour is identical.
- ConversationSearch: same shape for `loading`; no spinner consumes it.
- ChatSidebar: drop the `onBack` prop, which neither Chat.jsx call site
  passes, and the trailing `msgId` param of `onSelectMessage`, which only
  needs the conversation id to navigate.
- MessageComposer: drop the unused event arg from the textarea `onFocus`;
  the handler only resets scroll position.
- OrgAdminLayout: `setCollapsed` was never called, so `collapsed` was a
  constant `false` — keep the value alone. The collapsed-width branches
  are left in place for when a toggle is wired up.

Verified: `npm run lint` reports 0 problems, `npm run build` succeeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
generatePassword drew all 12 characters uniformly from a flat 67-character
alphabet, so nothing forced the character classes the backend requires.
enforce_password_policy (backend/app/core/security.py) rejects any password
without both a letter and a digit, and a uniform draw misses the 10 digits
(57/67)^12 of the time — 14% of generated values. Measured over 200k
samples: 14.363% rejected, against the theoretical 14.375%. So roughly one
in seven times an admin used the auto-generate button, user creation failed
with "Password must contain both letters and numbers" on a credential the
UI had just produced for them. A missing letter is (15/67)^12, about one in
60 million, and never occurred in the sample.

One letter and one digit are now reserved up front and the remaining
len - 2 positions filled from the full alphabet, then the whole array is
shuffled with Fisher-Yates off the same CSPRNG so the reserved pair is not
pinned to positions 0 and 1 — otherwise every generated password visibly
starts letter-then-digit, which narrows a guess to the remaining len - 2.

The byte-level uniformity of the original is kept and generalised. The
reject-above-the-largest-whole-multiple rule that made the 67-way draw
exactly uniform now lives in a `uniform(n)` helper, so the narrower draws
added here get it too — it matters more for them, since n = 10 would split
256 into 25 byte values for six digits and 26 for the other four. Bytes are
still pulled in blocks rather than one syscall per character.

`len` below 2 cannot express the guarantee and could not satisfy the
policy's 10-character minimum anyway, so it now throws rather than
silently returning a password that would be rejected. Every call site uses
the default 12.

Verified: 0 of 200k generated passwords rejected by the policy predicate
(was 14.363%); class distribution flat across all 12 positions (max
deviation 0.34pp from the expected 20.77% digit / 73.01% letter / 6.22%
symbol); all 67 characters still reachable; digit frequencies within 1.8%;
len=2, 10 and 64 valid. `npm run lint` clean, `npm run build` succeeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@frontend/src/utils/generatePassword.js`:
- Around line 67-83: Update generatePassword to reject lengths greater than 256
in its existing validation before byteReader and shuffling run, using the same
maximum password length enforced by the backend. Preserve the current integer
and minimum-length checks and ensure oversized values throw the established
RangeError instead of reaching uniform with an unsupported bound.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0809a174-580b-43a1-8397-86c2ad9ee816

📥 Commits

Reviewing files that changed from the base of the PR and between 712b813 and abf8eaa.

📒 Files selected for processing (9)
  • frontend/src/components/calls/ActiveCallView.jsx
  • frontend/src/components/calls/CallsTab.jsx
  • frontend/src/components/calls/VideoGrid.jsx
  • frontend/src/components/chat/ChatPanel.jsx
  • frontend/src/components/chat/ChatSidebar.jsx
  • frontend/src/components/chat/ConversationSearch.jsx
  • frontend/src/components/chat/MessageComposer.jsx
  • frontend/src/components/org-admin/OrgAdminLayout.jsx
  • frontend/src/utils/generatePassword.js
💤 Files with no reviewable changes (2)
  • frontend/src/components/calls/ActiveCallView.jsx
  • frontend/src/components/calls/VideoGrid.jsx
🚧 Files skipped from review as they are similar to previous changes (4)
  • frontend/src/components/calls/CallsTab.jsx
  • frontend/src/components/chat/ChatPanel.jsx
  • frontend/src/components/chat/ConversationSearch.jsx
  • frontend/src/components/chat/MessageComposer.jsx

Comment thread frontend/src/utils/generatePassword.js
arxdeployments and others added 2 commits August 5, 2026 13:44
`reactHooks.configs.recommended` on eslint-plugin-react-hooks 5.2.0 is still
the eslintrc-shaped object: its `plugins` key is an array of strings, which
flat config rejects with a schema error. Only `.rules` was spread, so it
worked — but silently, and only because of that one detail. Every other
entry in this file spreads a config whose whole object is flat-safe, so the
next person to follow the local pattern and spread this one gets a broken
config for a reason nothing in the file explains.

Switched to `configs['recommended-latest']`, the flat-native export, and
recorded why in a comment so it does not get "simplified" back. Safe on the
declared `^5.2.0` range: `recommended-latest` was added in 5.2.0 itself, and
the range cannot resolve below that.

No behaviour change, verified rather than assumed: `eslint --print-config`
output is byte-for-byte identical before and after, all 66 resolved rules
including `react-hooks/rules-of-hooks: 2` and the deliberately disabled
`react-hooks/exhaustive-deps: 0`. Confirmed the rule is live and not merely
listed by linting a component that calls useState conditionally, which still
errors. `npm run lint` clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
uniform(nextByte, n) draws a byte and redraws while it lands at or above
`256 - (256 % n)`. For n > 256 that limit is 0, because `256 % n` is 256, so
every possible byte fails the test and the do/while never exits. The shuffle
added in abf8eaa calls it as `uniform(nextByte, i + 1)` with i starting at
len - 1, so the bound peaks at exactly len: any len above 256 spins the
calling thread forever. Confirmed under a watchdog — 257, 300, 1000 and
100000 all had to be SIGKILLed, while 256 returns immediately (256 % 256 is
0, so the limit is 256 and no byte can reach it).

Not reachable today, since every call site takes the default 12, but a
length control in the admin UI is the obvious next caller and the failure
mode is a frozen tab rather than a bad password.

Capped at 72 rather than 256. 256 is where uniform breaks, but it is not
where the value stops being usable: bcrypt ignores anything past 72 bytes,
so enforce_password_policy refuses it outright
(BCRYPT_MAX_PASSWORD_BYTES, backend/app/core/security.py). Stopping at 256
would trade the hang for the bug abf8eaa just fixed — handing an admin a
generated credential the backend rejects. The alphabet is single-byte ASCII,
so the character count is the byte count and one number covers both.

Also guarded uniform() itself. The len cap makes n <= 72 by construction, so
the guard is unreachable through the public API; it is there because the
helper is shared and its n <= 256 contract was implicit, which is precisely
how this bug arose. A future caller now gets a RangeError instead of a hung
thread.

Verified: 257 through 100000 throw RangeError instead of hanging; uniform
rejects 257, 300, 1000, 0, -1, 2.5, NaN and Infinity, and stays in [0, n)
for n = 1, 2, 10, 52, 67, 72, 255, 256; all lengths 2..72 produce the right
length with a letter and a digit and no more than 72 bytes, over 142k
samples; the default len=12 is unchanged at 0 of 200k policy rejections,
digit rate flat to 0.17pp, all 67 characters reachable. Lint clean, build
succeeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant