diff --git a/apps/backend/src/middleware.tsx b/apps/backend/src/middleware.tsx index 1dd940a2f..a44227fbe 100644 --- a/apps/backend/src/middleware.tsx +++ b/apps/backend/src/middleware.tsx @@ -9,6 +9,10 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { SmartRouter } from './smart-router'; +const DEV_RATE_LIMIT_MAX_REQUESTS = 30; +const DEV_RATE_LIMIT_WINDOW_MS = 10_000; +const devRateLimitTimestamps: number[] = []; + const corsAllowedRequestHeaders = [ // General 'content-type', @@ -65,6 +69,50 @@ export async function middleware(request: NextRequest) { const url = new URL(request.url); const isApiRequest = url.pathname.startsWith('/api/'); + const corsHeadersInit = isApiRequest ? { + // CORS headers + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS", + "Access-Control-Max-Age": "86400", // 1 day (capped to lower values, eg. 10min, by some browsers) + "Access-Control-Allow-Headers": corsAllowedRequestHeaders.join(', '), + "Access-Control-Expose-Headers": corsAllowedResponseHeaders.join(', '), + } : undefined; + + // ensure our clients can handle 429 responses + if (isApiRequest && getNodeEnvironment() === 'development' && request.method !== 'OPTIONS') { + const now = Date.now(); + while (devRateLimitTimestamps.length > 0 && now - devRateLimitTimestamps[0] > DEV_RATE_LIMIT_WINDOW_MS) { + devRateLimitTimestamps.shift(); + } + console.log('devRateLimitTimestamps', devRateLimitTimestamps.length); + if (devRateLimitTimestamps.length >= DEV_RATE_LIMIT_MAX_REQUESTS) { + const waitMs = Math.max(0, DEV_RATE_LIMIT_WINDOW_MS - (now - devRateLimitTimestamps[0])); + const retryAfterSeconds = Math.max(1, Math.ceil(waitMs / 1000)); + + const response = NextResponse.json({ + message: 'Artificial development rate limit triggered. Wait before retrying.', + }, { + status: 429, + }); + + // since not all firewalls return CORS headers with their 429 responses, 50% chance that we don't set the CORS headers + if (Math.random() < 0.5 && corsHeadersInit) { + for (const [key, value] of Object.entries(corsHeadersInit)) { + response.headers.set(key, value); + } + } + + if (Math.random() < 0.5) { + // for debugging, make sure we don't always set the Retry-After header + response.headers.set('Retry-After', retryAfterSeconds.toString()); + } + + return response; + } else { + devRateLimitTimestamps.push(now); + } + } + const newRequestHeaders = new Headers(request.headers); // here we could update the request headers (currently we don't) @@ -72,14 +120,7 @@ export async function middleware(request: NextRequest) { request: { headers: newRequestHeaders, }, - headers: { - // CORS headers - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS", - "Access-Control-Max-Age": "86400", // 1 day (capped to lower values, eg. 10min, by some browsers) - "Access-Control-Allow-Headers": corsAllowedRequestHeaders.join(', '), - "Access-Control-Expose-Headers": corsAllowedResponseHeaders.join(', '), - }, + headers: corsHeadersInit, } as const : undefined; // we want to allow preflight requests to pass through diff --git a/packages/stack-shared/src/interface/client-interface.ts b/packages/stack-shared/src/interface/client-interface.ts index 7bc43aff0..f5ec136df 100644 --- a/packages/stack-shared/src/interface/client-interface.ts +++ b/packages/stack-shared/src/interface/client-interface.ts @@ -21,6 +21,7 @@ import { CurrentUserCrud } from './crud/current-user'; import { ItemCrud } from './crud/items'; import { NotificationPreferenceCrud } from './crud/notification-preferences'; import { OAuthProviderCrud } from './crud/oauth-providers'; +import { CustomerProductsListResponse, ListCustomerProductsOptions } from './crud/products'; import { TeamApiKeysCrud, UserApiKeysCrud, teamApiKeysCreateInputSchema, teamApiKeysCreateOutputSchema, userApiKeysCreateInputSchema, userApiKeysCreateOutputSchema } from './crud/project-api-keys'; import { ProjectPermissionsCrud } from './crud/project-permissions'; import { AdminUserProjectsCrud, ClientProjectsCrud } from './crud/projects'; @@ -29,7 +30,6 @@ import { TeamInvitationCrud } from './crud/team-invitation'; import { TeamMemberProfilesCrud } from './crud/team-member-profiles'; import { TeamPermissionsCrud } from './crud/team-permissions'; import { TeamsCrud } from './crud/teams'; -import { CustomerProductsListResponse, ListCustomerProductsOptions } from './crud/products'; export type ClientInterfaceOptions = { clientVersion: string, @@ -156,29 +156,33 @@ export class StackClientInterface { token_endpoint_auth_method: 'client_secret_post', }; - const rawResponse = await this._networkRetryException( - async () => await oauth.refreshTokenGrantRequest( + const response = await this._networkRetryException(async () => { + const rawResponse = await oauth.refreshTokenGrantRequest( as, client, refreshToken.token, - ) - ); - const response = await this._processResponse(rawResponse); + ); - if (response.status === "error") { - const error = response.error; - if (KnownErrors.RefreshTokenError.isInstance(error)) { - return null; + const response = await this._processResponse(rawResponse); + + if (response.status === "error") { + const error = response.error; + if (KnownErrors.RefreshTokenError.isInstance(error)) { + return null; + } + throw error; } - throw error; - } - if (!response.data.ok) { - const body = await response.data.text(); - throw new Error(`Failed to send refresh token request: ${response.status} ${body}`); - } + if (!response.data.ok) { + const body = await response.data.text(); + throw new Error(`Failed to send refresh token request: ${response.status} ${body}`); + } - const result = await oauth.processRefreshTokenResponse(as, client, response.data); + return response.data; + }); + if (!response) return null; + + const result = await oauth.processRefreshTokenResponse(as, client, response); if (oauth.isOAuth2Error(result)) { // TODO Handle OAuth 2.0 response body error throw new StackAssertionError("OAuth error", { result });