Add rate limiting to meet creation endpoint (#1950)

# Restrict Video Meeting Creation to Pro Users

## Description

This PR restricts the video meeting creation functionality to Pro users only. It also adds rate limiting to the meeting creation endpoint to prevent abuse. The UI has been updated to hide the video meeting button for non-Pro users.

## Type of Change

- [x]  New feature (non-breaking change which adds functionality)
- [x] 🔒 Security enhancement
- [x]  Performance improvement

## Areas Affected

- [x] User Interface/Experience
- [x] Authentication/Authorization
- [x] API Endpoints

## Testing Done

- [x] Manual testing performed

## Security Considerations

- [x] Authentication checks are in place
- [x] Rate limiting is implemented

## Checklist

- [x] I have performed a self-review of my code
- [x] My changes generate no new warnings

## Additional Notes

The PR includes:
1. Refactoring the Pro user detection logic into a reusable utility function
2. Adding rate limiting to the meeting creation endpoint (10 requests per minute)
3. Conditionally rendering the video meeting button in the sidebar based on Pro status
4. Proper error handling for unauthorized meeting creation attempts
This commit is contained in:
Adam
2025-08-08 10:33:08 -07:00
committed by GitHub
parent 3ca38991fe
commit fb29c6b737
11 changed files with 124 additions and 134 deletions

View File

@@ -20,8 +20,8 @@ import { useSession } from '@/lib/auth-client';
import { useAIFullScreen } from './ai-sidebar';
import { useStats } from '@/hooks/use-stats';
import { useLocation } from 'react-router';
import { cn, FOLDERS } from '@/lib/utils';
import { m } from '@/paraglide/messages';
import { FOLDERS } from '@/lib/utils';
import { Video } from 'lucide-react';
import { NavUser } from './nav-user';
import { NavMain } from './nav-main';
@@ -39,11 +39,8 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
return true;
});
const [, setPricingDialog] = useQueryState('pricingDialog');
const { isFullScreen } = useAIFullScreen();
const { data: stats } = useStats();
const location = useLocation();
const { data: session } = useSession();
const { currentSection, navItems } = useMemo(() => {
@@ -107,15 +104,17 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
{showComposeButton && (
<div className="flex gap-1">
<div className="w-[80%]">
<div className={cn(isPro ? 'w-[80%]' : 'w-full')}>
<ComposeButton />
</div>
<button
onClick={handleCreateMeet}
className="hover:bg-muted-foreground/10 inline-flex h-8 w-[20%] items-center justify-center gap-1 overflow-hidden rounded-lg border bg-white px-1.5 dark:border-none dark:bg-[#313131]"
>
<Video className="text-muted-foreground h-4 w-4" />
</button>
{isPro ? (
<button
onClick={handleCreateMeet}
className="hover:bg-muted-foreground/10 inline-flex h-8 w-[20%] items-center justify-center gap-1 overflow-hidden rounded-lg border bg-white px-1.5 dark:border-none dark:bg-[#313131]"
>
<Video className="text-muted-foreground h-4 w-4" />
</button>
) : null}
</div>
)}
</SidebarHeader>

View File

@@ -1,5 +1,6 @@
import { useAutumn, useCustomer } from 'autumn-js/react';
import { signOut } from '@/lib/auth-client';
import { isProCustomer } from '@/lib/utils';
import { useEffect, useMemo } from 'react';
type FeatureState = {
@@ -58,8 +59,6 @@ const FEATURE_IDS = {
BRAIN: 'brain-activity',
} as const;
const PRO_PLANS = ['pro-example', 'pro_annual', 'team', 'enterprise'] as const;
export const useBilling = () => {
const { customer, refetch, isLoading, error } = useCustomer();
const { attach, track, openBillingPortal } = useAutumn();
@@ -69,12 +68,7 @@ export const useBilling = () => {
}, [error]);
const { isPro, ...customerFeatures } = useMemo(() => {
const isPro =
customer?.products && Array.isArray(customer.products)
? customer.products.some((product) =>
PRO_PLANS.some((plan) => product.id?.includes(plan) || product.name?.includes(plan)),
)
: false;
const isPro = customer ? isProCustomer(customer) : false;
if (!customer?.features) return { isPro, ...DEFAULT_FEATURES };

View File

@@ -3,6 +3,7 @@ import { getBrowserTimezone } from './timezones';
import { formatInTimeZone } from 'date-fns-tz';
import { MAX_URL_LENGTH } from './constants';
import { clsx, type ClassValue } from 'clsx';
import type { Customer } from 'autumn-js';
import { twMerge } from 'tailwind-merge';
import type { Sender } from '@/types';
import LZString from 'lz-string';
@@ -617,3 +618,13 @@ export const withExponentialBackoff = async <T>(
}
}
};
const PRO_PLANS = ['pro-example', 'pro_annual', 'team', 'enterprise'] as const;
export const isProCustomer = (customer: Customer) => {
return customer?.products && Array.isArray(customer.products)
? customer.products.some((product) =>
PRO_PLANS.some((plan) => product.id?.includes(plan) || product.name?.includes(plan)),
)
: false;
};

View File

@@ -1,13 +1,13 @@
import type { Env } from './env';
import type { Autumn } from 'autumn-js';
import type { Auth } from './lib/auth';
import type { ZeroEnv } from './env';
export type SessionUser = NonNullable<Awaited<ReturnType<Auth['api']['getSession']>>>['user'];
export type HonoVariables = {
auth: Auth;
sessionUser?: SessionUser;
autumn: Autumn;
autumn?: Autumn;
};
export type HonoContext = { Variables: HonoVariables; Bindings: Env };
export type HonoContext = { Variables: HonoVariables; Bindings: ZeroEnv };

View File

@@ -13,18 +13,17 @@ import { getBrowserTimezone, isValidTimezone } from './timezones';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { getSocialProviders } from './auth-providers';
import { redis, resend, twilio } from './services';
import { getContext } from 'hono/context-storage';
import { dubAnalytics } from '@dub/better-auth';
import { defaultUserSettings } from './schemas';
import { disableBrainFunction } from './brain';
import { APIError } from 'better-auth/api';
import { getZeroDB } from './server-utils';
import { type EProviders } from '../types';
import type { HonoContext } from '../ctx';
import { env } from '../env';
import { createDriver } from './driver';
import { Autumn } from 'autumn-js';
import { createDb } from '../db';
import { Effect } from 'effect';
import { env } from '../env';
import { Dub } from 'dub';
const scheduleCampaign = (userInfo: { address: string; name: string }) =>
@@ -191,9 +190,9 @@ export const createAuth = () => {
if (!request) throw new APIError('BAD_REQUEST', { message: 'Request object is missing' });
const db = await getZeroDB(user.id);
const connections = await db.findManyConnections();
const context = getContext<HonoContext>();
const autumn = new Autumn({ secretKey: env.AUTUMN_SECRET_KEY });
try {
await context.var.autumn.customers.delete(user.id);
await autumn.customers.delete(user.id);
} catch (error) {
console.error('Failed to delete Autumn customer:', error);
// Continue with deletion process despite Autumn failure

View File

@@ -364,11 +364,7 @@ export const forceReSync = async (connectionId: string) => {
await agent.stub.forceReSync();
};
type GetThreadsAccumulator = {
threads: any[];
nextPageToken: string | null;
maxResults: number;
};
export const getThreadsFromDB = async (
connectionId: string,
@@ -380,80 +376,40 @@ export const getThreadsFromDB = async (
pageToken?: string;
},
): Promise<IGetThreadsResponse> => {
console.log(`[getThreadsFromDB] Called with connectionId: ${connectionId}, params:`, params);
await sendDoState(connectionId);
// Fire and forget - don't block the thread query on state updates
void sendDoState(connectionId);
const maxResults = params.maxResults ?? 20;
return Effect.runPromise(
aggregateShardDataSequentialEffect<IGetThreadsResponse, GetThreadsAccumulator>(
aggregateShardDataEffect<IGetThreadsResponse>(
connectionId,
(shard, shardId, accumulator) =>
Effect.gen(function* () {
if (accumulator.threads.length >= accumulator.maxResults) {
console.log(
`[getThreadsFromDB] Reached maxResults (${accumulator.maxResults}), breaking loop`,
);
return { shouldContinue: false, accumulator };
}
(shard) =>
Effect.promise(() =>
shard.stub.getThreadsFromDB({
...params,
maxResults: maxResults * 2, // Request more from each shard to ensure we have enough
}),
),
(shardResults) => {
// Combine all threads from all shards
const allThreads = shardResults.flatMap((result) => result.threads);
// Sort by some criteria if needed (assuming threads have a sortable field)
// allThreads.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
// Take only the requested amount
const threads = allThreads.slice(0, maxResults);
// Determine if there's a next page token (simplified logic)
const hasMoreResults = allThreads.length > maxResults;
const nextPageToken = hasMoreResults
? shardResults.find(r => r.nextPageToken)?.nextPageToken || null
: null;
const remainingResults = accumulator.maxResults - accumulator.threads.length;
console.log(
`[getThreadsFromDB] Querying shard ${shardId} for up to ${remainingResults} threads`,
);
const shardResult = (yield* Effect.promise(() =>
shard.stub.getThreadsFromDB({
...params,
maxResults: remainingResults,
}),
)) as IGetThreadsResponse;
console.log(
`[getThreadsFromDB] Shard ${shardId} returned ${shardResult.threads.length} threads, nextPageToken: ${shardResult.nextPageToken}`,
);
const newThreads = [...accumulator.threads, ...shardResult.threads];
let newNextPageToken = accumulator.nextPageToken;
if (shardResult.nextPageToken) {
newNextPageToken = shardResult.nextPageToken;
console.log(
`[getThreadsFromDB] Setting nextPageToken from shard ${shardId}: ${newNextPageToken}`,
);
}
const shouldContinue =
newThreads.length < accumulator.maxResults &&
shardResult.threads.length >= remainingResults;
if (!shouldContinue) {
console.log(
`[getThreadsFromDB] Stopping after shard ${shardId} (threads.length: ${newThreads.length}, shardResult.threads.length: ${shardResult.threads.length}, remainingResults: ${remainingResults})`,
);
}
return {
shouldContinue,
accumulator: {
threads: newThreads,
nextPageToken: newNextPageToken,
maxResults: accumulator.maxResults,
},
};
}),
{ threads: [], nextPageToken: null, maxResults },
(accumulator) => {
const slicedThreads = accumulator.threads.slice(
0,
maxResults === Infinity ? accumulator.threads.length : maxResults,
);
console.log(
`[getThreadsFromDB] Returning ${slicedThreads.length} threads, nextPageToken: ${accumulator.nextPageToken}`,
);
return {
threads: slicedThreads,
nextPageToken: accumulator.nextPageToken,
threads,
nextPageToken,
};
},
),

View File

@@ -1,4 +1,5 @@
import type { AppContext, EProviders, Sender } from '../types';
import type { Customer } from 'autumn-js';
import { env } from '../env';
export const parseHeaders = (token: string) => {
@@ -365,3 +366,13 @@ export const cleanSearchValue = (q: string): string => {
.replace(/\s+/g, ' ')
.trim();
};
const PRO_PLANS = ['pro-example', 'pro_annual', 'team', 'enterprise'] as const;
export const isProCustomer = (customer: Customer) => {
return customer?.products && Array.isArray(customer.products)
? customer.products.some((product) =>
PRO_PLANS.some((plan) => product.id?.includes(plan) || product.name?.includes(plan)),
)
: false;
};

View File

@@ -45,7 +45,6 @@ import type { HonoContext } from './ctx';
import { createDb, type DB } from './db';
import { createAuth } from './lib/auth';
import { aiRouter } from './routes/ai';
import { Autumn } from 'autumn-js';
import { appRouter } from './trpc';
import { cors } from 'hono/cors';
import { Hono } from 'hono';
@@ -587,13 +586,9 @@ const api = new Hono<HonoContext>()
}
}
const autumn = new Autumn({ secretKey: env.AUTUMN_SECRET_KEY });
c.set('autumn', autumn);
await next();
c.set('sessionUser', undefined);
c.set('autumn', undefined as any);
c.set('auth', undefined as any);
})
.route('/ai', aiRouter)

View File

@@ -1,4 +1,4 @@
import { fetchPricingTable } from 'autumn-js';
import { Autumn, fetchPricingTable } from 'autumn-js';
import type { HonoContext } from '../ctx';
import { env } from '../env';
import { Hono } from 'hono';
@@ -38,6 +38,7 @@ export const autumnApi = new Hono<AutumnContext>()
},
},
);
c.set('autumn', new Autumn({ secretKey: env.AUTUMN_SECRET_KEY }));
await next();
})
.post('/customers', async (c) => {
@@ -46,7 +47,7 @@ export const autumnApi = new Hono<AutumnContext>()
if (!customerData) return c.json({ error: 'No customer ID found' }, 401);
return c.json(
await autumn.customers
await autumn!.customers
.create({
id: customerData.customerId,
...customerData.customerData,
@@ -62,7 +63,7 @@ export const autumnApi = new Hono<AutumnContext>()
if (!customerData) return c.json({ error: 'No customer ID found' }, 401);
return c.json(
await autumn
await autumn!
.attach({
...sanitizedBody,
customer_id: customerData.customerId,
@@ -78,7 +79,7 @@ export const autumnApi = new Hono<AutumnContext>()
if (!customerData) return c.json({ error: 'No customer ID found' }, 401);
return c.json(
await autumn
await autumn!
.cancel({
...sanitizedBody,
customer_id: customerData.customerId,
@@ -93,7 +94,7 @@ export const autumnApi = new Hono<AutumnContext>()
if (!customerData) return c.json({ error: 'No customer ID found' }, 401);
return c.json(
await autumn
await autumn!
.check({
...sanitizedBody,
customer_id: customerData.customerId,
@@ -109,7 +110,7 @@ export const autumnApi = new Hono<AutumnContext>()
if (!customerData) return c.json({ error: 'No customer ID found' }, 401);
return c.json(
await autumn
await autumn!
.track({
...sanitizedBody,
customer_id: customerData.customerId,
@@ -124,7 +125,9 @@ export const autumnApi = new Hono<AutumnContext>()
if (!customerData) return c.json({ error: 'No customer ID found' }, 401);
return c.json(
await autumn.customers.billingPortal(customerData.customerId, body).then((data) => data.data),
await autumn!.customers
.billingPortal(customerData.customerId, body)
.then((data) => data.data),
);
})
.post('/openBillingPortal', async (c) => {
@@ -133,7 +136,7 @@ export const autumnApi = new Hono<AutumnContext>()
if (!customerData) return c.json({ error: 'No customer ID found' }, 401);
return c.json(
await autumn.customers
await autumn!.customers
.billingPortal(customerData.customerId, {
...body,
return_url: `${env.VITE_PUBLIC_APP_URL}`,
@@ -147,7 +150,7 @@ export const autumnApi = new Hono<AutumnContext>()
if (!customerData) return c.json({ error: 'No customer ID found' }, 401);
return c.json(
await autumn.entities.create(customerData.customerId, body).then((data) => data.data),
await autumn!.entities.create(customerData.customerId, body).then((data) => data.data),
);
})
.get('/entities/:entityId', async (c) => {
@@ -168,7 +171,7 @@ export const autumnApi = new Hono<AutumnContext>()
}
return c.json(
await autumn.entities
await autumn!.entities
.get(customerData.customerId, entityId, { expand })
.then((data) => data.data),
);
@@ -190,7 +193,7 @@ export const autumnApi = new Hono<AutumnContext>()
}
return c.json(
await autumn.entities.delete(customerData.customerId, entityId).then((data) => data.data),
await autumn!.entities.delete(customerData.customerId, entityId).then((data) => data.data),
);
})
.get('/components/pricing_table', async (c) => {
@@ -198,7 +201,7 @@ export const autumnApi = new Hono<AutumnContext>()
return c.json(
await fetchPricingTable({
instance: autumn,
instance: autumn!,
params: {
customer_id: customerData?.customerId || undefined,
},

View File

@@ -47,6 +47,5 @@ export const serverTrpc = () => {
c,
sessionUser: c.var.sessionUser,
auth: c.var.auth,
autumn: c.var.autumn,
});
};

View File

@@ -1,5 +1,8 @@
import { activeDriverProcedure, router } from '../trpc';
import { activeDriverProcedure, createRateLimiterMiddleware, router } from '../trpc';
import { isProCustomer } from '../../lib/utils';
import { Ratelimit } from '@upstash/ratelimit';
import { TRPCError } from '@trpc/server';
import { Autumn } from 'autumn-js';
import { env } from '../../env';
type MeetResponse = {
@@ -18,22 +21,42 @@ type MeetResponse = {
};
export const meetRouter = router({
create: activeDriverProcedure.mutation(async () => {
const AuthHeader = env.MEET_AUTH_HEADER;
const response = await fetch(env.MEET_API_URL + '/meetings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: AuthHeader,
},
});
create: activeDriverProcedure
.use(
createRateLimiterMiddleware({
limiter: Ratelimit.slidingWindow(10, '1m'),
generatePrefix: ({ sessionUser }) => `ratelimit:meet-create-${sessionUser?.id}`,
}),
)
.mutation(async ({ ctx }) => {
const autumn = new Autumn({ secretKey: env.AUTUMN_SECRET_KEY });
const customer = await autumn.customers.get(ctx.sessionUser?.id);
if (!customer.data) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Customer not found' });
}
if (!response.ok) {
console.error(await response.text());
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to create meeting' });
}
if (!isProCustomer(customer.data)) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Customer is not a pro customer, please upgrade to a pro plan',
});
}
const data = await response.json<MeetResponse>();
return data;
}),
const AuthHeader = env.MEET_AUTH_HEADER;
const response = await fetch(env.MEET_API_URL + '/meetings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: AuthHeader,
},
});
if (!response.ok) {
console.error(await response.text());
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to create meeting' });
}
const data = await response.json<MeetResponse>();
return data;
}),
});