You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Parts of the remediation plan below were superseded during implementation, and the corrections live in the comments. If you are reading this to decide what to build, read those first — the most significant is that P0-1's prescribed fix (a new consultation call type, plus call_cids-scoped tokens) was rejected: a call's type is immutable after creation, and the video client is an app-wide singleton. What shipped instead is the default call type hardened in place, with membership granted server-side by POST /api/meetings/[meetingId]/join.
The comment of 2026-08-12 titled "Second validation pass — corrections to THIS AUDIT" lists the findings in this body that turned out to be wrong, and the one of 2026-08-12 titled "Review triage on #1136" corrects three live-app facts. The SDK drift table below quotes package.json ranges as though they were installed versions; trust node_modules.
Supersedes #899 and #689. Both are audits of this subsystem; #899's headline conclusion — "the core works and the security posture is genuinely good" — is contradicted below. Each is closed with an evidence comment mapping its findings to carried-forward / already-fixed / incorrect.
TL;DR
The Stream webhook pipeline has never processed a single event in production, and the video calls are joinable by any signed-in user. Neither was found by reading code alone — both were confirmed against the live Stream app and the production database.
WebhookEvent WHERE provider='stream' ............ 0 (all-time)
MeetingSession rows .............................. 1,663
... with endedAt set .......................... 0 (0.0%)
... orphaned >1h past slot end ................ 1,417
MeetingAttendance rows ........................... 0 (all-time)
Recording rows with streamRecordingId ............ 0 (191 rows, all seed data)
Stream calls with ended_at (live API, n=25) ...... 0 (oldest open since 2026-01-17)
Stream call types in use ......................... "default" × 25 (no custom type exists)
Root cause of the first column: STREAM_WEBHOOK_SECRET is not set on Netlify. Verified with netlify env:list — the only Stream vars in production are NEXT_PUBLIC_STREAM_API_KEY, STREAM_API_KEY, STREAM_API_SECRET, STREAM_SYNC_SECRET. app/api/stream/webhooks/route.ts:232-241 returns HTTP 500 when the secret is absent, so every delivery has been rejected since the endpoint shipped. Stream retries 5× over 15s and then drops the event permanently, and sweep-stuck-webhook-events filters provider: "razorpay" only — so there is no recovery path and no alert.
Everything downstream of a webhook is therefore dead: sessions never end, attendance is never recorded, recordings are never persisted, and detect-consultant-no-shows has been running daily against a permanently empty MeetingAttendance table.
8 P0s. 21 P1s. The fix is a sequenced train of 11 PRs off dev, below.
Method
Read-only static audit across lib/stream/*, lib/stream-*.ts, actions/stream/**, app/api/stream/**, app/meetings/**, components/chat/*, jobs/, .github/workflows/, prisma/schema.prisma and 19 docs — then verified against reality:
mcp__streamio__video_query_calls / chat_query_channels against the live Stream app
SELECT probes against the production Postgres
netlify env:list for the production environment
git log -L to date the regressions
npm view for SDK drift
Stream's current documentation for every claimed API behaviour (linked inline)
Nothing was mutated.
P0 — security and data loss
P0-1 · Any signed-in user can join any private consultation
Three independent gaps line up:
The call type is permissive. All 25 live calls are type: "default", whose user role retains join-call. lib/meeting.ts:230-238 documents not sending backstage or member-gating, and notes "the default call type does not restrict entry to members".
The token is account-wide.grep -r call_cids returns zero hits. generateVideoToken (lib/stream-client.ts:115-129) mints a plain user token valid for every call in the app for an hour.
The gate is a React conditional.app/meetings/[id]/page.tsx:104 renders "Access Denied" — client-side only.
Call IDs are deterministic: slot-<slotOfAppointmentId> (lib/meeting.ts:126). Slot IDs travel in availability and calendar payloads.
Repro: sign in as any user, open devtools on any page where the video client is mounted:
A custom consultation call type with join-call removed from the user role and granted to call_member. Settable from the server SDK (createCallType / updateCallType), so it belongs in a migration script, not a dashboard runbook.
Call-scoped tokens: generateCallToken({ user_id, call_cids: ["consultation:slot-x"], validity_in_seconds: 900 }) (Users & Tokens), issued by a new POST /api/meetings/:id/join-tokenonly aftervalidate-access passes.
Existing default: calls need a migration or a dual-read window.
P0-2 · The client mints Stream calls before authorization runs
app/meetings/[id]/hooks/useGetCallById.ts:64-69 calls getOrCreate() on a miss. Its effect runs in parallel with the access check — page.tsx:28 vs page.tsx:35-63. Any signed-in user hitting /meetings/<anything> creates a billable Stream call, becomes its created_by, and then sees Access Denied.
Live proof — these exist in the production Stream app with no appointmentId, no starts_at, and no MeetingSession row:
default:smoke-test-nonexistent created 2026-06-18
default:test-meeting created 2026-02-03
The first is a smoke test that hit a deliberately nonexistent ID and created it.
Fix. Delete the client fallback. Resolve the call server-side from MeetingSession.streamCallId; if there is no row, there is no meeting.
P0-3 · DM split-brain — the same pair has two channels, history orphaned
localeCompare is collation-dependent. It sorts case-insensitively by primary weight and varies with the runtime's ICU build and default locale (Intl resolves en-IN on this machine; Netlify's Node may ship different ICU data). Code-unit .sort() does not.
Better Auth IDs are mixed-case; cuids are lowercase. That refactor silently re-keyed most consultant↔consultee pairs, orphaning their conversation history behind a new empty channel.
Fix.a < b ? [a, b] : [b, a] — locale-independent. Plus a one-off reconcile that merges or aliases the orphans. Add a test asserting both orderings agree for a mixed-case pair.
P0-4 · Banning a user locks them out of chat permanently
lib/stream-client.ts:96-108 mints chat tokens as client.createToken(userId, exp). Per the Node tokens docs the signature is createToken(userId, exp, iat) — the third argument is iat, and it is omitted. Video tokens do set it (:122); chat tokens do not.
lib/moderation/side-effects.ts:256 calls revokeUserToken(id, new Date()). Stream: "Tokens which have no iat will be considered invalid" once revocation is active, and revoke_tokens_issued_before persists until explicitly set to null.
grep returns zero hits for revokeUserToken(…, null) and zero for reactivateUser — while USER_BANNED also calls deactivateUser (:259-261).
So a 7-day suspension is permanent, twice over. The comment at :251-253 asserts otherwise:
// revokeUserToken expires every previously-issued Stream token; the// token provider re-mints only for non-banned users, so suspension// self-heals after banExpires without an un-revoke.
It does not self-heal. After banExpires, the token provider happily mints a fresh token — with no iat — and Stream rejects it.
Fix. Pass iat on chat tokens; add revokeUserToken(id, null) + reactivateUser(id) to the unban path.
P0-5 · STREAM_WEBHOOK_SECRET is unset in production — the entire pipeline is down
Confirmed via netlify env:list: not present. Also absent from .env and .env.sample. Present only in docs/enterprise/50-operations/07-required-secrets.md:141.
app/api/stream/webhooks/route.ts:232-241 returns 500 when it is missing. Result, all-time:
count
WebhookEvent where provider = 'stream'
0
MeetingSession with endedAt set
0 of 1,663
MeetingAttendance rows
0
Recording with streamRecordingId
0 of 191 (rest are seeds)
Naming is also wrong: Stream signs webhooks with the API secret (Webhooks Overview), not a separate signing secret — so docs/stream/13-recording-webhooks.md:842 ("Signing Secret: Copy to STREAM_WEBHOOK_SECRET") describes a dashboard field that does not exist.
Fix. Default the secret to STREAM_API_SECRET, set it in Netlify, and fail loudly at boot rather than per-request. Then backfill the 1,417 orphaned sessions.
P0-6 · One Stream app serves dev, preview and production
created_by: test-consultee-001 / test-consultant-001 and messaging:test-channel-1773685283726 sit in the same app as real user data. A dev-issued token authenticates against production; a local test can end a real call or delete a real channel.
Fix. Separate Stream apps per environment. The env plumbing already exists; only the credentials differ.
P0-7 · Consultation and subscription channels delete themselves on the next page load
But syncUserEventChannels builds expectedChannelIds from webinars + classes + DMs only (actions/stream/chat/event-channel.action.ts:532-559), while the stale filter (:632-637) treats those prefixes as managed:
Every such channel is classified stale and the user is removeMembers'd from it (:654) on their very next dashboard load.
The codebase already disagrees with itself here: createConsultationChannel (actions/stream/chat/channel.action.ts:413) mints a DM, not a consultation- channel.
Fix — decided: delete the concept. The pair already gets a DM. Removing it is the fix, and it removes a contradiction rather than a feature.
P0-8 · Every org-sponsored booking's DM is created under the wrong key, then deleted
All four createDirectMessageChannel call sites pass two arguments and omit organizationId, so the channel is minted as personal dm-<a>-<b> (channel.action.ts:176). getDmPairsForUser then recomputes the expected ID with org precedence (event-channel.action.ts:727-740), yielding dmo-….
The dm- channel is not in the expected set, and dm-is a managed prefix → the user is removed from the only DM they have. dmo-/dmh- don't match startsWith("dm-"), so the cleanup is asymmetric.
Fix. Thread organizationId through all four call sites.
Sequencing note. P0-3, P0-7 and P0-8 are three independent mechanisms that each destroy a user's chat. Their blast radius is currently limited by a fourth bug: the reconcile loop requests limit: 100 while Stream caps queryChannels at 30, so page.length === PAGE_SIZE is false after one iteration and only the first 30 memberships are ever examined (event-channel.action.ts:607-627). Fixing that pagination before P0-7 and P0-8 would widen the damage. Order matters.
P1 — reliability, money, correctness
#
Finding
Evidence
P1-1
The session reconciler is never scheduled. 64 workflows in .github/workflows/; none runs reconcile-sessions. Its own docstring claims "every 30 minutes". Backlog: 1,417 orphans.
jobs/meetings/reconcile-orphaned-sessions.ts:8
P1-2
No durable backstop for Stream webhooks. Stream retries 5× / 15s total / 6s per attempt. The route has no maxDuration and does a DB health probe + idempotency read + handler + mark inside that budget. sweep-stuck-webhook-events filters provider: "razorpay".
scripts/cleanup/sweep-stuck-webhook-events.ts:108
P1-3
Maintenance drain is unsafe. Unbounded findMany({endedAt: null}) with a 6-level include and no take — that is 1,663 rows today, each with a serial call.end(). Stamps endedReason:"maintenance" on months-dead sessions. Never sets SlotOfAppointment.completionStatus, so drained sessions never complete. notifyMaintenanceStarted is a broadcast to every user while result.notified reports a fabricated per-participant count.
actions/maintenance/drain-sessions.ts:32-184
P1-4
No chat freeze during maintenance. Zero frozen/freeze usage anywhere — users keep messaging while the app is offline and nothing syncs.
—
P1-5
call_cid handling inconsistent. Three sites split on :; the reconciler passes it raw, so a prefixed value always 404s → UNVERIFIED.
recording-service.ts:54,90,123 vs reconcile-orphaned-sessions.ts:88
P1-6
Recording is structurally impossible for 1:1.isAppointmentOwner / isRecordingEnabledForAppointment handle only webinar/class, so consultation and subscription always 403.
lib/stream/recording-utils.ts:34-84
P1-7
No recording consent. The consultee sees a passive REC pill only after recording starts. No pre-join disclosure, no opt-out, no consent record. DPDP exposure on a consultation product.
RecordingControls.tsx:204-217
P1-8
call.session_started is unhandled; duration is computed then only logged, never persisted — every duration uses the scheduled start.
session-handlers.ts:136-145, 231-242
P1-9
Idempotency key stream_<type>_chat_<created_at> collides for two chat flags in the same second; participant events omit the user ID. Stream sends X-Webhook-ID, stable across retries — unused.
webhooks/route.ts:277
P1-10
handleCallEnded marks the slot COMPLETED with no minimum-duration or attendance check — a host who ends at 0:30 completes a paid hour.
session-handlers.ts:213-229
P1-11
Zero rate limiting on any /api/stream/* or /api/meetings/* route, or on token minting. Neither prefix appears in RATE_LIMIT_RULES.
middleware.ts:236-310
P1-12
/api/stream/debug has no session check. With ALLOW_DEBUG_IN_PRODUCTION=true the only control is a query-string secret, and it dumps any user's full channel list.
app/api/stream/debug/route.ts:20-40
P1-13
setVideoConnected(true) fires synchronously after the constructor, before any handshake — the flag lies, and the surrounding try/catch can never catch a connection failure.
StreamProviderImpl.tsx:320
P1-14
StreamErrorBoundary never reports to Sentry in production — the branch is a console.error beside a comment saying Sentry should go there.
StreamErrorBoundary.tsx:171-180
P1-15
Channel provisioning at payment is void (async …)() — never awaited, so the Lambda can freeze before it settles. Its catch does not reach Sentry, and its comment ("sync job will catch up") names a job that only deletes users. No outbox, no DLQ, no reconciliation.
lib/payments/webhooks/handlers.ts:851-931
P1-16
TRIAL buyers get no chat at all — no branch in the provisioning block, and "trial" is not in eventTypeSchema, so it cannot even be requested.
event-channel.action.ts:26-31
P1-17
No post-event lifecycle.getWebinarIdsForUser/getClassIdsForUser have no date or status filter, so attendees stay members of every webinar and class channel forever.
event-channel.action.ts:942-1020
P1-18
Memory leak:initialSyncCompletedUsers is a module-level, unbounded, never-evicted Set<string>. The TTL caches beside it evict FIFO-by-insertion, not LRU, so they thrash under load — and all of them are per-process, so they are near-useless on serverless.
lib/stream-cache.ts:132, :50-57, :114-126
P1-19
syncUserEventChannels blocks chat connect. Cost ≈ 1 + W + C + D + ⌈N/100⌉ Stream calls in batches of 5. A consultant with 200 clients pays ~40 sequential round-trips — 8–20s — beforesetChatConnected(true).
No member cap, no chunking, no batched addMembers. Lazy-create of a 100+ attendee webinar puts the whole roster in one upsertUsers and one channel.create(); at 500 it fails into the un-Sentry'd catch and the attendee silently gets no chat. Webinar.maxParticipants is unbounded.
channel.action.ts:98-124, user.action.ts:220-228
P1-21
stream-sync cannot finish at scale. Redis lock TTL is 10 min; a 100k-user run takes 15–30 min (the 500ms per-page sleep alone is 8 min), so runs overlap — and requireLock defaults to false, so it proceeds even when the lock is held. The 30-min Actions timeout kills it and the keyset cursor is lost, so the tail of the table is never swept. One failed soft-delete makes the whole run return HTTP 500.
scripts/stream/stream-sync.ts:48-79, :154, :318
P2 — product surface and UX
Video
No reconnecting UI — everything that isn't JOINED collapses to a bare <Loader /> (MeetingRoom.tsx:237), so a mid-call network drop shows an unexplained spinner, and RECONNECTING_FAILED (terminal — the SDK has given up) is indistinguishable from it. Stream's own guidance is explicit that this state needs its own alert and a retry.
Zero hits for setPreferredIncomingVideoResolution / incomingVideoSettings — no HD/SD control, no audio-only fallback on a bad line (Manual Video Quality Selection). No network-quality indicator.
@stream-io/audio-filters-web (noise cancellation) and @stream-io/video-filters-web (background blur) are not installed — and the call type has noise_cancellation.mode: "auto-on" server-side, so Stream is asking for a filter the client cannot provide. Enabling them also needs a CSP change: next.config.mjs has no worker-src (the comment says "nothing in this app constructs a Worker") and no wasm-unsafe-eval.
Replacing CallControls with hand-rolled buttons dropped SpeakingWhileMutedNotification. No in-call device picker, no PiP, no transcription or captions, no waiting room, no ring, no session extension, and no server-side late-join enforcement (a held URL joins indefinitely).
Chat UI
components/chat/* was structurally excluded from the monochrome modernization — commit 3c1d1972 touched none of it. CustomChannelHeader.tsx was last designed 2025-08-23.
147 hardcoded colors, 0 design tokens. A bg-blue-600 sidebar sits directly against bg-zinc-50, with blue-500/gray-100 bubbles as a third palette.
Mobile is broken.ChatLayout.tsx:8-13 renders an unconditional w-80 sidebar → 55px of conversation on a 375px screen. No breakpoint, no collapse, no back button. The ChatSkeleton placeholder (DashboardSkeletons.tsx:43) is responsive — the loading state is better built than the thing it stands in for.
Message actions are unreachable on touch.CustomMessage.tsx:264 is opacity-0 group-hover:opacity-100, so react / reply / edit / delete / report do not exist on a phone. Same for the timestamp (:473) — messages have no visible time on mobile, ever.
One aria-label in the whole feature. Icon-only buttons rely on title; two hand-rolled dropdowns replace the Radix ones already in the repo, losing Escape, focus trap and arrow keys.
Threads are display:none-hidden in app/globals.css:1250-1256 but still render and stay keyboard-focusable — a tab-order trap.
No read receipts.markRead() is called; nothing renders.
22 console.logs, including console.log("Stream event received:", event.type, event) inside client.on("*.**") (ChatSidebar.tsx:579) — every event payload, message text included, is logged to the production console. Privacy issue, not noise.
No dark mode: <ChatProvider> gets no theme prop, so the SDK is pinned to str-chat__theme-light.
Zero adoption of ResponsiveModal (used in 15 other files) or ResponsiveTable (37) — five different magic dialog widths instead.
Journey and dashboard parity
No deep link to a channel exists. No ?channel=/?cid= param, no [channelId] route segment, no URL-driven setActiveChannel. The consultee messages page declares searchParams and explicitly discards it. After buying, the user lands on /messages and must hunt — and ChatSidebar auto-selects the channel with the most recent message, so a brand-new channel (last_message_at: null) sorts last. Novu's Surface union is appointments | requests | recordings | earnings — no chat surface. No email links to a channel.
Consultee has no unread badge anywhere. Org has none either — useChatUnreadCount.ts:46-48 hardcodes organization_id: { $exists: false }, so it is B2C-only by construction.
Staff and admin have no chat surface at all. A moderator handling a reported message cannot open it, see it in context, delete it, or ban from chat. The report pipeline writes into a queue whose only consumer renders an icon.
No in-call chat — app/meetings/layout.tsx:17 sets enableChat={false}. Chat and video never meet.
The consultee joins meetings via bare router.push, while consultant and org use useLazyJoinMeeting — different failure modes for the same action.
Dead code:checkEventChannelExists, getUserEventChannels, initializeAllChannels (no production callers); the unreachable <RecordCallButton /> branch; the never-passed organizationId on useGetCallById; the ?personal room mode nothing sets; the deprecated global searchUsers.
Dead schema:MeetingSession.passcode / hostKeys are declared and never read. Class.recordingUrls String[] is a parallel recording store bypassing the Recording model and its retention sweep. Redundant indexes on Recording.streamRecordingId (already @unique) and MeetingAttendance.meetingSessionId (already the leading column of a composite unique). Recording.streamCallId is an unindexed denormalized copy with nothing enforcing agreement.
Seeds:MeetingAttendance is never seeded, so no-show and overrun logic has no local test data. generateStreamCallId() emits call_{uuid}, contradicting production's slot-{slotId} — and matching the stale format in docs/stream/05-video-implementation.md.
Docs (19 files, 14,779 lines):docs/stream/README.md is missing but linked from three files. Zero occurrences of "BetterAuth" anywhere in docs/stream/ while five files still show NextAuth. 05-video-implementation.md documents the wrong streamCallId format. 13-recording-webhooks.md declares a StorageType enum that does not exist (it is RecordingStorageType). 04-chat-implementation.md documents DM IDs without the dm- prefix. Three different names for the org tag across three docs (organization_id / organizationId / organizationProfileId). docs/notifications/04 points four times at app/api/webhooks/stream/recording/route.ts, which does not exist. prisma/schema.prisma:769 cites jobs/stream/cleanup-old-stream-recordings.ts, also wrong.
Scope decisions
Capability
Decision
Webhooks
Fix urgently — nothing else works until they do
Token revocation
Fix — exists but cannot work without iat
Banning
Fix — currently permanent (P0-4)
Channel freeze
Add — needed for post-event lifecycle and the maintenance window
Moderation
Have it; give it a surface. Reports dead-end today
Groups
Have them. Need lifecycle + chunking, not new capability
Post-event channels
Freeze on end, purge at retention, reusing the streamRecordingRetentionDays precedent
In-call chat
Add — small, high value
Read receipts
Add — markRead() already runs
Guest / anonymous access
Do not build. Accounts stay mandatory; add a one-tap magic-link join instead. Guests still cost MAU, cannot be banned durably, and break attendance, certificates and refunds
Peer DMs / "add friends"
Do not build. Blocked by the 2026-07-11 ADR; invites off-platform leakage and a moderation burden with no revenue attached
Transcription / captions / AI summary
Defer to its own issue — new cost centre, new consent surface
Push notifications
Defer until there is a mobile app
consultation-/subscription- channels
Delete — the pair already gets a DM, and this is the fix for P0-7
Org compliance endpoints
Delete — fully implemented, MANAGER-gated, audited, and with zero UI consumers
CSS-hidden threads
Open question. Disable properly at the SDK level (recommended — inline quote-reply already covers the need) or restore as a designed feature
PR train
Stacked off dev, each Part of #<this issue>.
PR
Scope
A
P0 call security — custom consultation call type, call_cids tokens after validate-access, server-side call resolution, delete the client getOrCreate. Carries the same-major SDK bumps (video-react, node-sdk, stream-chat). Blocked on a negative-auth test proving Stream refuses a non-member
B
P0 chat identity — code-unit DM sort + orphan reconcile; thread organizationId through all four DM call sites; delete the consultation-/subscription- concept; fix the queryChannels page-size bug last
P1 scale — freeze-on-end + purge-at-retention; date-filter the event resolvers; chunk upsertUsers/addMembers; move syncUserEventChannels off the connect path; bound initialSyncCompletedUsers; fix the stream-sync lock TTL and cursor loss
G1
P2 chat function — mobile two-pane + back button, touch message actions, visible timestamps, aria-labels + Radix menus, threads disabled properly, strip the logging
G2
P2 chat visual — 147 literals → design tokens, --str-chat__* theming, ResponsiveModal/ResponsiveTable/EmptyState/Skeleton, theme on ChatProvider (unblocks dark mode)
H
P2 video + journey — reconnect/RECONNECTING_FAILED UI, incoming-resolution control, noise cancellation + background blur (with the CSP worker-src/wasm-unsafe-eval change), channel deep links, unread badges for consultee and org, staff/admin moderation context, in-call chat
I
P3 — stream-chat-react v14 major (own PR), dead code, dead schema, seeds
J
Docs refresh + two new ADRs (call-type/token model; post-event channel lifecycle)
Close — superseded. Its headline ("security posture is genuinely good") is contradicted by P0-1 and P0-4; its channel-sync analysis is carried forward into P0-7/P0-8
Keep — half done.withStreamCircuitBreaker is live at 13 call sites, but all of them are chat. Every video/server path calls getStreamVideoClient() raw: recording-service.ts (×3), drain-sessions.ts, reconcile-orphaned-sessions.ts. app/api/health/route.ts has no Stream check. Remaining scope folded into PR D
Important
Parts of the remediation plan below were superseded during implementation, and the corrections live in the comments. If you are reading this to decide what to build, read those first — the most significant is that P0-1's prescribed fix (a new
consultationcall type, pluscall_cids-scoped tokens) was rejected: a call's type is immutable after creation, and the video client is an app-wide singleton. What shipped instead is thedefaultcall type hardened in place, with membership granted server-side byPOST /api/meetings/[meetingId]/join.The comment of 2026-08-12 titled "Second validation pass — corrections to THIS AUDIT" lists the findings in this body that turned out to be wrong, and the one of 2026-08-12 titled "Review triage on #1136" corrects three live-app facts. The SDK drift table below quotes
package.jsonranges as though they were installed versions; trustnode_modules.TL;DR
The Stream webhook pipeline has never processed a single event in production, and the video calls are joinable by any signed-in user. Neither was found by reading code alone — both were confirmed against the live Stream app and the production database.
Root cause of the first column:
STREAM_WEBHOOK_SECRETis not set on Netlify. Verified withnetlify env:list— the only Stream vars in production areNEXT_PUBLIC_STREAM_API_KEY,STREAM_API_KEY,STREAM_API_SECRET,STREAM_SYNC_SECRET.app/api/stream/webhooks/route.ts:232-241returns HTTP 500 when the secret is absent, so every delivery has been rejected since the endpoint shipped. Stream retries 5× over 15s and then drops the event permanently, andsweep-stuck-webhook-eventsfiltersprovider: "razorpay"only — so there is no recovery path and no alert.Everything downstream of a webhook is therefore dead: sessions never end, attendance is never recorded, recordings are never persisted, and
detect-consultant-no-showshas been running daily against a permanently emptyMeetingAttendancetable.8 P0s. 21 P1s. The fix is a sequenced train of 11 PRs off
dev, below.Method
Read-only static audit across
lib/stream/*,lib/stream-*.ts,actions/stream/**,app/api/stream/**,app/meetings/**,components/chat/*,jobs/,.github/workflows/,prisma/schema.prismaand 19 docs — then verified against reality:mcp__streamio__video_query_calls/chat_query_channelsagainst the live Stream appSELECTprobes against the production Postgresnetlify env:listfor the production environmentgit log -Lto date the regressionsnpm viewfor SDK driftNothing was mutated.
P0 — security and data loss
P0-1 · Any signed-in user can join any private consultation
Three independent gaps line up:
type: "default", whoseuserrole retainsjoin-call.lib/meeting.ts:230-238documents not sendingbackstageor member-gating, and notes "thedefaultcall type does not restrict entry to members".grep -r call_cidsreturns zero hits.generateVideoToken(lib/stream-client.ts:115-129) mints a plain user token valid for every call in the app for an hour.app/meetings/[id]/page.tsx:104renders "Access Denied" — client-side only.Call IDs are deterministic:
slot-<slotOfAppointmentId>(lib/meeting.ts:126). Slot IDs travel in availability and calendar payloads.Repro: sign in as any user, open devtools on any page where the video client is mounted:
You are in a paid 1:1 consultation you have no relationship to.
Fix. Both gates, per Permissions & Moderation and Call Types:
consultationcall type withjoin-callremoved from theuserrole and granted tocall_member. Settable from the server SDK (createCallType/updateCallType), so it belongs in a migration script, not a dashboard runbook.generateCallToken({ user_id, call_cids: ["consultation:slot-x"], validity_in_seconds: 900 })(Users & Tokens), issued by a newPOST /api/meetings/:id/join-tokenonly aftervalidate-accesspasses.Existing
default:calls need a migration or a dual-read window.P0-2 · The client mints Stream calls before authorization runs
app/meetings/[id]/hooks/useGetCallById.ts:64-69callsgetOrCreate()on a miss. Its effect runs in parallel with the access check —page.tsx:28vspage.tsx:35-63. Any signed-in user hitting/meetings/<anything>creates a billable Stream call, becomes itscreated_by, and then sees Access Denied.Live proof — these exist in the production Stream app with no
appointmentId, nostarts_at, and noMeetingSessionrow:The first is a smoke test that hit a deliberately nonexistent ID and created it.
Fix. Delete the client fallback. Resolve the call server-side from
MeetingSession.streamCallId; if there is no row, there is no meeting.P0-3 · DM split-brain — the same pair has two channels, history orphaned
lib/stream-utils.ts:73:localeCompareis collation-dependent. It sorts case-insensitively by primary weight and varies with the runtime's ICU build and default locale (Intlresolvesen-INon this machine; Netlify's Node may ship different ICU data). Code-unit.sort()does not.Measured on two real production user IDs:
Both resulting channels exist live:
git log -L 68,90:lib/stream-utils.tsdates it — commit01162093 refactor: standardize Stream channel ID conventions:Better Auth IDs are mixed-case; cuids are lowercase. That refactor silently re-keyed most consultant↔consultee pairs, orphaning their conversation history behind a new empty channel.
Fix.
a < b ? [a, b] : [b, a]— locale-independent. Plus a one-off reconcile that merges or aliases the orphans. Add a test asserting both orderings agree for a mixed-case pair.P0-4 · Banning a user locks them out of chat permanently
lib/stream-client.ts:96-108mints chat tokens asclient.createToken(userId, exp). Per the Node tokens docs the signature iscreateToken(userId, exp, iat)— the third argument isiat, and it is omitted. Video tokens do set it (:122); chat tokens do not.lib/moderation/side-effects.ts:256callsrevokeUserToken(id, new Date()). Stream: "Tokens which have noiatwill be considered invalid" once revocation is active, andrevoke_tokens_issued_beforepersists until explicitly set tonull.grepreturns zero hits forrevokeUserToken(…, null)and zero forreactivateUser— whileUSER_BANNEDalso callsdeactivateUser(:259-261).So a 7-day suspension is permanent, twice over. The comment at
:251-253asserts otherwise:It does not self-heal. After
banExpires, the token provider happily mints a fresh token — with noiat— and Stream rejects it.Fix. Pass
iaton chat tokens; addrevokeUserToken(id, null)+reactivateUser(id)to the unban path.P0-5 ·
STREAM_WEBHOOK_SECRETis unset in production — the entire pipeline is downConfirmed via
netlify env:list: not present. Also absent from.envand.env.sample. Present only indocs/enterprise/50-operations/07-required-secrets.md:141.app/api/stream/webhooks/route.ts:232-241returns 500 when it is missing. Result, all-time:WebhookEventwhereprovider = 'stream'MeetingSessionwithendedAtsetMeetingAttendancerowsRecordingwithstreamRecordingIdNaming is also wrong: Stream signs webhooks with the API secret (Webhooks Overview), not a separate signing secret — so
docs/stream/13-recording-webhooks.md:842("Signing Secret: Copy toSTREAM_WEBHOOK_SECRET") describes a dashboard field that does not exist.Fix. Default the secret to
STREAM_API_SECRET, set it in Netlify, and fail loudly at boot rather than per-request. Then backfill the 1,417 orphaned sessions.P0-6 · One Stream app serves dev, preview and production
created_by: test-consultee-001/test-consultant-001andmessaging:test-channel-1773685283726sit in the same app as real user data. A dev-issued token authenticates against production; a local test can end a real call or delete a real channel.Fix. Separate Stream apps per environment. The env plumbing already exists; only the credentials differ.
P0-7 · Consultation and subscription channels delete themselves on the next page load
Payment creates
consultation-<id>/subscription-<id>(lib/payments/webhooks/handlers.ts:906,909;app/api/bookings/consultations/[consultationId]/route.ts:792;.../subscriptions/[subscriptionId]/route.ts:818).But
syncUserEventChannelsbuildsexpectedChannelIdsfrom webinars + classes + DMs only (actions/stream/chat/event-channel.action.ts:532-559), while the stale filter (:632-637) treats those prefixes as managed:Every such channel is classified stale and the user is
removeMembers'd from it (:654) on their very next dashboard load.The codebase already disagrees with itself here:
createConsultationChannel(actions/stream/chat/channel.action.ts:413) mints a DM, not aconsultation-channel.Fix — decided: delete the concept. The pair already gets a DM. Removing it is the fix, and it removes a contradiction rather than a feature.
P0-8 · Every org-sponsored booking's DM is created under the wrong key, then deleted
All four
createDirectMessageChannelcall sites pass two arguments and omitorganizationId, so the channel is minted as personaldm-<a>-<b>(channel.action.ts:176).getDmPairsForUserthen recomputes the expected ID with org precedence (event-channel.action.ts:727-740), yieldingdmo-….The
dm-channel is not in the expected set, anddm-is a managed prefix → the user is removed from the only DM they have.dmo-/dmh-don't matchstartsWith("dm-"), so the cleanup is asymmetric.Fix. Thread
organizationIdthrough all four call sites.P1 — reliability, money, correctness
.github/workflows/; none runsreconcile-sessions. Its own docstring claims "every 30 minutes". Backlog: 1,417 orphans.jobs/meetings/reconcile-orphaned-sessions.ts:8maxDurationand does a DB health probe + idempotency read + handler + mark inside that budget.sweep-stuck-webhook-eventsfiltersprovider: "razorpay".scripts/cleanup/sweep-stuck-webhook-events.ts:108findMany({endedAt: null})with a 6-level include and notake— that is 1,663 rows today, each with a serialcall.end(). StampsendedReason:"maintenance"on months-dead sessions. Never setsSlotOfAppointment.completionStatus, so drained sessions never complete.notifyMaintenanceStartedis a broadcast to every user whileresult.notifiedreports a fabricated per-participant count.actions/maintenance/drain-sessions.ts:32-184frozen/freezeusage anywhere — users keep messaging while the app is offline and nothing syncs.call_cidhandling inconsistent. Three sites split on:; the reconciler passes it raw, so a prefixed value always 404s →UNVERIFIED.recording-service.ts:54,90,123vsreconcile-orphaned-sessions.ts:88isAppointmentOwner/isRecordingEnabledForAppointmenthandle only webinar/class, so consultation and subscription always 403.lib/stream/recording-utils.ts:34-84RECpill only after recording starts. No pre-join disclosure, no opt-out, no consent record. DPDP exposure on a consultation product.RecordingControls.tsx:204-217call.session_startedis unhandled; duration is computed then only logged, never persisted — every duration uses the scheduled start.session-handlers.ts:136-145, 231-242stream_<type>_chat_<created_at>collides for two chat flags in the same second; participant events omit the user ID. Stream sendsX-Webhook-ID, stable across retries — unused.webhooks/route.ts:277handleCallEndedmarks the slotCOMPLETEDwith no minimum-duration or attendance check — a host who ends at 0:30 completes a paid hour.session-handlers.ts:213-229/api/stream/*or/api/meetings/*route, or on token minting. Neither prefix appears inRATE_LIMIT_RULES.middleware.ts:236-310/api/stream/debughas no session check. WithALLOW_DEBUG_IN_PRODUCTION=truethe only control is a query-string secret, and it dumps any user's full channel list.app/api/stream/debug/route.ts:20-40setVideoConnected(true)fires synchronously after the constructor, before any handshake — the flag lies, and the surrounding try/catch can never catch a connection failure.StreamProviderImpl.tsx:320StreamErrorBoundarynever reports to Sentry in production — the branch is aconsole.errorbeside a comment saying Sentry should go there.StreamErrorBoundary.tsx:171-180void (async …)()— never awaited, so the Lambda can freeze before it settles. Its catch does not reach Sentry, and its comment ("sync job will catch up") names a job that only deletes users. No outbox, no DLQ, no reconciliation.lib/payments/webhooks/handlers.ts:851-931"trial"is not ineventTypeSchema, so it cannot even be requested.event-channel.action.ts:26-31getWebinarIdsForUser/getClassIdsForUserhave no date or status filter, so attendees stay members of every webinar and class channel forever.event-channel.action.ts:942-1020initialSyncCompletedUsersis a module-level, unbounded, never-evictedSet<string>. The TTL caches beside it evict FIFO-by-insertion, not LRU, so they thrash under load — and all of them are per-process, so they are near-useless on serverless.lib/stream-cache.ts:132,:50-57,:114-126syncUserEventChannelsblocks chat connect. Cost ≈1 + W + C + D + ⌈N/100⌉Stream calls in batches of 5. A consultant with 200 clients pays ~40 sequential round-trips — 8–20s — beforesetChatConnected(true).event-channel.action.ts:459-697,StreamProviderImpl.tsx:245-271addMembers. Lazy-create of a 100+ attendee webinar puts the whole roster in oneupsertUsersand onechannel.create(); at 500 it fails into the un-Sentry'd catch and the attendee silently gets no chat.Webinar.maxParticipantsis unbounded.channel.action.ts:98-124,user.action.ts:220-228stream-synccannot finish at scale. Redis lock TTL is 10 min; a 100k-user run takes 15–30 min (the 500ms per-page sleep alone is 8 min), so runs overlap — andrequireLockdefaults to false, so it proceeds even when the lock is held. The 30-min Actions timeout kills it and the keyset cursor is lost, so the tail of the table is never swept. One failed soft-delete makes the whole run return HTTP 500.scripts/stream/stream-sync.ts:48-79,:154,:318P2 — product surface and UX
Video
No reconnecting UI — everything that isn't
JOINEDcollapses to a bare<Loader />(MeetingRoom.tsx:237), so a mid-call network drop shows an unexplained spinner, andRECONNECTING_FAILED(terminal — the SDK has given up) is indistinguishable from it. Stream's own guidance is explicit that this state needs its own alert and a retry.Zero hits for
setPreferredIncomingVideoResolution/incomingVideoSettings— no HD/SD control, no audio-only fallback on a bad line (Manual Video Quality Selection). No network-quality indicator.@stream-io/audio-filters-web(noise cancellation) and@stream-io/video-filters-web(background blur) are not installed — and the call type hasnoise_cancellation.mode: "auto-on"server-side, so Stream is asking for a filter the client cannot provide. Enabling them also needs a CSP change:next.config.mjshas noworker-src(the comment says "nothing in this app constructs a Worker") and nowasm-unsafe-eval.Replacing
CallControlswith hand-rolled buttons droppedSpeakingWhileMutedNotification. No in-call device picker, no PiP, no transcription or captions, no waiting room, no ring, no session extension, and no server-side late-join enforcement (a held URL joins indefinitely).Chat UI
components/chat/*was structurally excluded from the monochrome modernization — commit3c1d1972touched none of it.CustomChannelHeader.tsxwas last designed 2025-08-23.bg-blue-600sidebar sits directly againstbg-zinc-50, withblue-500/gray-100bubbles as a third palette.ChatLayout.tsx:8-13renders an unconditionalw-80sidebar → 55px of conversation on a 375px screen. No breakpoint, no collapse, no back button. TheChatSkeletonplaceholder (DashboardSkeletons.tsx:43) is responsive — the loading state is better built than the thing it stands in for.CustomMessage.tsx:264isopacity-0 group-hover:opacity-100, so react / reply / edit / delete / report do not exist on a phone. Same for the timestamp (:473) — messages have no visible time on mobile, ever.aria-labelin the whole feature. Icon-only buttons rely ontitle; two hand-rolled dropdowns replace the Radix ones already in the repo, losing Escape, focus trap and arrow keys.text-blue-200onbg-blue-600≈ 4.08:1 (attext-[10px]),text-blue-300≈ 3.18:1.display:none-hidden inapp/globals.css:1250-1256but still render and stay keyboard-focusable — a tab-order trap.markRead()is called; nothing renders.console.logs, includingconsole.log("Stream event received:", event.type, event)insideclient.on("*.**")(ChatSidebar.tsx:579) — every event payload, message text included, is logged to the production console. Privacy issue, not noise.<ChatProvider>gets nothemeprop, so the SDK is pinned tostr-chat__theme-light.ResponsiveModal(used in 15 other files) orResponsiveTable(37) — five different magic dialog widths instead.Journey and dashboard parity
?channel=/?cid=param, no[channelId]route segment, no URL-drivensetActiveChannel. The consultee messages page declaressearchParamsand explicitly discards it. After buying, the user lands on/messagesand must hunt — andChatSidebarauto-selects the channel with the most recent message, so a brand-new channel (last_message_at: null) sorts last. Novu'sSurfaceunion isappointments | requests | recordings | earnings— no chat surface. No email links to a channel.useChatUnreadCount.ts:46-48hardcodesorganization_id: { $exists: false }, so it is B2C-only by construction.app/meetings/layout.tsx:17setsenableChat={false}. Chat and video never meet.router.push, while consultant and org useuseLazyJoinMeeting— different failure modes for the same action.P3 — debt, scale, docs
SDK drift:
@stream-io/video-react-sdk1.31.5 → 1.40.2,@stream-io/node-sdk0.7.36 → 0.7.63,stream-chat9.30.1 → 9.50.3,stream-chat-react13.13.4 → 14.11.0 (major).Dead code:
checkEventChannelExists,getUserEventChannels,initializeAllChannels(no production callers); the unreachable<RecordCallButton />branch; the never-passedorganizationIdonuseGetCallById; the?personalroom mode nothing sets; the deprecated globalsearchUsers.Dead schema:
MeetingSession.passcode/hostKeysare declared and never read.Class.recordingUrls String[]is a parallel recording store bypassing theRecordingmodel and its retention sweep. Redundant indexes onRecording.streamRecordingId(already@unique) andMeetingAttendance.meetingSessionId(already the leading column of a composite unique).Recording.streamCallIdis an unindexed denormalized copy with nothing enforcing agreement.Seeds:
MeetingAttendanceis never seeded, so no-show and overrun logic has no local test data.generateStreamCallId()emitscall_{uuid}, contradicting production'sslot-{slotId}— and matching the stale format indocs/stream/05-video-implementation.md.Docs (19 files, 14,779 lines):
docs/stream/README.mdis missing but linked from three files. Zero occurrences of "BetterAuth" anywhere indocs/stream/while five files still show NextAuth.05-video-implementation.mddocuments the wrongstreamCallIdformat.13-recording-webhooks.mddeclares aStorageTypeenum that does not exist (it isRecordingStorageType).04-chat-implementation.mddocuments DM IDs without thedm-prefix. Three different names for the org tag across three docs (organization_id/organizationId/organizationProfileId).docs/notifications/04points four times atapp/api/webhooks/stream/recording/route.ts, which does not exist.prisma/schema.prisma:769citesjobs/stream/cleanup-old-stream-recordings.ts, also wrong.Scope decisions
iatstreamRecordingRetentionDaysprecedentmarkRead()already runsconsultation-/subscription-channelsPR train
Stacked off
dev, eachPart of #<this issue>.consultationcall type,call_cidstokens aftervalidate-access, server-side call resolution, delete the clientgetOrCreate. Carries the same-major SDK bumps (video-react, node-sdk, stream-chat). Blocked on a negative-auth test proving Stream refuses a non-memberorganizationIdthrough all four DM call sites; delete theconsultation-/subscription-concept; fix thequeryChannelspage-size bug lastiaton chat tokens, un-revoke + reactivate on unban, webhook secret, per-environment Stream apps, boot-time validationafter(); Stream branch insweep-stuck-webhook-events;X-Webhook-IDidempotency;call.session_started+ persisted duration; bounded maintenance drain + chat freeze; one sharedcall_cidhelperupsertUsers/addMembers; movesyncUserEventChannelsoff the connect path; boundinitialSyncCompletedUsers; fix thestream-synclock TTL and cursor loss--str-chat__*theming,ResponsiveModal/ResponsiveTable/EmptyState/Skeleton,themeonChatProvider(unblocks dark mode)RECONNECTING_FAILEDUI, incoming-resolution control, noise cancellation + background blur (with the CSPworker-src/wasm-unsafe-evalchange), channel deep links, unread badges for consultee and org, staff/admin moderation context, in-call chatstream-chat-reactv14 major (own PR), dead code, dead schema, seedsExisting issue triage
withStreamCircuitBreakeris live at 13 call sites, but all of them are chat. Every video/server path callsgetStreamVideoClient()raw:recording-service.ts(×3),drain-sessions.ts,reconcile-orphaned-sessions.ts.app/api/health/route.tshas no Stream check. Remaining scope folded into PR DTODOatstream-sync.ts:263still standsnext/dynamicsplit; re-measure after the v14 upgradeends_atgapAppendix — verification commands