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
- Sign in as a consultee who has an upcoming session — for example
aarav.campbell@hotmail.com.
- Go to Dashboard → Home. Find any card under "Upcoming Sessions" showing a Join button.
- Click Join. Observe the macOS camera light turn on while still on the dashboard, before any meeting page has rendered.
- Wait. The button shows "Joining…" for several seconds, then the browser navigates to
/meetings/slot-<slotId>.
- The meeting page renders its skeleton loader for several more seconds.
- The red Meeting Error card appears with the text above.
- 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 only — expect(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
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
devandprodtoday, works like this.A booking produces an
Appointmentwith one or moreSlotOfAppointmentatoms. When a participant first clicks Join, the browser callsgetOrCreateAppointmentMeeting(lib/meeting.ts), which resolves an anchor slot for the session run, derives a Stream call id of the formslot-<anchorSlotId>, creates the Stream call client-side, and writes aMeetingSessionrow recording that id. The user is then routed to/meetings/<streamCallId>.On that route,
useGetCallByIdcallsPOST /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 thatjoin-callwould be stripped from Stream's plainuserrole byscripts/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_memberrole. Second, it is also the intended repair path for calls whoseMeetingSessionrow 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:
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.POST /api/meetings/[meetingId]/jointhrows on every requestSCHEDULEDappointmentsConsentRequiredErrortakes down the whole appointment pageThese 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
aarav.campbell@hotmail.com./meetings/slot-<slotId>.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_idas part of the request body for bothcreateandgetOrCreate: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'smembersarray. 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.
connectUserwill 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?.getOrCreateapplies device settings. Stream documents that a call'scamera_default_onandmic_default_onsettings determine whether the devices come up, that these can be overridden per call viasettings_override, and thatcamera.disable()/microphone.disable()should be awaited before joining when you do not want capture running. See Camera & Microphone — React and Lobby Preview — React.join-callis role-scoped. Removingjoin-callfrom theuserrole prevents ordinary users from joining a call of that type; thecall_memberrole 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:getStreamVideoClient()(lib/stream-client.ts:71) constructsnew StreamClient(KEY, SECRET)— a server-side client with no user context. Per the documentation cited above,GetOrCreateCallunder server-side auth requiresdata.created_by_id. This call passes no argument at all, so it throws unconditionally.This is the only server-side
getOrCreatein 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: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 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:179and returned as HTTP 500. On the client,useGetCallById.ts:158-172classifies only 401, 403 and 404 as an authorization refusal; anything else is rethrown as anError, whichpage.tsx:96-103renders asFailed 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 beforeupdateCallMembers()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 arehost(4),user(16),admin(4) — and zerocall_member, with every member'supdated_atequal to itscreated_at. Thehostanduserroles are stamped at creation time bymeeting.action.ts:294-295;call_memberis 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:
A SUBSCRIPTION call minted seven minutes later shows the same untouched
updated_at, confirming this is not class-specific.Alternative explanations ruled out:
resolveMeetingAccessgrants access. TheMeetingSessionrow exists (cmtescrrz000009jsgylriqu6 · slot c0d2fdc8-… · endedAt null); both consultant and consultee are attached to the slot in_SlotOfAppointmentToUser;Appointment.deletedAtis null; andClass.status = IN_PROGRESSis not inTERMINAL_APPOINTMENT_STATUSES. A refusal would also have produced 403/404 and rendered "Access Denied" rather than the error card.getSessionJoinStatecompares epoch milliseconds and is timezone-independent (lib/appointments/slots.ts:288). At failure time the session had been in progress for twenty minutes.MeetingSession.streamCallIdstores the fullslot-<anchorSlotId>string and is looked up whole (access.ts:141). There is no prefix-stripping bug.backstageandjoin_ahead_time_secondsare false/zero on the live call, and the repository never sends them.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.tsstill 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:cmqb162ha000ctxyoeth429bqis Amit Anderson, a consultant — present in Postgres, absent from Stream. As the documentation states, Stream will not auto-create a user referenced inmembers; it rejects the entire call instead.Measured across every consultant in the database:
So even with Defect 1 fixed, roughly one appointment in three would still fail to mint a call, because
resolveSessionCallProfilenames 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, andapp/api/stream/search/route.ts:44. Neitherlib/meeting.tsnoractions/stream/meetings/meeting.action.tscalls 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.tsdoes 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 tokenonPOST /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:145constructs aCallas a function-localconst:getOrCreateapplies device settings. From the installed SDK,@stream-io/video-client/dist/index.es.js:13282-13302:which reaches
:14835camera.apply(...)/microphone.apply(...), and then:This call has
camera_default_on: trueandmic_default_on: true, sogetUserMediafires insidegetOrCreateAppointmentMeeting. TheCallobject owning the resultingMediaStreamis never stored, never returned — the function returns onlystreamCallId— 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 onif (!call) return;. On the failing pathcallisnull, so it does nothing. Neither "Go Back" button is media-aware:components/Alert.tsx:19is a barerouter.back()andpage.tsx:86a barewindow.history.back().lib/stream/media-teardown.tsis correct — it callsdisable({ forceStop: true })and thentrack.stop()— but the failing paths never reach it.The same shape recurs in
useGetCallById.ts, wherecallInstanceis a function-localconst(:186) andawait callInstance.get()(:187) also opens the devices. Three paths drop it without teardown::189(cancelledafterget()),:199(cancelledafter rejoin), and:208-213(thecatch).Defect 4 — Home hides Join on
SCHEDULEDappointmentsThe 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.
SCHEDULEDis a member ofCONFIRMED_STATUSES(status.ts:39) but is notAPPROVED, socanShowJoinevaluates false.ConsulteeAppointmentsAdapter.tsx:236gates the same action onisConfirmedStatus, which does acceptSCHEDULED. The two surfaces disagree about the same appointment.Defect 5 —
ConsentRequiredErrortakes down the appointment pageSentry — FAMILIARISE_WEB-12, twelve events, marked escalating, culprit
POST /dashboard/consultee/[consulteeId]/appointments/[appointmentId]: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:created_by_idis 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 beforecall.getOrCreate(...), mirror what every chat path already does: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:In
useGetCallById.ts, hold the in-flight instance so every exit can release it: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
isConfirmedStatuson Home, matchingConsulteeAppointmentsAdapter.Fix 5 — handle the consent gate (Defect 5)
Catch
ConsentRequiredErrorat 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-78mocks the Stream SDK and asserts call ordering only —expect(sequence).toEqual(["resolveMeetingAccess","getOrCreate","updateCallMembers"])— never the arguments, so a missingcreated_by_idis invisible to it. Add:7. Operational notes and sequencing
scripts/stream/ensure-call-type-grants.tswas never run. The livedefaultcall type still grantsjoin-callto every role:#1134 P0-1's hardening is therefore not applied, and every signed-in user still holds
join-callon every call — the hole that PR set out to close.join-callfromuserandguest, 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.tswrites the consultant's member role as"host"— a key with no grants at all on thedefaulttype — so consultants depend on the join route rewriting them tocall_member.Seeded
MeetingSessionrows point at calls that do not exist.prisma/seedFiles/6a-create-appointments.ts:150writesstreamCallId: faker.string.uuid()and11b-create-meeting-sessions.ts:44writescall_<uuid>; neither matches the canonicalslot-<anchorSlotId>, and no Stream call exists for either. The join route'sgetOrCreateis 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.
getOrCreateAppointmentMeetingissues five sequential Server Actions plus a Stream REST call, none of them bounded. WithPG_POOL_MAX=1on 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:718resets it in afinally.useEventActions.ts:347-373andConsulteeAppointmentsAdapter.tsx:195-225clear it only incatch, so a success that fails to navigate spins forever.8. Follow-ups worth separate issues
created_byon the reproduction call is the consultee. That is precisely the shape Stream SDK subsystem audit v2 — the webhook pipeline has never run, and any signed-in user can join any call #1134 P0-2 set out to remove, still live on a different surface.