Skip to content

release: dev → prod — 2026-08-13 (Stream SDK audit remediation, #1134) - #1152

Merged
teetangh merged 13 commits into
prodfrom
release/dev-to-prod-2026-08-13
Aug 13, 2026
Merged

release: dev → prod — 2026-08-13 (Stream SDK audit remediation, #1134)#1152
teetangh merged 13 commits into
prodfrom
release/dev-to-prod-2026-08-13

Conversation

@teetangh

Copy link
Copy Markdown
Contributor

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_SECRET was absent from Netlify and the route 500s without it, so WebhookEvent WHERE provider='stream' was 0, MeetingAttendance was 0, and 1,417 meeting sessions sat orphaned more than an hour past their slot end with no endedAt. 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 default call type, whose user role holds join-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

Area Pull requests
P0 security — call access, self-deleting channels, permanent bans #1136
Webhook durability and the reconciler that never ran #1137, #1147
1:1 recording, Stream rate limiting, debug lockdown, trial chat #1138
Pre-join recording consent #1139
Bulk-call chunking, bounded sync cache, event-channel expiry #1140
Missing webhook event subscriptions, unfreeze after maintenance #1141
Mobile chat and touch message actions #1142
Review tail on already-merged files #1149
Stream documentation correction #1150
Grants-script deploy gate #1151
Enterprise audit residuals #1133

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 logWebhookEvent to always return isNew: true. #1149 closed eight review findings that had landed on files the earlier merges had already absorbed, the most serious being that #1136's TRIAL branch had never once executed — a trial appointment's consultant hangs off TrialSession, 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.prisma reports 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

  1. Deploy this release and confirm POST /api/meetings/[meetingId]/join is serving.
  2. npx tsx scripts/stream/ensure-call-type-grants.ts --apply --join-route-is-deployed
  3. npx tsx scripts/stream/ensure-webhook-subscription.ts --apply

Step two must not run before step one. Applying the grants strips join-call from the user role, and the only thing that grants call_member is 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.flagged and user.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 postinstall on 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.

teetangh and others added 13 commits August 10, 2026 11:17
…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
@netlify

netlify Bot commented Aug 13, 2026

Copy link
Copy Markdown

Deploy Preview for familiarise ready!

Name Link
🔨 Latest commit 4d60f75
🔍 Latest deploy log https://app.netlify.com/projects/familiarise/deploys/6a7dd948551a2600088aef3d
😎 Deploy Preview https://deploy-preview-1152--familiarise.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
Lighthouse
Lighthouse
1 paths audited
Performance: 36 (🔴 down 22 from production)
Accessibility: 90 (no change from production)
Best Practices: 83 (no change from production)
SEO: 82 (🟢 up 8 from production)
PWA: -
View the detailed breakdown and full score reports

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: cab99e6e-d53e-4988-869a-6781b0013840

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sonarqubecloud

Copy link
Copy Markdown

@teetangh
teetangh merged commit d4f73a6 into prod Aug 13, 2026
15 checks passed
@teetangh teetangh self-assigned this Aug 13, 2026
@teetangh
teetangh deleted the release/dev-to-prod-2026-08-13 branch August 26, 2026 07:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant