Skip to content

Video join is broken for every user and every appointment type — two independent P0s, both live on production #1270

Description

@teetangh

Video join is broken for every user and every appointment type — two independent P0s, both live on production

1. Background context

Video meetings on this platform are built on Stream.io. The architecture, as it stands on dev and prod today, works like this.

A booking produces an Appointment with one or more SlotOfAppointment atoms. When a participant first clicks Join, the browser calls getOrCreateAppointmentMeeting (lib/meeting.ts), which resolves an anchor slot for the session run, derives a Stream call id of the form slot-<anchorSlotId>, creates the Stream call client-side, and writes a MeetingSession row recording that id. The user is then routed to /meetings/<streamCallId>.

On that route, useGetCallById calls POST /api/meetings/[meetingId]/join. That route is the security boundary introduced by #1136 to close #1134 P0-1: it resolves authorization against the database and, only on success, grants the caller Stream call membership. The design intent was that join-call would be stripped from Stream's plain user role by scripts/stream/ensure-call-type-grants.ts, leaving membership — which only this server route can grant — as the sole way into a call.

Two facts about that design matter for everything below. First, the route is the only writer of the call_member role. Second, it is also the intended repair path for calls whose MeetingSession row exists without a corresponding Stream call, a state the seeds produce deliberately.

2. What is happening

Clicking Join never reaches a meeting room. The user sees a long "Joining…" spinner on the dashboard, then the meeting page skeleton, and finally a red card:

Meeting Error
Failed to load meeting: Could not join this meeting. Please try again.
[Go Back]

This reproduces on every appointment type — consultation, subscription, class — and for every user tested. After clicking Go Back, the macOS camera indicator light and the Chrome tab recording dot remain on indefinitely, for the life of the tab.

There are five distinct defects behind this. Two of them independently block all video, and both are on origin/prod.

# Defect Severity On prod?
1 POST /api/meetings/[meetingId]/join throws on every request P0 — total outage Yes, since 2026-08-13
2 29% of consultants do not exist in Stream, so the call mint fails P0 — blocks ~1 in 3 Yes
3 Camera and microphone tracks are orphaned on the dashboard and never stopped P1 — privacy Yes
4 Home hides the Join button for SCHEDULED appointments P2 Yes
5 ConsentRequiredError takes down the whole appointment page P1 Unknown

These were invisible until PR #1268 began reporting deploy-preview errors to Sentry. A total outage on the join gate went unnoticed for seventeen days.

3. Reproduction steps

  1. Sign in as a consultee who has an upcoming session — for example aarav.campbell@hotmail.com.
  2. Go to Dashboard → Home. Find any card under "Upcoming Sessions" showing a Join button.
  3. Click Join. Observe the macOS camera light turn on while still on the dashboard, before any meeting page has rendered.
  4. Wait. The button shows "Joining…" for several seconds, then the browser navigates to /meetings/slot-<slotId>.
  5. The meeting page renders its skeleton loader for several more seconds.
  6. The red Meeting Error card appears with the text above.
  7. Click Go Back. The camera indicator light and the Chrome tab recording dot stay on.

Repeat with a subscription or consultation appointment; the outcome is identical.

4. What the documentation says

Every claim below is taken from Stream's official documentation rather than inferred.

Server-side call creation requires an author. Stream's server-side guide shows created_by_id as part of the request body for both create and getOrCreate:

call.create({ data: { created_by_id: 'john' } });

The field identifies the user creating the call and is required for server-side API calls, because a server-side client authenticates as the application rather than as any particular user. See Get started — Server-side Video and Audio Docs and Calls — Platform Video and Audio Docs.

Call members must already exist as Stream users. Stream states that call members need to be existing users, and that the way to guarantee this is the server-side batch endpoint upsertUsers, which accepts up to 100 users per call. Stream will not auto-create a user merely because it was referenced in a call's members array. See Joining & Creating Calls — React and Users & Tokens — Platform Video and Audio Docs.

Generating a token does not create a user; connecting does. Tokens are minted server-side and are independent of user creation. connectUser will create the user as a side effect, which is why users who have signed in exist in Stream while users who have not do not. See Managing Users — Node chat and How do I migrate users and add new users to Stream Chat?.

getOrCreate applies device settings. Stream documents that a call's camera_default_on and mic_default_on settings determine whether the devices come up, that these can be overridden per call via settings_override, and that camera.disable() / microphone.disable() should be awaited before joining when you do not want capture running. See Camera & Microphone — React and Lobby Preview — React.

join-call is role-scoped. Removing join-call from the user role prevents ordinary users from joining a call of that type; the call_member role must then carry the permission, and members must be explicitly added. See Permissions & Moderation — React.

5. Root cause analysis

Defect 1 — the join gate throws on every request

app/api/meetings/[meetingId]/join/route.ts:138:

await call.getOrCreate();   // server-side auth, no created_by_id

getStreamVideoClient() (lib/stream-client.ts:71) constructs new StreamClient(KEY, SECRET) — a server-side client with no user context. Per the documentation cited above, GetOrCreateCall under server-side auth requires data.created_by_id. This call passes no argument at all, so it throws unconditionally.

This is the only server-side getOrCreate in the repository. The other one, lib/meeting.ts:223, runs in the browser on the user-authenticated client, where the author is implicit — which is why call creation has continued to work while joining has not.

Sentry — FAMILIARISE_WEB-17, culprit POST /api/meetings/[meetingId]/join:

Error: Stream error code 4: GetOrCreateCall failed with error:
"either data.created_by or data.created_by_id must be provided when using server side auth."

Its client-side shadow is FAMILIARISE_WEB-16, Error: Could not join this meeting. Please try again. on /meetings/:id.

Reproduced directly against the live Stream app (k4n5crxtstrx) using the repository's own @stream-io/node-sdk:

TEST 1  call.getOrCreate()  — call ALREADY EXISTS  → THREW (code 4, message as above)
TEST 2  call.getOrCreate()  — call does NOT exist  → THREW (identical)
CONTROL getOrCreate({ data: { created_by_id } })   → OK
        updateCallMembers({ update_members: [...] }) → OK

Test 1 is the important one: the requirement holds even when the call already exists, so the "existing call" path offers no escape.

The throw is caught at route.ts:179 and returned as HTTP 500. On the client, useGetCallById.ts:158-172 classifies only 401, 403 and 404 as an authorization refusal; anything else is rethrown as an Error, which page.tsx:96-103 renders as Failed to load meeting: <message>. That is exactly the string on screen, and it confirms the status was 500 rather than a refusal.

The route has never once completed. Because getOrCreate() throws before updateCallMembers() on line 142, no participant has ever been granted membership. Querying the ten most recent calls in the entire Stream application, the distinct member roles are host (4), user (16), admin (4) — and zero call_member, with every member's updated_at equal to its created_at. The host and user roles are stamped at creation time by meeting.action.ts:294-295; call_member is written only by this route. Its complete absence is direct proof that the route's Stream block has never run to completion.

The failing call, read back from Stream:

id:         slot-c0d2fdc8-523f-48c7-9fed-fa1a5d9830fb
created_by: Aarav Campbell        (the CONSULTEE — client-side mint, lib/meeting.ts:223)
members:    [ Ananya Anderson · role "host" · updated_at == created_at ]
backstage:  false     join_ahead_time_seconds: 0

A SUBSCRIPTION call minted seven minutes later shows the same untouched updated_at, confirming this is not class-specific.

Alternative explanations ruled out:

  • Authorization. resolveMeetingAccess grants access. The MeetingSession row exists (cmtescrrz000009jsgylriqu6 · slot c0d2fdc8-… · endedAt null); both consultant and consultee are attached to the slot in _SlotOfAppointmentToUser; Appointment.deletedAt is null; and Class.status = IN_PROGRESS is not in TERMINAL_APPOINTMENT_STATUSES. A refusal would also have produced 403/404 and rendered "Access Denied" rather than the error card.
  • Time window. getSessionJoinState compares epoch milliseconds and is timezone-independent (lib/appointments/slots.ts:288). At failure time the session had been in progress for twenty minutes.
  • Call-id parsing. MeetingSession.streamCallId stores the full slot-<anchorSlotId> string and is looked up whole (access.ts:141). There is no prefix-stripping bug.
  • Backstage. Both backstage and join_ahead_time_seconds are false/zero on the live call, and the repository never sends them.
  • "Registered" status. Not an appointment status at all — it is a UI label from components/ui/registration-badge.tsx:18.

Provenance. Introduced by 22da01b8, "fix(stream): close the P0s — open call access, self-deleting channels, permanent bans" (#1136, 2026-08-13). git show origin/prod:app/api/meetings/[meetingId]/join/route.ts still contains the bare call at line 138. Video joining has been dead in production for approximately seventeen days.

Defect 2 — 29% of consultants do not exist in Stream

Sentry — FAMILIARISE_WEB-19 and -18, culprit /dashboard/consultee/:consulteeId/appointments/:appointmentId:

ErrorFromResponse: Stream error code 4: GetOrCreateCall failed with error:
"The following users are involved in call create operation, but don't exist:
[cmqb162ha000ctxyoeth429bq]. Please create users before referencing them in a call."

cmqb162ha000ctxyoeth429bq is Amit Anderson, a consultant — present in Postgres, absent from Stream. As the documentation states, Stream will not auto-create a user referenced in members; it rejects the entire call instead.

Measured across every consultant in the database:

DB consultants:       83
present in Stream:    59
MISSING from Stream:  24   (29%)

So even with Defect 1 fixed, roughly one appointment in three would still fail to mint a call, because resolveSessionCallProfile names a host who was never upserted into Stream.

The repository already contains the fix; the video path simply never calls it. upsertUsersToStream (actions/stream/chat/user.action.ts:155) is invoked before every channel create in the chat paths — channel.action.ts:117, 305, 381, 465, 562, event-channel.action.ts:183, and app/api/stream/search/route.ts:44. Neither lib/meeting.ts nor actions/stream/meetings/meeting.action.ts calls it at all. That asymmetry is the entire defect, and it explains the 59/24 split precisely: a consultant exists in Stream only if a chat channel or a sign-in happened to create them.

Note that scripts/stream/stream-sync.ts does not help here. It soft-deletes Stream users that no longer exist in the database — the opposite direction.

Related and probably the same identity seam failing at token mint: FAMILIARISE_WEB-13 and -10, Error: Unauthorized: sign in to request a Stream token on POST /dashboard/consultee/[consulteeId]/home.

Defect 3 — camera and microphone are never released

The camera light comes on on the dashboard, not on the meeting page, and nothing ever stops the tracks.

lib/meeting.ts:145 constructs a Call as a function-local const:

const call: Call = client.call("default", streamCallId);
...
await call.getOrCreate({ data: { starts_at, custom, members } });   // :223

getOrCreate applies device settings. From the installed SDK, @stream-io/video-client/dist/index.es.js:13282-13302:

this.getOrCreate = async (data) => {
    ...
    await this.applyDeviceConfig(response.call.settings, false, skipSpeakerApply);
};

which reaches :14835 camera.apply(...) / microphone.apply(...), and then:

// :11654
if (canPublish && settings.camera_default_on && enabledInCallType) await this.enable();
// :12393
if (canPublish && settings.mic_default_on) await this.enable();

This call has camera_default_on: true and mic_default_on: true, so getUserMedia fires inside getOrCreateAppointmentMeeting. The Call object owning the resulting MediaStream is never stored, never returned — the function returns only streamCallId — and never torn down. It becomes unreachable the moment the function returns, with live tracks still open.

The only route-level teardown in the application, app/meetings/[id]/page.tsx:35-57, early-returns on if (!call) return;. On the failing path call is null, so it does nothing. Neither "Go Back" button is media-aware: components/Alert.tsx:19 is a bare router.back() and page.tsx:86 a bare window.history.back().

lib/stream/media-teardown.ts is correct — it calls disable({ forceStop: true }) and then track.stop() — but the failing paths never reach it.

The same shape recurs in useGetCallById.ts, where callInstance is a function-local const (:186) and await callInstance.get() (:187) also opens the devices. Three paths drop it without teardown: :189 (cancelled after get()), :199 (cancelled after rejoin), and :208-213 (the catch).

Defect 4 — Home hides Join on SCHEDULED appointments

The Basic Subscription card shows no Join button while the Beginner Class beside it does, and the same subscription does offer Join on the Appointments tab.

// HomeTab.tsx:140-143
const isApproved =
  event.type === "webinar" || event.type === "class"
    ? event.bookingStatus === "CONFIRMED"
    : isApprovedStatus(event.status);

// lib/appointments/status.ts:82-84 — strict equality
export function isApprovedStatus(status) { return normalizeStatus(status) === "APPROVED"; }

SCHEDULED is a member of CONFIRMED_STATUSES (status.ts:39) but is not APPROVED, so canShowJoin evaluates false. ConsulteeAppointmentsAdapter.tsx:236 gates the same action on isConfirmedStatus, which does accept SCHEDULED. The two surfaces disagree about the same appointment.

Defect 5 — ConsentRequiredError takes down the appointment page

Sentry — FAMILIARISE_WEB-12, twelve events, marked escalating, culprit POST /dashboard/consultee/[consulteeId]/appointments/[appointmentId]:

ConsentRequiredError: Video and chat are unavailable because data-processing consent
for messaging has not been granted (or was withdrawn).

The event is tagged handled: no, so it escapes as a server-component render error rather than resolving to a UI state. A consultee without a messaging-consent row loses the entire appointment page, not merely its video affordance.

6. Proposed fixes

Fix 1 — pass the call author (Defect 1)

app/api/meetings/[meetingId]/join/route.ts:138:

-      await call.getOrCreate();
+      // Server-side auth mandates an author on GetOrCreateCall, even when the
+      // call already exists. Omitting it throws code 4 on every request.
+      await call.getOrCreate({ data: { created_by_id: session.user.id } });

created_by_id is honoured only when the call is actually created, so an existing call retains its original author. This is safe for the repair path the route's own comment describes.

Fix 2 — upsert participants before naming them as members (Defect 2)

In lib/meeting.ts, immediately before call.getOrCreate(...), mirror what every chat path already does:

await upsertUsersToStream([...hostIds, ...guestIds]);
await call.getOrCreate({ data: { starts_at, custom, ...members } });

The join route should do the same before updateCallMembers, so that a participant who has never signed in can still be admitted.

Fix 3 — release media on every path that drops a Call (Defect 3)

In lib/meeting.ts, the mint must not leave capture running — creating a room is not joining one:

try {
  await call.getOrCreate({ data: { starts_at, custom, ...members } });
} finally {
  await releaseLocalMedia(call);   // lib/stream/media-teardown.ts
}

In useGetCallById.ts, hold the in-flight instance so every exit can release it:

const inFlight = useRef<Call | null>(null);
...
inFlight.current = callInstance;
...
} catch (err) {
  if (inFlight.current) await leaveCallAndReleaseMedia(inFlight.current);
  inFlight.current = null;

Both "Go Back" buttons should take a teardown callback rather than performing a bare history pop.

Fix 4 — align the two Join gates (Defect 4)

Use isConfirmedStatus on Home, matching ConsulteeAppointmentsAdapter.

Fix 5 — handle the consent gate (Defect 5)

Catch ConsentRequiredError at the appointment page boundary and render a consent prompt, so a missing consent row degrades the video affordance instead of the whole page.

Fix 6 — make the test able to catch this

__tests__/stream/meeting-join-gate.test.ts:57-78 mocks the Stream SDK and asserts call ordering onlyexpect(sequence).toEqual(["resolveMeetingAccess","getOrCreate","updateCallMembers"]) — never the arguments, so a missing created_by_id is invisible to it. Add:

expect(call.getOrCreate).toHaveBeenCalledWith(
  expect.objectContaining({ data: expect.objectContaining({ created_by_id: expect.any(String) }) }),
);

7. Operational notes and sequencing

scripts/stream/ensure-call-type-grants.ts was never run. The live default call type still grants join-call to every role:

user  join-call = true     guest  join-call = true     call_member  join-call = true

#1134 P0-1's hardening is therefore not applied, and every signed-in user still holds join-call on every call — the hole that PR set out to close.

⚠️ Do not run that script before Fixes 1 and 2 have shipped and been verified. It strips join-call from user and guest, leaving membership as the only way in; membership is granted exclusively by the join route, which currently throws before granting it. Running it today would convert a 500 into a hard lockout recoverable only by a deploy. Compounding this, lib/meeting.ts writes the consultant's member role as "host" — a key with no grants at all on the default type — so consultants depend on the join route rewriting them to call_member.

Seeded MeetingSession rows point at calls that do not exist. prisma/seedFiles/6a-create-appointments.ts:150 writes streamCallId: faker.string.uuid() and 11b-create-meeting-sessions.ts:44 writes call_<uuid>; neither matches the canonical slot-<anchorSlotId>, and no Stream call exists for either. The join route's getOrCreate is the intended repair path for exactly this state, which is a further reason Fix 1 must land first.

There is no timeout anywhere on the join path. getOrCreateAppointmentMeeting issues five sequential Server Actions plus a Stream REST call, none of them bounded. With PG_POOL_MAX=1 on a cold Netlify instance, this is the long "Joining…" spinner and the long skeleton. The click is slow, not hung.

The "joining" state diverges across four surfaces. Only HomeTab.tsx:718 resets it in a finally. useEventActions.ts:347-373 and ConsulteeAppointmentsAdapter.tsx:195-225 clear it only in catch, so a success that fails to navigate spins forever.

8. Follow-ups worth separate issues

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions