release: dev → prod — 2026-08-13 (Stream SDK audit remediation, #1134) - #1152
Conversation
…6-08-10 chore(release): back-merge prod → dev after the 2026-08-10 release
… heads, payout freeze, dead lead form, age gate (#1133) * fix(security): close auth-plugin escalation paths, refund webhook dedup collision, and webhook egress Four defects surfaced by the enterprise audit, all small and all reachable from outside. BetterAuth plugin surface (lib/auth.ts): - `allowUserToCreateOrganization` was unset, which the plugin resolves to `true`. Org creation must go through POST /api/organizations so the ORG_WORKSPACE/ADMIN gate, ENABLE_HOST_ORGS, slug validation and BillingAccount/OrgWorkspaceProfile creation all run. Set it false. - `staffAc` granted `user:["set-role","ban"]`. The admin plugin's /admin/set-role authorises on that permission alone and never compares actor rank to target rank, so STAFF could assign itself ADMIN — which auth-helpers then treats as OWNER on every org. Reduced to list+get, matching BACKOFFICE_PERMISSIONS where users.moderate is ADMIN_ONLY. Ban writes already go through lib/moderation via Prisma. Razorpay webhook dedup (app/api/webhooks/razorpay/route.ts): Razorpay sends `contains: ["refund","payment"]` on refund events, so the payment-first entity-id chain keyed every refund on a payment to the same id. The second partial refund matched as a duplicate and never reached handleRefundCreated — no Refund row, no earnings reversal, no credit note, no ledger posting, after the money had already left. Probe the most specific entity first. Outbound webhook egress (new lib/enterprise/outbound-webhooks/ssrf-guard.ts): Endpoint URLs are customer-supplied and were validated only for https:// and length, while the worker followed redirects — reaching link-local metadata from our function. Added assertPublicUrl (blocks loopback/RFC1918/CGNAT/ link-local/ULA/6to4/NAT64, non-443 ports, embedded credentials, and hosts with any non-public DNS answer), applied on create and on PATCH, re-run immediately before each delivery to defeat rebinding, plus redirect:"manual". Part of #1132 * fix(money): correct the GST place-of-supply comparison, the payout TDS ledger leg, and the consultant payout freeze GST place-of-supply (lib/compliance/gst.ts, new lib/compliance/state-codes.ts): The two sides of the comparison are stored in different representations and so never compared equal — the seller is env-sourced alpha (SUPPLIER_STATE_CODE, "KA") while the buyer is written numeric ("29") because the settings form strips non-digits. `"29" === "KA"` is never true, so the intra-state branch was unreachable and every domestic invoice and credit note went out as IGST: a Bengaluru customer billed IGST instead of a CGST+SGST split, landing in the wrong GSTR-1 table and failing their 2B match. Extracted numericStateCode and the alpha-to-numeric map out of irp-payload.ts into a shared module and normalised both sides through it. IGST_STATE_UNKNOWN now also keys off the resolved code, so a code we hold but cannot map reads as unknown rather than as a real inter-state supply. Payout ledger leg (lib/payments/payouts/payout-service.ts): `payout.amount` is the GROSS payable; the gateway is called with `payout.amount - tds`. Crediting CASH with the gross over-stated it by the withheld amount on every deduction and pushed CONSULTANT_PAYABLE toward a debit balance. The transaction still balanced, so neither the deferred trigger nor the reconciler caught it. Now Dr PAYABLE(gross) / Cr CASH(gross-tds) / Cr TDS(tds). Consultant payout freeze (lib/payments/payouts/payout-service.ts): ADR 11 and the payout-pipeline doc say gateway submission is held behind ENABLE_LIVE_PAYOUTS, but the flag was read only on the org rail. The consultant rail was held back solely by RazorpayX credentials being absent, so the go-live flip for org payouts would have released every APPROVED consultant payout to a real bank account. assertPayoutBalance is not a substitute — it short-circuits to ok when the flag is off. Section 194-O base and threshold (behind ENABLE_TDS_194O_GROSS, default off): The base is the consultant share net of our commission, but 194-O (s.393(1) Table Sl. 8(v), IT Act 2025) is charged on the gross amount of the sale and CBDT Circulars 17/2020 and 20/2021 are explicit that retained commission is not deductible — so we under-withhold by the commission fraction. The threshold in use is the 194J figure; the 194-O exemption is 5,00,000 and only applies when all three limbs hold (individual/HUF, cumulative within the limit, PAN on file). Added resolve194OTaxablePaise + TDS_194O_EXEMPTION_PAISE and a nullable ConsultantTaxInfo.taxEntityType, all behind a flag that is OFF by default: this changes real withholding and needs written CA sign-off before it is flipped. Also adds the two missing BillingSubscription indexes — the model carried none at all while the cycle-close and invoice-generation crons scan it by nextInvoiceDate / currentCycleEnd on every run. Schema changes are additive only; `prisma db push` is required before the taxEntityType read or the new indexes take effect. Part of #1132 * fix(enterprise): unbreak the activation checklist link, the slot-conflict error, the SSO cert gate, and the contract cron isolation Four small defects from the enterprise audit, none of them money. Activation checklist 404 (lib/enterprise/org-activation.ts): The "Invite members" step linked to `${base}/invitations`, a route that does not exist — ADR 19 folded invitations into the members page as a tab and this link was never updated. Every new org's Getting-Started checklist sent them to a not-found page. Now points at `${base}/members?tab=invitations`, matching the tab value in MembersTabs and UrlTabs' default paramName. Slot conflict reported as an unknown error (lib/payments/operations/checkout.ts): validateSlotAvailability throws "Time slot is already booked" from INSIDE the transaction, and the STEP-5 catch rewrites anything not in `preservedMessages` to "Failed to record payment information", which renders as "Something Went Wrong". A user who lost a slot had no idea to pick another one. ADR 16's clean-error claim only ever held for the pre-transaction check. Added the two slot strings to the allowlist. SSO certificate accepted any string (schemas/organizations.ts): `cert: z.string().min(1)` let a non-certificate through provider creation, and BetterAuth's SAML library then threw "Cannot read properties of undefined (reading 'metadata')" deep inside POST /api/auth/sign-in/sso — a 500 with an empty body and no indication of the cause. Validates the PEM envelope and the base64 body so the failure lands at configuration time naming the field. Contract expiry ran at READ COMMITTED (jobs/contracts/expire-contracts.ts): 01-concurrency-and-idempotency.md states each nightly lifecycle cron runs inside a Serializable transaction; no isolation level was passed. The CAS claim is the real correctness guard, but the documented behaviour should be true of the code. Part of #1132 * docs(enterprise): correct four claims the code does not support The audit's meta-finding was that the enterprise docs are good enough to be trusted, and several describe protections that do not exist. These are the four that were load-bearing. ADR 13 (13-postgres-native-concurrency.md) said database-level CHECK constraints and triggers "are not currently expressible" because there is no prisma/migrations directory, and concluded that CAS WHERE clauses are therefore the enforcement layer. That is false: prisma/sql/ holds the deferred ledger_txn_balanced CONSTRAINT TRIGGER, the slot_no_confirmed_overlap GiST exclusion and ~30 CHECK constraints, applied by `npm run db:sidecars` and verified in CI by scripts/ci/check-db-sidecars.ts. Rewrote the paragraph, named the real trap (`db:push:schema` skips the sidecars), and kept the prisma-migrate follow-up on its actual merit — reviewable history and rollback. 01-concurrency-and-idempotency.md claimed the layer "deliberately uses no Redis locks" and used "conditional raw-SQL UPDATEs". Neither holds: the CAS writes go through the Prisma ORM, and 55 files wrap in withCronLock while checkout takes slot, consultee and event locks — which ADR 13 itself documents as Layer 4. Narrowed the claim to the enterprise row-scoped mutations, where it is true. 06-feature-flags-and-rollout.md listed "exactly five flags" including ENABLE_IRP_UPLOADER, which is not exported from the module, and documented ENABLE_ROUTED_WALLET in lib/payments/payouts/razorpay-route.ts — neither the flag nor the file exists. Corrected to the six real exports, added the previously-undocumented ENABLE_DUNNING_SUSPEND (which gates a checkout block) and the new ENABLE_TDS_194O_GROSS, and moved ENABLE_IRP_UPLOADER to the direct-read env table where it belongs. 06-hierarchy.md is correctly banner-marked `status: dropped`, but its gap block still said the hierarchy columns were "present and inert". They were removed by the #705 freeze. Corrected, and noted the consequence: no hierarchy means no inheritance, no cycle risk and no depth bound. lib/compliance/dpdp.ts asserted Familiarise is "likely NOT an SDF until >= 5M active users". There is no user-count trigger anywhere in the Act or the Rules — s.10(1) designation is qualitative and reachable on data sensitivity alone, which matters for a platform holding recorded mentoring sessions. Replaced with the actual statutory factors. This is the kind of comment that ends up in a customer questionnaire answer. Part of #1132 * feat(enterprise): capture contact leads, align client permission gates with the server, and add the DPDP age gate Contact / enterprise lead capture (new app/api/contact + ContactForm): The /contactus form was a bare <form> with no onSubmit, no action and no `name` attributes on its inputs, so submitting it native-GET'd back to the same page and cleared the fields. Both /enterprise CTAs link there, so every enterprise lead was silently discarded while the page promised a reply in 24-48 hours. Extracted the markup into a client component wired to a new POST /api/contact with real pending/success/error states and field-level errors. The route is public by necessity (a prospect has no account), so it carries a per-IP rate limit, a honeypot that responds indistinguishably from success, and strict length caps. Delivery failures fall through to the existing FailedEmail retry worker, so a transient Resend outage does not lose the lead. Client permission gates (7 sites): The client used the rank ladder where the server uses the permission matrix. BILLING_ADMIN sits at rank 70, below MAINTAINER, so `isAtLeast("OWNER")` on a money control hid it from the one role that exists to use it — wallet top-up, invoice pay/create and payout batches were all authorised server-side and unreachable in the UI. Inversely, the SCIM tab rendered for anyone with `integrations.read` while its routes are requireOrgOwner, and Data exports rendered the same way against requireOrgBillingAdminOrOwner, so both 403'd on load. Added a `can()` helper to useOrgRole and moved these to the matrix keys their routes actually enforce. Purchase-order mutations move from a MAINTAINER rank floor to `purchaseOrders.manage`. This is the drift ADR 19 was written to prevent. DPDP age gate (new lib/compliance/age.ts): India's age of majority is 18 (DPDP s.2(f)), below which processing needs verifiable parental consent (s.9) and behavioural tracking is banned outright (s.9(3)). `dateOfBirth` was optional and never checked, so there was no age gate anywhere, while the privacy page quoted the COPPA age of 13. Added a shared DateOfBirthSchema used by both the canonical user schema and all three onboarding step schemas so they cannot drift, a date input on the personal-info step, and threading through transformFrontendToServerData — a gate that validates a value the database never receives is not a gate. Collecting the DOB solely to confirm non-child status is itself an exempt purpose (Fourth Schedule Part B item 6), so this adds no new obligation. Privacy page corrected to 18. Also moves the 194-O taxable-base rules into lib/compliance/tds-194o.ts so they are unit-testable without pulling Prisma into the test; tds-service re-exports them, so no call site changes. Tests: 30 new cases across __tests__/compliance and __tests__/security covering the GST state-code comparison, the age gate boundaries, the 194-O three-limb exemption, the SSRF guard's blocked ranges, the webhook dedup ordering and the payout freeze. Two existing suites needed updating for behaviour that genuinely changed: the webhook worker now takes an injectable URL guard (its fixtures use the reserved, non-resolving `.example` TLD), and the payout tests force ENABLE_LIVE_PAYOUTS on and stub the balance preflight, which stops short-circuiting once the flag is set. Part of #1132 * fix(enterprise): address CodeRabbit review on #1133 Twenty-one review comments, all verified against the current code before acting. Eleven were real defects this branch introduced. Money / ledger: - markConsultantPayoutReversed still debited CASH by the gross and credited CONSULTANT_PAYABLE by gross+tds. The completion leg now credits CASH with gross-tds, so an unadjusted reversal left excess cash and an overstated payable behind on every reversed payout that withheld TDS. Its own comment already described the correct shape. - The 194-O resolver was fed `cumulativeBeforePayout`, which getCurrentFYCumulativePayments derives from ConsultantPayout.amount — net of our commission — while the resolver expects gross consideration. Mixing the bases delayed the 5,00,000 crossing by the commission fraction and under-withheld. Now aggregates prior-FY ConsultantEarnings.grossAmount. - The gross-base branch required only ENABLE_TDS_194O_GROSS, so it could change the withholding base while TDS_ENGINE was still LEGACY. Now requires both: the gross base is a refinement of the 194-O engine, not its own engine. GST: - placeOfSupply was derived from the raw buyer input while the tax legs used the resolved code, so an invoice could declare state 27 and be taxed as 29. It is now derived after resolution, and reports null when the buyer gave us something unresolvable rather than echoing it or falling back to the supplier's own state. - An unresolvable SUPPLIER state also fails the intra-state test, so reporting it as INTER_STATE_IGST recorded an unverified classification as confirmed. IGST_STATE_UNKNOWN now covers both sides. Onboarding: - The required date-of-birth field sat inside the collapsed "Additional Details (optional)" disclosure, so a submit failed with the input and its error both invisible. Moved outside the collapsible. - `new Date("2001-02-29")` does not throw, it rolls forward to 2001-03-01, so a typo silently became a different birthday. Added isRealCalendarDate and a string branch that validates before conversion. That makes the schema's input and output types differ, so useForm now names both. Webhook egress: - url.hostname KEEPS the brackets on an IPv6 literal, so isIP returned 0 and every IPv6 address fell through to a DNS lookup. Loopback was rejected only because "[::1]" does not resolve — the right outcome for the wrong reason — hex spellings like ::ffff:7f00:1 and ::7f00:1 were not classified at all, and no legitimate IPv6-only endpoint could ever be registered. Now strips the brackets and classifies on the expanded 16 bytes. - Extracted the duplicated guard-to-400 mapping into rejectIfNotPublicUrl. Contact route: - The honeypot used z.string().max(0), so a populated value failed safeParse and returned 400 while a clean submit returned 202 — exactly the signal the silent-success branch exists to deny a bot. - sendContactInquiryEmail returned before `sentMessage` was built when Resend is unconfigured, so the catch could not record a FailedEmail row and the inquiry was discarded — contradicting what the route tells the sender. - Rate-limit key now uses the shared getClientIp, which prefers Netlify's canonical client-IP header over a caller-supplied x-forwarded-for. Reliability: - Raising expire-contracts to Serializable introduced P2034 without a retry: one aborted transaction would throw out of the loop and leave every later contract unprocessed. Wrapped in the existing withSerializableRetry, returning stats from the callback so a retry cannot double-count. - Slot-conflict messages were added to preservedMessages but not modelledOutcomePatterns, so ordinary contention reached Sentry as `expected: false`. Added to both — the two lists are deliberately separate (see the comment at the tagging site), so they were not merged. Tests: - The security tests computed positions with indexOf and asserted on the result without proving the anchor was found; -1 satisfies toBeLessThan and yields an empty slice that satisfies not.toMatch, so a rename would have turned a security regression test green. Anchors are now asserted first. - Added a worker case that omits assertUrlFn, so removing the guard call from runDispatchTick fails a test; hoisted the repeated bypass comment. - New coverage for IPv6 classification, impossible calendar dates, resolved place-of-supply and unresolvable supplier state. 2,635 pass. Docs: - Module-flag count corrected at the two remaining "five" references. - The hierarchy doc's present-tense schema section now reads as historical context, matching the dropped banner above it. - ADR 13 qualifies the CI guarantee: db:push:schema is still standalone and the sidecar guard exits 0 when DATABASE_URL is unset. - Rule 13(4) exists in the Rules as notified; it is its commencement that is deferred and the proposed acceleration that is unnotified. - Privacy page attributes the age-confirmation exemption to the Fourth Schedule of the DPDP Rules 2025, not the Act. Deferred: pinning the validated address at connect time to close the residual DNS-rebinding window needs a custom fetch dispatcher; the guard plus redirect:"manual" closes the practical path. Thread left unresolved. Part of #1132 * refactor(compliance): let Zod validate the date of birth instead of hand-rolled parsing Review feedback: the age gate was doing its own runtime type-checking and date parsing when Zod already validates both, better. Deleted `isRealCalendarDate`. Zod 3.25's `z.string().date()` and `z.string().datetime()` already reject dates that do not exist — verified leap-year and month-length aware: "2001-02-29", "1990-04-31" and "1990-13-01" are all refused, "2000-02-29" is accepted. The string branch now uses those directly, so no regex or calendar arithmetic of ours is involved. It stays a union rather than `z.coerce.date()` for the original reason: coercion converts before validating, and `new Date("2001-02-29")` does not throw — it rolls forward to 2001-03-01 and destroys the evidence that the input was wrong. The two string formats cover both real shapes: `.date()` is the YYYY-MM-DD the date input emits, `.datetime()` is what a Date becomes after the wizard's JSON round-trip between steps. Narrowed `ageInYears`/`isAdult` from `Date | string | null | undefined` to `Date | null | undefined`. The only production caller is the schema's `.refine`, which by then always holds a parsed Date, so the string branch was dead code reachable only from its own tests. Nullish stays because `User.dateOfBirth` is nullable in Prisma, so a caller reading a stored profile legitimately has one. The helpers now do only the arithmetic Zod cannot express — whole-year calculation, the future-date and implausible-lifespan guards. Moved `DateOfBirthSchema` into lib/compliance/age.ts, next to the rule it enforces. schemas/user.ts imports @prisma/client, which cannot load in the jsdom test environment, so the schema was untestable where it was. No re-export — schemas/user.ts and utils/onboarding.ts both import it directly from the owning module. Tests now exercise the schema boundary rather than a helper that no longer sees strings, and cover both accepted string shapes plus the age gate applying through the string branch. Replaced a conditional `expect` that tripped jest/no-conditional-expect — an ESLint error, which fails `next build` even though the lint job is continue-on-error. 2,637 pass. Part of #1132 * fix(enterprise): restrict contract-expiry closure to ACTIVE assignments, and record the 194-O concurrency precondition Second CodeRabbit round. expire-contracts: the ProgramAssignment sweep filtered only on `periodEnd`, so an assignment already CLOSED with a future period end had its end date rewritten and was counted again in `assignmentsClosed`. Pre-existing — the where clause is unchanged since before this branch — but the comment directly above it has always said "their still-ACTIVE assignments", so the query now says it too. ENABLE_TDS_194O_GROSS: documented a second precondition alongside CA approval. `priorGrossAgg` counts earnings that are already PAID, and earnings become PAID only when a completion webhook lands. processApprovedPayouts serialises its own workers, but completion webhooks do not serialise against it, and the schema permits several payouts per consultant per financial year — so two payouts either side of an in-flight PROCESSING one can both read a prior-gross below the ₹5L exemption and both under-withhold. Recorded at the flag rather than fixed, because the flag is off and the fix is a cumulative-gross reservation per (consultant, FY) that belongs with the CA-approved rollout, not smuggled into a residuals PR. Until then the legacy base over-withholds relative to the statute, which is the safe direction to be wrong in. Declined: a style-linter suggestion to rewrite "needs written CA sign-off" as "requires written CA approval". Sign-off is ordinary English and the phrasing is consistent across the flag, the PR and the issue. Still deferred: pinning the validated address at connect time to close the residual DNS-rebinding window. It needs a custom fetch dispatcher that dials the verified IP while preserving the hostname for SNI and Host. The registration and per-delivery checks plus redirect:"manual" close the practical path; both threads stay unresolved so the work stays visible. Part of #1132
…, permanent bans (#1136) * fix(stream): close the P0s — open call access, self-deleting channels, permanent bans (#1134) Six P0s from the #1134 audit, four of them confirmed against the live Stream app and the production database rather than by reading code. P0-1/P0-2 — any signed-in user could join any call. The `default` call type grants `join-call` to the plain `user` role, no token was call-scoped, and the only gate was a React conditional, so `client.call(type, id).join()` from devtools opened any consultation. Worse, `useGetCallById` ran `getOrCreate()` in parallel with the access check, so an unauthorized visitor minted a real billable call and became its `created_by` before seeing "Access Denied" — `default:smoke-test-nonexistent` and `default:test-meeting` were still sitting in production months later. - scripts/stream/ensure-call-type-grants.ts moves `join-call` off `user` onto `call_member` (dry-run by default, reversible with --restore-user-join). Hardens `default` in place because a call's type is immutable, so a new type would protect only future calls. - POST /api/meetings/[id]/join is now the sole grantor of membership, and grants only after resolveMeetingAccess passes. Membership rather than a `call_cids` token: the video client is an app-wide singleton holding one user token, and the JS SDK has no per-call token on a shared client. - The client creates nothing. validate-access keeps its read-only probe role and shares the one resolver so the two can't drift. P0-3 — `getDmChannelId` sorted with `localeCompare`, which orders by ICU collation and varies with build and locale. Commit 0116209 swapped `.sort()` for it and silently re-keyed most pairs; both variants were still live. Now code-unit ordering, pinned by tests using the real production ids that disagree. P0-7 — `consultation-<id>` / `subscription-<id>` channels deleted themselves. syncUserEventChannels expected only webinars, classes and DMs while treating both prefixes as MANAGED, so each was swept on the buyer's next dashboard load. Removed: the pair already gets a DM, and createConsultationChannel minted a DM anyway. Prefixes stay for legacy type resolution, out of MANAGED so survivors are left alone. P0-8 — all four createDirectMessageChannel call sites omitted organizationId, so an org-funded booking's DM landed on the personal `dm-` key while the reconciler expected `dmo-` — and then swept it. Threaded through, precedence matching bookingOrgId(). P0-4 — chat tokens were minted without `iat`. Stream treats an iat-less token as invalid once revocation is active, and that flag never clears, so a 7-day suspension was a permanent chat ban. Added, plus restoreStreamAccess() for lifting a deactivation. P0-5 — STREAM_WEBHOOK_SECRET is absent from Netlify and the route 500'd without it: 0 stream WebhookEvents, 0 MeetingAttendance, 0 of 1,663 sessions ended. Stream signs with the API secret, so that is now the default. Also adds lib/stream/call-cid.ts, one definition of the `type:id` split that four sites had each reinvented, and reports the payment-time channel provisioning failure to Sentry instead of claiming a nonexistent sync job will catch up. Part of #1134 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019pbBn6yWAr2DXjfACyocUv * fix(stream): correct the grants fix against LIVE call-type data (#1134) Adversarial validation against the running Stream app found the P0 fix would have caused a total video outage, and would not have closed the hole it claimed to. Both verified by reading the live `default` call type, not by reasoning. The grants map has exactly six role keys: guest, user, call_member, admin, global_read_only, global_admin. There is NO `host` key and no `moderator` key. 1. LOCKOUT. The join route assigned `role: "host"` to consultants and `"user"` to everyone else. `host` is not a key in the grants map, so it confers nothing; `user` was about to lose `join-call`. Applying the script as written would have refused BOTH sides of every 1:1. Now always `call_member`, which is a strict superset of `user` on the live type (34 perms vs 33). Nothing is lost: host-ness in the UI comes from `custom.consultantUserId` via useCallCustomData(), never from the Stream role. The script now refuses to write a config where call_member lacks join-call. 2. GUEST. `guest` also holds `join-call`, and the app has `guest_user_creation_disabled: false` — guest sessions are creatable client-side with nothing but the public API key, which we ship as NEXT_PUBLIC_STREAM_API_KEY. Stripping only `user` would have left the devtools bypass fully intact behind a fix advertised as closing it. 3. `user` also holds `end-call`, `start-recording` and `stop-recording`. Any participant could end a call for everyone or start a recording directly from the client SDK — which walks straight around the consent gate in /api/stream/recordings/start, since that gate only guards our own endpoint. Stripped in the same pass. The old PRIVILEGED_ROLES loop was inert: `moderator`/`host` are not keys so the guard skipped them, and `admin`/`call_member` already held join-call. It read as protection and provided none. Removed. Also corrects the comment in lib/meeting.ts asserting that "the default call type does not restrict entry to members ... so naming members cannot turn a working join into a refusal". True when written; the exact opposite after this change, and precisely the comment someone would read while debugging a lockout. Part of #1134 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019pbBn6yWAr2DXjfACyocUv * fix(stream): address PR review — heal call-less meetings, share the org resolver Triage of the 11 inline comments plus the outside-diff finding on #1136. Ten were real, one was wrong, and three were understated in ways that only showed up against the live Stream app. Verified live, because the docs disagree: the `default` call type's grants map has exactly six role keys — admin, call_member, global_admin, global_read_only, guest, user. There is no `host` and no `moderator`, and it is spelled `call_member`. Stream's published docs list five built-in roles including both of those and spell it `call-member`, which is what led review to suggest initialising grant entries for roles this app never assigns. No change made there. Three findings review understated: A MeetingSession row does not imply its Stream call exists. The seeds write rows with faker ids and no Stream object, `createDbMeetingSession` is a "use server" action validating the id as only `z.string().min(1)`, and maintenance drain ends the call while keeping the row. lib/meeting.ts skips its own getOrCreate whenever a row exists, and P0-2 removed the client-side one that used to paper over it — so resolveMeetingAccess granted access and updateCallMembers then 500'd. The join route now calls getOrCreate after the access check. Creating after authorization is the ordering P0-2 was about; creating before it was the vulnerability. The comment in lib/stream-utils.ts pointed at __tests__/stream/types.test.ts as the guard against localeCompare ordering. That test never imported getDmChannelId — it declared a local lambda that sorted with localeCompare and asserted that, so it would have passed straight through the regression it read like a guard against. It calls the real function now. The DM org divergence was in the query, not the precedence. All eight sites agreed on plan-then-appointment; the creator filtered `appointments` to org- tagged rows while consumers read an unordered `[0]`, so a mixed subscription got `dmo-…` from one and `dm-…` from the other. bookingOrgId moves to lib/stream-utils.ts, uses `find` rather than `[0]`, and the three `take: 1` reads gain the filter — truncation happens server-side, before `find` can run. Also found while verifying, not raised in review: call_member held start-recording and stop-recording, and the join route assigns call_member to every participant — so revoking them from `user` alone changed nothing while reading as a fix, and left the pre-join consent gate bypassable. Recording is server-only here (no client-side call.startRecording exists), so they are revoked from call_member too. `end-call` is deliberately kept: EndCallButton calls call.endCall() client-side and the Stream role no longer separates host from participant. Tracked separately rather than half-fixed. The refuse-guard could never fire — the transform adds join-call to call_member a few lines above the check, making the condition false by construction. It now runs against the post-apply re-read, where it can genuinely fail. That is the second zero-branch safety net in this file. updateCallType has undocumented merge-vs-replace semantics and the chat twin is a full replace, so the script snapshots settings and notification_settings, re- reads after applying, and writes a recoverable pre-image to disk on any drift. Re-sending them would require casting CallSettingsResponse into CallSettingsRequest, which could corrupt the config on its own. Remaining: MeetingAccess carries a `reason` discriminator so neither route infers its status from message text; the class/webinar participant check is an existence query rather than a slot fan-out; restoreStreamAccess rethrows anything that is not the already-active response; the consultation-approval catch reports to Sentry like its subscription twin; test fixtures are synthetic rather than real production user ids. tsc clean (cold), eslint clean (the two remaining warnings are byte-identical at HEAD), 236 suites / 2677 tests pass. Dry-run verified against the live app. Part of #1134 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019pbBn6yWAr2DXjfACyocUv * fix(stream): second review round — org divergence in the webhook, healthy-path noise Four follow-up comments on #1136, all legit, all owned by this PR. The webhook handler was still resolving the DM org from the appointment being paid for. A subscription carries many appointments and is funded once, so for a mixed subscription that row can be the personal one — while createSubscriptionChannel resolves the first ORG-tagged row and mints `dmo-…`. Same pair, two channel ids: exactly the divergence this PR set out to close, surviving in the one site converted by hand. The query now loads an org-tagged subscription appointment and passes it to bookingOrgId, filtered in the query because `take: 1` truncates before `find` can choose. restoreStreamAccess reported the expected already-active response to Sentry before checking whether it was expected, so a lifted suspension — the healthy path — paged every time. Checked first now; only unexpected failures are reported, and those still throw. The settings-drift comparison used raw JSON.stringify over two independent getCallType reads, where key order is not guaranteed. A false positive there tells the operator Stream discarded a config it never touched, which is an expensive thing to be wrong about. Comparison is canonical now, sorting by code unit rather than localeCompare. The drift test drove the pre-image write into a real file in tmpdir on every run and left the payload unasserted — the payload being the only copy of the config Stream would have discarded. node:fs is mocked and the pre-image is asserted, plus a case pinning that key-order-only differences do not report drift. tsc clean, eslint clean, 236 suites / 2678 tests pass. Part of #1134 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019pbBn6yWAr2DXjfACyocUv * fix(stream): a Stream outage is 503, and is not an access denial Two findings routed to this PR from #1138's review — both about files this PR owns, and the first is made worse by the getOrCreate added in the last round. `useGetCallById` treated EVERY non-ok response from the join route as an access denial, defaulting the copy to "You are not authorized to join this meeting". So a 500, a 503 or a 400 told a legitimate participant they had been refused, with no retry affordance and no hint that anything was actually broken. Adding getOrCreate widened that: a Stream outage now reaches the client as a failure on a path that previously could not fail. Only 401/403/404 are verdicts now; everything else throws and gets the error UI. The join route returned 500 for a circuit-breaker trip. Stream being down is neither our fault nor the caller's, so it is a 503 with a retry message, which also keeps a provider outage out of the bucket that means "we broke something". `reason` stays absent on it — that field marks an authorization verdict, and an outage is not one, which is what lets the client tell them apart. The catch logs a hoisted meetingId rather than re-awaiting `params`, since re-awaiting would rethrow if `params` was itself what failed. Tests pin both: an outage is 503 with no `reason`, a genuine fault is still 500. The mocked error class is built inside the jest.mock factory — it is hoisted above every const in the file. tsc clean, eslint clean, 236 suites / 2680 tests pass. Part of #1134 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019pbBn6yWAr2DXjfACyocUv * fix(stream): make the org-tagged appointment choice deterministic, not just filtered Follow-up to the shared resolver. Filtering `appointments` to org-tagged rows fixed the case where a caller saw a personal row; it did not fix the case where two callers see DIFFERENT org-tagged rows. `take: 1` over a result with no `orderBy` — and `find` over one — is whatever Postgres returns, so a subscription carrying two org-tagged appointments could still resolve two different orgs across the creator, the webhook, the reconciler and the search route. Same divergence this PR set out to close, one layer down. Every appointment read that feeds bookingOrgId now orders by `[{ createdAt: "asc" }, { id: "asc" }]` — createdAt alone is not enough, because appointments created in one transaction share a timestamp. Eight sites: the five filtered `take: 1` reads and the three transaction-scoped reads in the subscription approval route that load the full list. Note this is the deterministic-resolver half of the review's suggestion. The structural half — storing the organization on `Subscription` so there is one canonical answer rather than a convention every caller has to honour — is a schema change and is tracked in #1144 rather than bolted on here. Pinned by a test asserting the ordering is present at every site, alongside the existing filter assertions. tsc clean, eslint clean, 236 suites / 2685 tests pass. Part of #1134 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019pbBn6yWAr2DXjfACyocUv --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…r that never ran (#1137) * fix(stream): make the webhook path durable and schedule the reconciler (#1134) The P1 durability cluster. Every one of these compounds the P0-5 webhook outage: even once the secret is set, nothing was going to survive a transient failure. P1-1 — reconcile-orphaned-sessions has NEVER run. Its docstring claimed "every 30 minutes via cleanup API route"; there are 64 workflows and none invoked it, and nothing called the route. It is the only backstop for a session whose `call.session_ended` never landed, which — with the secret missing — was every session. Added the workflow. Also fixed two bugs in the job itself: it was the one site passing `streamCallId` to Stream without normalising the cid (so a prefixed value always 404'd and was recorded UNVERIFIED), and it had no breaker, so a Stream outage meant 100 sequential 30s timeouts per run. P1-2 — Stream retries at most five times inside a FIFTEEN SECOND total budget, six seconds per attempt, then drops the event forever. The route was doing a DB health probe, an idempotency read, the handler and the completion mark inside that, on a platform where this repo has measured ~30s of event-loop stall on instance boot. It now verifies the signature, acknowledges, and processes in after(). Durability moves to sweep-stuck-webhook-events, which previously filtered `provider: "razorpay"` and would never have touched a Stream row. That required moving the dispatch out of the route — a Next route module cannot export anything but its HTTP handlers, and the sweeper needs to call it. The schemas and switch now live in lib/stream/webhook-dispatch.ts. The three shared bookkeeping helpers moved from app/api/webhooks/utils.ts down to lib/webhooks/event-log.ts (lib/ may not import from app/), re-exported from their old home so no existing caller changes. P1-9 — idempotency now keys on Stream's `X-Webhook-ID`, which is documented as stable across the retries of one delivery. The hand-rolled key collapsed to `stream_<type>_chat_<created_at>` for chat events, so two flags in the same second deduped to one, and participant joined/left omitted the user id, so two people joining in the same second collapsed into a single attendance write. P1-3 — the maintenance drain selected `{ endedAt: null }` and called that "active". That matched 1,663 rows going back months, each of which it would have ended on Stream serially and stamped `endedReason: "maintenance"` — rewriting the history of sessions that finished in February. Now bounded to a 6h window with a 200-row cap. It also never set SlotOfAppointment.completionStatus, so a drained session sat SCHEDULED forever and its earnings never became releasable; and `result.notified` reported a per-participant count for what is actually a platform-wide broadcast taking no recipient list. Both fixed, and participants now get an in-call event telling them why they were disconnected. P1-4 — nothing froze chat during maintenance, so messages kept flowing while the app was offline and unable to sync or moderate them. Group channels for drained appointments are frozen. P1-5 — one `call_cid` helper (lib/stream/call-cid.ts) replaces four reimplementations of the `type:id` split. #473 — the breaker covered chat only; every video/server path called getStreamVideoClient() raw, and /api/health had no Stream check. Both closed. Part of #1134 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019pbBn6yWAr2DXjfACyocUv * fix(ci): stagger the reconciler cron off reconcile-orphaned-confirmations `13,43` is reconcile-orphaned-confirmations' slot (`13-59/30`). Two half-hourly jobs on the same minute start together every single time rather than occasionally, and scripts/ci/check-workflow-hygiene.ts fails the build on exactly that — which is why this PR and every PR stacked above it have been red since it was opened, not because of anything they changed. Of the sixty minutes, only :00, :03, :10, :15, :25 and :55 are unclaimed by an existing sub-daily schedule, and :25/:55 is the only pair thirty minutes apart. `check-workflow-hygiene` now reports ok across 65 workflows with no recurring start collisions. Part of #1134 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019pbBn6yWAr2DXjfACyocUv * fix(ci): harden the reconciler workflow's install steps SonarCloud flags three supply-chain issues on this workflow (githubactions:S6505 ×2, S8543): `npm ci` and `npx` both run lifecycle scripts by default, and the runner holds deploy-scoped secrets — so a compromised transitive dependency's postinstall would run with them. `npx --yes` without a pinned version compounds it. Matched to the posture expire-reschedule-proposals.yml already uses: `npm ci --ignore-scripts`, `npx --no-install --ignore-scripts prisma generate`, and `--ignore-scripts` on the pinned tsx invocation. Nothing here needs lifecycle scripts — the workflow runs `prisma generate` explicitly on the next step, which is exactly why the existing pattern does both. These were failing the `new_security_rating` quality gate at 3, not any test. Part of #1134 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019pbBn6yWAr2DXjfACyocUv * fix(stream): make the durability argument non-circular Seventeen review findings routed to this PR by file ownership; ten were legit and they share one root. The ack-first design is right and stays — Stream retries inside a 15-second total budget at 6 seconds per request, which a cold Netlify instance cannot fit a full handler into. What was wrong was the durability ARGUMENT, which was circular: it said durability comes from the WebhookEvent row and the sweeper, while the row itself was written inside `after()`, on the far side of the acknowledgement. Three paths therefore lost a first delivery silently, and in every one Stream had already been told 200 and would never redeliver: the instance freezing before `after()` ran; the DB-health probe returning early before anything was written; and logWebhookEvent itself failing. The sweeper can only re-drive rows that exist, so none was recoverable. `recordStreamEventReceipt` splits out the durable half so the route can call it BEFORE acknowledging. One indexed insert of an already-parsed body fits inside the six-second timeout where the handler does not — which is the whole reason the handler moved to `after()`. A failure there now returns 503 so Stream redelivers, which is correct precisely because nothing was recorded. The in-progress guard was defeated for every row it most needed to protect. The retry path reset `processed` and `error` but never `receivedAt`, and the staleness escape measures `now - receivedAt` — so a retried row was instantly older than the five-minute threshold and any worker would re-claim an event another was mid-way through. Both escapes were also check-then-act: read, decide, write, with two workers able to win simultaneously. Both are conditional writes now, and the affected row count IS the claim. The handled-event list and the dispatch switch were two independent lists that could drift silently. They are bound by a `never` assertion in the default branch now. Written first as `eventType as never`, which compiles unconditionally and verifies nothing — the same zero-branch shape this audit keeps finding. The cast is gone; the guard narrows first, and adding a list entry without a case now fails tsc. Verified by adding a fake entry and watching it fail. Malformed JSON returned 500 rather than 400, because JSON.parse throws SyntaxError and only ZodError was checked — so Stream spent its whole retry budget redelivering a body that could never parse. The fallback event key also dropped `.filter(Boolean)`, which collapsed positions and could align two different events onto one key. In the drain: it stamped a session ended even when call.end() failed or the breaker tripped, so a call could still be live and billing while the row claimed it finished, with nothing to revisit it. `endedAt` is only written on a confirmed end now. The window bounded `endsAt` but not `startsAt`, so a room opened early would be drained before it happened. The courtesy warning sat outside the breaker with no timeout on the video client — 200 serial iterations of an unbounded call during the very outage it announces — and is now deadlined at 2s. And `Promise.allSettled` inside the circuit breaker meant the breaker recorded success however many channels failed to freeze, with not one failure reaching result.errors. Declined: pinning actions to SHAs, since zero of 66 workflows do and that is a repo-wide decision, not a gate on this PR. Added `permissions: contents: read`, matching expire-reschedule-proposals.yml. tsc clean, eslint clean, 238 suites / 2698 tests. Part of #1134 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019pbBn6yWAr2DXjfACyocUv * fix(stream): ship the freeze with its inverse, and bound the recording calls Three follow-ups on this PR's own code, moved here from downstream because this is the PR that introduces the thing being fixed. The drain freezes group chat channels on the way into OFFLINE. The unfreeze was sitting four PRs later in #1141, which meant merging this one alone left a release window where ending maintenance silently bricked group chat: Stream grants `use-frozen-channel` to NO role by default, so a channel left frozen is unwritable by every user and every admin with no visible cause. A PR that introduces a freeze ships its inverse. `unfreezeChannelsAfterMaintenance` is wired into the maintenance exit, scoped to sessions the drain actually ended (`endedReason: "maintenance"`) inside the recent window so it cannot unfreeze a channel a moderator froze deliberately, and its partial failures are both reported to Sentry and returned to the operator — a silent unfreeze failure is indistinguishable from success, and invisibility is the whole problem with a frozen channel. The `maintenance.draining` custom event is gone rather than bounded. My previous commit put a 2s deadline on it, which was fixing the wrong layer: nothing in the client subscribes to `call.on("custom", …)`, and `end()` fires microseconds later, so no toast could paint even with a listener. Counting Stream's acknowledgement as a person warned is the same fabricated metric this function was already fixed for twenty lines below. Restore it together with a subscriber in MeetingRoom.tsx and a delay before end() — not before. The circuit breaker on the channel freeze was in the wrong position, and my previous commit only half-fixed it. Wrapping `Promise.allSettled` reports failures into `result.errors` but the breaker still sees a resolved promise however many channels failed — it cannot trip during the very outage it exists for. The breaker belongs inside the map, per channel, which is what the unfreeze already did. `lib/stream/recording-service.ts` had zero breaker coverage. Three of its sixteen methods touch Stream (`startRecording`, `stopRecording`, `getCallRecordingsFromStream`); the rest are Prisma-only. This matters twice for `stopRecording`, which the maintenance drain calls in a loop of up to MAX_DRAIN_BATCH sessions — an unbounded call there holds the OFFLINE transition open for the length of the outage it is transitioning for. That was a second unbounded path in the same loop as the one bounded last commit. Also verified and NOT changed: review flagged as Critical that the workflow never invokes the reconciler. It does. The package has no `"type"` field, so tsx runs CommonJS, `require.main === module` is true when the workflow executes the file directly, and the guard exists because `app/api/cleanup/reconcile-sessions` imports the same function. tsc clean, eslint clean, 238 suites / 2698 tests. Part of #1134 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019pbBn6yWAr2DXjfACyocUv * fix(stream): stop an empty error message reading as success Four accuracy and correctness items from review, one of which is a real defect in the state machine this PR just hardened. `markWebhookEventProcessed` wrote `error: error || null`. A handler throwing `new Error("")` produces an empty `processingError`, and `"" || null` collapses to null — which is the SUCCESS shape in the three-state machine. The event had failed and was permanently marked handled, and the sweeper's selector explicitly skips `processed=true, error=null`, so nothing would ever revisit it. `??` now, with an empty message becoming a readable placeholder rather than a silent success. Its docstring also claimed processed=true is set "only on success", which the implementation contradicts and has to: processed=true with a non-null error IS the FAILED state that logWebhookEvent re-drives. The route asserted "at most five times" in two comments. Stream's own documentation contradicts itself here — the webhooks overview gives 3 attempts for 408/429/5xx and 2 for network errors, while their retries announcement says "a maximum of five attempts, whichever comes first". Both agree on six seconds per request inside a fifteen-second total budget, and the budget is what this design turns on, so the comments state that and no longer assert a count. The sweeper's terminal marker was `gave up: payment never arrived`, written for Stream events too now that the sweep covers both providers — sending whoever reads the row looking for a payment that was never involved. It is provider-aware, keeping the `gave up:` prefix that the selector matches on. And `processStreamEvent`'s outer catch still warned the event "may be lost" because no row would exist for the sweeper. That stopped being true one commit ago, when the receipt moved ahead of the acknowledgement. Still paged on — broken completion bookkeeping is worth knowing — but it no longer describes a loss that cannot happen. tsc clean, eslint clean, 238 suites / 2701 tests. Part of #1134 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019pbBn6yWAr2DXjfACyocUv --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n debug, give trials chat (#1138) * fix(stream): 1:1 recording, Stream rate limits, debug lockdown, trial chat (#1134) P1-6 — recording a 1:1 was not disabled, it was impossible. isAppointmentOwner and isRecordingEnabledForAppointment each hand-rolled an if/else over webinar and class only, so for a consultation or subscription both fell through to false: the consultant who OWNED the session failed the ownership check and start-recording answered 403 to them, while recording-info reported recordingEnabled:false regardless, above a comment noting the 1:1 plans had no such field. They have it now. ConsultationPlan and SubscriptionPlan gain recordingEnabled + recordingStoragePolicy mirroring WebinarPlan/ClassPlan, defaulting OFF — a 1:1 is the most sensitive session type here, so recording stays an explicit per-plan opt-in. Both predicates now read one resolveAppointmentPlan(), so they cannot disagree about which plan they are looking at, which was the shape of the original bug. The three routes that gate on them had to learn to fetch the 1:1 relations too; recording-info already resolved them for the consultant id but not for the flag. P1-11 — no Stream route or server action had ANY rate limit; neither /api/stream/ nor /api/meetings/ appeared in RATE_LIMIT_RULES. Two budgets: streamJoinLimiter (20/min) on the meeting join gate, which is the enumeration surface now that call ids are deterministic `slot-<anchorSlotId>`; and streamApiLimiter (60/min) on /api/stream/*, where every unbounded request was also a billable Stream API call. P1-12 — /api/stream/debug had no session check at all. Its only production gate was a shared secret in the QUERY STRING — which lands in access logs, browser history and Referer headers — and it dumps an arbitrary user's full Stream channel list. Now requires a session and staff/admin, with the secret demoted to defence in depth. Also fails closed when STREAM_DEBUG_SECRET is unset. P1-16 — TRIAL had no branch in channel provisioning and "trial" was not even in eventTypeSchema, so a trial buyer got video and no way to message the consultant. A trial is the platform's first impression and was the one session type that shipped mute. It gets the same DM as any other 1:1, so the thread merges if they go on to book. Part of #1134 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019pbBn6yWAr2DXjfACyocUv * fix(test): type the recording-capability fixtures tsc rejected the `unknown` plan param — I ran the typecheck before adding this file and only ran jest afterwards, so CI would have caught what I did not. Part of #1134 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019pbBn6yWAr2DXjfACyocUv * fix(stream): stop the edge rate limiter throttling Stream's webhook deliveries The `stream: api` rule matched `/api/stream/` by prefix, which swept in `/api/stream/webhooks`. That is worse than an ordinary throttle. Stream POSTs every delivery from its own infrastructure, so all of them collapse onto one rate-limit key rather than spreading across users, and bursts are the normal shape — a 200-attendee webinar emits 200 `call.session_participant_joined` events at once. A 429 is not a deferral: Stream retries 408/429/5xx inside a fifteen-second total budget and then drops the event permanently. So this would have silently reintroduced exactly the loss #1137's persist-before-ack work exists to prevent, and done it in the middleware, before the route ran, where none of that machinery applies. One PR making webhooks durable and the next making them droppable. Excluding the path is safe because the endpoint is not open: it verifies an HMAC signature against the API secret and 401s anything unsigned before doing any work. The signature is the gate; the limiter never was. Separately, the join rule's comment claimed it was "keyed per user by the shared resolver". It is not. `applyEdgeRateLimits` falls back to the client IP whenever a rule supplies no `key`, and this rule supplies none — per-user keying is not available here by design, since the middleware is cookie-presence only with no DB hit and no JWT parsing. IP-keying is the right shape for enumeration anyway, because a walker works from one address; the cost is that shared-NAT users share a bucket, which is why the limit is generous. The comment now says so. Pinned by a test that asserts the rule table directly rather than booting the middleware, since the matcher predicates are the whole behaviour and the middleware pulls in the edge runtime, Redis and the maintenance store. tsc clean, eslint clean (the one remaining warning is pre-existing on dev), 240 suites / 2717 tests. Part of #1134 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019pbBn6yWAr2DXjfACyocUv --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
… works (#1139) * feat(stream): pre-join recording consent with a refusal that actually works (#1134 P1-7) Before this, a consultee's first sign that a session was being recorded was a small `REC hh:mm` pill that appeared AFTER recording had already started. No pre-join notice, no way to refuse, and no record that anyone had been told — on a platform whose 1:1 sessions are career and health conversations. #1138 makes 1:1 recording possible for the first time, which makes this urgent rather than theoretical, and is why that flag defaults off. Two regimes, because one rule cannot be honest for both: 1:1 (consultation / subscription / trial) — a real opt-out. Declining costs nothing: they still join, and POST /api/stream/recordings/start returns 409. If a refusal has no effect it is not consent, it is a notice wearing a consent costume. Group (webinar / class) — notice and acknowledgement. The recording IS the product: attendees buy the replay and it was disclosed at purchase. One attendee cannot veto what 199 others paid for, so the API refuses to write DECLINED for a group session and the copy says plainly that opting out means cancelling for a refund. The gate also ignores any DECLINED row that reaches a group session by another route, so a legacy or hand-written row cannot kill a replay. New `MeetingRecordingConsent`, unique on (meetingSession, user). Its own model rather than columns on MeetingAttendance: those rows are written by Stream's participant webhooks at join time, while consent is a lobby decision taken BEFORE joining, and a compliance record must not depend on a webhook having fired. Not ConsentArtifact either — that is account-level with no session key. `noticeVersion` records which wording was shown, so a later copy change cannot retroactively reinterpret an old decision. The prompt renders in the existing lobby and disables Join until answered, so nobody reaches the call without having seen it — and only when the plan has recording enabled, so it stays off the overwhelming majority of sessions. The notice fetch fails OPEN: a lobby must never trap someone because a disclosure endpoint 500'd, and recording is separately gated server-side, so failing open cannot produce an unconsented recording. Two deliberate details. Decline and Allow carry equal visual weight — making the refusal quieter is how a consent prompt becomes a dark pattern. And the host-facing block reason never names who declined, because telling a consultant which participant refused makes refusing socially costly, which is the same as not offering the choice. Part of #1134 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019pbBn6yWAr2DXjfACyocUv * docs(stream): say plainly that consent enforcement is start-time only Review found that a refusal has no effect once a recording is already running, in the PR whose title is "a refusal that actually works". The finding is right, and the gap is reachable rather than theoretical: `recordRecordingConsent` upserts, so a participant who granted in the lobby can switch to DECLINED at any point, and a late joiner can decline while a recording the host started earlier keeps going. In the OPT_OUT regime that leaves the refusal inert for the rest of the session. Closing it means the decline path reading `MeetingSession.isRecording` and, when a recording is live, stopping it through the same route the stop endpoint uses and clearing the recording columns. That is a product decision rather than a refactor. It hands any participant the ability to terminate a host's in-progress recording mid-call, and getting it wrong costs a consultant a session they believed was being recorded. Guessing at it unattended is worse than tracking it. So this takes the reviewer's own stated alternative: record the gap where someone will actually read it, rather than let the contract imply enforcement the code does not provide. The SCOPE note sits on `getRecordingBlock`, which is what the start route consults, and the decline write path now says what it does not do. No behaviour change. Part of #1134 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019pbBn6yWAr2DXjfACyocUv * fix(stream): make the decline check part of the recording claim, not a read before it Two review findings on the consent gate, one a real race and one a scope limit worth stating loudly. The race: `getRecordingBlock` counts DECLINED rows, and the start route then claims `isRecording` in a separate `updateMany`. A participant committing a decline in the window between them meant recording started despite a refusal already in the database — check-then-act with a compliance record as the loser, the same shape as the webhook claim fixed in #1137. The consent condition is now part of the claim itself, as a relation filter that Postgres evaluates at write time, so a decline that commits first makes the update match zero rows. No transaction needed. The earlier read stays because it produces the specific user-facing reason; the predicate on the write is the part that has to be true. On a zero-row result the route re-reads to distinguish "a decline landed" from "already recording", so a refused host is not told the wrong thing. The scope limit: this gate guards an HTTP route, not Stream. On the LIVE call type `call_member` — the role every participant gets at join — still holds `start-recording` and `stop-recording`, so `call.startRecording()` from devtools walks straight past the 409. #1136 ships the script that strips both, but merging #1136 does not RUN it; it is an operator action still outstanding. Until it is applied, this feature is advisory, and the module now says so where someone will read it rather than leaving the contract to imply enforcement that is not switched on yet. tsc clean, eslint clean, 243 suites / 2742 tests. Part of #1134 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019pbBn6yWAr2DXjfACyocUv * refactor(stream): one definition for the recording-refusal response SonarCloud failed #1139 on new-code duplication at 4.5% against a 3% threshold — the only condition not passing. The cause was mine: the race fix added a second `getRecordingBlock` check on the zero-row path whose refusal response was near-identical to the pre-claim gate's. Both now go through one `refuse()` helper, which also means the "recording refused" log line has a single call site rather than one branch logging and the other silently returning. Note for anyone reading the Sonar report: it also lists a cross-file duplication between this route's preamble (lines 22-45) and stop/route.ts. That one is pre-existing, untouched by this PR, and is the shared auth-and-parse opening the two recording routes have always had. Worth extracting eventually, but not here — it is not what failed the gate. tsc clean, eslint clean. Part of #1134 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019pbBn6yWAr2DXjfACyocUv --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t channels (#1140) * perf(stream): chunk bulk calls, bound the sync cache, and expire event channels (#1134) The P1 scale cluster. None of this bites at current volume; all of it bites before we would notice. P1-20 — every bulk Stream call passed the whole array through. Stream caps upsertUsers, addMembers/removeMembers and deleteChannels at 100 per request (verified against current docs), so a webinar roster above 100 — the plan default, and Webinar.maxParticipants is unbounded — produced an oversized request that threw into a catch which, before #1136, did not even reach Sentry. The attendee silently got no chat. lib/stream/batch.ts now owns the ceiling and chunks sequentially, because firing twenty chunks at once to shave latency off one create is how you 429 every other request in flight. P1-19 — syncUserEventChannels was awaited inside connectChat, before setChatConnected(true). It costs roughly 1 + W + C + D + ceil(N/100) Stream round-trips in batches of five, so a consultant with 200 clients waited 8-20 seconds with chat apparently dead. Now fire-and-forget: the socket is up in under a second and channels stream into the sidebar as they land, since it already re-renders on Stream events. The in-flight marker moved to BEFORE the call — marking on completion let a re-render start a second sync while the first was still running. P1-18 — initialSyncCompletedUsers was a plain Set that nothing evicted; "cleared only on server restart" is a memory leak with a nicer name. At 100k users a warm instance retained 100k strings, growing monotonically. Bounded at 10k with FIFO eviction and recency refresh. Evicting early is harmless: one user re-runs a sync that is already idempotent. P1-17 — nothing ever ended a webinar or class chat. getWebinarIdsForUser has no date filter, so the reconcile pass could never mark a finished event stale and attendees stayed members forever — unbounded growth on a per-MAU product with no retention answer. New daily job: freeze at +7 days (history readable, no new sends — long enough for the follow-up Q&A, which for a cohort is often where the value lands), hard-delete at the org's existing streamRecordingRetentionDays rather than inventing a second number to explain. Both stages idempotent, so a partial run resumes. A webinar spans many appointments but one channel, so the job collapses to the LATEST end across them — freezing on the earliest would cut off a channel whose later sessions are still running. P1-21 — stream-sync's Redis lock TTL was 10 minutes, shorter than the run it guards: 100k users is 1,000 pages with a 500ms sleep per deletion (8 minutes of sleep alone) plus a Stream round-trip and a Prisma query each. The lock expired mid-run and a second scheduled run could start deleting concurrently. TTL is now 40 minutes with the workflow timeout raised to 35 so the lock always outlives any run that can exist, and requireLock defaults to true — proceeding without a lock made the one guard against concurrent deletion advisory. Part of #1134 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019pbBn6yWAr2DXjfACyocUv * docs(skill): correct the Stream skill against live app + current docs Two validation passes found factual errors in what I encoded here. - Retry budget is 3 attempts (2 on network error), not 5. - The access mechanism is call MEMBERSHIP, not call-scoped tokens — the JS SDK has no per-call token on a shared client, and ours is an app-wide singleton. - Added the live grants map: six role keys, no `host`, no `moderator`. `guest` holds join-call with guest creation enabled; `user` holds end-call and start-recording, so server-side endpoint gates do not constrain a client calling the SDK directly. - package.json ranges are not installed versions. video-filters-web ships as a hard dep of the video SDK; only audio-filters-web needs installing, and it is billed per participant-minute. - The CSP blocker for filters is connect-src (unpkg), not worker-src. Part of #1134 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019pbBn6yWAr2DXjfACyocUv * fix(ci): harden the expire-event-channels workflow's install steps Same three SonarCloud supply-chain findings as the reconciler workflow (githubactions:S6505 ×2, S8543), same fix, same reasoning: `npm ci` and `npx` run lifecycle scripts by default on a runner holding deploy-scoped secrets, and `npx --yes` unpinned compounds it. Matched to expire-reschedule-proposals.yml: `npm ci --ignore-scripts`, `npx --no-install --ignore-scripts prisma generate`, and `--ignore-scripts` on the pinned tsx call. Part of #1134 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019pbBn6yWAr2DXjfACyocUv * fix(stream): bound the channel-expiry scan, and lock down its workflow Two Major findings on this PR's own files. The expiry job scanned EVERY ended webinar and class in history on every daily run, then re-issued `deleteChannels` for channels deleted months earlier. Both stages are idempotent, which is why nothing broke and why it was easy to miss — but the work grew monotonically with the product and every run paid for it in Stream API calls, forever. An event is fully handled once `endsAt + retentionDays` has passed, so anything older than the longest retention we honour has nothing left to do. The query now carries a lower bound of 365 + 60 days. The margin is what makes that safe: the job can be down for a month, or an org can carry a longer dial than the 90-day default, and the window still covers it. Generous rather than derived on purpose — being wrong in this direction costs one wasted query, being wrong the other way leaves a channel undeleted forever. A 5,000-row cap backstops a pathological run. The workflow ran with the default `GITHUB_TOKEN` scope and left the token in `.git/config` after checkout. It only reads the repo to run a script, so it now declares `permissions: contents: read` and `persist-credentials: false` — the same posture applied to the reconciler workflow and already used by expire-reschedule-proposals.yml. check-workflow-hygiene passes across 66 workflows. tsc clean, eslint clean, 242 suites / 2735 tests. Part of #1134 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019pbBn6yWAr2DXjfACyocUv * fix(stream): stop the freeze stage firing 100 concurrent requests at Stream `STREAM_BATCH_LIMIT` is a PAYLOAD ceiling — how many items fit inside one request — and the delete stage uses it correctly, sending 100 cids in a single `deleteChannels` call. The freeze stage borrowed the same constant, but Stream has no batch freeze, so each channel is its own request: chunking by 100 and awaiting the chunk put a hundred simultaneous requests in flight against an app that is also serving live user traffic. `lib/stream/batch.ts` documents exactly this hazard on `forEachChunk` — "firing twenty chunks concurrently to shave latency off one webinar create is how you 429 every other request in flight" — and the caller two files away walked into it with five times that width. The two numbers answer different questions and only coincidentally started out the same, so there are now two: `STREAM_BATCH_LIMIT` stays at 100 for payloads, `STREAM_CONCURRENCY_LIMIT` is 10 for fan-out. Ten is a conservative width, not a figure derived from a published quota. Stream documents the per-request ceilings but not a concurrency limit worth citing. Being wrong low costs a slower background job; being wrong high costs 429s on live traffic. Also: `chunk()` rejected `size < 1` but accepted a non-integer, and `items.slice(i, i + 2.5)` does not throw — it produces uneven chunks, so a caller that believes it bounded a payload has not. The other Major on this PR, the workflow permissions block, was already fixed in the previous commit; that thread is stale rather than outstanding. tsc clean, eslint clean, 242 suites / 2737 tests. Part of #1134 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019pbBn6yWAr2DXjfACyocUv --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…k disabled (#1147) A regression I introduced in #1137 and did not catch until review of the test I wrote to cover it. Persisting the receipt before acknowledging closed the loss window, but it also meant the route created the `WebhookEvent` row and then, milliseconds later, asked `logWebhookEvent` to claim the very same id from inside `after()`. That row is `processed=false, error=null` — the IN-PROGRESS state — and aged nothing, so the staleness escape correctly refused it as another worker's in-flight work. `isNew` came back false and `processStreamEvent` returned having dispatched nothing. Nothing was lost: the row exists, and sweep-stuck-webhook-events re-drives it. But the sweeper wakes on `4-59/10` and only takes rows older than six minutes, so every recording, session-end, attendance and moderation event was arriving six to sixteen minutes late instead of running inline. The fix removed the loss and disabled the fast path in the same change. `processStreamEvent` now takes `claimAlreadyHeld`. The route passes it, because it wrote the receipt and therefore owns the claim. The sweeper passes nothing and claims normally, which is what makes the concurrency guard meaningful for the caller that actually competes for a row. The test I wrote stubbed `logWebhookEvent` to always return `isNew: true`, which is exactly why it did not catch this — it asserted the shape it assumed rather than the one production produces. Both paths are pinned now: with the claim held, dispatch proceeds; without it, a genuine competitor still wins. Branched off dev rather than added to the train, because both files are dev-owned now and no open PR carries them. tsc clean, eslint clean, 238 suites / 2703 tests. Part of #1134 Claude-Session: https://claude.ai/code/session_019pbBn6yWAr2DXjfACyocUv Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…r maintenance (#1141) * fix(stream): subscribe the missing webhook events, unfreeze after maintenance (#1134) The four items left open after two rounds of adversarial validation against the live Stream app. 1. The webhook subscription covered SIX event types while the dispatcher handles TEN. The four missing ones were never delivered, so two features shipped as dead code: MeetingAttendance was never written (detect-consultant-no-shows has been running daily against a permanently empty table, and #471/#472 were never actually unblocked), and every chat moderation flag landed in a queue nothing fed. This is the SECOND independent cause of the zero-attendance figure — the first was the missing webhook secret. Fixing one without the other changes nothing for attendance, which the P0-5 write-up did not make clear. scripts/stream/ensure-webhook-subscription.ts fixes it, dry-run by default. Verified against the live app: it correctly reports the five gaps (participant joined/left, session_started, user.flagged, message.flagged). It unions rather than replaces, so another integration relying on event types we do not handle is not silently unsubscribed. HANDLED_EVENT_TYPES moved to lib/stream/webhook-events.ts — a dependency-free module, because webhook-dispatch transitively imports Prisma, Supabase and `server-only`, none of which load in a bare tsx process. One list, two consumers, so handling and subscribing can never drift again. 2. Frozen channels were never unfrozen. The drain freezes group chat on the way into OFFLINE and the helper's own docstring claimed "unfrozen again by the maintenance exit path" — no such path existed. Worse, Stream grants `use-frozen-channel` to NO role by default, so every channel a drain touched stayed unwritable by every user AND every admin, permanently, with no visible cause. unfreezeChannelsAfterMaintenance() is wired into the DELETE handler, scoped to sessions this drain actually ended so it cannot unfreeze one a moderator froze deliberately. 3. The retry budget is 3 attempts (2 on a network error), not 5. Wrong in two places in the webhook route. 6s per attempt and 15s total were right. The real budget is tighter than documented, which makes acking first more necessary rather than less. 4. sendCallEvent warned nobody. Nothing subscribes to call.on("custom"), and end() fires microseconds later, so no toast could paint even with a listener. Counting Stream's acknowledgement as a person warned is the same fabricated metric this function was fixed for twenty lines below, so the call and the counter are gone rather than left implying a courtesy that does not exist. `notified` now honestly describes broadcast reach and is only set after the broadcast succeeds. Part of #1134 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019pbBn6yWAr2DXjfACyocUv * fix(stream): explicit code-unit sort — and NOT the localeCompare Sonar suggests SonarCloud raises typescript:S2871 (CRITICAL, ×2) on the two bare `.sort()` calls here, which is what has been failing the `new_reliability_rating` gate at 4. The rule is right that a comparator should be explicit. Its suggested remedy is not: the message recommends `localeCompare`, which would be a real bug in this file rather than a style change. These are event-type strings — `call.session_started`, `message.flagged`, `call_member`. ICU collation treats `.` and `_` as ignorable punctuation at the primary level; code units do not. So the two orderings genuinely disagree, and this sorted list is compared against the live hook's `event_types` to decide whether an update is needed. A locale-dependent order makes that decision depend on the runtime's ICU build — the same failure mode as the DM channel ids in #1134 P0-3, where a "standardize the conventions" refactor swapped `.sort()` for `.sort(localeCompare)` and silently re-keyed most pairs. A bare `.sort()` on strings already compares by code unit, so behaviour is unchanged. The comparator just states it, and the comment says plainly not to "fix" it to localeCompare, because Sonar will keep suggesting exactly that. Part of #1134 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019pbBn6yWAr2DXjfACyocUv * fix(stream): stop the subscription script deleting every other event hook `updateAppSettings({ event_hooks })` REPLACES the array. The script submitted `[theOneHookBeingWidened]` from inside a loop over hooks, so applying it deleted every other hook on the app — including the SQS and Pusher hooks that the `hook_type === "webhook"` filter removes before the loop even sees them, and any second webhook another integration owns. Writing inside the loop compounded it. Each payload was built from a read taken before the previous iteration's write landed, so with two hooks to widen only the last one's widening would have survived. The comment above it said "Union, never replace", which is true of the event TYPES and false of the hooks ARRAY. That is why it read as safe. Now: collect the widenings, then make ONE write carrying every hook the app has, with only the targeted ones modified. Latent today — this app has exactly one hook, which is why a dry run looked correct. That is not a defence. This is the operator script for a shared production Stream app with no rehearsal environment, run by hand, and the first time someone adds a second hook it would have silently destroyed it. Pinned by tests that were verified to FAIL against the previous implementation: reinstating the per-hook write fails exactly the three cases about preserving foreign hooks and widening two in one write. tsc clean, eslint clean, 242 suites / 2734 tests. Part of #1134 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019pbBn6yWAr2DXjfACyocUv * refactor(stream): drop the unchecked app-settings casts (#1134) `getAppSettings()` already returns `AppSettingsAPIResponse`, whose `app` carries `event_hooks?: Array<EventHook>` (stream-chat types.d.ts:101), and `updateAppSettings(options: AppSettings)` accepts `event_hooks` directly (types.d.ts:1842). Both casts predated a check of the installed types and were hiding the SDK's own shape — a future response change would have compiled clean. `EventHook.id` is optional in the SDK because the id is server-generated on create, so the widening candidates are narrowed to hooks that actually have one. The preservation list stays unfiltered: it is what stops the write deleting every other hook. Part of #1134 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…1149) * fix(stream): close the review tail on files the train already merged (#1134) Eight findings from the #1141 review that live on files now merged to dev. They cannot be fixed in #1141 — its diff owns four files — so they land here as a sibling off dev. No overlap with #1142 or #1143. **The TRIAL DM branch was unreachable.** #1136 added a `TRIAL` case to payment-success channel provisioning, and it has never run. The whole block is guarded by `if (appointmentForChannel && consultantUserId)`, and the consultant was resolved from consultation/subscription/webinar/class only. A trial appointment has none of those — the consultant is a REQUIRED relation on `TrialSession`, a model the query did not include. So the resolution came back undefined for exactly the appointments the branch was written to serve. A trial buyer got video and no way to message their consultant, which is the failure #1136 set out to fix, reintroduced one layer up. Resolved from `TrialSession.consultantProfile`, not `trialSession.subscriptionPlan.consultantProfile` — the latter is the plan author and can differ from whoever is running the trial. **The consent endpoints loaded the meeting twice** and picked their HTTP status by comparing `access.message` to `"Meeting not found"` — the exact coupling the resolver's own docblock warns against, still live in two handlers. `resolveMeetingAccess` already joins all four plan relations for its ownership test, so `recordingEnabled` is one more column on an existing join, not a query; the resolver now hands back what it loaded and the second round trip is gone. `MeetingAccess` becomes a discriminated union while doing it. "Present only when the meeting exists" was a comment on optional fields; it is now a fact the compiler enforces, and narrowing on `hasAccess` gives callers the appointment non-optionally instead of a `!`. Also: - The channel-expiry job reported `success: true` when Stream was not configured. #1134 found a webhook secret silently unset in Netlify; this would have shown that as a green nightly run for as long as it lasted. - `prisma.$disconnect()` moved into a `finally` — a throw skipped it. - `syncUserEventChannels` signals failure by RESOLVING `{success: false}`, so the `.then` persisted "synced" to sessionStorage on failure and suppressed the retry for the rest of the tab's life. - The consent hook re-checks `cancelled` after `res.json()`; that second await could land an old meeting's notice in the state that gates Join. - The Join button sat disabled and unlabelled while the notice fetched. - `stream-sync`'s `requireLock` doc said `default: false` and its warning said "proceeding without distributed lock". #1134 P1-21 made it default true and throw, so both sent a reader looking for a run that never happened. 243 suites / 2,749 tests, tsc and eslint clean. Part of #1134 * refactor(stream): re-export resolveAppointmentPlan directly (#1134) Imported only to be re-exported, which routes the binding through this module for no reason. `export … from` says the same thing in one line. Part of #1134
…g index (#1150) PR 10 of the #1134 plan, which was never written. Eight pull requests shipped with zero doc changes, so these pages describe an application that no longer exists in several load-bearing places. **The docs taught the banned pattern.** `04-chat-implementation.md` showed `localeCompare` for direct-message id derivation in three places — including one presented under a "Good" heading, with the correct code shown as "Bad" only for being unsorted. That comparator orders by ICU collation, so the same pair of ids yields different channel ids on different machines. Commit 0116209 already shipped it once and silently re-keyed every mixed-case pair, orphaning their history behind a new empty channel; both variants were still live months later. A doc sample presenting it as correct is how it comes back. All three now route through `getDmChannelId`, and the note explains what it cost rather than just forbidding it. **`docs/stream/README.md` did not exist**, while three files linked to it. It now indexes all nineteen documents and states the rules that have each cost an incident. **Five files described NextAuth; none mentioned Better Auth.** Server samples showed `getServerSession()` and client samples showed next-auth's `useSession`. The application uses `auth.api.getSession({ headers })` and `@/lib/auth-client`. **`06-channel-management.md`'s `syncUserEventChannels` sample** was a transcribed body that had drifted past recognition — sequential loops where the code fans out in chunks, and `throw` where the code RESOLVES `{ success: false }`. That last one matters: the resolve-on-failure contract is the whole reason the provider bug in #1149 existed, and the doc showed the opposite. Replaced with the signature and the three properties callers get wrong, plus a pointer to read the body in source. **`03-provider-authentication.md`** showed the sync awaited and its success marker set unconditionally — the exact bug #1149 fixed, and a pattern #1140 had already changed. Diagram and sample both now show the failure branch. **`13-recording-webhooks.md`** documented every recording route except the consent endpoints, which did not appear anywhere in `docs/stream/`. Added, with the reason the second load is gone and the reason 404-vs-403 comes from `reason` and not from a message string. Also corrected a documented `StorageType` enum that does not exist; it is `RecordingStorageType`. Adds an ADR for the `MeetingAccess` discriminated union. Files with only table-separator reformatting were reverted, so every changed line here is a content change. Part of #1134
…age (#1134) (#1142) * fix(chat): unbreak mobile chat, touch actions and the console leak `components/chat/*` was skipped by the design-system migration and never got a responsive or touch pass. Three of these are functional outages, not polish. Mobile layout. ChatLayout rendered an unconditional 320px sidebar beside the conversation with no breakpoint logic, leaving 55px of conversation on a 375px phone. It is now list-only below `md`, the conversation replaces it on select, and CustomChannelHeader carries a back control. Two panes from `md` up, as before. This mirrors ChatSkeleton, which already collapsed its list correctly. The pane state lives in a small context because the back button sits inside Stream's own `<Channel><Window>` tree. Touch message actions. The whole toolbar was `opacity-0 group-hover:opacity-100`, so on a device with no hover react/reply/edit/delete/report did not exist, and the timestamp never rendered. Hover-reveal is now scoped to `pointer: fine`; on coarse pointers the toolbar sits inline beside the bubble and is always visible. The timestamp is always visible everywhere. Accessibility. The two hand-rolled absolutely-positioned dropdowns are now the repo's Radix DropdownMenu and Popover, which brings Escape, a focus trap and focus return — the old handler closed on `mousedown` only. Icon-only buttons carry `aria-label` (react, reply, more-options, channel details, remove-member, remove-chip); remove-member names the member, since every row announced the same thing. The hover toolbar also reveals on `focus-within`, so a keyboard user no longer tabs through invisible controls. Reaction pills are buttons. Privacy. ChatSidebar logged 22 lines to the browser console, the worst being `console.log("Stream event received:", event.type, event)` inside a handler bound to `client.on("*.**")` — every Stream event payload, message text included, in the production console. Those and the five in CreateChannelDialog are gone. Threads. The `display: none !important` block hid the thread panel and the reply-count button while leaving them rendered and keyboard-focusable. Deleted; threads are off because no `<Thread>` is mounted and CustomMessage replaces the SDK message UI. Also: hover on a channel row was the same blue as the active state; two `sticky top-0` headers overlapped in one scroll container; the avatar's placeholder src made AvatarFallback dead code; the Conversations section rendered an empty div on error and silently vanished; unescaped quotes in ChannelSearch. Part of #1134 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019pbBn6yWAr2DXjfACyocUv * style(chat): put chat on the design system and theme the Stream SDK Commit 3c1d197 ("monochrome-premium modernization") touched zero files under `components/chat/`, so chat kept a palette nothing else in the product uses: 147 hardcoded colour literals against 9 design-token usages, and all 9 of those were in DebugDialog, a localhost-only dev tool. Tokens. Every literal in the production chat components is now `bg-card`, `bg-muted`, `text-muted-foreground`, `border-border`, `bg-primary` and friends. The 320px `bg-blue-600` slab, the `bg-zinc-50` conversation pane beside it and the `bg-blue-500`/`bg-gray-100` bubbles were three separate palettes touching each other, with Tailwind `gray` and `zinc` mixed side by side. Two of those literals also failed WCAG AA: `text-blue-200` on `bg-blue-600` at 4.08:1 for sidebar timestamps and `text-blue-300` at 3.18:1, which made the search placeholder effectively illegible. Both are 4.74:1 on tokens. Stream SDK theming. Zero `--str-chat__*` variables were set, so the composer, attachment cards, file previews, image gallery, date separators, scroll-to-bottom pill and typing indicator all rendered stock Stream inside custom chrome — the most obvious "dropped in" tell in the product. The theme-v2 palette is now mapped onto the app's own tokens, which flip under `.dark`, so chat follows the app once a theme provider lands. `<Chat>` also finally gets a `theme` prop; without it the SDK used a legacy theme string and none of the palette applied. The quoted-reply CSS loses its raw hex and eleven `!important`s to a specificity bump. Primitives. The five magic dialog widths become `ResponsiveModal`, so chat dialogs are bottom sheets on a phone instead of cramped centred modals, and the member list becomes a `ResponsiveTable`. `EmptyState` replaces the hand-rolled empty channel state it was already a near-copy of. One loading language. The three duplicated bare spinners and the two `animate-pulse bg-blue-700` blocks are gone, replaced by the repo's Skeleton and a new `ChatSkeletonPanes` export — the wrapped `ChatSkeleton` could not be nested inside the Messages tabs without applying its full-bleed margin twice. Dead weight. The consultant messages page ran a whole `createConsultantQueries(...).details` round-trip to feed props MessagesTab discarded on arrival; both are gone. CreateChannelDialog is hidden from consultees, whose dropdown could only ever say "No events found". That gate reads the app role from the session rather than `client.user.role`, which `mapRoleToStream` collapses to "user" for everyone outside staff — the reason the sidebar's existing `=== "consultant"` check could never be true. Part of #1134 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019pbBn6yWAr2DXjfACyocUv * fix(chat): open the mobile conversation, and match the loading layout (#1134) Five review findings on this PR's own files. **Selecting a channel outside the sidebar did nothing visible on mobile.** Below `md`, ChatLayout renders the conversation pane `hidden` until `openConversation()` runs, and only ChatSidebar was calling it. Channel search and channel creation both set the active channel directly, so on a phone the person searched, tapped a result, and stayed on the list they started from — with the channel silently active behind it. Wired into both ChannelSearch handlers and both CreateChannelDialog activation paths. **The chat skeleton was the inverse of the chat.** It hid the channel list below `sm` and always showed the conversation; the real layout shows the list and hides the conversation below `md`. So under 640px the loading state displayed the one pane that would not be there, and between 640 and 768 it showed two panes for a layout that is list-only. Both now use `md` and the same default pane. **`(pointer: fine)` is not a test for hover.** It also matches precise pointers with no hover at all, where `group-hover` can never fire — the floating message toolbar would stay transparent and react/reply/edit/delete would be unreachable, which is the exact failure #1134 logged for touch. Now `(hover: hover) and (pointer: fine)`. Also `word-wrap` -> `overflow-wrap` (deprecated, and Stylelint's `property-no-deprecated` fails on it), and `EmptyState` moved onto semantic tokens. On EmptyState, the reviewer's stated reason does not hold: `--card` is `0 0% 100%`, `next-themes` is not installed and nothing adds the `dark` class, so there is no dark surface to have poor contrast against. The change is still right — hardcoded zinc is precisely what this PR exists to remove, and the light values are near-identical, so it is a visual no-op that makes the `.dark` block work if it is ever switched on. Scoped to `EmptyState`; the other 20 hardcoded sites in DataCard.tsx are not on a chat surface and are left for a design-system pass. 242 suites / 2,739 tests, tsc and eslint clean. Part of #1134 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…oyed (#1151) * fix(stream): refuse to apply the grants before the join route is deployed (#1134) The deploy order for this script is load-bearing and was recorded nowhere in the repository — not in #1134, not in #1146, not in the script. `--apply` strips `join-call` from the `user` role. After that write the only way into a call is to hold `call_member`, and the only thing that grants `call_member` is POST /api/meetings/[meetingId]/join. Run it before that route is live and every user is locked out of every call for as long as the window lasts. The existing post-apply guard does not catch this, and reads as though it does. It re-reads the call type and checks that Stream stored `join-call` on `call_member` — which it will have. The grant is present; there is simply nobody holding the role. It passes and reports success for exactly the failure that matters. It cannot be checked automatically either. Verified against production: `/api/meetings/x/join`, `/api/meetings/x/nope` and `/api/definitely-not-a-route` all answer 404 to an unauthenticated POST, so no external probe distinguishes deployed from not. So the operator asserts it, behind a flag named after the assertion: `--apply --join-route-is-deployed`. The refusal prints the hazard and both recovery commands. Three properties matter and are covered by tests. The gate runs before the Stream config check and before any read, so a refusal never surfaces as a connection error. The dry run is untouched, because requiring the flag to read a diff would train people to pass it reflexively. And `--apply --restore-user-join` is never gated — that is what someone runs while every user is locked out, and the worst possible moment to add a step. Part of #1134 * test(stream): thread the new option through the existing grants suite Adding `deployConfirmed` to `Options` made every `--apply` case in `ensure-call-type-grants.test.ts` hit the new gate and return 1. Those tests exercise the grant computation, not the gate, so they now assert the deploy as a real operator would after deploying. The failure was mine and it was caught late: I pushed in the same command that ran the suite, so the push happened regardless of the result. 245 suites / 2,764 tests pass now. Part of #1134
✅ Deploy Preview for familiarise ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|




Thirteen commits. Almost all of it is the #1134 Stream remediation train — eleven of the twelve planned pull requests, plus two that the train itself produced.
Why this release matters
Two of the findings in #1134 were live production failures that no amount of code reading would have surfaced. Both are fixed here.
The Stream webhook pipeline had never processed a single event.
STREAM_WEBHOOK_SECRETwas absent from Netlify and the route 500s without it, soWebhookEvent WHERE provider='stream'was 0,MeetingAttendancewas 0, and 1,417 meeting sessions sat orphaned more than an hour past their slot end with noendedAt. A second, independent cause was found during validation: the live webhook hook was subscribed to six event types while the dispatcher handles ten, so even with the secret set, attendance and moderation would have stayed dead.Any signed-in user could join any call. Every call used the
defaultcall type, whoseuserrole holdsjoin-call, no token was call-scoped, and the only gate was a React conditional.client.call(type, id).join()from devtools walked into a private consultation.What is in it
Two of these were caused by the train rather than found by it, and are worth naming plainly. #1147 reverted a regression introduced in #1137, where persist-before-ack disabled inline dispatch and webhooks began arriving six to sixteen minutes late; the test that should have caught it stubbed
logWebhookEventto always returnisNew: true. #1149 closed eight review findings that had landed on files the earlier merges had already absorbed, the most serious being that #1136'sTRIALbranch had never once executed — a trial appointment's consultant hangs offTrialSession, which the query did not include, so the guard failed and a trial buyer got video with no way to message their consultant.Database
No action required.
prisma migrate diff --from-config-datasource --to-schema prisma/schema.prismareports no difference: the schema changes in this release were pushed to the live database earlier, verified at the time as +12 columns, +1 table, +1 enum with zero rows lost.Operator actions after this deploys — order matters
POST /api/meetings/[meetingId]/joinis serving.npx tsx scripts/stream/ensure-call-type-grants.ts --apply --join-route-is-deployednpx tsx scripts/stream/ensure-webhook-subscription.ts --applyStep two must not run before step one. Applying the grants strips
join-callfrom theuserrole, and the only thing that grantscall_memberis the join route — running it first locks every user out of every call. #1151 is in this release specifically to make that mistake harder: the script now refuses to apply without the explicit flag, because the failure cannot be detected automatically (production answers 404 to an unauthenticated POST whether or not a route exists) and the script's existing post-apply guard passes right through it.Step three is not optional. A dry run against the live application today still reports five unsubscribed event types —
call.session_participant_joined,call.session_participant_left,call.session_started,message.flaggedanduser.flagged. Attendance and chat moderation stay dead until it runs, regardless of what has shipped.Not in this release
#1143 (connection quality, receive-side video quality, background blur and noise cancellation) is held back. Its required checks pass and it builds cleanly locally, but its Netlify deploy preview fails, and the likely cause is environmental — it adds roughly 32MB of self-hosted WASM and model assets at
postinstallon top of an already-large meeting bundle. That is being investigated separately rather than shipped on a guess.Verification
Every pull request in this release merged with both required checks green and zero unresolved review threads. All 27 review comments that had been orphaned by earlier merges were re-verified against the current code, answered individually and resolved; the ones that remain genuinely open are carried in #1146 rather than left to rot on a merged pull request.