diff --git a/apps/sim/app/(landing)/components/footer/footer.tsx b/apps/sim/app/(landing)/components/footer/footer.tsx index 3a3e131403b..6eddc8f736e 100644 --- a/apps/sim/app/(landing)/components/footer/footer.tsx +++ b/apps/sim/app/(landing)/components/footer/footer.tsx @@ -43,6 +43,7 @@ const RESOURCES_LINKS: FooterItem[] = [ { label: 'Partners', href: '/partners' }, { label: 'Careers', href: 'https://jobs.ashbyhq.com/sim', external: true }, { label: 'Changelog', href: '/changelog' }, + { label: 'Contact', href: '/contact' }, ] /** Top model providers, sourced from the catalog so labels/hrefs never drift. */ diff --git a/apps/sim/app/(landing)/contact/components/contact-form/contact-form.tsx b/apps/sim/app/(landing)/contact/components/contact-form/contact-form.tsx new file mode 100644 index 00000000000..70a2c3a9e5c --- /dev/null +++ b/apps/sim/app/(landing)/contact/components/contact-form/contact-form.tsx @@ -0,0 +1,341 @@ +'use client' + +import { type ReactNode, useId, useRef, useState } from 'react' +import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile' +import { Chip, ChipDropdown, ChipInput, ChipTextarea, Label } from '@sim/emcn' +import { Check } from '@sim/emcn/icons' +import { toError } from '@sim/utils/errors' +import { + CONTACT_TOPIC_OPTIONS, + type ContactRequestPayload, + contactRequestSchema, +} from '@/lib/api/contracts/contact' +import { flattenFieldErrors } from '@/lib/api/contracts/primitives' +import { getEnv } from '@/lib/core/config/env' +import { captureClientEvent } from '@/lib/posthog/client' +import { useSubmitContact } from '@/hooks/queries/contact' + +/** + * Field control height — slightly taller than the 30px in-app chip default and + * just under the 36px auth field, so the form reads as a roomy landing surface. + * Applied to each control's `className`, the sanctioned way to own only a chip + * field's height (mirrors the demo form). + */ +const FIELD_HEIGHT = 'h-[34px]' + +/** Build-time-inlined Turnstile site key; absent when captcha isn't configured. */ +const TURNSTILE_SITE_KEY = getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY') + +type ContactField = keyof ContactRequestPayload +type ContactErrors = Partial> + +interface ContactFormState { + name: string + email: string + company: string + topic: ContactRequestPayload['topic'] | '' + subject: string + message: string +} + +const INITIAL_STATE: ContactFormState = { + name: '', + email: '', + company: '', + topic: '', + subject: '', + message: '', +} + +interface ContactFieldProps { + label: string + /** Set for native controls (inputs/textarea) to associate the label by `id`. */ + htmlFor?: string + required?: boolean + error?: string + /** The control. Dropdowns (no `htmlFor`) are wrapped in a labeled group. */ + children: ReactNode +} + +/** + * A labeled field row matching the chip field rhythm (`gap-[9px]`, muted label, + * caption-sized error). Native controls associate via `htmlFor`/`id`; controls + * that can't take a label `id` (the dropdown) become a `role='group'` named by + * the label instead, so every field has an accessible name. + */ +function ContactField({ label, htmlFor, required, error, children }: ContactFieldProps) { + const labelId = useId() + const isGroup = htmlFor === undefined + return ( +
+ + {children} + {error ?

{error}

: null} +
+ ) +} + +/** + * The `/contact` form — rendered inside the card chrome owned by the page, so it + * returns just its heading and fields. Fields are hand-composed at the slightly + * taller {@link FIELD_HEIGHT}, stacked at the platform `gap-4` rhythm with no + * divider lines, mirroring the demo booking form. + * + * On submit it validates against the shared {@link contactRequestSchema}, runs an + * invisible Turnstile challenge (falling back gracefully when the widget is + * unavailable), and posts through {@link useSubmitContact}, which emails the help + * inbox and sends the visitor a confirmation. A honeypot `website` field and the + * captcha token ride along on the payload. A successful submit swaps the card to a + * confirmation state. + */ +export function ContactForm() { + const turnstileRef = useRef(null) + + const contactMutation = useSubmitContact() + + const [form, setForm] = useState(INITIAL_STATE) + const [errors, setErrors] = useState({}) + const [isSubmitting, setIsSubmitting] = useState(false) + const [website, setWebsite] = useState('') + const [widgetLoaded, setWidgetLoaded] = useState(false) + + function updateField( + field: TField, + value: ContactFormState[TField] + ) { + setForm((prev) => ({ ...prev, [field]: value })) + setErrors((prev) => { + if (!prev[field as ContactField]) { + return prev + } + const nextErrors = { ...prev } + delete nextErrors[field as ContactField] + return nextErrors + }) + if (contactMutation.isError) { + contactMutation.reset() + } + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault() + if (contactMutation.isPending || isSubmitting) return + setIsSubmitting(true) + + const parsed = contactRequestSchema.safeParse({ + ...form, + company: form.company || undefined, + }) + + if (!parsed.success) { + setErrors(flattenFieldErrors(parsed.error)) + setIsSubmitting(false) + return + } + + let captchaToken: string | undefined + const widget = turnstileRef.current + + if (TURNSTILE_SITE_KEY && widgetLoaded && widget) { + try { + widget.reset() + widget.execute() + captchaToken = await widget.getResponsePromise(30_000) + } catch { + captchaToken = undefined + } + } + + contactMutation.mutate( + { ...parsed.data, website, captchaToken }, + { + onSuccess: () => { + captureClientEvent('landing_contact_submitted', { topic: parsed.data.topic }) + setForm(INITIAL_STATE) + setErrors({}) + }, + onError: () => { + turnstileRef.current?.reset() + }, + onSettled: () => { + setIsSubmitting(false) + }, + } + ) + } + + const isBusy = contactMutation.isPending || isSubmitting + + const submitError = contactMutation.isError + ? toError(contactMutation.error).message || 'Failed to send message. Please try again.' + : null + + if (contactMutation.isSuccess) { + return ( +
+
+ +
+

Message received

+

+ Thanks for reaching out. Our team will get back to you shortly. +

+ +
+ ) + } + + return ( + <> +

+ Send us a message +

+

+ Ask a question, request an integration, or get help — we'll get back to you shortly. +

+ +
+ + +
+ + updateField('name', event.target.value)} + error={Boolean(errors.name)} + placeholder='Jane Doe' + autoComplete='name' + /> + + + updateField('email', event.target.value)} + error={Boolean(errors.email)} + placeholder='jane@acme.co' + autoComplete='email' + /> + +
+ +
+ + updateField('company', event.target.value)} + error={Boolean(errors.company)} + placeholder='Acme Inc.' + autoComplete='organization' + /> + + + updateField('topic', value as ContactRequestPayload['topic'])} + options={CONTACT_TOPIC_OPTIONS} + placeholder='Select a topic' + /> + +
+ + + updateField('subject', event.target.value)} + error={Boolean(errors.subject)} + placeholder='How can we help?' + /> + + + + updateField('message', event.target.value)} + error={Boolean(errors.message)} + placeholder='Share details so we can help as quickly as possible.' + rows={4} + /> + + + {TURNSTILE_SITE_KEY ? ( + setWidgetLoaded(true)} + onError={() => setWidgetLoaded(false)} + onUnsupported={() => setWidgetLoaded(false)} + /> + ) : null} + + {submitError ? ( +

+ {submitError} +

+ ) : null} + + + {isBusy ? 'Sending…' : 'Send message'} + + + + ) +} diff --git a/apps/sim/app/(landing)/contact/components/contact-form/index.ts b/apps/sim/app/(landing)/contact/components/contact-form/index.ts new file mode 100644 index 00000000000..ab5151da9c7 --- /dev/null +++ b/apps/sim/app/(landing)/contact/components/contact-form/index.ts @@ -0,0 +1 @@ +export { ContactForm } from './contact-form' diff --git a/apps/sim/app/(landing)/contact/contact.tsx b/apps/sim/app/(landing)/contact/contact.tsx new file mode 100644 index 00000000000..ff15713e77d --- /dev/null +++ b/apps/sim/app/(landing)/contact/contact.tsx @@ -0,0 +1,75 @@ +import { chipBorderShadowRing, cn } from '@sim/emcn' +import { TrustedBy } from '@/app/(landing)/components/trusted-by' +import { ContactForm } from '@/app/(landing)/contact/components/contact-form' + +/** + * Contact page — mirrors the demo page's two-column split: value proposition and + * customer proof on the left, the message form in a content-height card on the + * right. + * + * The section is a two-column CSS grid capped and centered at the shared + * `max-w-[1446px]` with the navbar-aligned `px-12` gutter, so the headline starts + * on the same vertical line as the wordmark. The desktop split is `xl:grid-cols-2` + * with `xl:gap-x-0` — the columns split at the exact horizontal center, so the + * right card occupies the same rectangle as the hero's right panel. The card is + * inset from the section's top and bottom by 32px (`xl:pt-8`/`xl:pb-8`), spans both + * rows (`xl:row-span-2`), and its content drives the column height — the left + * column stretches to match, bottom-anchoring the logos to the card's lower edge. + * + * Three grid children, ordered in the DOM as headline → form → logos so the + * COLLAPSE below `xl` (single column) yields the best mobile reading order: value + * proposition first, the form immediately after it, then the customer logos as + * reinforcing social proof. On desktop the headline cell adds `xl:pt-[80px]` so its + * text sits on the hero's line, while the card top stays on the higher `top-8` + * line. The customer proof reuses the shared {@link TrustedBy} block, + * bottom-anchored (`xl:row-start-2 xl:self-end`). The gutter follows the navbar + * convention (`px-12 max-lg:px-8 max-sm:px-5`), and `max-sm` drops to the smallest + * type scale. + * + * Carries an sr-only product summary for AI citation (landing CLAUDE.md → GEO). + */ +export default function Contact() { + return ( +
+
+
+

+ Get in touch with Sim, the open-source AI workspace where teams build, deploy, and + manage AI agents and workflows. Ask a question, request an integration, or get help from + the team — send a message and we'll get back to you shortly. +

+ +

+ Get in touch with Sim,
+ the AI agent workspace. +

+

+ Ask a question, request an integration, or get help from the team. Tell us what you need + and we'll get back to you shortly. +

+
+ +
+
+ +
+
+ + +
+
+ ) +} diff --git a/apps/sim/app/(landing)/contact/page.tsx b/apps/sim/app/(landing)/contact/page.tsx new file mode 100644 index 00000000000..3876509d709 --- /dev/null +++ b/apps/sim/app/(landing)/contact/page.tsx @@ -0,0 +1,18 @@ +import { buildLandingMetadata } from '@/lib/landing/seo' +import Contact from '@/app/(landing)/contact/contact' + +export const revalidate = 3600 + +const TITLE = 'Contact Us | Sim, the AI Workspace' +const DESCRIPTION = + 'Get in touch with Sim, the open-source AI workspace where teams build, deploy, and manage AI agents. Ask a question, request an integration, or get help from the team.' + +export const metadata = buildLandingMetadata({ + title: TITLE, + description: DESCRIPTION, + path: '/contact', +}) + +export default function Page() { + return +} diff --git a/apps/sim/app/api/contact/route.ts b/apps/sim/app/api/contact/route.ts new file mode 100644 index 00000000000..2b610ec2114 --- /dev/null +++ b/apps/sim/app/api/contact/route.ts @@ -0,0 +1,191 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { renderHelpConfirmationEmail } from '@/components/emails' +import { + getContactTopicLabel, + mapContactTopicToHelpType, + submitContactContract, +} from '@/lib/api/contracts/contact' +import { parseRequest } from '@/lib/api/server' +import { env } from '@/lib/core/config/env' +import type { TokenBucketConfig } from '@/lib/core/rate-limiter' +import { RateLimiter } from '@/lib/core/rate-limiter' +import { isTurnstileConfigured, verifyTurnstileToken } from '@/lib/core/security/turnstile' +import { generateRequestId, getClientIp } from '@/lib/core/utils/request' +import { getEmailDomain } from '@/lib/core/utils/urls' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { sendEmail } from '@/lib/messaging/email/mailer' +import { getFromEmailAddress } from '@/lib/messaging/email/utils' + +const logger = createLogger('ContactAPI') +const rateLimiter = new RateLimiter() + +const PUBLIC_ENDPOINT_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 10, + refillRate: 5, + refillIntervalMs: 60_000, +} + +const CAPTCHA_UNAVAILABLE_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 3, + refillRate: 1, + refillIntervalMs: 60_000, +} + +const SUCCESS_RESPONSE = { success: true, message: "Thanks — we'll be in touch soon." } +const TOO_MANY_REQUESTS_RESPONSE = { error: 'Too many requests. Please try again later.' } + +/** + * Public contact-form endpoint: per-IP rate limit, honeypot drop, captcha, then a + * help-inbox notification plus a best-effort visitor confirmation. + * + * Captcha is server-authoritative — a valid Turnstile token is the only way past + * the stricter fallback bucket, so a caller cannot opt out of the challenge. A + * missing token (widget could not load) or a Cloudflare transport error falls + * back to the tighter no-captcha bucket rather than a free pass; an outright + * invalid token is rejected. That backstop is enforced `failClosed`, so an + * unavailable limiter rejects token-less submits instead of admitting them. No + * `expectedHostname` is pinned: the site key is already domain-bound in + * Cloudflare, and a single-host pin would reject valid self-hosted/preview/apex + * tokens. + */ +export const POST = withRouteHandler(async (req: NextRequest) => { + const requestId = generateRequestId() + + try { + const ip = getClientIp(req) + const storageKey = `public:contact:${ip}` + + const { allowed, remaining, resetAt } = await rateLimiter.checkRateLimitDirect( + storageKey, + PUBLIC_ENDPOINT_RATE_LIMIT + ) + + if (!allowed) { + logger.warn(`[${requestId}] Rate limit exceeded for IP ${ip}`, { remaining, resetAt }) + return NextResponse.json(TOO_MANY_REQUESTS_RESPONSE, { + status: 429, + headers: { 'Retry-After': String(Math.ceil((resetAt.getTime() - Date.now()) / 1000)) }, + }) + } + + const parsed = await parseRequest(submitContactContract, req, {}) + if (!parsed.success) { + logger.warn(`[${requestId}] Invalid contact request data`) + return parsed.response + } + + const { name, email, company, topic, subject, message, website, captchaToken } = + parsed.data.body + + if (typeof website === 'string' && website.trim().length > 0) { + logger.warn(`[${requestId}] Honeypot triggered, discarding`, { ip }) + return NextResponse.json(SUCCESS_RESPONSE, { status: 201 }) + } + + if (isTurnstileConfigured()) { + let captchaVerified = false + const token = + typeof captchaToken === 'string' && captchaToken.length > 0 ? captchaToken : null + + if (token) { + const verification = await verifyTurnstileToken({ token, remoteIp: ip }) + if (verification.success) { + captchaVerified = true + } else if (!verification.transportError) { + logger.warn(`[${requestId}] Captcha verification failed`, { + ip, + errorCodes: verification.errorCodes, + }) + return NextResponse.json( + { error: 'Captcha verification failed. Please try again.' }, + { status: 400 } + ) + } else { + logger.warn( + `[${requestId}] Captcha transport error, falling back to no-captcha rate limit`, + { ip } + ) + } + } + + if (!captchaVerified) { + const nocaptchaKey = `public:contact:nocaptcha:${ip}` + const { allowed: nocaptchaAllowed } = await rateLimiter.checkRateLimitDirect( + nocaptchaKey, + CAPTCHA_UNAVAILABLE_RATE_LIMIT, + { failClosed: true } + ) + if (!nocaptchaAllowed) { + logger.warn(`[${requestId}] Rate limit rejected (no-captcha) for IP ${ip}`) + return NextResponse.json(TOO_MANY_REQUESTS_RESPONSE, { status: 429 }) + } + } + } + + const topicLabel = getContactTopicLabel(topic) + + logger.info(`[${requestId}] Processing contact request`, { + email: `${email.substring(0, 3)}***`, + topic, + }) + + const emailText = `Contact form submission +Submitted: ${new Date().toISOString()} +Topic: ${topicLabel} +Name: ${name} +Email: ${email} +Company: ${company ?? 'Not provided'} + +Subject: ${subject} + +Message: +${message} +` + + const helpInboxDomain = env.EMAIL_DOMAIN || getEmailDomain() + const emailResult = await sendEmail({ + to: [`help@${helpInboxDomain}`], + subject: `[CONTACT:${topic.toUpperCase()}] ${subject}`, + text: emailText, + from: getFromEmailAddress(), + replyTo: email, + emailType: 'transactional', + }) + + if (!emailResult.success) { + logger.error(`[${requestId}] Error sending contact request email`, emailResult.message) + return NextResponse.json({ error: 'Failed to send message' }, { status: 500 }) + } + + logger.info(`[${requestId}] Contact request email sent successfully`) + + try { + const confirmationHtml = await renderHelpConfirmationEmail( + mapContactTopicToHelpType(topic), + 0 + ) + + await sendEmail({ + to: [email], + subject: `We've received your message: ${subject}`, + html: confirmationHtml, + from: getFromEmailAddress(), + replyTo: `help@${helpInboxDomain}`, + emailType: 'transactional', + }) + } catch (err) { + logger.warn(`[${requestId}] Failed to send contact confirmation email`, err) + } + + return NextResponse.json(SUCCESS_RESPONSE, { status: 201 }) + } catch (error) { + if (error instanceof Error && error.message.includes('not configured')) { + logger.error(`[${requestId}] Email service configuration error`, error) + return NextResponse.json({ error: 'Email service configuration error.' }, { status: 500 }) + } + + logger.error(`[${requestId}] Error processing contact request`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/sitemap.ts b/apps/sim/app/sitemap.ts index 150caf3ba7c..668ff239095 100644 --- a/apps/sim/app/sitemap.ts +++ b/apps/sim/app/sitemap.ts @@ -42,6 +42,9 @@ export default async function sitemap(): Promise { { url: `${baseUrl}/demo`, }, + { + url: `${baseUrl}/contact`, + }, { url: `${baseUrl}/enterprise`, }, diff --git a/apps/sim/hooks/queries/contact.ts b/apps/sim/hooks/queries/contact.ts new file mode 100644 index 00000000000..17daab2470f --- /dev/null +++ b/apps/sim/hooks/queries/contact.ts @@ -0,0 +1,25 @@ +import { createLogger } from '@sim/logger' +import { useMutation } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + type SubmitContactBody, + type SubmitContactResult, + submitContactContract, +} from '@/lib/api/contracts/contact' + +const logger = createLogger('ContactMutation') + +/** + * Submit an inbound contact request. The route emails the help inbox (replying to + * the visitor) and sends the visitor a confirmation. Used by the public `/contact` + * form; the honeypot and captcha fields ride along on the same payload. + */ +export function useSubmitContact() { + return useMutation({ + mutationFn: (variables: SubmitContactBody): Promise => + requestJson(submitContactContract, { body: variables }), + onError: (error) => { + logger.error('Failed to submit contact request:', error) + }, + }) +} diff --git a/apps/sim/lib/api/contracts/contact.ts b/apps/sim/lib/api/contracts/contact.ts new file mode 100644 index 00000000000..6ae9f573340 --- /dev/null +++ b/apps/sim/lib/api/contracts/contact.ts @@ -0,0 +1,106 @@ +import { z } from 'zod' +import type { ContractBodyInput } from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { NO_EMAIL_HEADER_CONTROL_CHARS_REGEX } from '@/lib/messaging/email/utils' +import { quickValidateEmail } from '@/lib/messaging/email/validation' + +export const CONTACT_TOPIC_VALUES = [ + 'general', + 'support', + 'integration', + 'feature_request', + 'sales', + 'partnership', + 'billing', + 'other', +] as const + +export const CONTACT_TOPIC_OPTIONS = [ + { value: 'general', label: 'General question' }, + { value: 'support', label: 'Technical support' }, + { value: 'integration', label: 'Integration request' }, + { value: 'feature_request', label: 'Feature request' }, + { value: 'sales', label: 'Sales & pricing' }, + { value: 'partnership', label: 'Partnership' }, + { value: 'billing', label: 'Billing' }, + { value: 'other', label: 'Other' }, +] as const + +export const contactRequestSchema = z.object({ + name: z + .string() + .trim() + .min(1, 'Name is required') + .max(120, 'Name must be 120 characters or less') + .regex(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX, 'Invalid characters'), + email: z + .string() + .trim() + .min(1, 'Email is required') + .max(320) + .transform((value) => value.toLowerCase()) + .refine((value) => quickValidateEmail(value).isValid, 'Enter a valid email'), + company: z + .string() + .trim() + .max(120, 'Company must be 120 characters or less') + .optional() + .transform((value) => (value && value.length > 0 ? value : undefined)), + topic: z.enum(CONTACT_TOPIC_VALUES, { + error: 'Please select a topic', + }), + subject: z + .string() + .trim() + .min(1, 'Subject is required') + .max(200, 'Subject must be 200 characters or less') + .regex(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX, 'Invalid characters'), + message: z + .string() + .trim() + .min(1, 'Message is required') + .max(5000, 'Message must be 5,000 characters or less'), +}) + +export type ContactRequestPayload = z.infer +export type ContactRequestBody = z.input + +export const submitContactBodySchema = contactRequestSchema.extend({ + website: z.string().optional(), + captchaToken: z.string().optional(), +}) + +export function getContactTopicLabel(value: ContactRequestPayload['topic']): string { + return CONTACT_TOPIC_OPTIONS.find((option) => option.value === value)?.label ?? value +} + +export type HelpEmailType = 'bug' | 'feedback' | 'feature_request' | 'other' + +/** + * Map a contact topic to the confirmation-email type. Only `feature_request` has + * a matching email label ("Feature Request"); every other topic — support, sales, + * billing, etc. — resolves to `other` ("General Inquiry") so the confirmation copy + * never mislabels the request (e.g. calling a support inquiry a "bug report"). + */ +export function mapContactTopicToHelpType(topic: ContactRequestPayload['topic']): HelpEmailType { + return topic === 'feature_request' ? 'feature_request' : 'other' +} + +export const contactResponseSchema = z.object({ + success: z.literal(true), + message: z.string(), +}) + +export type SubmitContactResult = z.output + +export const submitContactContract = defineRouteContract({ + method: 'POST', + path: '/api/contact', + body: submitContactBodySchema, + response: { + mode: 'json', + schema: contactResponseSchema, + }, +}) + +export type SubmitContactBody = ContractBodyInput diff --git a/apps/sim/lib/core/rate-limiter/rate-limiter.test.ts b/apps/sim/lib/core/rate-limiter/rate-limiter.test.ts index a53a29b77ae..93e482613fd 100644 --- a/apps/sim/lib/core/rate-limiter/rate-limiter.test.ts +++ b/apps/sim/lib/core/rate-limiter/rate-limiter.test.ts @@ -266,6 +266,43 @@ describe('RateLimiter', () => { }) }) + describe('checkRateLimitDirect', () => { + const config = { maxTokens: 3, refillRate: 1, refillIntervalMs: 60_000 } + + it('should reflect the storage decision when it succeeds', async () => { + mockAdapter.consumeTokens.mockResolvedValue({ + allowed: true, + tokensRemaining: 2, + resetAt: new Date(), + }) + + const result = await rateLimiter.checkRateLimitDirect('public:contact:ip', config) + + expect(result.allowed).toBe(true) + expect(result.remaining).toBe(2) + }) + + it('should fail open on storage error by default', async () => { + mockAdapter.consumeTokens.mockRejectedValue(new Error('Storage error')) + + const result = await rateLimiter.checkRateLimitDirect('public:contact:ip', config) + + expect(result.allowed).toBe(true) + expect(result.remaining).toBe(1) + }) + + it('should fail closed on storage error when failClosed is set', async () => { + mockAdapter.consumeTokens.mockRejectedValue(new Error('Storage error')) + + const result = await rateLimiter.checkRateLimitDirect('public:contact:ip', config, { + failClosed: true, + }) + + expect(result.allowed).toBe(false) + expect(result.remaining).toBe(0) + }) + }) + describe('subscription plan handling', () => { it('should use pro plan limits', async () => { const proSubscription = { plan: 'pro', referenceId: testUserId } diff --git a/apps/sim/lib/core/rate-limiter/rate-limiter.ts b/apps/sim/lib/core/rate-limiter/rate-limiter.ts index da5ce07d74e..9e274839d86 100644 --- a/apps/sim/lib/core/rate-limiter/rate-limiter.ts +++ b/apps/sim/lib/core/rate-limiter/rate-limiter.ts @@ -165,9 +165,16 @@ export class RateLimiter { } } + /** + * Consume one token from a bucket. On storage failure the default is to fail + * open (allow the request) so a limiter outage never takes down normal traffic. + * Pass `failClosed: true` for security-critical checks — e.g. a captcha + * backstop — where an unenforceable limit must reject rather than admit. + */ async checkRateLimitDirect( storageKey: string, - config: { maxTokens: number; refillRate: number; refillIntervalMs: number } + config: { maxTokens: number; refillRate: number; refillIntervalMs: number }, + options?: { failClosed?: boolean } ): Promise { try { const result = await this.storage.consumeTokens(storageKey, 1, config) @@ -181,13 +188,17 @@ export class RateLimiter { retryAfterMs: result.retryAfterMs, } } catch (error) { - logger.error('Rate limit storage error - failing open (allowing request)', { - error: toError(error).message, - storageKey, - }) + const failClosed = options?.failClosed === true + logger.error( + `Rate limit storage error - failing ${failClosed ? 'closed (rejecting request)' : 'open (allowing request)'}`, + { + error: toError(error).message, + storageKey, + } + ) return { - allowed: true, - remaining: 1, + allowed: !failClosed, + remaining: failClosed ? 0 : 1, resetAt: new Date(Date.now() + RATE_LIMIT_WINDOW_MS), } } diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 987f7f578de..0d0d94230aa 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 881, - zodRoutes: 881, + totalRoutes: 882, + zodRoutes: 882, nonZodRoutes: 0, } as const