From 07961767e83ddfcad78fe33c3a7e39d0faef4ba8 Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:29:31 +0530 Subject: [PATCH 1/3] fix(stream): 1:1 recording, Stream rate limits, debug lockdown, trial chat (#1134) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-`; 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) Claude-Session: https://claude.ai/code/session_019pbBn6yWAr2DXjfACyocUv --- __tests__/stream/recording-capability.test.ts | 110 +++++++++++++++++ app/api/stream/debug/route.ts | 27 ++++- .../[streamCallId]/recording-info/route.ts | 30 +++-- app/api/stream/recordings/start/route.ts | 24 ++++ app/api/stream/recordings/stop/route.ts | 20 +++ lib/payments/webhooks/handlers.ts | 8 +- lib/rate-limit.ts | 17 +++ lib/stream/recording-utils.ts | 83 ++++++------- middleware.ts | 21 ++++ prisma/schema.prisma | 114 ++++++++++-------- 10 files changed, 345 insertions(+), 109 deletions(-) create mode 100644 __tests__/stream/recording-capability.test.ts diff --git a/__tests__/stream/recording-capability.test.ts b/__tests__/stream/recording-capability.test.ts new file mode 100644 index 000000000..fe9a5cc4f --- /dev/null +++ b/__tests__/stream/recording-capability.test.ts @@ -0,0 +1,110 @@ +/** + * @jest-environment node + */ + +/** + * #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. For a consultation or a + * subscription both fell through to `false`, so the consultant who owned the + * session failed the ownership check and `POST /api/stream/recordings/start` + * answered 403 — to the owner. `recording-info` reported `recordingEnabled: + * false` no matter what, above a comment noting the 1:1 plans had no such + * field. + * + * Both predicates now read one resolver, so they cannot disagree about which + * plan they are looking at — which is the shape of the original bug. + */ + +import { + isAppointmentOwner, + isRecordingEnabledForAppointment, + resolveAppointmentPlan, +} from "@/lib/stream/recording-utils"; + +const OWNER = "consultant-profile-1"; +const OTHER = "consultant-profile-2"; + +const withPlan = (kind: string, plan: unknown) => { + switch (kind) { + case "webinar": + return { webinar: { webinarPlan: plan } }; + case "class": + return { class: { classPlan: plan } }; + case "consultation": + return { consultation: { consultationPlan: plan } }; + default: + return { subscription: { subscriptionPlan: plan } }; + } +}; + +const KINDS = ["webinar", "class", "consultation", "subscription"] as const; + +describe("recording capability covers every appointment kind", () => { + it.each(KINDS)("%s: the owning consultant is the owner", (kind) => { + const appointment = withPlan(kind, { + consultantProfileId: OWNER, + recordingEnabled: true, + }); + expect(isAppointmentOwner(appointment, OWNER)).toBe(true); + expect(isAppointmentOwner(appointment, OTHER)).toBe(false); + }); + + it.each(KINDS)("%s: recordingEnabled is read from the plan", (kind) => { + expect( + isRecordingEnabledForAppointment( + withPlan(kind, { consultantProfileId: OWNER, recordingEnabled: true }), + ), + ).toBe(true); + expect( + isRecordingEnabledForAppointment( + withPlan(kind, { consultantProfileId: OWNER, recordingEnabled: false }), + ), + ).toBe(false); + }); + + it("defaults closed when the plan omits the flag", () => { + // A 1:1 is the most sensitive session type on the platform. An absent flag + // must never read as consent to record. + expect( + isRecordingEnabledForAppointment( + withPlan("consultation", { consultantProfileId: OWNER }), + ), + ).toBe(false); + }); + + it("never treats a missing consultantProfileId as ownership", () => { + // Guards the null-vs-null trap: an appointment whose plan has no consultant + // must not match a caller who also has none. + const appointment = withPlan("consultation", { + consultantProfileId: null, + recordingEnabled: true, + }); + expect(isAppointmentOwner(appointment, null)).toBe(false); + expect(isAppointmentOwner(appointment, undefined)).toBe(false); + }); + + it("resolves nothing for an empty or absent appointment", () => { + expect(resolveAppointmentPlan(null)).toBeNull(); + expect(resolveAppointmentPlan(undefined)).toBeNull(); + expect(resolveAppointmentPlan({})).toBeNull(); + expect(isAppointmentOwner(null, OWNER)).toBe(false); + expect(isRecordingEnabledForAppointment(null)).toBe(false); + }); + + it("the two predicates always read the same plan", () => { + // The original defect was divergence: ownership looked at one set of kinds + // and the recording flag at another. Assert they agree on every kind. + for (const kind of KINDS) { + const appointment = withPlan(kind, { + consultantProfileId: OWNER, + recordingEnabled: true, + }); + const plan = resolveAppointmentPlan(appointment); + expect(plan?.consultantProfileId).toBe(OWNER); + expect(isAppointmentOwner(appointment, OWNER)).toBe(true); + expect(isRecordingEnabledForAppointment(appointment)).toBe(true); + } + }); +}); diff --git a/app/api/stream/debug/route.ts b/app/api/stream/debug/route.ts index 3a3c89b5d..4a58020c0 100644 --- a/app/api/stream/debug/route.ts +++ b/app/api/stream/debug/route.ts @@ -10,6 +10,8 @@ import * as Sentry from "@sentry/nextjs"; import { NextRequest, NextResponse } from "next/server"; import { getStreamChatClient, isStreamConfigured } from "@/lib/stream-client"; +import { getSession } from "@/lib/auth-server"; +import { isPrivileged } from "@/lib/auth-helpers"; import { streamLogger } from "@/lib/stream-logger"; import prisma from "@/lib/prisma"; @@ -21,6 +23,23 @@ export async function GET(req: NextRequest) { // Security checks const isDev = process.env.NODE_ENV === "development"; + // #1134 P1-12 — this route 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 any Referer header — and it dumps an arbitrary user's + // full Stream channel list. A session is now required everywhere, and staff + // or admin on top of that, so the secret is defence in depth rather than the + // whole defence. + const session = await getSession(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (!isPrivileged(session.user.role)) { + streamLogger.warn("Non-privileged Stream debug attempt", { + userId: session.user.id, + }); + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + if (!isDev && !ALLOW_IN_PRODUCTION) { return NextResponse.json( { error: "Debug endpoint not available in production" }, @@ -33,8 +52,12 @@ export async function GET(req: NextRequest) { const url = new URL(req.url); const secret = url.searchParams.get("secret"); - if (!secret || secret !== DEBUG_SECRET) { - streamLogger.warn("Unauthorized debug attempt"); + // A missing STREAM_DEBUG_SECRET must fail closed. `secret !== undefined` + // would have compared two undefineds and passed. + if (!DEBUG_SECRET || !secret || secret !== DEBUG_SECRET) { + streamLogger.warn("Unauthorized debug attempt", { + userId: session.user.id, + }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } } diff --git a/app/api/stream/meetings/[streamCallId]/recording-info/route.ts b/app/api/stream/meetings/[streamCallId]/recording-info/route.ts index 494c1d205..3e1b3003d 100644 --- a/app/api/stream/meetings/[streamCallId]/recording-info/route.ts +++ b/app/api/stream/meetings/[streamCallId]/recording-info/route.ts @@ -8,6 +8,7 @@ import { NextRequest, NextResponse } from "next/server"; import prisma from "@/lib/prisma"; import { isPaymentEntitled } from "@/lib/payments/utils/refund-balance"; +import { isRecordingEnabledForAppointment } from "@/lib/stream/recording-utils"; import { isPrivileged } from "@/lib/auth-helpers"; import { getSession } from "@/lib/auth-server"; @@ -60,7 +61,13 @@ export async function GET(req: NextRequest, { params }: RouteParams) { consultation: { include: { consultationPlan: { - select: { consultantProfileId: true }, + // #1134 P1-6 — recordingEnabled is new on the 1:1 plans; + // without selecting it the resolver reports recording as + // unavailable for every consultation. + select: { + consultantProfileId: true, + recordingEnabled: true, + }, }, requestedBy: { select: { userId: true }, @@ -70,7 +77,10 @@ export async function GET(req: NextRequest, { params }: RouteParams) { subscription: { include: { subscriptionPlan: { - select: { consultantProfileId: true }, + select: { + consultantProfileId: true, + recordingEnabled: true, + }, }, requestedBy: { select: { userId: true }, @@ -173,15 +183,13 @@ export async function GET(req: NextRequest, { params }: RouteParams) { return NextResponse.json({ error: "Access denied" }, { status: 403 }); } - // Determine if recording is enabled based on appointment type - let recordingEnabled = false; - - if (appointment?.webinar?.webinarPlan) { - recordingEnabled = appointment.webinar.webinarPlan.recordingEnabled; - } else if (appointment?.class?.classPlan) { - recordingEnabled = appointment.class.classPlan.recordingEnabled; - } - // Consultations and subscriptions don't have recordingEnabled on their plans + // #1134 P1-6 — one resolver for all four plan kinds. This used to be a + // hand-rolled if/else over webinar and class with a comment explaining that + // consultations and subscriptions "don't have recordingEnabled on their + // plans" — true at the time, and the reason 1:1 recording was impossible + // rather than merely off. They have it now, and the shared helper means + // this can no longer disagree with the ownership check beside it. + const recordingEnabled = isRecordingEnabledForAppointment(appointment); return NextResponse.json({ meetingSessionId: meetingSession.id, diff --git a/app/api/stream/recordings/start/route.ts b/app/api/stream/recordings/start/route.ts index 666f1a1b9..247f23795 100644 --- a/app/api/stream/recordings/start/route.ts +++ b/app/api/stream/recordings/start/route.ts @@ -67,6 +67,30 @@ export async function POST(req: NextRequest) { }, }, }, + // #1134 P1-6 — without these two the resolver sees no plan for a + // 1:1, so the actual owner fails isAppointmentOwner and start + // returns 403. Recording a consultation was not disabled, it was + // impossible. + consultation: { + include: { + consultationPlan: { + select: { + consultantProfileId: true, + recordingEnabled: true, + }, + }, + }, + }, + subscription: { + include: { + subscriptionPlan: { + select: { + consultantProfileId: true, + recordingEnabled: true, + }, + }, + }, + }, }, }, }, diff --git a/app/api/stream/recordings/stop/route.ts b/app/api/stream/recordings/stop/route.ts index a59c16fe0..5a9882e69 100644 --- a/app/api/stream/recordings/stop/route.ts +++ b/app/api/stream/recordings/stop/route.ts @@ -67,6 +67,26 @@ export async function POST(req: NextRequest) { }, }, }, + // #1134 P1-6 — mirror the start route: without these the owner + // of a 1:1 cannot stop a recording they were able to start. + consultation: { + include: { + consultationPlan: { + select: { + consultantProfileId: true, + }, + }, + }, + }, + subscription: { + include: { + subscriptionPlan: { + select: { + consultantProfileId: true, + }, + }, + }, + }, }, }, }, diff --git a/lib/payments/webhooks/handlers.ts b/lib/payments/webhooks/handlers.ts index 1ef7c80b7..19abaf61a 100644 --- a/lib/payments/webhooks/handlers.ts +++ b/lib/payments/webhooks/handlers.ts @@ -943,7 +943,13 @@ ACTION REQUIRED: Customer was charged but appointment was NOT created! // cohered. One thread per relationship-context is the whole model now. if ( (eventType === "CONSULTATION" && consultation) || - (eventType === "SUBSCRIPTION" && subscription) + (eventType === "SUBSCRIPTION" && subscription) || + // #1134 P1-16 — TRIAL had no branch here at all, so a trial buyer got + // video and no way to message the consultant before or after it. A + // trial is the platform's first impression; it is the LAST session + // type that should be mute. Same DM as any other 1:1, so it merges + // with their thread if they go on to book. + eventType === "TRIAL" ) { await createDirectMessageChannel( consultantUserId, diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts index 62a8506c9..ff2e37eb9 100644 --- a/lib/rate-limit.ts +++ b/lib/rate-limit.ts @@ -87,6 +87,23 @@ export const spamLimiter = makeLimiter(5, "1 h", "rl:spam"); */ export const cspReportLimiter = makeLimiter(120, "1 m", "rl:csp-report"); +/** + * #1134 P1-11 — Stream had NO rate limiting on any route or server action. + * + * Two shapes, two budgets: + * + * `streamJoinLimiter` guards the meeting join gate. It is the enumeration + * surface: call ids are deterministic (`slot-`), so an attacker + * who has one slot id can walk neighbours. Generous enough that a flaky network + * retrying a join never trips it, tight enough that scanning is useless. + * + * `streamApiLimiter` guards the search / channel-create / block routes, which + * are ordinary authenticated reads and writes but were completely unbounded — + * every one of them costs a Stream API call we are billed for. + */ +export const streamJoinLimiter = makeLimiter(20, "1 m", "rl:stream-join"); +export const streamApiLimiter = makeLimiter(60, "1 m", "rl:stream-api"); + /** 3 per 24 hours — POST /api/trials (prevents flooding consultant inboxes) */ export const trialRequestLimiter = makeLimiter(3, "24 h", "rl:trial-request"); diff --git a/lib/stream/recording-utils.ts b/lib/stream/recording-utils.ts index b76de0d87..90bd230a3 100644 --- a/lib/stream/recording-utils.ts +++ b/lib/stream/recording-utils.ts @@ -9,19 +9,39 @@ import prisma from "@/lib/prisma"; * Type for appointment with ownership relations * Used for checking if a consultant owns a recording/session */ +export interface OwnedPlan { + consultantProfileId: string | null; + recordingEnabled?: boolean; +} + export interface AppointmentWithOwnership { - webinar?: { - webinarPlan?: { - consultantProfileId: string | null; - recordingEnabled?: boolean; - } | null; - } | null; - class?: { - classPlan?: { - consultantProfileId: string | null; - recordingEnabled?: boolean; - } | null; - } | null; + webinar?: { webinarPlan?: OwnedPlan | null } | null; + class?: { classPlan?: OwnedPlan | null } | null; + // #1134 P1-6 — 1:1 was simply absent here, which is why recording a + // consultation or a subscription was impossible rather than merely disabled: + // isAppointmentOwner returned false for the actual owner, so start-recording + // 403'd, and isRecordingEnabledForAppointment reported false regardless of + // what the plan said. + consultation?: { consultationPlan?: OwnedPlan | null } | null; + subscription?: { subscriptionPlan?: OwnedPlan | null } | null; +} + +/** + * The plan behind an appointment, whichever of the four kinds it is. + * One resolver so ownership and the recording flag can never disagree about + * which plan they are reading — the bug above was exactly that divergence. + */ +export function resolveAppointmentPlan( + appointment: AppointmentWithOwnership | null | undefined, +): OwnedPlan | null { + if (!appointment) return null; + return ( + appointment.webinar?.webinarPlan ?? + appointment.class?.classPlan ?? + appointment.consultation?.consultationPlan ?? + appointment.subscription?.subscriptionPlan ?? + null + ); } /** @@ -35,26 +55,9 @@ export function isAppointmentOwner( appointment: AppointmentWithOwnership | null | undefined, consultantProfileId: string | null | undefined, ): boolean { - if (!appointment || !consultantProfileId) { - return false; - } - - // Check webinar ownership - if (appointment.webinar?.webinarPlan) { - return ( - appointment.webinar.webinarPlan.consultantProfileId === - consultantProfileId - ); - } - - // Check class ownership - if (appointment.class?.classPlan) { - return ( - appointment.class.classPlan.consultantProfileId === consultantProfileId - ); - } - - return false; + if (!consultantProfileId) return false; + const plan = resolveAppointmentPlan(appointment); + return !!plan && plan.consultantProfileId === consultantProfileId; } /** @@ -66,21 +69,7 @@ export function isAppointmentOwner( export function isRecordingEnabledForAppointment( appointment: AppointmentWithOwnership | null | undefined, ): boolean { - if (!appointment) { - return false; - } - - // Check webinar plan - if (appointment.webinar?.webinarPlan) { - return appointment.webinar.webinarPlan.recordingEnabled === true; - } - - // Check class plan - if (appointment.class?.classPlan) { - return appointment.class.classPlan.recordingEnabled === true; - } - - return false; + return resolveAppointmentPlan(appointment)?.recordingEnabled === true; } /** diff --git a/middleware.ts b/middleware.ts index 61f635ea7..39f204264 100644 --- a/middleware.ts +++ b/middleware.ts @@ -22,6 +22,8 @@ import { applyRateLimit, getClientIp, isBypassableIp, + streamJoinLimiter, + streamApiLimiter, } from "@/lib/rate-limit"; import { Ratelimit } from "@upstash/ratelimit"; @@ -248,6 +250,25 @@ const RATE_LIMIT_RULES: RateRule[] = [ limiter: authLimiter, skipLocalhost: true, }, + { + // #1134 P1-11 — the meeting join gate. Call ids are deterministic + // (`slot-`), so this is the enumeration surface: without a + // limit, someone holding one slot id can walk neighbours and probe which + // meetings they can reach. Authenticated, so keyed per user by the shared + // resolver. + label: "stream: meeting join", + match: (p, m) => m === "POST" && /^\/api\/meetings\/[^/]+\/join$/.test(p), + limiter: streamJoinLimiter, + skipLocalhost: true, + }, + { + // Ordinary authenticated Stream reads/writes — search, channel create, + // block. Unbounded before, and each one costs a billable Stream API call. + label: "stream: api", + match: (p) => p.startsWith("/api/stream/"), + limiter: streamApiLimiter, + skipLocalhost: true, + }, { label: "public: consultant search / explore", match: (p) => p.startsWith("/api/user/consultants"), diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 43ef3695a..6354a63e7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2838,27 +2838,36 @@ model SlotOfAvailabilityCustom { // 1-1 Consultation model ConsultationPlan { - id String @id @default(cuid()) - title String + id String @id @default(cuid()) + title String // One-line pitch rendered under the title. Distinct from `description`, // which is long-form and gets truncated in cards. - subtitle String? @db.VarChar(160) - description String? @db.Text - durationInHours Float @default(1) - price BigInt // in paise (smallest currency unit, e.g. 50000 = ₹500) - priceCurrency Currency @default(INR) - language String @default("English") - level PlanLevel @default(BEGINNER) - prerequisites String? @default("None") - materialProvided String? @default("None") - learningOutcomes String[] @default([]) + subtitle String? @db.VarChar(160) + description String? @db.Text + durationInHours Float @default(1) + price BigInt // in paise (smallest currency unit, e.g. 50000 = ₹500) + priceCurrency Currency @default(INR) + + // #1134 P1-6 — 1:1 recording was structurally impossible: isAppointmentOwner + // and isRecordingEnabledForAppointment only understood webinar and class, so + // a consultation or subscription always 403'd on start and reported + // recordingEnabled:false. Mirrors WebinarPlan/ClassPlan exactly, and defaults + // OFF — a 1:1 is the most sensitive session type on the platform, so + // recording it stays an explicit per-plan opt-in by the consultant. + recordingEnabled Boolean @default(false) + recordingStoragePolicy RecordingStoragePolicy @default(STREAM_ONLY) + language String @default("English") + level PlanLevel @default(BEGINNER) + prerequisites String? @default("None") + materialProvided String? @default("None") + learningOutcomes String[] @default([]) // Buyer-facing positioning: who the offering is for, and what the price buys. - targetAudience String[] @default([]) - whatsIncluded String[] @default([]) - imageUrl String? + targetAudience String[] @default([]) + whatsIncluded String[] @default([]) + imageUrl String? // Reserved for SEO-friendly plan URLs. Nullable until routing lands (#696). - slug String? @unique - topics Topic[] @relation("TopicToConsultationPlan") + slug String? @unique + topics Topic[] @relation("TopicToConsultationPlan") consultantProfile ConsultantProfile @relation(fields: [consultantProfileId], references: [id], onUpdate: Cascade, onDelete: Cascade) consultantProfileId String @@ -2929,32 +2938,41 @@ model Consultation { } model SubscriptionPlan { - id String @id @default(cuid()) - title String + id String @id @default(cuid()) + title String // See ConsultationPlan.subtitle. - subtitle String? @db.VarChar(160) - description String? @db.Text - durationInMonths Int @default(1) - price BigInt // in paise (smallest currency unit, e.g. 50000 = ₹500) - priceCurrency Currency @default(INR) + subtitle String? @db.VarChar(160) + description String? @db.Text + durationInMonths Int @default(1) + price BigInt // in paise (smallest currency unit, e.g. 50000 = ₹500) + priceCurrency Currency @default(INR) + + // #1134 P1-6 — 1:1 recording was structurally impossible: isAppointmentOwner + // and isRecordingEnabledForAppointment only understood webinar and class, so + // a consultation or subscription always 403'd on start and reported + // recordingEnabled:false. Mirrors WebinarPlan/ClassPlan exactly, and defaults + // OFF — a 1:1 is the most sensitive session type on the platform, so + // recording it stays an explicit per-plan opt-in by the consultant. + recordingEnabled Boolean @default(false) + recordingStoragePolicy RecordingStoragePolicy @default(STREAM_ONLY) // #1011 — was `callsPerWeek`. Renamed to match ClassPlan so both recurring // plan types name the same quantity the same way before the schema freeze. - sessionsPerWeek Int @default(1) - sessionDurationInHours Float @default(1.0) // Duration of each session in hours - totalSessions Int @default(4) // sessionsPerWeek × durationInMonths × 4 - totalHours Float @default(4.0) // totalSessions × sessionDurationInHours - emailSupport PlanEmailSupport @default(GENERAL) - language String @default("English") - level PlanLevel @default(BEGINNER) - prerequisites String? @default("None") - materialProvided String? @default("None") - learningOutcomes String[] @default([]) + sessionsPerWeek Int @default(1) + sessionDurationInHours Float @default(1.0) // Duration of each session in hours + totalSessions Int @default(4) // sessionsPerWeek × durationInMonths × 4 + totalHours Float @default(4.0) // totalSessions × sessionDurationInHours + emailSupport PlanEmailSupport @default(GENERAL) + language String @default("English") + level PlanLevel @default(BEGINNER) + prerequisites String? @default("None") + materialProvided String? @default("None") + learningOutcomes String[] @default([]) // See ConsultationPlan.targetAudience. - targetAudience String[] @default([]) - whatsIncluded String[] @default([]) + targetAudience String[] @default([]) + whatsIncluded String[] @default([]) imageUrl String? - slug String? @unique - topics Topic[] @relation("TopicToSubscriptionPlan") + slug String? @unique + topics Topic[] @relation("TopicToSubscriptionPlan") // Trial session offer. Free by default ONLY until paid-trial checkout is // wired (booking rejects paid trials today); the wiring PR flips the @@ -3622,9 +3640,9 @@ model RescheduleRequest { /// withdraw route and slotsAllowReschedule all read "at most one" as given. openForAppointmentId String? @unique - resolvedAt DateTime? @db.Timestamptz - resolvedBy User? @relation("RescheduleResolver", fields: [resolvedById], references: [id], onDelete: SetNull) - resolvedById String? + resolvedAt DateTime? @db.Timestamptz + resolvedBy User? @relation("RescheduleResolver", fields: [resolvedById], references: [id], onDelete: SetNull) + resolvedById String? /// The answering party's reply. `reason` runs consultee -> consultant; this /// is the return leg, and it is the difference between "your sessions moved" /// and "moved to Thursdays, I have blocked Tuesdays from September". @@ -4547,15 +4565,15 @@ model PayoutAccount { ////////////////////////////////////////// TAX & COMPLIANCE ////////////////////////////////////////// model ConsultantTaxInfo { - id String @id @default(cuid()) - consultantProfileId String @unique + id String @id @default(cuid()) + consultantProfileId String @unique panEncrypted Bytes? // AES-256-GCM encrypted PAN. Format: [12B IV][ciphertext][16B auth tag] - panLast4 String? @db.VarChar(4) // Cleartext last 4 chars for masked display - panVerified Boolean @default(false) + panLast4 String? @db.VarChar(4) // Cleartext last 4 chars for masked display + panVerified Boolean @default(false) gstin String? // GSTIN for registered consultants - gstinVerified Boolean @default(false) - country String @default("IN") // ISO 3166-1 alpha-2 - isIndianResident Boolean @default(true) + gstinVerified Boolean @default(false) + country String @default("IN") // ISO 3166-1 alpha-2 + isIndianResident Boolean @default(true) lutNumber String? // Letter of Undertaking for export zero-rating lutValidUntil DateTime? /// #1132 — limb 1 of the 194-O ₹5L exemption: only INDIVIDUAL/HUF get a From 5f84d534469f4b4637e6c58379586d49c9f5e9fe Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:15:51 +0530 Subject: [PATCH 2/3] fix(test): type the recording-capability fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_019pbBn6yWAr2DXjfACyocUv --- __tests__/stream/recording-capability.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/__tests__/stream/recording-capability.test.ts b/__tests__/stream/recording-capability.test.ts index fe9a5cc4f..778ffbf7e 100644 --- a/__tests__/stream/recording-capability.test.ts +++ b/__tests__/stream/recording-capability.test.ts @@ -21,12 +21,14 @@ import { isAppointmentOwner, isRecordingEnabledForAppointment, resolveAppointmentPlan, + type AppointmentWithOwnership, + type OwnedPlan, } from "@/lib/stream/recording-utils"; const OWNER = "consultant-profile-1"; const OTHER = "consultant-profile-2"; -const withPlan = (kind: string, plan: unknown) => { +const withPlan = (kind: string, plan: OwnedPlan): AppointmentWithOwnership => { switch (kind) { case "webinar": return { webinar: { webinarPlan: plan } }; From a3d45d32d3f7b94277a0cfdd32677d3045d50cfd Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Thu, 13 Aug 2026 02:42:11 +0530 Subject: [PATCH 3/3] fix(stream): stop the edge rate limiter throttling Stream's webhook deliveries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_019pbBn6yWAr2DXjfACyocUv --- .../stream/webhook-not-rate-limited.test.ts | 78 +++++++++++++++++++ middleware.ts | 29 ++++++- 2 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 __tests__/stream/webhook-not-rate-limited.test.ts diff --git a/__tests__/stream/webhook-not-rate-limited.test.ts b/__tests__/stream/webhook-not-rate-limited.test.ts new file mode 100644 index 000000000..cb3abb9af --- /dev/null +++ b/__tests__/stream/webhook-not-rate-limited.test.ts @@ -0,0 +1,78 @@ +/** + * @jest-environment node + */ + +/** + * #1134 P1-11 — the Stream webhook endpoint must not be edge rate-limited. + * + * The `stream: api` rule matched `/api/stream/` by prefix, which swept in + * `/api/stream/webhooks`. That is a worse failure than it sounds: + * + * - Stream POSTs every delivery from its own infrastructure, so all of them + * collapse onto a single rate-limit key rather than spreading across users. + * - Bursts are the NORMAL shape. A 200-attendee webinar emits 200 + * `call.session_participant_joined` events at once. + * - A 429 is not a deferral here. Stream retries inside a fifteen-second + * total budget and then drops the event permanently. + * + * So throttling this path would have silently reintroduced the exact loss that + * #1137's persist-before-ack work exists to prevent — and done it in the + * middleware, before the route ever ran, where none of that machinery applies. + * + * Excluding it 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. + * + * This asserts the ROUTE TABLE rather than booting the middleware, because the + * matcher predicates are the whole of the behaviour under test and the + * middleware itself pulls in Next's edge runtime, Redis and the maintenance + * store. + */ + +import { readFileSync } from "fs"; +import { join } from "path"; + +const middleware = readFileSync( + join(process.cwd(), "middleware.ts"), + "utf8", +); + +/** The `stream: api` rule's match predicate, lifted from the source. */ +function streamApiMatches(pathname: string): boolean { + return ( + pathname.startsWith("/api/stream/") && + !pathname.startsWith("/api/stream/webhooks") + ); +} + +describe("the stream: api rate-limit rule", () => { + it("does NOT match the webhook endpoint", () => { + expect(streamApiMatches("/api/stream/webhooks")).toBe(false); + }); + + it("still matches the ordinary authenticated Stream routes", () => { + for (const p of [ + "/api/stream/channels/search-appointments", + "/api/stream/recordings/start", + "/api/stream/search-consultees", + "/api/stream/debug", + ]) { + expect(streamApiMatches(p)).toBe(true); + } + }); + + it("is wired that way in middleware.ts, not just in this test", () => { + // The predicate above is a copy. This is the part that fails if someone + // simplifies the rule back to a bare prefix match. + expect(middleware).toContain('!p.startsWith("/api/stream/webhooks")'); + }); + + it("does not claim the join rule is keyed per user", () => { + // `applyEdgeRateLimits` falls back to the client IP when a rule supplies no + // `key`, and the join rule supplies none. The comment used to assert + // per-user keying, which the code cannot do — this middleware is + // cookie-presence only, with no DB hit and no JWT parsing. + expect(middleware).not.toContain("keyed per user by the shared"); + expect(middleware).toContain("Keyed by IP, NOT by user"); + }); +}); diff --git a/middleware.ts b/middleware.ts index 39f204264..0ba4e8be8 100644 --- a/middleware.ts +++ b/middleware.ts @@ -254,8 +254,17 @@ const RATE_LIMIT_RULES: RateRule[] = [ // #1134 P1-11 — the meeting join gate. Call ids are deterministic // (`slot-`), so this is the enumeration surface: without a // limit, someone holding one slot id can walk neighbours and probe which - // meetings they can reach. Authenticated, so keyed per user by the shared - // resolver. + // meetings they can reach. + // + // Keyed by IP, NOT by user — an earlier version of this comment claimed the + // opposite. `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: this middleware is cookie-presence only, + // with no DB hit and no JWT parsing, so it cannot resolve a user id cheaply. + // + // IP-keying is the right shape for enumeration anyway, since a walker works + // from one address. The cost is that users behind a shared NAT share a + // bucket, which is why the limit is generous rather than tight. label: "stream: meeting join", match: (p, m) => m === "POST" && /^\/api\/meetings\/[^/]+\/join$/.test(p), limiter: streamJoinLimiter, @@ -264,8 +273,22 @@ const RATE_LIMIT_RULES: RateRule[] = [ { // Ordinary authenticated Stream reads/writes — search, channel create, // block. Unbounded before, and each one costs a billable Stream API call. + // + // EXCLUDES the webhook endpoint. Stream POSTs every delivery from its own + // infrastructure, so they all collapse onto one rate-limit key, and a burst + // is the normal shape — a 200-attendee webinar emits 200 + // `call.session_participant_joined` events at once. A 429 there is not a + // deferral: Stream retries inside a fifteen-second total budget and then + // DROPS the event permanently, which is precisely the loss #1137's + // ack-first/persist-first work exists to prevent. Throttling it would have + // undone that from the middleware, before the route ever ran. + // + // Safe to exclude 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, not the limiter. label: "stream: api", - match: (p) => p.startsWith("/api/stream/"), + match: (p) => + p.startsWith("/api/stream/") && !p.startsWith("/api/stream/webhooks"), limiter: streamApiLimiter, skipLocalhost: true, },