The lobby system relied on cookies to identify the participant
across the wait/enter cycle, which does not work in an iframe
context where our cookies are dropped.
Simplify the lobby behavior:
* The POST request that enters the lobby now returns the
participant id in the response.
* The frontend passes that id back on subsequent requests to keep a
sticky session while trying to enter the room.
This moves a bit more logic to the frontend but should be a
transparent refactoring, without decreasing the security of the
lobby flow.
Moving off cookie-based authentication surfaced several hard
issues, especially around loading virtual backgrounds: requests
used to be sent with cookies automatically, which trivially
authenticated those loads. With bearer tokens, those requests need
to be authenticated explicitly.
The situation is made harder by the fact that, when the custom
virtual background was introduced, some of the loading was done as
module-level, blocking imports that are not handled by React and
therefore live outside the normal auth flow.
Ship a functional patch to unblock third parties currently waiting
on this integration. The virtual background loading path should
definitely be refactored and simplified in a follow-up.
Wire the frontend to the backend's token exchange flow: when the
expected URL fragment is present, gate the app loading on exchanging
that fragment for a proper access token, which is then used to
interact with the API.
When no such fragment is present, the code path is a no-op and
should have minimal impact on load performance.
We now use `Authorization: Bearer <token>` to authenticate users
from the token exchange flow (used for iframe embeds).
Until now, the `Bearer` scheme was also reused for the alternative
LiveKit authentication, where a client presents its LiveKit token
issued by the backend to prove room membership on actions open to
any room participant. Sharing the scheme between the two flows is
not viable anymore.
Switch the LiveKit token authentication to a dedicated
`Authorization` scheme, so `Bearer` stays reserved for the iframe /
token-exchange flow.
Follow-up: a broader effort should look into harmonizing and
hardening the backend authentication stack of the app.
Some integrators render our videoconference inside an iframe, where
our cookie-based authentication does not work: our cookies are
SameSite=Lax/Strict, so the iframe drops them.
We looked at what Jitsi offers: a shared secret used to sign JWTs
that authenticate users coming from external services. Since we
already expose an external API where third parties authenticate as
a given user, it was simpler for us to add an exchange mechanism on
top of that.
Flow:
* Through the external API, mint a short-lived, single-use exchange
code for a user.
* The third party hands that code to the frontend as a URL fragment.
* The frontend exchanges the code for a longer-lived JWT that can be
used to query the regular API viewsets.
Known limitations and follow-ups:
* At some point it would be nice to shorten the JWT lifetime and
add a refresh mechanism. This will be handled in a follow-up PR
when actually needed.
* CSP rules to control which origins are allowed to embed the app
in an iframe still need to be added.
* This alternative authentication cannot easily be scoped to a
subset of endpoints without adding a lot of complexity, so it is
accepted globally on the API for now.
Builds through the Docker API of the Podman service receive none of the
proxy variables in their RUN steps and fail systematically because of
the proxy rejection. Plain HTTP connections are also rejected by the
proxy with a HTTP 405 method error.
- Passes http_proxy, https_proxy and no_proxy from the shell as build args
- Make the Debian mirror of the agents image a build argument and
override it for bureautix to force https usage
Add the minimal requirements to build and run the project locally on NixOS
- `devenv update` update devenv using NixOS 26.05 stable repositories
- `devenv shell` activates the devenv
- `devenv --profile <profile>` shell uses additional packages when
activated (profile=agent|summary|k8s)
Pin the LiveKit rtc section: the browser reaches the server through
podman's published ports on loopback while egress and the agents reach
it over the podman network, and those two have no address in common.
`advertise_internal_ip` keeps the container's own interface address as a
host candidate alongside the node_ip one, so LiveKit offers both and ICE
picks whichever works. Without it egress only ever sees 127.0.0.1, which
is the egress container itself, and its peer connection timeouts
`use_external_ip` is turned off since it would advertise the STUN-discovered
public IP, which no local peer can hairpin to.
Rootless podman maps container UID 0 to the host user and every other
container UID to a subuid that owns nothing in the worktree, so the usual
DOCKER_USER=$(id -u):$(id -g) makes every bind mount effectively
read-only.
Add a compose.podman.yml to override the compose.yml and set
`userns_mode: keep-id` in order to map the host user to the same UID and UID
inside the container. The merge of the docker compose file is now done with
the COMPOSE_FILE environment variable
The waiting room has its own sound in notifications.mp3, and nothing could play
it: the sprite is named "waiting" while triggerNotificationSound passes a
NotificationType, and howler returns without playing when the sprite id is
unknown. ParticipantWaiting was also absent from the sound settings, so the
check on the store would have refused it first. The toast borrowed the
participant joined sound instead.
The sound was tied to the waiting list going from empty to non empty, so a
second person arriving while someone was still waiting was silent, which is the
case the issue describes.
Name the sprite after the notification type, register the type in the settings,
and sound every arrival, detected on the participant ids so that an admission
and an arrival between two refreshes do not cancel each other out.
closes#1705
get_release() read the version from a version.json file but nothing generates
it during the CI Docker image build, therefore the release reported to Sentry
was always "NA".
Read the version from pyproject.toml instead, which is bumped at each
release and copied into the image.
Incorrect file permissions in the frontend Docker image caused
problems when running the project, and were surfaced by @briquet
while setting it up with Podman.
Adjust the ownership and permissions applied during the build so
the image works cleanly under Docker and Podman alike.
Docker Hub now denies anonymous pulls of minio/minio (pull access
denied), which fails the test-back job for every pull request. The
quay.io/minio/minio mirror remains publicly available, accepts the
same environment variables, and the container lookup in the Configure
MinIO step still matches the image name.
thx @mmaudet
Load the Crisp JavaScript module only once the frontend is idle,
instead of during the initial page load.
Keeps the critical path lighter and prevents Crisp from competing
with the app's own bootstrap for network and CPU on slow devices.
The resource server backend returned any user matching the token's
`sub` claim without checking `User.is_active`. The upstream lasuite
backend only validates the token's introspection `active` claim, so a
deactivated Django account kept API access until its token expired.
Raise `SuspiciousOperation` in `get_or_create_user` when the user is
inactive, which the authentication class turns into a 401, consistent
with `BaseJWTAuthentication`. Add unit and end-to-end tests.
Add a configuration knob for the trace sampling rate, so we can
enable tracing on middleware and cache spans when debugging slow
requests in production.
Sampling is set to 0 by default, so tracing stays fully off unless
explicitly enabled.
Attach the LiveKit SIDs (room and participant) to the connection
analytics event.
Makes it easier to debug problematic sessions and to correlate a
room session with the corresponding LiveKit logs.
Around 0.76% of incoming LiveKit webhooks were being flagged as
unprocessable and returned a 422, even though LiveKit was sending
legitimate data — just with event types we do not handle. This
inflated error metrics and made real webhook issues harder to spot.
Return a 200 for these webhooks instead. When a new, unhandled
event type shows up, log a warning so we can decide whether it is
worth adding explicit handling.
The request summary info logs were spamming the log stream, making
around 46% of the total volume, without carrying any exploitable
information.
Silence them so the remaining logs are easier to explore and cheaper
to store; roughly halves the overall log volume.
On a busy morning, `/me` alone produced 72k warning logs — 97% of
all warnings. They all come from anonymous requests to `/me`
without credentials, which is normal: `/me` is how the app
determines the current auth status.
These warnings carry no diagnostic value on this endpoint, so
silence them there to cut down on log volume.
AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME was only enforced at
LiveKit token generation and by hiding the name field in the frontend.
The `rooms/{id}/rename/` endpoint never checked it, so any authenticated
user with a valid room token could rename themselves via the API even
when the self-hoster had disabled it.
Return 403 from the rename action for authenticated users when the
setting is disabled, mirroring the `can_edit` rule in
`core.utils.generate_token`. Anonymous participants are unaffected, as
they have no account name to fall back on.
Add tests covering the disabled/enabled cases for authenticated users
and the anonymous exception.
When the lobby is disabled mid-meeting (e.g. the room is switched to
public), the waiting participants list stopped being refetched, so
the previously cached list stayed visible with stale data.
Trigger a refetch in that case as well, so the list is cleared and
the moderator UI no longer shows waiting participants for a lobby
that is no longer active.
Highlighted by a suggestion from @florent, the waiting participant
list was not sorted, so moderators could see participants in an
arbitrary order.
Add an explicit `entered_at` attribute on each waiting participant,
so the list can be sorted by arrival time. Participants are now
shown in a stable order of arrival, both across polls and across
moderators.
Compute the position of the login hint at render time so it is
always displayed close to the login button, regardless of the
button's placement or the current viewport size.
When a moderator accepts or rejects a lobby entry that no longer
exists, emit a tracking event so we can measure how often it
happens.
This signal will help tune the lobby polling interval: too many
"not found" events means the moderator side is working from a stale
list. Keep raising the error to the client on top of tracking it,
so the frontend still surfaces the issue (its current handling of
this case is still incomplete).
Include the time taken by each request in the Gunicorn worker
access logs, so we can spot slow endpoints and correlate latency
patterns directly from the logs.
The previous presence cache lookup keyed off a scan over the whole
cache, so its cost was O(db_size) rather than O(room_size).
Combined with the recent switch to cursor-based `SCAN` at an
inappropriate page size, this caused a lot of Redis round-trips and
noticeably slowed down the backend pods under load.
Refactor the presence cache to keep a per-room set of all its
participant keys. Lookups now iterate that set instead of scanning
the whole database.
Complexity is now bounded by room size, not database size, which
should restore the backend performance to its previous levels while
keeping the lobby behavior unchanged.
The previous lobby lookup keyed off a scan over the whole cache, so
its cost was O(db_size) rather than O(room_size). Combined with the
recent switch to cursor-based `SCAN` at an inappropriate page size,
this caused a lot of Redis round-trips and noticeably slowed down
the backend pods under load.
Refactor the lobby storage to keep a per-room set of all its lobby
keys. Lookups now iterate that set instead of scanning the whole
database:
* Membership in the set acts as a memory of who is supposedly in
the lobby for a given room.
* Individual keys are then read to check who is actually still
waiting or accepted.
Complexity is now bounded by room size, not database size, which
should restore the backend performance to its previous levels while
keeping the lobby behavior unchanged.
The `/me` endpoint was called without a trailing slash, so every
request was going through a 301 redirect before hitting the actual
endpoint.
This endpoint is called by every user at least once per session, so
based on the logs, avoiding the redirect should cut the volume of
requests hitting it by around 10%.
Increase the polling interval used by the lobby feature, on both the
waiting participant side and the moderator side.
The goal is to reduce the volume of requests the lobby generates,
trading a bit of data freshness for better performance.
It will de facto reduce pressure on the backend.
We will observe the impact in production, and revisit these
intervals if the delays turn out to be too aggressive.
Bump the frontend base image to `1.30.4-alpine3.24`, which picks up
fixes for the CVEs listed below and lets us drop the individual
dependency pins that were only there to address earlier known CVEs.
Address the following HIGH severity CVEs in libuuid / util-linux,
reported by Trivy. Bumping to 2.41.6-r1 (bundled in the new base
image) covers all of them:
* CVE-2026-53612 — TOCTOU in mount post-mount ownership/mode
changes.
* CVE-2026-53613 — TOCTOU in mount via ancestor directory swap.
* CVE-2026-53614 — SUID mount(8) nosuid/noexec bypass via
LIBMOUNT_FORCE_MOUNT2.
* CVE-2026-76642 — failed external mount helper still runs
privileged X-mount post-hooks.
* CVE-2026-78408 — nsenter --join-cgroup leaks root cgroup
migration authority (fixed in 2.41.6-r1).
* CVE-2026-78410 — restricted bind mounts do not pin the source,
allowing X-mount.owner/group/mode escalation.
A minor layout regression appeared when switching to the Marianne
font: the feedback buttons wrapped onto two lines instead of
staying on one.
Adjust the layout so the buttons stay on a single line regardless
of the font in use.
The previous implementation centered initials by measuring `<text>`
with `getBBox()`, which returns the font's advance-width by
ascent-to-descent band, not the ink of the glyphs. On fonts with an
asymmetric band, initials rendered off-center. Marianne is a
particularly clear case: ascent 1131 / descent 256 puts the band
center 87.5/1000 above the caps' optical center, so every avatar
sat ~4.6 viewBox units (~1.5–1.8 px) too low. A follow-up rewrite
using canvas `actualBoundingBox*` metrics fixed it but pulled in a
runtime measurement rig (shared canvas, ref, state, layout effect,
`fonts.load` + `loadingdone`, plus combining-mark stripping) just
to place two uppercase letters.
Since initials are always uppercased, optical centering has a
closed form: `baseline = center + capHeight/2`. Real-world text
fonts have cap heights in a narrow ~0.66–0.73 em band (Marianne is
0.70), so:
translateY(calc(var(--avatar-cap-height, 0.7) * 0.5em))
is exact for the stock font and within ~0.4 px for any plausible
replacement. No JS, SSR-safe, no first-paint jump, no font-loading
race. Accents float above the cap box instead of dragging the
letter down.
The single font-dependent number remaining (cap height) is exposed
as a CSS variable, so self-hosters overriding the font can override
it next to the font itself, or leave the default. Once
`text-box: trim-both cap alphabetic` ships broadly, even the
variable can go.
Following the re-rendering optimization refactoring, the automatic
lower-hand feature broke: the way `isSpeaking` was read no longer
made sense once we limited how often components in the app
re-render.
Fix the detection so the raised hand is again lowered automatically
when the participant starts speaking, without relying on frequent
re-renders.
The user `sub` field was rejecting some ASCII characters that are
actually valid according to the OIDC spec.
Loosen the validation to accept the full ASCII range except control
characters, so the field is compliant with the RFC and works with
any spec-compliant identity provider.
Based on the Stack Overflow discussion in question 279832.
Closes#1609.